From c65395d644a10ad5d788f593f345ba6166d8fa42 Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Wed, 20 May 2026 15:44:24 +1000 Subject: [PATCH 01/10] Develop (#4) * added pip and homebrew release workflows * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Matthew Gribben * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Matthew Gribben * added gating to the release workflow * Add CI gate, concurrency control, and REPO_TOKEN for homebrew tap - Gate job verifies all CI checks passed on the tagged commit before any build or publish job runs; skipped for workflow_dispatch - Concurrency group prevents parallel release runs for the same ref - publish-homebrew now uses secrets.REPO_TOKEN for cross-repo push to homebrew-tap Co-Authored-By: Claude Sonnet 4.6 --------- Signed-off-by: Matthew Gribben Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/release.yml | 39 ++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20b4421..6848c98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,9 +15,45 @@ permissions: contents: write packages: write +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: + gate: + name: Verify CI + runs-on: ubuntu-latest + steps: + - name: Check CI on tagged commit + env: + GH_TOKEN: ${{ github.token }} + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "Manual dispatch — skipping CI gate" + exit 0 + fi + + sha="${{ github.sha }}" + runs=$(gh api "repos/${{ github.repository }}/commits/${sha}/check-runs" \ + --jq '[.check_runs[] | select(.name | test("Build & Test|AOT Publish|Format"))]') + + if [ "$(echo "$runs" | jq 'length')" -eq 0 ]; then + echo "::error::No CI check runs found for ${sha} — run CI before tagging" + exit 1 + fi + + not_passed=$(echo "$runs" | jq '[.[] | select(.conclusion != "success")] | length') + if [ "$not_passed" -gt 0 ]; then + echo "::error::CI did not fully pass on ${sha}" + echo "$runs" | jq -r '.[] | " \(.name): \(.conclusion)"' + exit 1 + fi + + echo "$runs" | jq -r '.[] | " \(.name): \(.conclusion)"' + build: name: Build ${{ matrix.rid }} + needs: [gate] runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -143,6 +179,7 @@ jobs: publish-sdk: name: Pack & Publish Hypa.Sdk runs-on: ubuntu-latest + needs: [gate] steps: - uses: actions/checkout@v4 @@ -374,7 +411,7 @@ jobs: - name: Push to tap repo env: - GITHUB_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }} VERSION: ${{ steps.version.outputs.version }} run: | git clone --depth=1 \ From 5e62076adc79d91cc1eed3984f9f0d84c5fb7af9 Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Thu, 21 May 2026 14:41:30 +1000 Subject: [PATCH 02/10] Add release workflows and CI gating plus timeout changes for long running commands (#5) * added pip and homebrew release workflows * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Matthew Gribben * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Matthew Gribben * added gating to the release workflow * Add CI gate, concurrency control, and REPO_TOKEN for homebrew tap - Gate job verifies all CI checks passed on the tagged commit before any build or publish job runs; skipped for workflow_dispatch - Concurrency group prevents parallel release runs for the same ref - publish-homebrew now uses secrets.REPO_TOKEN for cross-repo push to homebrew-tap Co-Authored-By: Claude Sonnet 4.6 * Sync public source from PR #29 This pull request adds significant architectural changes as well as a GitHub Actions workflow for syncing source and test files to a public repository. **Architectural Decisions:** * Establishes a package boundary between provider-neutral structural search contracts (`Hypa.Sdk`) and the Tree-sitter-based engine implementation (`Hypa.CodePatterns`). This separation enables downstream consumers to use structural search results without taking a dependency on native parsing libraries, and allows for future NuGet publication of the engine. * Details the sequencing and rationale for three major 2026 features: MCP Proxy Layer, code grep (structural search/scan/rewrite), and Code Mode Scripting Engine. We plan phased delivery, risk mitigation, and integration points to maximize compound value and minimize technical risk. **Timeouts** * Some commands would hit the hypa internal timeout limit, mainly package manager, so we've introduced pipeline specific limits and an override command to allow the agent to specify a timeout plus more intelligent feedback when there is an error. **Automation and Workflow:** * Introduced a GitHub Actions workflow (`.github/workflows/sync-public.yml`) that automatically syncs the `src` and `tests` directories to the public `Hypabolic/Hypa` repository on pushes to `main`. The workflow includes PR metadata extraction, payload creation, public repo checkout, file replacement, and commit/push logic with descriptive commit messages. Source repo: matt-gribben/Hypa Source SHA: 25f836cb7f71a4f63dc3423d5a971bf57c713083 PR URL: https://github.com/matt-gribben/Hypa/pull/29 --------- Signed-off-by: Matthew Gribben Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/Hypa.Cli/Commands/RunCommand.cs | 85 +++++++- .../Config/JsonConfigLoader.cs | 4 + .../DI/InfrastructureServiceExtensions.cs | 1 - .../Mcp/Transport/ContentLengthInputStream.cs | 115 ---------- .../Transport/ContentLengthOutputStream.cs | 88 -------- .../McpServerBuilderTransportExtensions.cs | 17 -- .../Runner/ProcessCommandRunner.cs | 28 ++- .../Updates/NpmUpdateStrategy.cs | 61 ------ .../Services/CommandRunnerService.cs | 63 +++++- src/Hypa.Runtime/Domain/Config/HypaConfig.cs | 2 + .../Domain/Runner/CommandOutput.cs | 13 +- .../Domain/Runner/CompressionOptions.cs | 1 + .../config_show_output/stdout.verified.txt | 3 +- .../Application/CommandRunnerServiceTests.cs | 53 +++++ tests/Hypa.UnitTests/Cli/RunCommandTests.cs | 125 +++++++++++ .../Hypa.UnitTests/Domain/HypaConfigTests.cs | 6 + .../Infrastructure/JsonConfigLoaderTests.cs | 12 ++ .../Mcp/ContentLengthTransportTests.cs | 200 ------------------ .../ProcessCommandRunnerTests.cs | 23 ++ 19 files changed, 395 insertions(+), 505 deletions(-) delete mode 100644 src/Hypa.Infrastructure/Mcp/Transport/ContentLengthInputStream.cs delete mode 100644 src/Hypa.Infrastructure/Mcp/Transport/ContentLengthOutputStream.cs delete mode 100644 src/Hypa.Infrastructure/Mcp/Transport/McpServerBuilderTransportExtensions.cs delete mode 100644 src/Hypa.Infrastructure/Updates/NpmUpdateStrategy.cs create mode 100644 tests/Hypa.UnitTests/Cli/RunCommandTests.cs delete mode 100644 tests/Hypa.UnitTests/Infrastructure/Mcp/ContentLengthTransportTests.cs diff --git a/src/Hypa.Cli/Commands/RunCommand.cs b/src/Hypa.Cli/Commands/RunCommand.cs index a014df1..c33321e 100644 --- a/src/Hypa.Cli/Commands/RunCommand.cs +++ b/src/Hypa.Cli/Commands/RunCommand.cs @@ -8,6 +8,10 @@ namespace Hypa.Cli.Commands; public sealed class RunCommand(CommandRunnerService runnerService, IShellLexer shellLexer) { + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan PackageManagerTimeout = TimeSpan.FromMinutes(10); + private static readonly HashSet PackageManagers = ["npm", "pnpm", "yarn", "bun", "npx", "corepack"]; + public void AttachTo(RootCommand root) { var cOpt = new Option(["-c"], "Run command through hypa: buffer output, compress, and return.") @@ -21,30 +25,38 @@ public void AttachTo(RootCommand root) AllowMultipleArgumentsPerToken = true, Arity = ArgumentArity.ZeroOrMore, }; + var timeoutOpt = new Option( + ["--timeout-ms"], + "Override command timeout in milliseconds. Package-manager commands default to 10 minutes; other commands default to 30 seconds.") + { + ArgumentHelpName = "milliseconds", + }; root.AddOption(cOpt); root.AddOption(tOpt); - root.AddCommand(BuildRawSubcommand()); + root.AddGlobalOption(timeoutOpt); + root.AddCommand(BuildRawSubcommand(timeoutOpt)); root.SetHandler(async context => { var cVal = context.ParseResult.GetValueForOption(cOpt); var tVals = context.ParseResult.GetValueForOption(tOpt); + var timeoutMs = context.ParseResult.GetValueForOption(timeoutOpt); var ct = context.GetCancellationToken(); if (cVal is not null) { - context.ExitCode = await HandleBufferedAsync(cVal, ct); + context.ExitCode = await HandleBufferedAsync(cVal, timeoutMs, ct); } else if (tVals is { Length: > 0 }) { - context.ExitCode = await HandlePassthroughAsync(tVals, ct); + context.ExitCode = await HandlePassthroughAsync(tVals, timeoutMs, ct); } // else: no option and no subcommand matched — System.CommandLine prints help. }); } - private Command BuildRawSubcommand() + private Command BuildRawSubcommand(Option timeoutOpt) { var argsArg = new Argument("args", "Command and arguments to run unmodified.") { @@ -55,13 +67,20 @@ private Command BuildRawSubcommand() cmd.SetHandler(async context => { var args = context.ParseResult.GetValueForArgument(argsArg); - context.ExitCode = await HandlePassthroughAsync(args, context.GetCancellationToken()); + var timeoutMs = context.ParseResult.GetValueForOption(timeoutOpt); + context.ExitCode = await HandlePassthroughAsync(args, timeoutMs, context.GetCancellationToken()); }); return cmd; } - private async Task HandleBufferedAsync(string command, CancellationToken ct) + private async Task HandleBufferedAsync(string command, int? timeoutMs, CancellationToken ct) { + if (!TryResolveTimeoutOverride(timeoutMs, out var timeout, out var error)) + { + await Console.Error.WriteLineAsync(error); + return 1; + } + var lexed = shellLexer.Lex(command); var usesShellSyntax = lexed.Any(t => t.Kind is TokenKind.Operator or TokenKind.Pipe or TokenKind.Redirect or TokenKind.Shellism); @@ -75,6 +94,7 @@ private async Task HandleBufferedAsync(string command, CancellationToken ct return 1; } + invocation = invocation with { Timeout = timeout ?? ResolveDefaultTimeout(invocation, lexed) }; var result = await runnerService.RunBufferedAsync(invocation, CompressionOptions.Default, ct); if (!result.IsOk) @@ -118,8 +138,14 @@ private static string StripQuotes(string value) return value; } - private async Task HandlePassthroughAsync(string[] args, CancellationToken ct) + private async Task HandlePassthroughAsync(string[] args, int? timeoutMs, CancellationToken ct) { + if (!TryResolveTimeoutOverride(timeoutMs, out var timeout, out var error)) + { + await Console.Error.WriteLineAsync(error); + return 1; + } + if (args.Length == 0) { await Console.Error.WriteLineAsync("hypa -t: no command specified."); @@ -127,6 +153,7 @@ private async Task HandlePassthroughAsync(string[] args, CancellationToken } var invocation = CommandInvocation.Passthrough(args[0], args[1..], string.Join(' ', args)); + invocation = invocation with { Timeout = timeout ?? ResolveDefaultTimeout(invocation, args) }; var result = await runnerService.RunPassthroughAsync(invocation, ct); if (!result.IsOk) @@ -137,4 +164,48 @@ private async Task HandlePassthroughAsync(string[] args, CancellationToken return result.Value; } + + private static bool TryResolveTimeoutOverride(int? timeoutMs, out TimeSpan? timeout, out string error) + { + timeout = null; + error = string.Empty; + + if (timeoutMs is null) + return true; + + if (timeoutMs <= 0) + { + error = "hypa: --timeout-ms must be greater than 0."; + return false; + } + + timeout = TimeSpan.FromMilliseconds(timeoutMs.Value); + return true; + } + + private static TimeSpan ResolveDefaultTimeout(CommandInvocation invocation, IReadOnlyList lexed) => + IsPackageManagerInvocation(invocation.Executable) || IsPackageManagerLexedCommand(lexed) + ? PackageManagerTimeout + : DefaultTimeout; + + private static TimeSpan ResolveDefaultTimeout(CommandInvocation invocation, IReadOnlyList args) => + IsPackageManagerInvocation(invocation.Executable) || (args.Count > 0 && IsPackageManagerInvocation(args[0])) + ? PackageManagerTimeout + : DefaultTimeout; + + private static bool IsPackageManagerLexedCommand(IReadOnlyList lexed) + { + var firstArg = lexed.FirstOrDefault(t => t.Kind is TokenKind.Arg or TokenKind.QuotedArg); + if (firstArg is null) + return false; + + var value = firstArg.Kind == TokenKind.QuotedArg ? StripQuotes(firstArg.Value) : firstArg.Value; + return IsPackageManagerInvocation(value); + } + + private static bool IsPackageManagerInvocation(string executable) + { + var name = Path.GetFileNameWithoutExtension(executable); + return PackageManagers.Contains(name); + } } diff --git a/src/Hypa.Infrastructure/Config/JsonConfigLoader.cs b/src/Hypa.Infrastructure/Config/JsonConfigLoader.cs index 8d9ed73..0a161e2 100644 --- a/src/Hypa.Infrastructure/Config/JsonConfigLoader.cs +++ b/src/Hypa.Infrastructure/Config/JsonConfigLoader.cs @@ -49,6 +49,7 @@ private static HypaConfig BindConfig(IConfiguration cfg) var enabled = cfg["enabled"]; var storagePath = cfg["storage_path"]; var logLevel = cfg["log_level"]; + var showCompressionMetadata = cfg["show_compression_metadata"]; var excludeChildren = cfg.GetSection("exclude_commands").GetChildren().ToArray(); var excludeCommands = excludeChildren.Length > 0 @@ -69,6 +70,9 @@ private static HypaConfig BindConfig(IConfiguration cfg) ? Enum.TryParse(logLevel, ignoreCase: true, out var ll) ? ll : defaults.LogLevel : defaults.LogLevel, ExcludeCommands = excludeCommands, + ShowCompressionMetadata = showCompressionMetadata is not null + ? bool.TryParse(showCompressionMetadata, out var scm) ? scm : defaults.ShowCompressionMetadata + : defaults.ShowCompressionMetadata, UpdateCheckEnabled = updateCheckEnabled is not null ? bool.TryParse(updateCheckEnabled, out var uce) ? uce : defaults.UpdateCheckEnabled : defaults.UpdateCheckEnabled, diff --git a/src/Hypa.Infrastructure/DI/InfrastructureServiceExtensions.cs b/src/Hypa.Infrastructure/DI/InfrastructureServiceExtensions.cs index 9a07207..f800339 100644 --- a/src/Hypa.Infrastructure/DI/InfrastructureServiceExtensions.cs +++ b/src/Hypa.Infrastructure/DI/InfrastructureServiceExtensions.cs @@ -148,7 +148,6 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/src/Hypa.Infrastructure/Mcp/Transport/ContentLengthInputStream.cs b/src/Hypa.Infrastructure/Mcp/Transport/ContentLengthInputStream.cs deleted file mode 100644 index df7996a..0000000 --- a/src/Hypa.Infrastructure/Mcp/Transport/ContentLengthInputStream.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Text; - -namespace Hypa.Infrastructure.Mcp.Transport; - -/// -/// Adapts a Content-Length-framed stdin (MCP spec / Claude Code client) to -/// the newline-delimited JSON format expected by the MCP C# SDK's StreamServerTransport. -/// -/// Each incoming message is: "Content-Length: N\r\n\r\n{N bytes of JSON}" -/// Each outgoing read to the SDK yields: "{N bytes of JSON}\n" -/// -internal sealed class ContentLengthInputStream : Stream -{ - private readonly Stream _inner; - private byte[] _pending = []; - private int _pendingPos; - - internal ContentLengthInputStream(Stream inner) => _inner = inner; - - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => false; - - public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) - { - if (_pendingPos < _pending.Length) - return Drain(buffer); - - var payload = await ReadNextFrameAsync(ct); - if (payload is null) - return 0; // EOF - - // Expose payload + '\n' as the next JSON line - _pending = new byte[payload.Length + 1]; - payload.CopyTo(_pending, 0); - _pending[payload.Length] = (byte)'\n'; - _pendingPos = 0; - - return Drain(buffer); - } - - private int Drain(Memory buffer) - { - var count = Math.Min(buffer.Length, _pending.Length - _pendingPos); - _pending.AsMemory(_pendingPos, count).CopyTo(buffer); - _pendingPos += count; - return count; - } - - private async Task ReadNextFrameAsync(CancellationToken ct) - { - int contentLength = -1; - - while (true) - { - var line = await ReadHeaderLineAsync(ct); - if (line is null) - return null; // EOF before any header - - if (line.Length == 0) - break; // blank line → end of headers - - if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase) && - int.TryParse(line["Content-Length:".Length..].Trim(), out var n)) - { - contentLength = n; - } - // Ignore unrecognised headers (e.g. Content-Type) - } - - if (contentLength <= 0) - return []; - - var body = new byte[contentLength]; - var read = 0; - while (read < contentLength) - { - var chunk = await _inner.ReadAsync(body.AsMemory(read, contentLength - read), ct); - if (chunk == 0) - break; // premature EOF: return what we have - read += chunk; - } - - return body; - } - - // Reads one header line, stripping the trailing \r. Returns null on EOF. - private async Task ReadHeaderLineAsync(CancellationToken ct) - { - var buf = new List(128); - var oneByte = new byte[1]; - - while (true) - { - var read = await _inner.ReadAsync(oneByte.AsMemory(), ct); - if (read == 0) - return buf.Count == 0 ? null : Encoding.ASCII.GetString([.. buf]).TrimEnd('\r'); - - if (oneByte[0] == '\n') - return Encoding.ASCII.GetString([.. buf]).TrimEnd('\r'); - - buf.Add(oneByte[0]); - } - } - - // Stream boilerplate - public override long Length => throw new NotSupportedException(); - public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } - public override void Flush() { } - public override int Read(byte[] buffer, int offset, int count) => - ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); -} diff --git a/src/Hypa.Infrastructure/Mcp/Transport/ContentLengthOutputStream.cs b/src/Hypa.Infrastructure/Mcp/Transport/ContentLengthOutputStream.cs deleted file mode 100644 index 789a796..0000000 --- a/src/Hypa.Infrastructure/Mcp/Transport/ContentLengthOutputStream.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Text; - -namespace Hypa.Infrastructure.Mcp.Transport; - -/// -/// Adapts the MCP C# SDK's newline-delimited JSON output to the -/// Content-Length framing required by the MCP spec and Claude Code's client. -/// -/// The SDK writes: "{JSON bytes}\n" -/// This stream writes: "Content-Length: N\r\n\r\n{N bytes of JSON}" -/// -internal sealed class ContentLengthOutputStream : Stream -{ - private readonly Stream _inner; - private readonly SemaphoreSlim _writeLock = new(1, 1); - private readonly MemoryStream _lineBuffer = new(); - - internal ContentLengthOutputStream(Stream inner) => _inner = inner; - - public override bool CanRead => false; - public override bool CanSeek => false; - public override bool CanWrite => true; - protected override void Dispose(bool disposing) - { - if (disposing) - { - _lineBuffer.Dispose(); - _writeLock.Dispose(); - _inner.Dispose(); - } - base.Dispose(disposing); - } - - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) - { - // Locate newline before acquiring the lock so we can work with Memory across await. - var nlIdx = buffer.Span.IndexOf((byte)'\n'); - - await _writeLock.WaitAsync(ct); - try - { - if (nlIdx < 0) - { - _lineBuffer.Write(buffer.Span); - return; - } - - // Write everything before '\n', flush the frame, then buffer the remainder. - _lineBuffer.Write(buffer[..nlIdx].Span); - await FlushFrameAsync(ct); - - if (nlIdx + 1 < buffer.Length) - _lineBuffer.Write(buffer[(nlIdx + 1)..].Span); - } - finally - { - _writeLock.Release(); - } - } - - public override async Task FlushAsync(CancellationToken ct) => - await _inner.FlushAsync(ct); - - private async Task FlushFrameAsync(CancellationToken ct) - { - var payload = _lineBuffer.ToArray(); - _lineBuffer.SetLength(0); - _lineBuffer.Position = 0; - - if (payload.Length == 0) - return; - - var header = Encoding.ASCII.GetBytes($"Content-Length: {payload.Length}\r\n\r\n"); - await _inner.WriteAsync(header.AsMemory(), ct); - await _inner.WriteAsync(payload.AsMemory(), ct); - await _inner.FlushAsync(ct); - } - - // Stream boilerplate - public override long Length => throw new NotSupportedException(); - public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } - public override void Flush() => _inner.Flush(); - public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => - WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); -} diff --git a/src/Hypa.Infrastructure/Mcp/Transport/McpServerBuilderTransportExtensions.cs b/src/Hypa.Infrastructure/Mcp/Transport/McpServerBuilderTransportExtensions.cs deleted file mode 100644 index b77ed47..0000000 --- a/src/Hypa.Infrastructure/Mcp/Transport/McpServerBuilderTransportExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace Hypa.Infrastructure.Mcp.Transport; - -public static class McpServerBuilderTransportExtensions -{ - /// - /// Replaces the default JSON-line stdio transport with Content-Length-framed - /// adapters so the server accepts the framing format used by Claude Code's MCP client. - /// - public static IMcpServerBuilder WithContentLengthStdioTransport(this IMcpServerBuilder builder) - { - var stdin = new ContentLengthInputStream(Console.OpenStandardInput()); - var stdout = new ContentLengthOutputStream(Console.OpenStandardOutput()); - return builder.WithStreamServerTransport(stdin, stdout); - } -} diff --git a/src/Hypa.Infrastructure/Runner/ProcessCommandRunner.cs b/src/Hypa.Infrastructure/Runner/ProcessCommandRunner.cs index c16157e..393f018 100644 --- a/src/Hypa.Infrastructure/Runner/ProcessCommandRunner.cs +++ b/src/Hypa.Infrastructure/Runner/ProcessCommandRunner.cs @@ -54,8 +54,8 @@ public async Task> RunAsync(CommandInvocation invoc if (invocation.Mode == ToolRunMode.Buffered) { // Read both streams concurrently to prevent deadlock on large output. - var stdoutTask = process.StandardOutput.ReadToEndAsync(linkedCts.Token); - var stderrTask = process.StandardError.ReadToEndAsync(linkedCts.Token); + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); try { @@ -64,7 +64,10 @@ public async Task> RunAsync(CommandInvocation invoc catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { try { process.Kill(entireProcessTree: true); } catch { /* best-effort */ } - return Result.Ok(CommandOutput.CreateTimedOut(sw.Elapsed)); + await WaitForKilledProcessAsync(process); + var (timedOutStdout, timedOutStderr) = await DrainBufferedOutputAsync(stdoutTask, stderrTask); + return Result.Ok( + CommandOutput.CreateTimedOut(sw.Elapsed, timedOutStdout, timedOutStderr)); } catch (OperationCanceledException) { @@ -110,4 +113,23 @@ public async Task> RunAsync(CommandInvocation invoc } } } + + private static async Task WaitForKilledProcessAsync(Process process) + { + try { await process.WaitForExitAsync(CancellationToken.None); } catch { /* best-effort */ } + } + + private static async Task<(string Stdout, string Stderr)> DrainBufferedOutputAsync( + Task stdoutTask, + Task stderrTask) + { + var stdout = await DrainAsync(stdoutTask); + var stderr = await DrainAsync(stderrTask); + return (stdout, stderr); + } + + private static async Task DrainAsync(Task task) + { + try { return await task; } catch { return string.Empty; } + } } diff --git a/src/Hypa.Infrastructure/Updates/NpmUpdateStrategy.cs b/src/Hypa.Infrastructure/Updates/NpmUpdateStrategy.cs deleted file mode 100644 index f104a98..0000000 --- a/src/Hypa.Infrastructure/Updates/NpmUpdateStrategy.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Diagnostics; -using System.Runtime.InteropServices; -using Hypa.Runtime.Application.Ports; -using Hypa.Runtime.Domain.Common; -using Hypa.Runtime.Domain.Updates; - -namespace Hypa.Infrastructure.Updates; - -public sealed class NpmUpdateStrategy : IUpdateStrategy -{ - public string Name => "npm"; - - public bool CanHandle(InstallMetadata metadata) => - string.Equals( - Environment.GetEnvironmentVariable("HYPA_INSTALL_SOURCE"), - "npm", - StringComparison.OrdinalIgnoreCase); - - public Task> PlanAsync(UpdateInfo update, InstallMetadata metadata, CancellationToken ct) - { - const string command = "npm update -g @hypabolic/hypa"; - var plan = new UpdatePlan( - Strategy: Name, - CanAutoUpdate: true, - Summary: "Update via npm", - Command: command, - Detail: $"Run: {command}"); - - return Task.FromResult(Result.Ok(plan)); - } - - public Task> ApplyAsync(UpdateInfo update, InstallMetadata metadata, CancellationToken ct) - { - try - { - var (file, args) = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? ("cmd.exe", "/c npm update -g @hypabolic/hypa") - : ("npm", "update -g @hypabolic/hypa"); - - using var process = Process.Start(new ProcessStartInfo - { - FileName = file, - Arguments = args, - UseShellExecute = false, - }); - - if (process is null) - return Task.FromResult(Result.Fail(new Error("Update.NpmFailed", "Failed to start npm process."))); - - process.WaitForExit(); - - return process.ExitCode == 0 - ? Task.FromResult(Result.Ok(Unit.Value)) - : Task.FromResult(Result.Fail(new Error("Update.NpmFailed", $"npm exited with code {process.ExitCode}."))); - } - catch (Exception ex) - { - return Task.FromResult(Result.Fail(new Error("Update.NpmFailed", ex.Message))); - } - } -} diff --git a/src/Hypa.Runtime/Application/Services/CommandRunnerService.cs b/src/Hypa.Runtime/Application/Services/CommandRunnerService.cs index 4bcce45..78fd1ba 100644 --- a/src/Hypa.Runtime/Application/Services/CommandRunnerService.cs +++ b/src/Hypa.Runtime/Application/Services/CommandRunnerService.cs @@ -1,5 +1,6 @@ using Hypa.Runtime.Application.Ports; using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Config; using Hypa.Runtime.Domain.Metrics; using Hypa.Runtime.Domain.Parsers; using Hypa.Runtime.Domain.Runner; @@ -15,6 +16,7 @@ public sealed class CommandRunnerService( IArtifactRepository artifacts, IEvidenceLedger evidence, ISessionResolver sessionResolver, + IConfigLoader configLoader, FilterService filterService, IFilterEngine filterEngine, IParseMetricsRepository parseMetrics, @@ -27,12 +29,15 @@ public async Task> RunBufferedAsync( CompressionOptions options, CancellationToken ct) { + var effectiveOptions = await ResolveCompressionOptionsAsync(options, ct); var runResult = await runner.RunAsync(invocation, ct); if (!runResult.IsOk) return Result.Fail(runResult.Error); var output = runResult.Value; var combined = output.Stdout + (output.Stderr.Length > 0 ? "\n" + output.Stderr : ""); + if (output.WasTimedOut) + combined = AppendTimeoutDiagnostic(combined, invocation, output); var originalTokens = tokenCounter.EstimateTokens(combined); string finalText; @@ -40,7 +45,7 @@ public async Task> RunBufferedAsync( int compressedTokens = originalTokens; bool wasTruncated = false; - if (originalTokens <= options.SmallOutputThreshold) + if (originalTokens <= effectiveOptions.SmallOutputThreshold) { finalText = combined; } @@ -50,7 +55,7 @@ public async Task> RunBufferedAsync( if (compressor is null) return Result.Fail(new Error("NO_COMPRESSOR", "No compressor registered.")); - var result = compressor.Compress(invocation, output, options); + var result = compressor.Compress(invocation, output, effectiveOptions); reducerId = result.ReducerId; wasTruncated = result.WasTruncated; @@ -70,11 +75,11 @@ public async Task> RunBufferedAsync( Guid? teeArtifactId = null; var sessionId = await ResolveSessionIdBestEffortAsync(ct); - if ((output.ExitCode != 0 || output.WasTimedOut) && options.TeeOnFailure) + if ((output.ExitCode != 0 || output.WasTimedOut) && effectiveOptions.TeeOnFailure) { teeArtifactId = await TeeAsync(combined, sessionId, ct); } - else if (wasTruncated && options.TeeOnTruncation) + else if (wasTruncated && effectiveOptions.TeeOnTruncation) { teeArtifactId = await TeeAsync(combined, sessionId, ct); } @@ -122,15 +127,19 @@ await RecordParseMetricsBestEffortAsync(new ParseMetricsRecord RecordedAt = DateTimeOffset.UtcNow, }, ct); - if (originalTokens > options.SmallOutputThreshold && compressedTokens < originalTokens) + var shouldShowCompressionMetadata = + effectiveOptions.ShowCompressionMetadata && + originalTokens > effectiveOptions.SmallOutputThreshold && + compressedTokens < originalTokens; + + if (shouldShowCompressionMetadata) { var saving = (int)Math.Round((1.0 - (double)compressedTokens / originalTokens) * 100); var metaLine = $"[hypa: {originalTokens}→{compressedTokens} tok, -{saving}%, reducer={reducerId}]"; - if (teeArtifactId.HasValue) - metaLine += $"\n[hypa: full output -> artifact:{teeArtifactId.Value:N}, expires in 24h]"; finalText = finalText.TrimEnd() + "\n" + metaLine; } - else if (teeArtifactId.HasValue) + + if (teeArtifactId.HasValue) { finalText = finalText.TrimEnd() + $"\n[hypa: full output -> artifact:{teeArtifactId.Value:N}, expires in 24h]"; } @@ -221,4 +230,42 @@ private async Task ResolveSessionIdBestEffortAsync(CancellationToken ct) return Guid.Empty; } } + + private async Task ResolveCompressionOptionsAsync( + CompressionOptions options, + CancellationToken ct) + { + try + { + var config = await configLoader.LoadAsync(ct); + var showMetadata = config.IsOk + ? config.Value.ShowCompressionMetadata + : HypaConfig.Default.ShowCompressionMetadata; + + return options with + { + ShowCompressionMetadata = options.ShowCompressionMetadata && showMetadata, + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogDebug(ex, "Failed to load config, using default compression options"); + return options; + } + } + + private static string AppendTimeoutDiagnostic( + string text, + CommandInvocation invocation, + CommandOutput output) + { + var line = + $"[hypa: command timed out after {invocation.Timeout.TotalSeconds:0.###}s; " + + $"killed process; exit={CommandOutput.TimeoutExitCode}; " + + $"elapsed={output.Duration.TotalSeconds:0.###}s]"; + + return string.IsNullOrWhiteSpace(text) + ? line + : text.TrimEnd() + "\n" + line; + } } diff --git a/src/Hypa.Runtime/Domain/Config/HypaConfig.cs b/src/Hypa.Runtime/Domain/Config/HypaConfig.cs index d836501..f212deb 100644 --- a/src/Hypa.Runtime/Domain/Config/HypaConfig.cs +++ b/src/Hypa.Runtime/Domain/Config/HypaConfig.cs @@ -13,6 +13,8 @@ public sealed record HypaConfig public bool GenericWrapperEnabled { get; init; } = true; + public bool ShowCompressionMetadata { get; init; } = true; + public LogLevel LogLevel { get; init; } = LogLevel.Warning; public bool UpdateCheckEnabled { get; init; } = true; diff --git a/src/Hypa.Runtime/Domain/Runner/CommandOutput.cs b/src/Hypa.Runtime/Domain/Runner/CommandOutput.cs index f1151ee..7a4ae31 100644 --- a/src/Hypa.Runtime/Domain/Runner/CommandOutput.cs +++ b/src/Hypa.Runtime/Domain/Runner/CommandOutput.cs @@ -22,12 +22,17 @@ public static CommandOutput Captured( WasTimedOut = false, }; - public static CommandOutput CreateTimedOut(TimeSpan elapsed) => + public const int TimeoutExitCode = 124; + + public static CommandOutput CreateTimedOut( + TimeSpan elapsed, + string stdout = "", + string stderr = "") => new() { - Stdout = string.Empty, - Stderr = string.Empty, - ExitCode = -1, + Stdout = stdout, + Stderr = stderr, + ExitCode = TimeoutExitCode, Duration = elapsed, WasTimedOut = true, }; diff --git a/src/Hypa.Runtime/Domain/Runner/CompressionOptions.cs b/src/Hypa.Runtime/Domain/Runner/CompressionOptions.cs index 1502976..fe376ab 100644 --- a/src/Hypa.Runtime/Domain/Runner/CompressionOptions.cs +++ b/src/Hypa.Runtime/Domain/Runner/CompressionOptions.cs @@ -7,6 +7,7 @@ public sealed record CompressionOptions public int MaxTotalLines { get; init; } = 500; public bool TeeOnFailure { get; init; } = false; public bool TeeOnTruncation { get; init; } = false; + public bool ShowCompressionMetadata { get; init; } = true; public int SmallOutputThreshold { get; init; } = 50; public static CompressionOptions Default { get; } = new(); diff --git a/tests/Hypa.GoldenTests/Fixtures/config_show_output/stdout.verified.txt b/tests/Hypa.GoldenTests/Fixtures/config_show_output/stdout.verified.txt index c36c4c9..3652cf9 100644 --- a/tests/Hypa.GoldenTests/Fixtures/config_show_output/stdout.verified.txt +++ b/tests/Hypa.GoldenTests/Fixtures/config_show_output/stdout.verified.txt @@ -3,8 +3,9 @@ "storage_path": "/.hypa", "exclude_commands": [], "generic_wrapper_enabled": true, + "show_compression_metadata": true, "log_level": 3, "update_check_enabled": true, "update_channel": "stable", "release_repository": "Hypabolic/Hypa" -} \ No newline at end of file +} diff --git a/tests/Hypa.UnitTests/Application/CommandRunnerServiceTests.cs b/tests/Hypa.UnitTests/Application/CommandRunnerServiceTests.cs index 9e656a2..f77fb3b 100644 --- a/tests/Hypa.UnitTests/Application/CommandRunnerServiceTests.cs +++ b/tests/Hypa.UnitTests/Application/CommandRunnerServiceTests.cs @@ -1,6 +1,7 @@ using Hypa.Runtime.Application.Ports; using Hypa.Runtime.Application.Services; using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Config; using Hypa.Runtime.Domain.Filters; using Hypa.Runtime.Domain.Metrics; using Hypa.Runtime.Domain.Runner; @@ -23,6 +24,7 @@ private static CommandRunnerService MakeService( IArtifactRepository? artifacts = null, IEvidenceLedger? evidence = null, ISessionResolver? resolver = null, + IConfigLoader? configLoader = null, IFilterRepository? filterRepo = null, IFilterEngine? filterEngine = null, IParseMetricsRepository? parseMetrics = null) @@ -33,6 +35,7 @@ private static CommandRunnerService MakeService( artifacts ??= Substitute.For(); evidence ??= Substitute.For(); resolver ??= MakeResolver(); + configLoader ??= MakeConfigLoader(HypaConfig.Default); if (filterRepo is null) { @@ -58,6 +61,7 @@ private static CommandRunnerService MakeService( artifacts, evidence, resolver, + configLoader, filterService, filterEngine, parseMetrics, @@ -116,6 +120,14 @@ private static ISessionResolver MakeResolver() return r; } + private static IConfigLoader MakeConfigLoader(HypaConfig config) + { + var loader = Substitute.For(); + loader.LoadAsync(Arg.Any()) + .Returns(Result.Ok(config)); + return loader; + } + [Fact] public async Task RunBufferedAsync_Success_ReturnsText() { @@ -146,6 +158,30 @@ public async Task RunBufferedAsync_PreservesExitCode() Assert.Equal(42, result.Value.ExitCode); } + [Fact] + public async Task RunBufferedAsync_Timeout_ReturnsDiagnosticAndTimeoutExitCode() + { + var runner = Substitute.For(); + runner.RunAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Ok( + CommandOutput.CreateTimedOut(TimeSpan.FromSeconds(30), "partial output\n"))); + + CommandMetricsRecord? commandMetrics = null; + var evidence = Substitute.For(); + evidence.RecordCommandMetricsAsync(Arg.Do(r => commandMetrics = r), Arg.Any()) + .Returns(Task.CompletedTask); + + var service = MakeService(runner: runner, evidence: evidence); + var result = await service.RunBufferedAsync(FakeInvocation, CompressionOptions.Default, CancellationToken.None); + + Assert.True(result.IsOk); + Assert.Equal(CommandOutput.TimeoutExitCode, result.Value.ExitCode); + Assert.Contains("partial output", result.Value.Text); + Assert.Contains("command timed out after 30s", result.Value.Text); + Assert.NotNull(commandMetrics); + Assert.Equal(CommandOutput.TimeoutExitCode, commandMetrics.ExitCode); + } + [Fact] public async Task RunBufferedAsync_RunnerFail_PropagatesError() { @@ -302,6 +338,23 @@ public async Task RunBufferedAsync_RecordsCompressedTokensAfterFilters() Assert.Contains("100\u21923 tok", result.Value.Text); } + [Fact] + public async Task RunBufferedAsync_ConfigCanHideCompressionMetadata() + { + var runner = Substitute.For(); + runner.RunAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Ok( + CommandOutput.Captured(new string('x', 400), "", 0, TimeSpan.Zero))); + + var configLoader = MakeConfigLoader(HypaConfig.Default with { ShowCompressionMetadata = false }); + var service = MakeService(runner: runner, configLoader: configLoader); + + var result = await service.RunBufferedAsync(FakeInvocation, CompressionOptions.Default, CancellationToken.None); + + Assert.True(result.IsOk); + Assert.DoesNotContain("[hypa:", result.Value.Text); + } + [Fact] public async Task RunBufferedAsync_AppliesSpecificFilterBeforeUniversalFilter() { diff --git a/tests/Hypa.UnitTests/Cli/RunCommandTests.cs b/tests/Hypa.UnitTests/Cli/RunCommandTests.cs new file mode 100644 index 0000000..4a4bee3 --- /dev/null +++ b/tests/Hypa.UnitTests/Cli/RunCommandTests.cs @@ -0,0 +1,125 @@ +using System.CommandLine; +using Hypa.Cli.Commands; +using Hypa.Infrastructure.Rewrite; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Config; +using Hypa.Runtime.Domain.Filters; +using Hypa.Runtime.Domain.Metrics; +using Hypa.Runtime.Domain.Runner; +using Hypa.Runtime.Domain.Sessions; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Cli; + +public sealed class RunCommandTests +{ + [Fact] + public async Task BufferedPackageManagerCommand_UsesLongDefaultTimeout() + { + var (root, runner) = BuildRoot(); + CommandInvocation? invocation = null; + runner.RunAsync(Arg.Do(i => invocation = i), Arg.Any()) + .Returns(Result.Ok( + CommandOutput.Captured("ok", "", 0, TimeSpan.Zero))); + + var exitCode = await root.InvokeAsync(["-c", "pnpm build"]); + + Assert.Equal(0, exitCode); + Assert.NotNull(invocation); + Assert.Equal(TimeSpan.FromMinutes(10), invocation.Timeout); + } + + [Fact] + public async Task BufferedCommand_TimeoutOverrideWins() + { + var (root, runner) = BuildRoot(); + CommandInvocation? invocation = null; + runner.RunAsync(Arg.Do(i => invocation = i), Arg.Any()) + .Returns(Result.Ok( + CommandOutput.Captured("ok", "", 0, TimeSpan.Zero))); + + var exitCode = await root.InvokeAsync(["--timeout-ms", "1234", "-c", "pnpm build"]); + + Assert.Equal(0, exitCode); + Assert.NotNull(invocation); + Assert.Equal(TimeSpan.FromMilliseconds(1234), invocation.Timeout); + } + + [Fact] + public async Task BufferedNonPackageManagerCommand_UsesShortDefaultTimeout() + { + var (root, runner) = BuildRoot(); + CommandInvocation? invocation = null; + runner.RunAsync(Arg.Do(i => invocation = i), Arg.Any()) + .Returns(Result.Ok( + CommandOutput.Captured("ok", "", 0, TimeSpan.Zero))); + + var exitCode = await root.InvokeAsync(["-c", "echo hello"]); + + Assert.Equal(0, exitCode); + Assert.NotNull(invocation); + Assert.Equal(TimeSpan.FromSeconds(30), invocation.Timeout); + } + + [Fact] + public async Task BufferedCommand_InvalidTimeoutReturnsError() + { + var (root, runner) = BuildRoot(); + + var exitCode = await root.InvokeAsync(["--timeout-ms", "0", "-c", "echo hello"]); + + Assert.Equal(1, exitCode); + await runner.DidNotReceive().RunAsync(Arg.Any(), Arg.Any()); + } + + private static (RootCommand Root, ICommandRunner Runner) BuildRoot() + { + var runner = Substitute.For(); + var root = new RootCommand(); + var command = new RunCommand(MakeService(runner), new ShellLexer()); + command.AttachTo(root); + return (root, runner); + } + + private static CommandRunnerService MakeService(ICommandRunner runner) + { + var compressor = Substitute.For(); + var tokenCounter = Substitute.For(); + var artifacts = Substitute.For(); + var evidence = Substitute.For(); + var resolver = Substitute.For(); + var configLoader = Substitute.For(); + var filterRepo = Substitute.For(); + var filterEngine = Substitute.For(); + var parseMetrics = Substitute.For(); + + tokenCounter.EstimateTokens(Arg.Any()).Returns(ci => ci.ArgAt(0).Length); + var session = new ContextSession { Id = Guid.NewGuid(), ProjectRoot = "/tmp" }; + resolver.ResolveAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Ok(session)); + configLoader.LoadAsync(Arg.Any()) + .Returns(Result.Ok(HypaConfig.Default)); + filterRepo.GetAll().Returns([]); + filterEngine.Apply(Arg.Any(), Arg.Any()) + .Returns(ci => new FilterResult(ci.ArgAt(1), "none", 0)); + parseMetrics.RecordAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + return new CommandRunnerService( + runner, + [compressor], + tokenCounter, + artifacts, + evidence, + resolver, + configLoader, + new FilterService(filterRepo, filterEngine), + filterEngine, + parseMetrics, + NullLogger.Instance); + } +} diff --git a/tests/Hypa.UnitTests/Domain/HypaConfigTests.cs b/tests/Hypa.UnitTests/Domain/HypaConfigTests.cs index aed5a7b..ca3337c 100644 --- a/tests/Hypa.UnitTests/Domain/HypaConfigTests.cs +++ b/tests/Hypa.UnitTests/Domain/HypaConfigTests.cs @@ -30,6 +30,12 @@ public void Default_LogLevel_Warning() Assert.Equal(LogLevel.Warning, HypaConfig.Default.LogLevel); } + [Fact] + public void Default_ShowCompressionMetadata_True() + { + Assert.True(HypaConfig.Default.ShowCompressionMetadata); + } + [Fact] public void RecordEquality_SameValues_Equal() { diff --git a/tests/Hypa.UnitTests/Infrastructure/JsonConfigLoaderTests.cs b/tests/Hypa.UnitTests/Infrastructure/JsonConfigLoaderTests.cs index d90b9af..7564b42 100644 --- a/tests/Hypa.UnitTests/Infrastructure/JsonConfigLoaderTests.cs +++ b/tests/Hypa.UnitTests/Infrastructure/JsonConfigLoaderTests.cs @@ -140,6 +140,18 @@ public async Task LoadAsync_ReleaseRepository_BindsCustomRepo() Assert.Equal("my-org/my-fork", result.Value.ReleaseRepository); } + [Fact] + public async Task LoadAsync_ShowCompressionMetadata_BindsFalse() + { + WriteJson(Path.Combine(_tempDir, "config.json"), new { show_compression_metadata = false }); + + var loader = new JsonConfigLoader(_noRootDetector, _tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + Assert.False(result.Value.ShowCompressionMetadata); + } + [Fact] public async Task LoadAsync_NoFiles_UpdateDefaults_AreCorrect() { diff --git a/tests/Hypa.UnitTests/Infrastructure/Mcp/ContentLengthTransportTests.cs b/tests/Hypa.UnitTests/Infrastructure/Mcp/ContentLengthTransportTests.cs deleted file mode 100644 index 32e9e59..0000000 --- a/tests/Hypa.UnitTests/Infrastructure/Mcp/ContentLengthTransportTests.cs +++ /dev/null @@ -1,200 +0,0 @@ -using System.Text; -using Hypa.Infrastructure.Mcp.Transport; -using Xunit; - -namespace Hypa.UnitTests.Infrastructure.Mcp; - -public sealed class ContentLengthTransportTests -{ - // ── ContentLengthInputStream ──────────────────────────────────────────── - - private static Stream MakeInput(string raw) => - new MemoryStream(Encoding.UTF8.GetBytes(raw)); - - private static async Task ReadLineAsync(ContentLengthInputStream stream) - { - using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); - return (await reader.ReadLineAsync())!; - } - - [Fact] - public async Task Input_SingleFrame_ReturnsPayload() - { - const string payload = """{"jsonrpc":"2.0","id":1,"method":"ping"}"""; - var frame = $"Content-Length: {Encoding.UTF8.GetByteCount(payload)}\r\n\r\n{payload}"; - using var stream = new ContentLengthInputStream(MakeInput(frame)); - - var line = await ReadLineAsync(stream); - Assert.Equal(payload, line); - } - - [Fact] - public async Task Input_MultipleFrames_ReturnsAllPayloads() - { - const string p1 = """{"id":1}"""; - const string p2 = """{"id":2}"""; - var raw = $"Content-Length: {p1.Length}\r\n\r\n{p1}Content-Length: {p2.Length}\r\n\r\n{p2}"; - using var stream = new ContentLengthInputStream(MakeInput(raw)); - - var line1 = await ReadLineAsync(stream); - var line2 = await ReadLineAsync(stream); - Assert.Equal(p1, line1); - Assert.Equal(p2, line2); - } - - [Fact] - public async Task Input_ExtraHeadersIgnored_ReturnsPayload() - { - const string payload = """{"method":"ping"}"""; - var frame = $"Content-Type: application/json\r\nContent-Length: {payload.Length}\r\n\r\n{payload}"; - using var stream = new ContentLengthInputStream(MakeInput(frame)); - - var line = await ReadLineAsync(stream); - Assert.Equal(payload, line); - } - - [Fact] - public async Task Input_ZeroContentLength_ReturnsEmptyLine() - { - const string frame = "Content-Length: 0\r\n\r\n"; - using var stream = new ContentLengthInputStream(MakeInput(frame)); - - // Should produce an empty line (or EOF) rather than hanging/throwing - var buf = new byte[16]; - var read = await stream.ReadAsync(buf.AsMemory()); - // Empty payload + '\n' = 1 byte - Assert.True(read is 0 or 1); - } - - [Fact] - public async Task Input_NoContentLengthHeader_ReturnsEmpty() - { - // No Content-Length header — body is 0 bytes, produces empty line - const string frame = "Content-Type: application/json\r\n\r\nignored_body"; - using var stream = new ContentLengthInputStream(MakeInput(frame)); - - var buf = new byte[256]; - var read = await stream.ReadAsync(buf.AsMemory()); - Assert.True(read is 0 or 1); // '\n' from empty payload - } - - [Fact] - public async Task Input_EofMidFrame_DoesNotThrow() - { - // Truncated frame — should not throw, returns what was read - const string frame = "Content-Length: 100\r\n\r\n{partial"; - using var stream = new ContentLengthInputStream(MakeInput(frame)); - - var buf = new byte[256]; - var ex = await Record.ExceptionAsync(() => stream.ReadAsync(buf.AsMemory()).AsTask()); - Assert.Null(ex); - } - - [Fact] - public async Task Input_EmptyStream_ReturnsZero() - { - using var stream = new ContentLengthInputStream(new MemoryStream()); - var buf = new byte[16]; - var read = await stream.ReadAsync(buf.AsMemory()); - Assert.Equal(0, read); - } - - // ── ContentLengthOutputStream ─────────────────────────────────────────── - - [Fact] - public async Task Output_SingleMessage_WritesContentLengthFrame() - { - using var sink = new MemoryStream(); - await using var stream = new ContentLengthOutputStream(sink); - - var payload = Encoding.UTF8.GetBytes("""{"id":1}""" + "\n"); - await stream.WriteAsync(payload.AsMemory()); - await stream.FlushAsync(); - - var result = Encoding.ASCII.GetString(sink.ToArray()); - Assert.StartsWith("Content-Length:", result, StringComparison.Ordinal); - Assert.Contains("\r\n\r\n", result); - Assert.Contains("""{"id":1}""", result); - } - - [Fact] - public async Task Output_FrameLength_MatchesPayloadBytes() - { - using var sink = new MemoryStream(); - await using var stream = new ContentLengthOutputStream(sink); - - const string payload = """{"jsonrpc":"2.0","result":null}"""; - await stream.WriteAsync(Encoding.UTF8.GetBytes(payload + "\n").AsMemory()); - await stream.FlushAsync(); - - var raw = Encoding.ASCII.GetString(sink.ToArray()); - var headerLine = raw.Split("\r\n")[0]; - var clStr = headerLine["Content-Length:".Length..].Trim(); - Assert.True(int.TryParse(clStr, out var cl)); - Assert.Equal(Encoding.UTF8.GetByteCount(payload), cl); - } - - [Fact] - public async Task Output_MultipleMessages_ProducesMultipleFrames() - { - using var sink = new MemoryStream(); - await using var stream = new ContentLengthOutputStream(sink); - - await stream.WriteAsync(Encoding.UTF8.GetBytes("{\"id\":1}\n").AsMemory()); - await stream.WriteAsync(Encoding.UTF8.GetBytes("{\"id\":2}\n").AsMemory()); - await stream.FlushAsync(); - - var raw = Encoding.ASCII.GetString(sink.ToArray()); - Assert.Equal(2, CountOccurrences(raw, "Content-Length:")); - } - - [Fact] - public async Task Output_PartialWritesThenNewline_ProducesOneFrame() - { - using var sink = new MemoryStream(); - await using var stream = new ContentLengthOutputStream(sink); - - // SDK may write JSON in chunks followed by a separate '\n' write - await stream.WriteAsync(Encoding.UTF8.GetBytes("{\"id\":").AsMemory()); - await stream.WriteAsync(Encoding.UTF8.GetBytes("42}").AsMemory()); - await stream.WriteAsync(new byte[] { (byte)'\n' }.AsMemory()); - await stream.FlushAsync(); - - var raw = Encoding.ASCII.GetString(sink.ToArray()); - Assert.Equal(1, CountOccurrences(raw, "Content-Length:")); - Assert.Contains("{\"id\":42}", raw); - } - - // ── Round-trip ────────────────────────────────────────────────────────── - - [Fact] - public async Task RoundTrip_EncodeDecodeRestoresPayload() - { - const string original = """{"jsonrpc":"2.0","id":42,"method":"tools/list"}"""; - - // Encode via output stream - using var wire = new MemoryStream(); - await using var output = new ContentLengthOutputStream(wire); - await output.WriteAsync(Encoding.UTF8.GetBytes(original + "\n").AsMemory()); - await output.FlushAsync(); - - // Decode via input stream - wire.Position = 0; - using var input = new ContentLengthInputStream(wire); - var decoded = await ReadLineAsync(input); - - Assert.Equal(original, decoded); - } - - private static int CountOccurrences(string text, string pattern) - { - var count = 0; - var idx = 0; - while ((idx = text.IndexOf(pattern, idx, StringComparison.Ordinal)) >= 0) - { - count++; - idx += pattern.Length; - } - return count; - } -} diff --git a/tests/Hypa.UnitTests/Infrastructure/ProcessCommandRunnerTests.cs b/tests/Hypa.UnitTests/Infrastructure/ProcessCommandRunnerTests.cs index b967e99..301fcc2 100644 --- a/tests/Hypa.UnitTests/Infrastructure/ProcessCommandRunnerTests.cs +++ b/tests/Hypa.UnitTests/Infrastructure/ProcessCommandRunnerTests.cs @@ -22,6 +22,8 @@ private static CommandInvocation PassthroughShell(string command) => private static string CreateStderrEchoCommand() => IsWindows ? "echo err 1>&2" : "echo err >&2"; private static string CreateExitCommand(int code) => IsWindows ? $"exit /b {code}" : $"exit {code}"; private static string CreateSleepCommand() => IsWindows ? "ping -n 11 127.0.0.1 >NUL" : "sleep 10"; + private static string CreateEchoThenSleepCommand() => + IsWindows ? "echo before-timeout && ping -n 11 127.0.0.1 >NUL" : "echo before-timeout; sleep 10"; [Fact] public async Task RunAsync_Buffered_CapturesStdout() @@ -74,6 +76,27 @@ public async Task RunAsync_Timeout_ReturnsTimedOut() var result = await _runner.RunAsync(inv, CancellationToken.None); Assert.True(result.IsOk); Assert.True(result.Value.WasTimedOut); + Assert.Equal(CommandOutput.TimeoutExitCode, result.Value.ExitCode); + } + + [Fact] + public async Task RunAsync_BufferedTimeout_PreservesCapturedOutput() + { + var command = CreateEchoThenSleepCommand(); + var inv = new CommandInvocation + { + Executable = IsWindows ? "cmd" : "sh", + Arguments = IsWindows ? ["/c", command] : ["-c", command], + OriginalCommand = command, + Mode = ToolRunMode.Buffered, + Timeout = TimeSpan.FromMilliseconds(200), + }; + + var result = await _runner.RunAsync(inv, CancellationToken.None); + + Assert.True(result.IsOk); + Assert.True(result.Value.WasTimedOut); + Assert.Contains("before-timeout", result.Value.Stdout); } [Fact] From bd2adb5f9ac5919350f4130c7fb0d6ce056cca8e Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Thu, 21 May 2026 16:49:33 +1000 Subject: [PATCH 03/10] Develop (#6) * added pip and homebrew release workflows * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Matthew Gribben * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Matthew Gribben * added gating to the release workflow * Add CI gate, concurrency control, and REPO_TOKEN for homebrew tap - Gate job verifies all CI checks passed on the tagged commit before any build or publish job runs; skipped for workflow_dispatch - Concurrency group prevents parallel release runs for the same ref - publish-homebrew now uses secrets.REPO_TOKEN for cross-repo push to homebrew-tap Co-Authored-By: Claude Sonnet 4.6 * Sync public source from PR #29 This pull request adds significant architectural changes as well as a GitHub Actions workflow for syncing source and test files to a public repository. **Architectural Decisions:** * Establishes a package boundary between provider-neutral structural search contracts (`Hypa.Sdk`) and the Tree-sitter-based engine implementation (`Hypa.CodePatterns`). This separation enables downstream consumers to use structural search results without taking a dependency on native parsing libraries, and allows for future NuGet publication of the engine. * Details the sequencing and rationale for three major 2026 features: MCP Proxy Layer, code grep (structural search/scan/rewrite), and Code Mode Scripting Engine. We plan phased delivery, risk mitigation, and integration points to maximize compound value and minimize technical risk. **Timeouts** * Some commands would hit the hypa internal timeout limit, mainly package manager, so we've introduced pipeline specific limits and an override command to allow the agent to specify a timeout plus more intelligent feedback when there is an error. **Automation and Workflow:** * Introduced a GitHub Actions workflow (`.github/workflows/sync-public.yml`) that automatically syncs the `src` and `tests` directories to the public `Hypabolic/Hypa` repository on pushes to `main`. The workflow includes PR metadata extraction, payload creation, public repo checkout, file replacement, and commit/push logic with descriptive commit messages. Source repo: matt-gribben/Hypa Source SHA: 25f836cb7f71a4f63dc3423d5a971bf57c713083 PR URL: https://github.com/matt-gribben/Hypa/pull/29 * feat: simplify commit message generation for public repo sync workflow action update --------- Signed-off-by: Matthew Gribben --- .../Updates/GitHubReleasesUpdateChecker.cs | 7 ++++--- .../Updates/InstallMetadataStore.cs | 21 ++++++++++++------- .../Updates/ScriptInstallUpdateStrategy.cs | 10 ++++----- tests/Hypa.GoldenTests/GoldenTestRunner.cs | 6 +++++- .../Hooks/BinaryRemoverTests.cs | 14 ++++++++++++- .../Infrastructure/Hooks/CodexAdapterTests.cs | 2 +- .../Hooks/HookInstallerTests.cs | 3 ++- .../Updates/ScriptInstallStrategyTests.cs | 6 ++++++ 8 files changed, 49 insertions(+), 20 deletions(-) diff --git a/src/Hypa.Infrastructure/Updates/GitHubReleasesUpdateChecker.cs b/src/Hypa.Infrastructure/Updates/GitHubReleasesUpdateChecker.cs index 0fd9eb9..e622ae6 100644 --- a/src/Hypa.Infrastructure/Updates/GitHubReleasesUpdateChecker.cs +++ b/src/Hypa.Infrastructure/Updates/GitHubReleasesUpdateChecker.cs @@ -64,9 +64,10 @@ public sealed class GitHubReleasesUpdateChecker(IHttpClientFactory httpClientFac if (release is null) return Result.Fail(new Error("UpdateCheck.ParseError", "Empty release response.")); - var assetName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? $"hypa-{runtimeIdentifier}.zip" - : $"hypa-{runtimeIdentifier}.tar.gz"; + var assetExt = runtimeIdentifier.StartsWith("win", StringComparison.OrdinalIgnoreCase) + ? "zip" + : "tar.gz"; + var assetName = $"hypa-{runtimeIdentifier}.{assetExt}"; var asset = release.Assets.FirstOrDefault(a => a.Name == assetName); var checksums = release.Assets.FirstOrDefault(a => a.Name == "SHA256SUMS"); diff --git a/src/Hypa.Infrastructure/Updates/InstallMetadataStore.cs b/src/Hypa.Infrastructure/Updates/InstallMetadataStore.cs index b3cd4bb..8b827f3 100644 --- a/src/Hypa.Infrastructure/Updates/InstallMetadataStore.cs +++ b/src/Hypa.Infrastructure/Updates/InstallMetadataStore.cs @@ -122,18 +122,23 @@ internal static string DetectSource( if (processPath.Contains("scoop/apps/hypa", StringComparison.OrdinalIgnoreCase)) return "scoop"; + // Normalize to forward slashes so comparisons work regardless of which OS the + // code is running on (tests may pass Unix-style paths on Windows or vice-versa). + var normalizedPath = processPath.Replace('\\', '/'); + var normalizedHome = home.Replace('\\', '/').TrimEnd('/'); + var normalizedLocalAppData = localAppData.Replace('\\', '/').TrimEnd('/'); + if (isWindows) { - var winScriptDir = Path.Combine(localAppData, "Hypa", "bin"); - var winScriptDirWithSep = winScriptDir.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; - if (processPath.StartsWith(winScriptDirWithSep, StringComparison.OrdinalIgnoreCase)) + var winScriptDirWithSep = normalizedLocalAppData + "/Hypa/bin/"; + if (normalizedPath.StartsWith(winScriptDirWithSep, StringComparison.OrdinalIgnoreCase)) return "script"; } else { - var stableDir = Path.Combine(home, ".local", "share", "hypa"); - var stableDirWithSep = stableDir + Path.DirectorySeparatorChar; - if (processPath.StartsWith(stableDirWithSep, StringComparison.Ordinal)) + var stableDir = normalizedHome + "/.local/share/hypa"; + var stableDirWithSep = stableDir + "/"; + if (normalizedPath.StartsWith(stableDirWithSep, StringComparison.Ordinal)) return "script"; // For versioned installs the stable dir is a symlink to the real versioned dir. // Resolve it so we only accept paths inside the actual symlink target, not any @@ -141,8 +146,8 @@ internal static string DetectSource( var resolvedTarget = tryResolveSymlink(stableDir); if (resolvedTarget is not null) { - var resolvedWithSep = resolvedTarget.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; - if (processPath.StartsWith(resolvedWithSep, StringComparison.Ordinal)) + var resolvedWithSep = resolvedTarget.Replace('\\', '/').TrimEnd('/') + "/"; + if (normalizedPath.StartsWith(resolvedWithSep, StringComparison.Ordinal)) return "script"; } } diff --git a/src/Hypa.Infrastructure/Updates/ScriptInstallUpdateStrategy.cs b/src/Hypa.Infrastructure/Updates/ScriptInstallUpdateStrategy.cs index a021bb2..72a940e 100644 --- a/src/Hypa.Infrastructure/Updates/ScriptInstallUpdateStrategy.cs +++ b/src/Hypa.Infrastructure/Updates/ScriptInstallUpdateStrategy.cs @@ -48,11 +48,6 @@ public Task> PlanAsync(UpdateInfo update, InstallMetad public async Task> ApplyAsync(UpdateInfo update, InstallMetadata metadata, CancellationToken ct) { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return Result.Fail(new Error( - "Update.WindowsNotSupported", - "Windows self-update is not yet supported. Re-run install.ps1 to upgrade.")); - if (!ValidatePreconditions(update, metadata, out var err)) return Result.Fail(err); @@ -69,6 +64,11 @@ public async Task> ApplyAsync(UpdateInfo update, InstallMeta "Update.PathMismatch", $"Executable '{execPath}' is not inside install directory '{installDir}'.")); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return Result.Fail(new Error( + "Update.WindowsNotSupported", + "Windows self-update is not yet supported. Re-run install.ps1 to upgrade.")); + var tempDir = Path.Combine(Path.GetTempPath(), $"hypa-update-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); diff --git a/tests/Hypa.GoldenTests/GoldenTestRunner.cs b/tests/Hypa.GoldenTests/GoldenTestRunner.cs index a9dc1b3..bef06c8 100644 --- a/tests/Hypa.GoldenTests/GoldenTestRunner.cs +++ b/tests/Hypa.GoldenTests/GoldenTestRunner.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Linq; using System.Reflection; using System.Text; using System.Text.Json; @@ -175,6 +176,9 @@ private static string Normalize(string raw) // Update check result varies by network state and current version; normalize the entire line // plus any optional hint detail line that follows it. text = UpdateStatusPattern().Replace(text, "[ ok] Update "); + // Strip trailing whitespace from each line — shells (especially CMD on Windows) + // sometimes append trailing spaces that are semantically meaningless. + text = string.Join("\n", text.Split('\n').Select(l => l.TrimEnd())); return text.TrimEnd(); } @@ -413,7 +417,7 @@ private static string AdjustArgsForPlatform(string fixtureName, string args) "run_t_echo" => "-t cmd /c echo hello", "run_raw_echo" => "raw cmd /c echo hello", "run_c_exit_code" => "-c \"pwsh -NoProfile -Command 'exit 42'\"", - "run_c_pipe" => "-c \"(echo b & echo a) | sort\"", + "run_c_pipe" => "-c \"(echo b&echo a) | sort\"", _ => args, }; } diff --git a/tests/Hypa.UnitTests/Infrastructure/Hooks/BinaryRemoverTests.cs b/tests/Hypa.UnitTests/Infrastructure/Hooks/BinaryRemoverTests.cs index c1d936a..baccd7e 100644 --- a/tests/Hypa.UnitTests/Infrastructure/Hooks/BinaryRemoverTests.cs +++ b/tests/Hypa.UnitTests/Infrastructure/Hooks/BinaryRemoverTests.cs @@ -75,6 +75,8 @@ public async Task RemoveAsync_DryRun_NothingPresent_ReturnsFalse() [Fact] public async Task RemoveAsync_WrongProcessPath_ReturnsErrorWithManualInstructions() { + if (OperatingSystem.IsWindows()) return; // process-path guard is Unix-only; Windows uses deferred cmd script + var symlinkPath = Path.Combine(_tempDir, "hypa"); var installDir = Path.Combine(_tempDir, "share", "hypa"); Directory.CreateDirectory(installDir); @@ -90,6 +92,8 @@ public async Task RemoveAsync_WrongProcessPath_ReturnsErrorWithManualInstruction [Fact] public async Task RemoveAsync_ProcessPathWithSamePrefixButDifferentDir_ReturnsError() { + if (OperatingSystem.IsWindows()) return; // process-path guard is Unix-only + var symlinkPath = Path.Combine(_tempDir, "hypa"); var installDir = Path.Combine(_tempDir, "share", "hypa"); Directory.CreateDirectory(installDir); @@ -106,6 +110,8 @@ public async Task RemoveAsync_ProcessPathWithSamePrefixButDifferentDir_ReturnsEr [Fact] public async Task RemoveAsync_ProcessPathMatchesSymlink_Removes() { + if (OperatingSystem.IsWindows()) return; // Unix removal is synchronous; Windows uses deferred cmd script + var symlinkPath = Path.Combine(_tempDir, "hypa"); var installDir = Path.Combine(_tempDir, "share", "hypa"); Directory.CreateDirectory(installDir); @@ -123,6 +129,8 @@ public async Task RemoveAsync_ProcessPathMatchesSymlink_Removes() [Fact] public async Task RemoveAsync_ProcessPathInsideInstallDir_Removes() { + if (OperatingSystem.IsWindows()) return; // Unix removal is synchronous; Windows uses deferred cmd script + var symlinkPath = Path.Combine(_tempDir, "hypa"); var installDir = Path.Combine(_tempDir, "share", "hypa"); Directory.CreateDirectory(installDir); @@ -142,6 +150,8 @@ public async Task RemoveAsync_ProcessPathInsideInstallDir_Removes() [Fact] public async Task RemoveAsync_ActualRemoval_DeletesSymlinkAndInstallDir() { + if (OperatingSystem.IsWindows()) return; // Unix removal is synchronous; Windows uses deferred cmd script + var symlinkPath = Path.Combine(_tempDir, "hypa"); var installDir = Path.Combine(_tempDir, "share", "hypa"); Directory.CreateDirectory(installDir); @@ -159,6 +169,8 @@ public async Task RemoveAsync_ActualRemoval_DeletesSymlinkAndInstallDir() [Fact] public async Task RemoveAsync_ActualRemoval_OnlyInstallDir_DeletesDir() { + if (OperatingSystem.IsWindows()) return; // Windows uses deferred cmd script; deletion not immediate + var symlinkPath = Path.Combine(_tempDir, "hypa"); var installDir = Path.Combine(_tempDir, "share", "hypa"); Directory.CreateDirectory(installDir); @@ -198,7 +210,7 @@ public async Task RemoveAsync_VersionedLayout_ProcessPathInVersionedDir_IsRecogn [Fact] public async Task RemoveAsync_VersionedLayout_ActualRemoval_DeletesBothSymlinkAndVersionedDir() { - if (!CanCreateDirectorySymlinks()) return; + if (OperatingSystem.IsWindows() || !CanCreateDirectorySymlinks()) return; // deferred deletion on Windows var symlinkPath = Path.Combine(_tempDir, "hypa"); var versionedDir = Path.Combine(_tempDir, "share", "hypa-abc123"); diff --git a/tests/Hypa.UnitTests/Infrastructure/Hooks/CodexAdapterTests.cs b/tests/Hypa.UnitTests/Infrastructure/Hooks/CodexAdapterTests.cs index 1df7607..af38f73 100644 --- a/tests/Hypa.UnitTests/Infrastructure/Hooks/CodexAdapterTests.cs +++ b/tests/Hypa.UnitTests/Infrastructure/Hooks/CodexAdapterTests.cs @@ -225,7 +225,7 @@ public void GetInstallPlan_Global_TargetsCodexHome() Assert.DoesNotContain(plan.Operations, op => op is InstallOperation.NotSupported); Assert.Contains(plan.Operations, op => op is InstallOperation.PatchJsonHook hook && hook.FilePath == Path.Combine(codexHome, "hooks.json")); - Assert.Contains(plan.Operations, op => op is InstallOperation.InjectLine inject && inject.Line.Contains("@/")); + Assert.Contains(plan.Operations, op => op is InstallOperation.InjectLine inject && inject.Line.StartsWith("@", StringComparison.Ordinal) && !inject.Line.Equals("@HYPA.md", StringComparison.Ordinal)); } [Fact] diff --git a/tests/Hypa.UnitTests/Infrastructure/Hooks/HookInstallerTests.cs b/tests/Hypa.UnitTests/Infrastructure/Hooks/HookInstallerTests.cs index 9bb6c9a..48e9083 100644 --- a/tests/Hypa.UnitTests/Infrastructure/Hooks/HookInstallerTests.cs +++ b/tests/Hypa.UnitTests/Infrastructure/Hooks/HookInstallerTests.cs @@ -345,7 +345,8 @@ await File.WriteAllTextAsync(tomlPath, await _installer.InstallAsync(plan, "codex", dryRun: false); var content = await File.ReadAllTextAsync(tomlPath); - Assert.Contains(" \"/repo\",\n \"/home/me/.hypa\"", content); + var normalized = content.Replace("\r\n", "\n", StringComparison.Ordinal); + Assert.Contains(" \"/repo\",\n \"/home/me/.hypa\"", normalized); } [Fact] diff --git a/tests/Hypa.UnitTests/Infrastructure/Updates/ScriptInstallStrategyTests.cs b/tests/Hypa.UnitTests/Infrastructure/Updates/ScriptInstallStrategyTests.cs index 2ead786..248f266 100644 --- a/tests/Hypa.UnitTests/Infrastructure/Updates/ScriptInstallStrategyTests.cs +++ b/tests/Hypa.UnitTests/Infrastructure/Updates/ScriptInstallStrategyTests.cs @@ -204,6 +204,8 @@ private static InstallMetadata MakeMetadata(string source, [Fact] public async Task ApplyAsync_Success_PromotesFilesAndRemovesStaleFile() { + if (OperatingSystem.IsWindows()) return; // Unix-only promotion flow; Windows returns WindowsNotSupported + // Real install dir containing a stale file that is NOT in the new archive. var installDir = Path.Combine(_tempDir, "install"); Directory.CreateDirectory(installDir); @@ -245,6 +247,8 @@ await _metadataStore.Received(1).SaveAsync( [Fact] public async Task ApplyAsync_SymlinkInstall_RenameFailsAfterUnlink_RestoresOldSymlink() { + if (OperatingSystem.IsWindows()) return; // symlinks require elevated privileges on Windows CI + // Set up symlink-based install. var oldVersionedDir = Path.Combine(_tempDir, "hypa-oldguid"); Directory.CreateDirectory(oldVersionedDir); @@ -291,6 +295,8 @@ public async Task ApplyAsync_SymlinkInstall_RenameFailsAfterUnlink_RestoresOldSy [Fact] public async Task ApplyAsync_SymlinkInstall_SwapsSymlinkAndRemovesOldVersionedDir() { + if (OperatingSystem.IsWindows()) return; // symlinks require elevated privileges on Windows CI + // Set up a symlink-based install (modern format from install.sh). var oldVersionedDir = Path.Combine(_tempDir, "hypa-oldguid"); Directory.CreateDirectory(oldVersionedDir); From 3132e58efbfaf9c85d62420a2e2313c5fe0111db Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Sun, 31 May 2026 19:20:00 +1000 Subject: [PATCH 04/10] Release: MCP Proxy, Browser OAuth, Markdown Mode, and Native Grammar Bundling (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added pip and homebrew release workflows * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Matthew Gribben * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Matthew Gribben * added gating to the release workflow * Add CI gate, concurrency control, and REPO_TOKEN for homebrew tap - Gate job verifies all CI checks passed on the tagged commit before any build or publish job runs; skipped for workflow_dispatch - Concurrency group prevents parallel release runs for the same ref - publish-homebrew now uses secrets.REPO_TOKEN for cross-repo push to homebrew-tap Co-Authored-By: Claude Sonnet 4.6 * Sync public source from PR #29 This pull request adds significant architectural changes as well as a GitHub Actions workflow for syncing source and test files to a public repository. **Architectural Decisions:** * Establishes a package boundary between provider-neutral structural search contracts (`Hypa.Sdk`) and the Tree-sitter-based engine implementation (`Hypa.CodePatterns`). This separation enables downstream consumers to use structural search results without taking a dependency on native parsing libraries, and allows for future NuGet publication of the engine. * Details the sequencing and rationale for three major 2026 features: MCP Proxy Layer, code grep (structural search/scan/rewrite), and Code Mode Scripting Engine. We plan phased delivery, risk mitigation, and integration points to maximize compound value and minimize technical risk. **Timeouts** * Some commands would hit the hypa internal timeout limit, mainly package manager, so we've introduced pipeline specific limits and an override command to allow the agent to specify a timeout plus more intelligent feedback when there is an error. **Automation and Workflow:** * Introduced a GitHub Actions workflow (`.github/workflows/sync-public.yml`) that automatically syncs the `src` and `tests` directories to the public `Hypabolic/Hypa` repository on pushes to `main`. The workflow includes PR metadata extraction, payload creation, public repo checkout, file replacement, and commit/push logic with descriptive commit messages. Source repo: matt-gribben/Hypa Source SHA: 25f836cb7f71a4f63dc3423d5a971bf57c713083 PR URL: https://github.com/matt-gribben/Hypa/pull/29 * feat: simplify commit message generation for public repo sync workflow action update * Enhance MCP server configuration, validation, and connection handling This pull request introduces several documentation updates and new documents to clarify workflows, address architectural issues, and plan for future features. The most significant changes include the addition of an ADR for GitHub Copilot Extension integration, detailed documentation for local preview installs, a bug report on `hypa_shell` error handling, and an architecture review highlighting key technical debt and recommendations. There are also minor roadmap clarifications and new usage/setup instructions for Hypa CLI tools. **Key changes:** **1. GitHub Copilot Extension Integration** - Added ADR 0011 describing the design and implementation plan for a thin Node.js ESM adapter to integrate Hypa tools and context with GitHub Copilot CLI sessions, including command rewriting and session lifecycle hooks. The ADR details the rationale, consequences, implementation strategy, and alternatives considered. **2. Local Preview Installs Documentation** - Added a new document and updated the `README.md` to describe the workflow for installing local preview builds of Hypa. This includes commands for installing, restoring, managing, and cleaning preview builds, as well as the effect on update behavior. [[1]](diffhunk://#diff-51a886bc8914e40ea5afcc9459caae38f5fa33b3e6bd25b5fc2e5cbe05f9af5aR1-R38) [[2]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R115-R153) **3. Bug Report: `hypa_shell` Error Handling** - Added a detailed bug report documenting how `hypa_shell` misreports missing executables as working directory errors, including root cause analysis, impact, suggested fixes, and regression test cases. **4. Architecture Review and Technical Debt** - Added an architecture review document identifying several high and medium priority issues, such as dependency rule violations, improper use of application ports, unimplemented ADRs, CLI orchestration bloat, adapter boundary drift, and improper unit test practices. Each issue includes recommendations for resolution. **5. Roadmap and Usage Documentation Updates** - Updated the strategic roadmap to remove week estimates and clarify the sequencing and rationale for technical spikes and MCP proxy layer work. [[1]](diffhunk://#diff-5e4d86614a4a31c4b4fef6106f6de445905aac468f94d0cbd512f57d825d1690L47-R55) [[2]](diffhunk://#diff-5e4d86614a4a31c4b4fef6106f6de445905aac468f94d0cbd512f57d825d1690L77-R77) [[3]](diffhunk://#diff-5e4d86614a4a31c4b4fef6106f6de445905aac468f94d0cbd512f57d825d1690L108-R112) [[4]](diffhunk://#diff-5e4d86614a4a31c4b4fef6106f6de445905aac468f94d0cbd512f57d825d1690R232-R237) - Added a "Hypa Rules" section to `CLAUDE.md` with best practices for using Hypa CLI wrappers and initial setup instructions. * Release: MCP Proxy, Browser OAuth, and Markdown Mode ## Summary Three features developed across this release cycle, plus post-integration fixes and hardening discovered during live testing. ### MCP Proxy Layer A full MCP proxy implementation that lets Hypa act as a man-in-the-middle between agents and external MCP servers — forwarding tool calls, compressing responses, and exposing a unified tool search index. - `McpProxyService` with `DirectMcpDispatcher` for request routing and response compression - `McpTransportBuilder` for transport creation; `McpClientConnectionFactory` refactored to use it - MCP import with bearer token authentication and error handling - `McpServerProbeAdapter` — probes remote servers on `mcp add` to validate connectivity and detect auth requirements; `--no-probe` flag to skip - Tool search index for fast tool lookup across registered servers - Credential resolution with secret redaction in test output ### Browser OAuth Onboarding Automated OAuth flow for MCP servers that require browser-based authentication. - `OAuth2BrowserConfig` and interactive/non-interactive auth modes in `McpTransportBuilder` - Browser launch + local callback listener for authorization code exchange - DCR secret storage, TLS options, state validation, and manual paste fallback for restricted environments - Auth defaults to `none` on `mcp add`; guided setup triggered on probe auth failure ### Markdown Mode Structured Markdown indexing and querying via tree-sitter, with a freshness-aware query gate and read hook compression. - `MarkdownStructureProvider` and `CodePatternExtractor` — extracts sections (heading text, level, anchor, byte spans, plain text) and frontmatter YAML via tree-sitter - `hypa md ` subcommand with `--toc`, `--section`, `--frontmatter`, and `--json` flags - Git-aware incremental indexer (`IndexIncrementalAsync`): clean tracked files compared by blob OID (no file I/O), dirty/untracked files fall back to mtime+size — only stale files are re-parsed - `EnsureFreshAsync` gate on `hypa md` — auto-indexes on first use, re-indexes on change, no-op when fresh - `ReadRedirector` extended to compress large `.md` files to a heading outline; `CLAUDE.md` / `SKILL.md` pass through unchanged - `hypa code index --full` flag for forced full rebuild ### Post-integration fixes (found during live testing) - **`IndexFullAsync` OID regression**: full rebuilds stored `git_blob_oid = NULL`, causing the next incremental run to re-index every file. Fixed by calling `GetCleanBlobOidsAsync` in `IndexFullAsync` and persisting the OID alongside mtime. - **`libtree-sitter-markdown.so` not bundled**: `TreeSitter.DotNet` 1.3.0 doesn't ship the markdown grammar. Fixed by building from source and bundling via `Directory.Build.targets` (RID-aware, copies to output/publish root on all platforms). - **Provider conflict**: once the native library loaded, both `TreeSitterCodeStructureProvider` and `MarkdownStructureProvider` claimed `CanHandle("markdown")`, causing the generic provider to win and extract C# symbols from markdown files. Fixed with an explicit exclusion in `TreeSitterCodeStructureProvider.CanHandle`. - **CI hardening**: grammar build script runs on all matrix runners before the .NET build; AOT publish job verifies the symbol export and asserts `markdown: ok` in provider health after indexing. - **Missing test coverage**: added `MarkdownStructureProviderIntegrationTests` (exercises real tree-sitter native library, asserts sections and provenance) and `CodeStructureProviderRegistryTests` (asserts correct provider selection per language; regression guard ensures no two non-fallback providers claim the same language). ## Test plan - [ ] `dotnet test tests/Hypa.UnitTests` — 1325 tests, all passing - [ ] `dotnet test tests/Hypa.GoldenTests` — all passing - [ ] `hypa mcp add ` probes the server and reports auth requirements - [ ] OAuth browser flow launches, completes exchange, and stores credentials - [ ] `hypa md README.md --toc` on a fresh clone auto-indexes and returns ToC - [ ] Second `hypa md README.md --toc` with no file changes is a no-op (no re-parse) - [ ] `hypa code index` is incremental by default; `--full` re-indexes then subsequent incremental is a no-op - [ ] `hypa code index --json` shows `markdown: ok` in provider health - [ ] Large `.md` files intercepted by the read hook produce a heading outline; `CLAUDE.md` passes through unchanged * feat(native): bundle libtree-sitter-markdown grammar and wire into CI and release pipeline - Add native/runtimes/linux-x64/native/libtree-sitter-markdown.so (pre-built) - Add scripts/build-tree-sitter-markdown.sh/.ps1 to build from source on any platform - Add Directory.Build.targets to copy the RID-appropriate grammar to every project output and publish root, matching how TreeSitter.DotNet flattens its native assets - ci.yml: build grammar before dotnet build on all matrix runners; aot-publish job rebuilds with FORCE_BUILD=1 and verifies symbol before and after publish - release.yml: build grammar (FORCE_BUILD=1) in every platform build job before dotnet publish; verify grammar file is present in publish output before packaging --------- Signed-off-by: Matthew Gribben Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 31 + .github/workflows/release.yml | 51 +- Directory.Build.targets | 44 + .../native/libtree-sitter-markdown.so | Bin 0 -> 372184 bytes scripts/build-tree-sitter-markdown.ps1 | 46 + scripts/build-tree-sitter-markdown.sh | 76 + src/Hypa.Cli/Commands/CodeCommand.cs | 111 +- src/Hypa.Cli/Commands/InitCommand.cs | 43 +- src/Hypa.Cli/Commands/McpCommand.cs | 1533 +++++++++++++++++ src/Hypa.Cli/Commands/ServeCommand.cs | 2 + src/Hypa.Cli/DI/CliServiceExtensions.cs | 3 + src/Hypa.Cli/Json/CodeJsonContext.cs | 5 + src/Hypa.Cli/Json/McpJsonContext.cs | 100 ++ .../CodeIntelligence/CodePatternExtractor.cs | 296 ++++ .../CodeIntelligence/GitFileStateProvider.cs | 108 ++ .../MarkdownStructureProvider.cs | 140 ++ .../TreeSitterCodeStructureProvider.cs | 5 +- .../TreeSitterQueryRegistry.cs | 3 + .../DI/InfrastructureServiceExtensions.cs | 57 + .../McpOAuthTokenFilePermissionsCheck.cs | 69 + .../Hooks/ReadRedirector.cs | 19 +- .../Hypa.Infrastructure.csproj | 7 + .../Mcp/Auth/BrowserLauncherAdapter.cs | 72 + .../Mcp/Auth/DeviceTokenStore.cs | 54 + .../Mcp/Auth/HypaBrowserOAuthDelegate.cs | 205 +++ .../Mcp/Auth/IOAuthTokenService.cs | 9 + .../Mcp/Auth/McpAuthProviderService.cs | 145 ++ .../Mcp/Auth/McpBrowserOAuthFlowProvider.cs | 156 ++ .../Auth/McpCredentialResolutionException.cs | 3 + .../Mcp/Auth/McpOAuthTokenJsonContext.cs | 25 + .../Mcp/Auth/McpOAuthTokenStore.cs | 264 +++ .../Mcp/Auth/McpOAuthTokenStoreFactory.cs | 23 + .../Mcp/Auth/OAuthCallbackListener.cs | 143 ++ .../Mcp/Auth/OAuthDeviceCodeResponse.cs | 8 + .../Mcp/Auth/OAuthTokenCache.cs | 31 + .../Mcp/Auth/OAuthTokenJsonContext.cs | 11 + .../Mcp/Auth/OAuthTokenResponse.cs | 7 + .../Mcp/Auth/OAuthTokenService.cs | 195 +++ .../Mcp/Auth/SecretRedactionRegistry.cs | 26 + .../Mcp/Config/McpServerConfigLoader.cs | 128 ++ .../Mcp/Config/McpServerConfigWriter.cs | 201 +++ .../Mcp/Config/McpServerJson.cs | 34 + .../Mcp/Config/McpServersJsonContext.cs | 13 + .../Mcp/Connection/DirectMcpDispatcher.cs | 285 +++ .../Connection/IMcpClientConnectionFactory.cs | 13 + .../Mcp/Connection/IMcpClientFacade.cs | 25 + .../Mcp/Connection/IMcpSdkBridge.cs | 66 + .../Connection/McpClientConnectionFactory.cs | 116 ++ .../Mcp/Connection/McpClientEntry.cs | 8 + .../Mcp/Connection/McpServerProbeAdapter.cs | 313 ++++ .../Mcp/Connection/McpTransportBuilder.cs | 282 +++ .../Mcp/Connection/WwwAuthenticateCapture.cs | 19 + .../Import/ClaudeMcpConnectionImportSource.cs | 222 +++ .../Mcp/Import/ClaudeSettingsJsonContext.cs | 25 + .../Import/CodexMcpConnectionImportSource.cs | 234 +++ src/Hypa.Infrastructure/Mcp/McpToolResult.cs | 3 + .../Mcp/Secrets/EnvironmentSecretResolver.cs | 61 + .../Mcp/Tools/HypaMcpTool.cs | 322 ++++ .../Storage/SqliteCodeIndexRepository.cs | 365 +++- .../Storage/SqliteSchemaInitializer.cs | 119 +- .../Application/Ports/IBrowserLauncher.cs | 6 + .../Application/Ports/ICodeIndexRepository.cs | 13 + .../Ports/IGitFileStateProvider.cs | 10 + .../Application/Ports/IMcpAuthProvider.cs | 8 + .../Ports/IMcpBrowserOAuthFlowProvider.cs | 15 + .../Ports/IMcpConnectionImportSource.cs | 40 + .../Application/Ports/IMcpDispatcher.cs | 11 + .../Ports/IMcpServerConfigReader.cs | 9 + .../Ports/IMcpServerConfigWriter.cs | 9 + .../Ports/IMcpServerDefinitionRepository.cs | 9 + .../Ports/IMcpServerImportService.cs | 9 + .../Application/Ports/IMcpServerProbe.cs | 8 + .../Ports/IOAuthCallbackListener.cs | 11 + .../Application/Ports/ISecretResolver.cs | 6 + .../Application/Services/CodeIndexService.cs | 198 ++- .../Services/CodeLanguageRegistry.cs | 1 + .../Application/Services/CodeQueryService.cs | 18 + .../Services/CodeStructureProviderRegistry.cs | 7 +- .../Application/Services/InitService.cs | 65 +- .../Services/McpBrowserOAuthFlowResult.cs | 9 + .../Services/McpBrowserOAuthOptions.cs | 9 + .../Services/McpConfigValidationService.cs | 158 ++ .../Application/Services/McpProxyService.cs | 52 + .../Services/McpResponseCompressionService.cs | 69 + .../Services/McpServerConfigService.cs | 270 +++ .../Services/McpServerImportService.cs | 263 +++ .../Services/McpToolSearchIndex.cs | 84 + .../Domain/Mcp/CompressionHint.cs | 8 + src/Hypa.Runtime/Domain/Mcp/JsonPayload.cs | 3 + src/Hypa.Runtime/Domain/Mcp/McpAuthConfig.cs | 39 + src/Hypa.Runtime/Domain/Mcp/McpAuthContext.cs | 10 + .../Domain/Mcp/McpAuthGuidance.cs | 9 + src/Hypa.Runtime/Domain/Mcp/McpAuthMode.cs | 13 + src/Hypa.Runtime/Domain/Mcp/McpErrorCodes.cs | 14 + .../Domain/Mcp/McpLatencyMetadata.cs | 3 + src/Hypa.Runtime/Domain/Mcp/McpProxyError.cs | 7 + .../Domain/Mcp/McpProxyRequest.cs | 7 + src/Hypa.Runtime/Domain/Mcp/McpResult.cs | 10 + .../Domain/Mcp/McpSchemaManifest.cs | 11 + .../Domain/Mcp/McpServerDefinition.cs | 9 + .../Domain/Mcp/McpServerProbeResult.cs | 6 + .../Domain/Mcp/McpServerProbeStatus.cs | 11 + src/Hypa.Runtime/Domain/Mcp/McpTlsConfig.cs | 6 + .../Domain/Mcp/McpToolSearchResult.cs | 7 + .../Domain/Mcp/McpTransportConfig.cs | 3 + .../Domain/Mcp/McpTransportKind.cs | 10 + .../CodeIntelligenceModels.cs | 31 + .../doctor_output/stdout.verified.txt | 1 + .../markdown/nested-headings/input.md | 19 + .../markdown/nested-headings/meta.json | 1 + .../nested-headings.verified.txt | 16 + .../Fixtures/markdown/special-chars/input.md | 7 + .../Fixtures/markdown/special-chars/meta.json | 1 + .../special-chars/special-chars.verified.txt | 10 + tests/Hypa.GoldenTests/MarkdownGoldenTests.cs | 71 + .../McpProxyIntegrationTests.cs | 401 +++++ .../McpRoundTripTests.cs | 12 +- .../CodeIndexServiceIncrementalTests.cs | 423 +++++ .../Application/CodeIndexServiceTests.cs | 44 +- .../Application/CodeQueryServiceTests.cs | 175 ++ .../Application/InitImportIntegrationTests.cs | 132 ++ .../McpServerConfigServiceProbeTests.cs | 268 +++ .../McpServerConfigServiceTests.cs | 401 +++++ .../McpServerImportServiceTests.cs | 430 +++++ tests/Hypa.UnitTests/AssemblyInfo.cs | 3 + .../Hypa.UnitTests/Cli/McpAddCommandTests.cs | 1332 ++++++++++++++ .../Cli/McpCommandRegressionTests.cs | 99 ++ .../Cli/McpDiscoveryCommandTests.cs | 301 ++++ .../Cli/McpImportCommandTests.cs | 252 +++ tests/Hypa.UnitTests/Cli/MdCommandTests.cs | 178 ++ .../CodeStructureProviderRegistryTests.cs | 102 ++ .../GitFileStateProviderTests.cs | 132 ++ ...rkdownStructureProviderIntegrationTests.cs | 111 ++ .../MarkdownStructureProviderTests.cs | 126 ++ .../McpOAuthTokenFilePermissionsCheckTests.cs | 85 + .../Hooks/ReadRedirectorTests.cs | 188 ++ .../ClaudeMcpConnectionImportSourceTests.cs | 260 +++ .../CodexMcpConnectionImportSourceTests.cs | 254 +++ .../McpServerConfigWriterTests.cs | 292 ++++ .../Storage/CodeQueryServiceSqliteTests.cs | 281 +++ ...SqliteCodeIndexRepositoryFreshnessTests.cs | 218 +++ .../Storage/SqliteSchemaInitializerTests.cs | 200 ++- .../Mcp/Auth/BrowserLauncherAdapterTests.cs | 64 + .../Mcp/Auth/HypaBrowserOAuthDelegateTests.cs | 59 + .../Mcp/Auth/McpAuthProviderServiceTests.cs | 205 +++ .../Mcp/Auth/McpOAuthTokenStoreTests.cs | 225 +++ .../Mcp/Auth/OAuthCallbackListenerTests.cs | 133 ++ .../Mcp/Auth/OAuthTokenCacheTests.cs | 86 + .../Mcp/Auth/OAuthTokenServiceTests.cs | 169 ++ .../Mcp/Auth/SecretRedactionRegistryTests.cs | 50 + .../Connection/DirectMcpDispatcherTests.cs | 468 +++++ .../McpClientConnectionFactoryTests.cs | 273 +++ .../Connection/McpServerProbeAdapterTests.cs | 683 ++++++++ .../Connection/McpTransportBuilderTests.cs | 364 ++++ .../Mcp/McpConfigValidationServiceTests.cs | 472 +++++ .../Mcp/McpProxyServiceTests.cs | 139 ++ .../Mcp/McpResponseCompressionServiceTests.cs | 109 ++ .../Mcp/McpServerConfigLoaderTests.cs | 375 ++++ .../Mcp/McpToolSearchIndexTests.cs | 101 ++ .../Secrets/EnvironmentSecretResolverTests.cs | 110 ++ .../Mcp/Tools/HypaMcpToolTests.cs | 426 +++++ 161 files changed, 19852 insertions(+), 49 deletions(-) create mode 100644 Directory.Build.targets create mode 100755 native/runtimes/linux-x64/native/libtree-sitter-markdown.so create mode 100644 scripts/build-tree-sitter-markdown.ps1 create mode 100755 scripts/build-tree-sitter-markdown.sh create mode 100644 src/Hypa.Cli/Commands/McpCommand.cs create mode 100644 src/Hypa.Cli/Json/McpJsonContext.cs create mode 100644 src/Hypa.Infrastructure/CodeIntelligence/GitFileStateProvider.cs create mode 100644 src/Hypa.Infrastructure/CodeIntelligence/MarkdownStructureProvider.cs create mode 100644 src/Hypa.Infrastructure/Doctor/McpOAuthTokenFilePermissionsCheck.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/BrowserLauncherAdapter.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/DeviceTokenStore.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/HypaBrowserOAuthDelegate.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/IOAuthTokenService.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/McpAuthProviderService.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/McpBrowserOAuthFlowProvider.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/McpCredentialResolutionException.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/McpOAuthTokenJsonContext.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/McpOAuthTokenStore.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/McpOAuthTokenStoreFactory.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/OAuthCallbackListener.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/OAuthDeviceCodeResponse.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenCache.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenJsonContext.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenResponse.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenService.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Auth/SecretRedactionRegistry.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Config/McpServerConfigLoader.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Config/McpServerConfigWriter.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Config/McpServerJson.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Config/McpServersJsonContext.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Connection/DirectMcpDispatcher.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Connection/IMcpClientConnectionFactory.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Connection/IMcpClientFacade.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Connection/IMcpSdkBridge.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Connection/McpClientConnectionFactory.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Connection/McpClientEntry.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Connection/McpServerProbeAdapter.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Connection/McpTransportBuilder.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Connection/WwwAuthenticateCapture.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Import/ClaudeMcpConnectionImportSource.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Import/ClaudeSettingsJsonContext.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Import/CodexMcpConnectionImportSource.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Secrets/EnvironmentSecretResolver.cs create mode 100644 src/Hypa.Infrastructure/Mcp/Tools/HypaMcpTool.cs create mode 100644 src/Hypa.Runtime/Application/Ports/IBrowserLauncher.cs create mode 100644 src/Hypa.Runtime/Application/Ports/IGitFileStateProvider.cs create mode 100644 src/Hypa.Runtime/Application/Ports/IMcpAuthProvider.cs create mode 100644 src/Hypa.Runtime/Application/Ports/IMcpBrowserOAuthFlowProvider.cs create mode 100644 src/Hypa.Runtime/Application/Ports/IMcpConnectionImportSource.cs create mode 100644 src/Hypa.Runtime/Application/Ports/IMcpDispatcher.cs create mode 100644 src/Hypa.Runtime/Application/Ports/IMcpServerConfigReader.cs create mode 100644 src/Hypa.Runtime/Application/Ports/IMcpServerConfigWriter.cs create mode 100644 src/Hypa.Runtime/Application/Ports/IMcpServerDefinitionRepository.cs create mode 100644 src/Hypa.Runtime/Application/Ports/IMcpServerImportService.cs create mode 100644 src/Hypa.Runtime/Application/Ports/IMcpServerProbe.cs create mode 100644 src/Hypa.Runtime/Application/Ports/IOAuthCallbackListener.cs create mode 100644 src/Hypa.Runtime/Application/Ports/ISecretResolver.cs create mode 100644 src/Hypa.Runtime/Application/Services/McpBrowserOAuthFlowResult.cs create mode 100644 src/Hypa.Runtime/Application/Services/McpBrowserOAuthOptions.cs create mode 100644 src/Hypa.Runtime/Application/Services/McpConfigValidationService.cs create mode 100644 src/Hypa.Runtime/Application/Services/McpProxyService.cs create mode 100644 src/Hypa.Runtime/Application/Services/McpResponseCompressionService.cs create mode 100644 src/Hypa.Runtime/Application/Services/McpServerConfigService.cs create mode 100644 src/Hypa.Runtime/Application/Services/McpServerImportService.cs create mode 100644 src/Hypa.Runtime/Application/Services/McpToolSearchIndex.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/CompressionHint.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/JsonPayload.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpAuthConfig.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpAuthContext.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpAuthGuidance.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpAuthMode.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpErrorCodes.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpLatencyMetadata.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpProxyError.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpProxyRequest.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpResult.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpSchemaManifest.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpServerDefinition.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpServerProbeResult.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpServerProbeStatus.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpTlsConfig.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpToolSearchResult.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpTransportConfig.cs create mode 100644 src/Hypa.Runtime/Domain/Mcp/McpTransportKind.cs create mode 100644 tests/Hypa.GoldenTests/Fixtures/markdown/nested-headings/input.md create mode 100644 tests/Hypa.GoldenTests/Fixtures/markdown/nested-headings/meta.json create mode 100644 tests/Hypa.GoldenTests/Fixtures/markdown/nested-headings/nested-headings.verified.txt create mode 100644 tests/Hypa.GoldenTests/Fixtures/markdown/special-chars/input.md create mode 100644 tests/Hypa.GoldenTests/Fixtures/markdown/special-chars/meta.json create mode 100644 tests/Hypa.GoldenTests/Fixtures/markdown/special-chars/special-chars.verified.txt create mode 100644 tests/Hypa.GoldenTests/MarkdownGoldenTests.cs create mode 100644 tests/Hypa.IntegrationTests/McpProxyIntegrationTests.cs create mode 100644 tests/Hypa.UnitTests/Application/CodeIndexServiceIncrementalTests.cs create mode 100644 tests/Hypa.UnitTests/Application/CodeQueryServiceTests.cs create mode 100644 tests/Hypa.UnitTests/Application/InitImportIntegrationTests.cs create mode 100644 tests/Hypa.UnitTests/Application/McpServerConfigServiceProbeTests.cs create mode 100644 tests/Hypa.UnitTests/Application/McpServerConfigServiceTests.cs create mode 100644 tests/Hypa.UnitTests/Application/McpServerImportServiceTests.cs create mode 100644 tests/Hypa.UnitTests/AssemblyInfo.cs create mode 100644 tests/Hypa.UnitTests/Cli/McpAddCommandTests.cs create mode 100644 tests/Hypa.UnitTests/Cli/McpCommandRegressionTests.cs create mode 100644 tests/Hypa.UnitTests/Cli/McpDiscoveryCommandTests.cs create mode 100644 tests/Hypa.UnitTests/Cli/McpImportCommandTests.cs create mode 100644 tests/Hypa.UnitTests/Cli/MdCommandTests.cs create mode 100644 tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/CodeStructureProviderRegistryTests.cs create mode 100644 tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/GitFileStateProviderTests.cs create mode 100644 tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/MarkdownStructureProviderIntegrationTests.cs create mode 100644 tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/MarkdownStructureProviderTests.cs create mode 100644 tests/Hypa.UnitTests/Infrastructure/Doctor/McpOAuthTokenFilePermissionsCheckTests.cs create mode 100644 tests/Hypa.UnitTests/Infrastructure/Hooks/ReadRedirectorTests.cs create mode 100644 tests/Hypa.UnitTests/Infrastructure/Mcp/ClaudeMcpConnectionImportSourceTests.cs create mode 100644 tests/Hypa.UnitTests/Infrastructure/Mcp/CodexMcpConnectionImportSourceTests.cs create mode 100644 tests/Hypa.UnitTests/Infrastructure/McpServerConfigWriterTests.cs create mode 100644 tests/Hypa.UnitTests/Infrastructure/Storage/CodeQueryServiceSqliteTests.cs create mode 100644 tests/Hypa.UnitTests/Infrastructure/Storage/SqliteCodeIndexRepositoryFreshnessTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/Auth/BrowserLauncherAdapterTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/Auth/HypaBrowserOAuthDelegateTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/Auth/McpAuthProviderServiceTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/Auth/McpOAuthTokenStoreTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/Auth/OAuthCallbackListenerTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/Auth/OAuthTokenCacheTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/Auth/OAuthTokenServiceTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/Auth/SecretRedactionRegistryTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/Connection/DirectMcpDispatcherTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/Connection/McpClientConnectionFactoryTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/Connection/McpServerProbeAdapterTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/Connection/McpTransportBuilderTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/McpConfigValidationServiceTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/McpProxyServiceTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/McpResponseCompressionServiceTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/McpServerConfigLoaderTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/McpToolSearchIndexTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/Secrets/EnvironmentSecretResolverTests.cs create mode 100644 tests/Hypa.UnitTests/Mcp/Tools/HypaMcpToolTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ad88bd..0214421 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,15 @@ jobs: with: dotnet-version: '10.0.x' + - name: Build tree-sitter-markdown grammar (Linux / macOS) + if: runner.os != 'Windows' + run: bash scripts/build-tree-sitter-markdown.sh + + - name: Build tree-sitter-markdown grammar (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: scripts/build-tree-sitter-markdown.ps1 + - name: Restore run: dotnet restore @@ -53,18 +62,40 @@ jobs: - name: Install AOT prerequisites run: sudo apt-get install -y clang zlib1g-dev + - name: Build tree-sitter-markdown grammar + run: FORCE_BUILD=1 bash scripts/build-tree-sitter-markdown.sh + + - name: Verify tree-sitter-markdown symbol + run: | + SO=native/runtimes/linux-x64/native/libtree-sitter-markdown.so + nm -D "$SO" | grep -q "tree_sitter_markdown" || \ + (echo "ERROR: tree_sitter_markdown symbol missing from $SO" && exit 1) + echo "Symbol verified in $SO" + - name: Restore run: dotnet restore - name: Publish AOT run: dotnet publish src/Hypa.Cli/Hypa.Cli.csproj -r linux-x64 -c Release -o publish/ /m:1 /p:BuildInParallel=false + - name: Verify libtree-sitter-markdown.so in publish output + run: | + ls -lh publish/libtree-sitter-markdown.so + nm -D publish/libtree-sitter-markdown.so | grep -q "tree_sitter_markdown" || \ + (echo "ERROR: tree_sitter_markdown symbol missing from publish output" && exit 1) + echo "Publish output verified." + - name: Smoke test — version run: ./publish/hypa --version || ./publish/hypa version - name: Smoke test — doctor run: ./publish/hypa doctor + - name: Smoke test — markdown provider healthy + run: | + ./publish/hypa code index --json | \ + python3 -c "import sys,json; h=json.load(sys.stdin)['providerHealth']; md=[p for p in h if p['providerId']=='markdown']; print(md); exit(0 if md and md[0]['status']=='ok' else 1)" + - name: Golden tests env: HYPA_BINARY_PATH: ${{ github.workspace }}/publish/hypa diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6848c98..431048a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,7 +85,7 @@ jobs: executable: hypa.exe steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Resolve version id: version @@ -103,7 +103,7 @@ jobs: "version=$version" >> $env:GITHUB_OUTPUT - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' @@ -111,6 +111,15 @@ jobs: if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev + - name: Build tree-sitter-markdown grammar (Linux / macOS) + if: runner.os != 'Windows' + run: FORCE_BUILD=1 bash scripts/build-tree-sitter-markdown.sh + + - name: Build tree-sitter-markdown grammar (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: scripts/build-tree-sitter-markdown.ps1 -Force + - name: Restore run: dotnet restore @@ -126,6 +135,30 @@ jobs: /p:FileVersion=${{ steps.version.outputs.version }} /p:InformationalVersion=${{ steps.version.outputs.version }} + - name: Verify grammar in publish output (Linux / macOS) + if: runner.os != 'Windows' + run: | + RID="${{ matrix.rid }}" + PUBLISH_DIR="artifacts/publish/$RID" + case "$RID" in + linux*) GRAMMAR="$PUBLISH_DIR/libtree-sitter-markdown.so" ;; + osx*) GRAMMAR="$PUBLISH_DIR/libtree-sitter-markdown.dylib" ;; + esac + ls -lh "$GRAMMAR" + case "$RID" in + linux*) nm -D "$GRAMMAR" | grep -q "tree_sitter_markdown" || (echo "ERROR: symbol missing" && exit 1) ;; + osx*) nm -gU "$GRAMMAR" | grep -q "tree_sitter_markdown" || (echo "ERROR: symbol missing" && exit 1) ;; + esac + echo "Grammar verified in $RID publish output." + + - name: Verify grammar in publish output (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $grammar = "artifacts/publish/${{ matrix.rid }}/tree-sitter-markdown.dll" + if (-not (Test-Path $grammar)) { Write-Error "Grammar DLL missing: $grammar"; exit 1 } + Write-Host "Grammar verified: $grammar" + - name: Package shell: pwsh run: | @@ -182,7 +215,7 @@ jobs: needs: [gate] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Resolve version id: version @@ -200,7 +233,7 @@ jobs: "version=$version" >> $env:GITHUB_OUTPUT - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' @@ -239,7 +272,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Resolve version id: version @@ -252,7 +285,7 @@ jobs: "tag=$tag" >> $env:GITHUB_OUTPUT "version=$version" >> $env:GITHUB_OUTPUT - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: '22' @@ -325,7 +358,7 @@ jobs: needs: [checksums, publish-sdk, publish-npm] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Resolve tag id: version @@ -365,7 +398,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Resolve version id: version @@ -433,7 +466,7 @@ jobs: id-token: write contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Resolve version id: version shell: pwsh diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..f052c67 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,44 @@ + + + + + <_MarkdownGrammarRID Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier) + + + <_MarkdownGrammarArch Condition="'$(_MarkdownGrammarRID)' == ''">$([System.Text.RegularExpressions.Regex]::Match('$(NETCoreSdkRuntimeIdentifier)', '-(\w+)$').Groups[1].Value) + + + <_MarkdownGrammarOS Condition="'$(_MarkdownGrammarRID)' == '' And $([MSBuild]::IsOSPlatform('Linux'))">linux + <_MarkdownGrammarOS Condition="'$(_MarkdownGrammarRID)' == '' And $([MSBuild]::IsOSPlatform('OSX'))">osx + <_MarkdownGrammarOS Condition="'$(_MarkdownGrammarRID)' == '' And $([MSBuild]::IsOSPlatform('Windows'))">win + + + <_MarkdownGrammarRID Condition="'$(_MarkdownGrammarRID)' == '' And '$(_MarkdownGrammarOS)' != '' And '$(_MarkdownGrammarArch)' != ''">$(_MarkdownGrammarOS)-$(_MarkdownGrammarArch) + + <_MarkdownGrammarNativeDir>$(MSBuildThisFileDirectory)native\runtimes\$(_MarkdownGrammarRID)\native\ + + + + + PreserveNewest + Always + %(Filename)%(Extension) + + + diff --git a/native/runtimes/linux-x64/native/libtree-sitter-markdown.so b/native/runtimes/linux-x64/native/libtree-sitter-markdown.so new file mode 100755 index 0000000000000000000000000000000000000000..0fa05bda9885cc64ae9a318354a7d6c9c69005f1 GIT binary patch literal 372184 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≀(seb>kQuhuB0 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 D*2BC@ literal 0 HcmV?d00001 diff --git a/scripts/build-tree-sitter-markdown.ps1 b/scripts/build-tree-sitter-markdown.ps1 new file mode 100644 index 0000000..398c04a --- /dev/null +++ b/scripts/build-tree-sitter-markdown.ps1 @@ -0,0 +1,46 @@ +# Builds tree-sitter-markdown.dll for Windows (x64 or arm64). +# Requires either MSVC (cl.exe via Developer Command Prompt) or mingw-w64. +# Outputs to native/runtimes//native/ relative to the repo root. +param([switch]$Force) + +$ErrorActionPreference = "Stop" + +$RepoRoot = Split-Path $PSScriptRoot -Parent +$GrammarRepo = "https://github.com/tree-sitter-grammars/tree-sitter-markdown.git" +$GrammarCache = Join-Path $env:TEMP "tree-sitter-markdown-src" + +$Arch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture +$RID = if ($Arch -eq "Arm64") { "win-arm64" } else { "win-x64" } + +$OutputDir = Join-Path $RepoRoot "native" "runtimes" $RID "native" +$OutputPath = Join-Path $OutputDir "tree-sitter-markdown.dll" + +if ((Test-Path $OutputPath) -and -not $Force) { + Write-Host "Already built: $OutputPath (use -Force to rebuild)" + exit 0 +} + +Write-Host "Building tree-sitter-markdown for $RID..." + +if (-not (Test-Path $GrammarCache)) { + git clone --depth 1 $GrammarRepo $GrammarCache +} + +$SrcDir = Join-Path $GrammarCache "tree-sitter-markdown" "src" +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +# Try cl.exe (MSVC) first, fall back to gcc (mingw-w64) +if (Get-Command cl.exe -ErrorAction SilentlyContinue) { + cl.exe /LD /O2 /Fe:"$OutputPath" ` + "$SrcDir\parser.c" "$SrcDir\scanner.c" ` + /I"$SrcDir" +} elseif (Get-Command gcc -ErrorAction SilentlyContinue) { + gcc -shared -O2 -o "$OutputPath" ` + "$SrcDir\parser.c" "$SrcDir\scanner.c" ` + -I"$SrcDir" +} else { + Write-Error "No C compiler found. Install Visual Studio Build Tools or mingw-w64." + exit 1 +} + +Write-Host "Built: $OutputPath" diff --git a/scripts/build-tree-sitter-markdown.sh b/scripts/build-tree-sitter-markdown.sh new file mode 100755 index 0000000..77c7380 --- /dev/null +++ b/scripts/build-tree-sitter-markdown.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Builds libtree-sitter-markdown.so/.dylib for the current platform. +# Outputs to native/runtimes//native/ relative to the repo root. +# Idempotent: skips build if the output already exists unless FORCE_BUILD=1. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname "$SCRIPT_DIR")" +GRAMMAR_REPO="https://github.com/tree-sitter-grammars/tree-sitter-markdown.git" +GRAMMAR_CACHE="/tmp/tree-sitter-markdown-src" + +# Detect RID and output filename +case "$(uname -s)" in + Linux) + case "$(uname -m)" in + x86_64) RID="linux-x64" ;; + aarch64) RID="linux-arm64" ;; + armv7l) RID="linux-arm" ;; + *) echo "Unsupported Linux architecture: $(uname -m)" >&2; exit 1 ;; + esac + OUTPUT_FILE="libtree-sitter-markdown.so" + COMPILE_FLAGS="-shared -fPIC" + ;; + Darwin) + case "$(uname -m)" in + x86_64) RID="osx-x64" ;; + arm64) RID="osx-arm64" ;; + *) echo "Unsupported macOS architecture: $(uname -m)" >&2; exit 1 ;; + esac + OUTPUT_FILE="libtree-sitter-markdown.dylib" + COMPILE_FLAGS="-dynamiclib" + ;; + *) + echo "Unsupported OS: $(uname -s). Use build-tree-sitter-markdown.ps1 on Windows." >&2 + exit 1 + ;; +esac + +OUTPUT_DIR="$REPO_ROOT/native/runtimes/$RID/native" +OUTPUT_PATH="$OUTPUT_DIR/$OUTPUT_FILE" + +if [[ -f "$OUTPUT_PATH" && "${FORCE_BUILD:-0}" != "1" ]]; then + echo "Already built: $OUTPUT_PATH (set FORCE_BUILD=1 to rebuild)" + exit 0 +fi + +echo "Building tree-sitter-markdown for $RID..." + +# Clone grammar source if not cached +if [[ ! -d "$GRAMMAR_CACHE" ]]; then + git clone --depth 1 "$GRAMMAR_REPO" "$GRAMMAR_CACHE" +fi + +SRC_DIR="$GRAMMAR_CACHE/tree-sitter-markdown/src" +mkdir -p "$OUTPUT_DIR" + +gcc $COMPILE_FLAGS -O2 \ + -o "$OUTPUT_PATH" \ + "$SRC_DIR/parser.c" \ + "$SRC_DIR/scanner.c" \ + -I"$SRC_DIR" + +# Verify the required symbol is exported. +# Use nm -gU on macOS (global defined symbols; -D is Linux-only and fails on dylibs). +# Use nm -D on Linux (dynamic symbol table for shared libraries). +case "$(uname -s)" in + Darwin) NM_FLAGS="-gU" ;; + *) NM_FLAGS="-D" ;; +esac + +if ! nm $NM_FLAGS "$OUTPUT_PATH" | grep -q "tree_sitter_markdown"; then + echo "ERROR: tree_sitter_markdown symbol not found in $OUTPUT_PATH" >&2 + exit 1 +fi + +echo "Built and verified: $OUTPUT_PATH" diff --git a/src/Hypa.Cli/Commands/CodeCommand.cs b/src/Hypa.Cli/Commands/CodeCommand.cs index b41b6be..856bc97 100644 --- a/src/Hypa.Cli/Commands/CodeCommand.cs +++ b/src/Hypa.Cli/Commands/CodeCommand.cs @@ -25,22 +25,28 @@ private Command BuildIndex() { var path = new Option("--path", "Path to a file or directory to index."); var json = new Option("--json", "Emit JSON."); + var full = new Option("--full", "Force a complete re-index, ignoring cached state."); var cmd = new Command("index", "Index source code structure."); cmd.AddOption(path); cmd.AddOption(json); + cmd.AddOption(full); cmd.SetHandler(async (context) => { var ct = context.GetCancellationToken(); var p = context.ParseResult.GetValueForOption(path); var asJson = context.ParseResult.GetValueForOption(json); - var result = await indexService.IndexAsync(p, ct); + var asFullRebuild = context.ParseResult.GetValueForOption(full); + var result = asFullRebuild + ? await indexService.IndexFullAsync(p, ct) + : await indexService.IndexIncrementalAsync(p, ct); if (asJson) { Console.WriteLine(JsonSerializer.Serialize(result, CodeJsonContext.Default.CodeIndexResult)); return; } - Console.WriteLine($"Indexed {result.FilesIndexed} files, skipped {result.FilesSkipped}."); + Console.WriteLine($"Indexed {result.FilesIndexed} files, skipped {result.FilesSkipped}" + + (result.FilesDeleted > 0 ? $", deleted {result.FilesDeleted}" : "") + "."); Console.WriteLine($"Symbols: {result.SymbolCount}, references: {result.ReferenceCount}, edges: {result.EdgeCount}, diagnostics: {result.DiagnosticCount}"); }); return cmd; @@ -171,4 +177,105 @@ private Command BuildDiagnostics() }); return cmd; } + + public Command BuildMd() + { + var file = new Argument("file", "Relative path to the indexed Markdown file."); + var toc = new Option("--toc", "Print the table of contents."); + var section = new Option("--section", "Print a specific section by heading path or text."); + var depth = new Option("--depth", () => 3, "Maximum heading depth for --toc."); + var frontmatter = new Option("--frontmatter", "Print frontmatter."); + var json = new Option("--json", "Emit JSON."); + var cmd = new Command("md", "Query indexed Markdown structure."); + cmd.AddArgument(file); + cmd.AddOption(toc); + cmd.AddOption(section); + cmd.AddOption(depth); + cmd.AddOption(frontmatter); + cmd.AddOption(json); + cmd.SetHandler(async (context) => + { + var ct = context.GetCancellationToken(); + var filePath = context.ParseResult.GetValueForArgument(file); + var absolutePath = Path.GetFullPath(filePath); + await indexService.EnsureFreshAsync(absolutePath, ct); + var printToc = context.ParseResult.GetValueForOption(toc); + var sectionValue = context.ParseResult.GetValueForOption(section); + var maxDepth = context.ParseResult.GetValueForOption(depth); + var printFrontmatter = context.ParseResult.GetValueForOption(frontmatter); + var asJson = context.ParseResult.GetValueForOption(json); + var printSection = sectionValue is not null; + + if (!printFrontmatter && !printToc && !printSection) + printToc = true; + + string? frontmatterResult = null; + IReadOnlyList? tocResult = null; + IReadOnlyList? sectionResult = null; + + if (printFrontmatter) + { + frontmatterResult = await queryService.QueryFrontmatterAsync(filePath, ct); + if (!asJson) + Console.WriteLine(frontmatterResult ?? "(no frontmatter)"); + } + + if (printToc) + { + tocResult = await queryService.QueryTocAsync(filePath, maxDepth, ct); + if (!asJson) + { + foreach (var s in tocResult) + Console.WriteLine($"{new string(' ', (s.HeadingLevel - 1) * 2)}{s.HeadingText}"); + } + } + + if (printSection) + { + var sections = await queryService.QueryMarkdownSectionsAsync(filePath, ct); + sectionResult = sections + .Where(s => s.HeadingPath == sectionValue || s.HeadingText == sectionValue) + .ToArray(); + if (!asJson) + { + if (sectionResult.Count == 0) + { + Console.WriteLine($"No Markdown section matched '{sectionValue}'."); + } + + foreach (var s in sectionResult) + { + Console.WriteLine($"{s.HeadingPath} (L{s.StartLine}-{s.EndLine})"); + Console.WriteLine(); + Console.WriteLine(s.PlainText ?? s.Text ?? "(no content)"); + } + } + } + + if (asJson) + { + var result = new MarkdownQueryJsonResult + { + FilePath = filePath, + Frontmatter = printFrontmatter ? frontmatterResult : null, + Toc = printToc ? tocResult ?? [] : null, + Section = printSection ? sectionValue : null, + Sections = printSection ? sectionResult ?? [] : null, + SectionMatched = printSection ? sectionResult?.Count > 0 : null, + }; + Console.WriteLine(JsonSerializer.Serialize(result, CodeJsonContext.Default.MarkdownQueryJsonResult)); + } + }); + return cmd; + } +} + +internal sealed record MarkdownQueryJsonResult +{ + public required string FilePath { get; init; } + public string? Frontmatter { get; init; } + public IReadOnlyList? Toc { get; init; } + public string? Section { get; init; } + public IReadOnlyList? Sections { get; init; } + public bool? SectionMatched { get; init; } } diff --git a/src/Hypa.Cli/Commands/InitCommand.cs b/src/Hypa.Cli/Commands/InitCommand.cs index d107e6d..3a2ef4d 100644 --- a/src/Hypa.Cli/Commands/InitCommand.cs +++ b/src/Hypa.Cli/Commands/InitCommand.cs @@ -2,6 +2,7 @@ using System.Text; using Hypa.Infrastructure.Hooks; using Hypa.Infrastructure.Storage; +using Hypa.Runtime.Application.Ports; using Hypa.Runtime.Application.Services; using Hypa.Runtime.Domain.Hooks; @@ -18,12 +19,14 @@ public Command Build() var projectRootOpt = new Option("--project-root", "Explicit project root for --project or --all."); var agentOpt = new Option("--agent", "Install only for the named harness (e.g. claude, codex)."); var dryRunOpt = new Option("--dry-run", "Show what would be installed without writing any files."); + var skipMcpImportOpt = new Option("--skip-mcp-import", "Skip importing MCP servers from agent config files."); cmd.AddOption(globalOpt); cmd.AddOption(projectOpt); cmd.AddOption(allOpt); cmd.AddOption(projectRootOpt); cmd.AddOption(agentOpt); cmd.AddOption(dryRunOpt); + cmd.AddOption(skipMcpImportOpt); cmd.SetHandler(async context => { var global = context.ParseResult.GetValueForOption(globalOpt); @@ -32,6 +35,7 @@ public Command Build() var projectRoot = context.ParseResult.GetValueForOption(projectRootOpt); var agentKey = context.ParseResult.GetValueForOption(agentOpt); var dryRun = context.ParseResult.GetValueForOption(dryRunOpt); + var skipMcpImport = context.ParseResult.GetValueForOption(skipMcpImportOpt); var ct = context.GetCancellationToken(); if (all && (global || project)) @@ -64,7 +68,8 @@ public Command Build() if (dryRun) Console.WriteLine("Dry run — no files will be written.\n"); - var result = await initService.InstallAsync(scope, agentKey, projectRoot, dryRun, ct); + var result = await initService.InstallAsync(scope, agentKey, projectRoot, dryRun, ct, + skipMcpImport: skipMcpImport); if (result.ErrorMessage is not null) { @@ -151,6 +156,13 @@ void PrintCodexStorageHint() } } + if (result.ImportReport is { } importReport && + importReport.Sources.Any(s => s.Connections.Count > 0)) + { + Console.WriteLine("[mcp-import]"); + PrintImportReport(importReport); + } + var hasErrors = reports.Any(r => r.Entries.Any(e => e.Status == InstallStatus.Error)); if (result.ProjectSkipped && scope == InitScope.All) Console.WriteLine("Project setup skipped — no project root detected."); @@ -162,6 +174,35 @@ void PrintCodexStorageHint() return cmd; } + private static void PrintImportReport(McpImportReport report) + { + foreach (var source in report.Sources) + { + foreach (var conn in source.Connections) + { + var symbol = conn.Status switch + { + McpImportCandidateStatus.Importable => "+", + McpImportCandidateStatus.SkippedSelf => "-", + McpImportCandidateStatus.SkippedUnsafeSecret => "!", + McpImportCandidateStatus.SkippedIncomplete => "!", + McpImportCandidateStatus.SkippedUnsupported => "=", + McpImportCandidateStatus.SkippedDuplicate => "=", + McpImportCandidateStatus.SkippedConflict => "~", + McpImportCandidateStatus.ParseError => "!", + _ => "?", + }; + + var label = conn.Status == McpImportCandidateStatus.Importable + ? $"imported {conn.SourceName}" + : $"skipped {conn.SourceName}"; + + var detail = conn.Detail is not null ? $" ({conn.Detail})" : string.Empty; + Console.WriteLine($" {symbol} {label}{detail}"); + } + } + } + private static string ToTomlBasicStringLiteral(string value) { var builder = new StringBuilder(value.Length + 2); diff --git a/src/Hypa.Cli/Commands/McpCommand.cs b/src/Hypa.Cli/Commands/McpCommand.cs new file mode 100644 index 0000000..bec7516 --- /dev/null +++ b/src/Hypa.Cli/Commands/McpCommand.cs @@ -0,0 +1,1533 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Hypa.Cli.Json; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Mcp; +using Microsoft.Extensions.Logging; + +namespace Hypa.Cli.Commands; + +public sealed class McpCommand( + McpProxyService proxyService, + IMcpServerDefinitionRepository serverDefinitionRepository, + IMcpAuthProvider authProvider, + McpServerConfigService mcpServerConfigService, + ILogger logger, + IMcpServerImportService? mcpServerImportService = null, + IMcpBrowserOAuthFlowProvider? browserOAuthFlowProvider = null) +{ + private static readonly HashSet ValidAgentKeys = + new(StringComparer.OrdinalIgnoreCase) { "claude", "codex", "all" }; + + public Command Build() + { + var cmd = new Command("mcp", "Interact with configured upstream MCP servers."); + cmd.AddCommand(BuildList()); + cmd.AddCommand(BuildAdd()); + cmd.AddCommand(BuildImport()); + cmd.AddCommand(BuildInvoke()); + cmd.AddCommand(BuildBatch()); + cmd.AddCommand(BuildSchema()); + cmd.AddCommand(BuildSearch()); + cmd.AddCommand(BuildTools()); + cmd.AddCommand(BuildAuth()); + return cmd; + } + + private Command BuildImport() + { + var agentOpt = new Option("--agent", () => "all", "Agent to import from: claude | codex | all."); + var scopeOpt = new Option("--scope", () => "global", "Scope: global | project | all."); + var projectRootOpt = new Option("--project-root", "Project root path for project or all scope."); + var dryRunOpt = new Option("--dry-run", "Report candidates without writing."); + var replaceOpt = new Option("--replace", "Replace existing entries with the same name."); + + var cmd = new Command("import", "Import MCP servers from agent harness configuration files."); + cmd.AddOption(agentOpt); + cmd.AddOption(scopeOpt); + cmd.AddOption(projectRootOpt); + cmd.AddOption(dryRunOpt); + cmd.AddOption(replaceOpt); + + cmd.SetHandler(async context => + { + var pr = context.ParseResult; + var ct = context.GetCancellationToken(); + + var agentArg = pr.GetValueForOption(agentOpt)!; + var scopeArg = pr.GetValueForOption(scopeOpt)!; + var projectRoot = pr.GetValueForOption(projectRootOpt); + var dryRun = pr.GetValueForOption(dryRunOpt); + var replace = pr.GetValueForOption(replaceOpt); + + if (!ValidAgentKeys.Contains(agentArg)) + { + await Console.Error.WriteLineAsync( + $"error: UnknownAgent: '{agentArg}' is not a known agent. Use claude, codex, or all."); + context.ExitCode = 1; + return; + } + + var scope = scopeArg.ToLowerInvariant() switch + { + "global" => McpImportScope.Global, + "project" => McpImportScope.Project, + "all" => McpImportScope.All, + _ => (McpImportScope?)null, + }; + + if (scope is null) + { + await Console.Error.WriteLineAsync( + $"error: InvalidOption: '{scopeArg}' is not a valid scope. Use global, project, or all."); + context.ExitCode = 1; + return; + } + + if ((scope == McpImportScope.Project || scope == McpImportScope.All) && + string.IsNullOrWhiteSpace(projectRoot)) + { + await Console.Error.WriteLineAsync( + "error: MissingOption: --project-root is required when --scope is project or all."); + context.ExitCode = 1; + return; + } + + if (dryRun) + Console.WriteLine("Dry run — no servers will be written."); + + var svc = mcpServerImportService; + if (svc is null) + { + await Console.Error.WriteLineAsync("error: Import service not available."); + context.ExitCode = 1; + return; + } + + McpImportReport report; + try + { + var agentKey = string.Equals(agentArg, "all", StringComparison.OrdinalIgnoreCase) + ? null + : agentArg; + var importResult = await svc.ImportAsync( + new McpImportRequest(agentKey, scope.Value, projectRoot, replace, dryRun), ct); + if (!importResult.IsOk) + { + await Console.Error.WriteLineAsync($"error: ImportFailed: {importResult.Error.Message}"); + context.ExitCode = 1; + return; + } + report = importResult.Value; + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"error: ImportFailed: {ex.Message}"); + context.ExitCode = 1; + return; + } + + PrintImportReport(report); + }); + + return cmd; + } + + private static void PrintImportReport(McpImportReport report) + { + foreach (var source in report.Sources) + { + Console.WriteLine($"[{source.Agent}/{source.Scope}]"); + foreach (var conn in source.Connections) + { + var symbol = conn.Status switch + { + McpImportCandidateStatus.Importable => "+", + McpImportCandidateStatus.SkippedSelf => "-", + McpImportCandidateStatus.SkippedUnsafeSecret => "!", + McpImportCandidateStatus.SkippedIncomplete => "!", + McpImportCandidateStatus.SkippedUnsupported => "=", + McpImportCandidateStatus.SkippedDuplicate => "=", + McpImportCandidateStatus.SkippedConflict => "~", + McpImportCandidateStatus.ParseError => "!", + _ => "?", + }; + + var label = conn.Status == McpImportCandidateStatus.Importable + ? $"imported {conn.SourceName}" + : $"skipped {conn.SourceName}"; + + var detail = conn.Detail is not null ? $" ({conn.Detail})" : string.Empty; + Console.WriteLine($" {symbol} {label}{detail}"); + } + } + } + + private Command BuildAdd() + { + var nameArg = new Argument("name", () => null, "Upstream server name."); + var transportOpt = new Option("--transport", "Transport: stdio | streamableHttp | sse | httpAutoDetect."); + var endpointOpt = new Option("--endpoint", "Command (stdio) or URL (remote transports)."); + var authOpt = new Option("--auth", "Auth mode: none | bearer | apiKey | basic | oauth2ClientCredentials | oauth2DeviceCode | mtls."); + var tokenRefOpt = new Option("--token-ref", "Secret reference for bearer token."); + var headerNameOpt = new Option("--header-name", "Header name for apiKey auth."); + var valueRefOpt = new Option("--value-ref", "Secret reference for apiKey value."); + var inQueryStringOpt = new Option("--in-query-string", "Send apiKey in query string instead of header."); + var usernameRefOpt = new Option("--username-ref", "Secret reference for basic auth username."); + var passwordRefOpt = new Option("--password-ref", "Secret reference for basic auth password."); + var tokenUrlOpt = new Option("--token-url", "Token URL for OAuth2 flows."); + var clientIdRefOpt = new Option("--client-id-ref", "Secret reference for OAuth2 client ID."); + var clientSecretRefOpt = new Option("--client-secret-ref", "Secret reference for OAuth2 client secret."); + var scopesOpt = new Option("--scopes", "Comma-separated OAuth2 scopes."); + var authUrlOpt = new Option("--auth-url", "Authorization URL for OAuth2 device-code flow."); + var clientIdOpt = new Option("--client-id", "Client ID for OAuth2 device-code flow."); + var clientCertRefOpt = new Option("--client-cert-ref", "Secret reference for mTLS client certificate."); + var clientKeyRefOpt = new Option("--client-key-ref", "Secret reference for mTLS client key."); + var caCertPathOpt = new Option("--ca-cert-path", "Path to CA certificate for TLS."); + var clientCertPathOpt = new Option("--client-cert-path", "Path to TLS client certificate."); + var clientKeyPathOpt = new Option("--client-key-path", "Path to TLS client key."); + var connectTimeoutOpt = new Option("--connect-timeout-seconds", "Connection timeout in seconds."); + var requestTimeoutOpt = new Option("--request-timeout-seconds", "Request timeout in seconds."); + var replaceOpt = new Option("--replace", "Replace an existing server with the same name."); + var dryRunOpt = new Option("--dry-run", "Validate and print generated config without writing."); + var loginOpt = new Option("--login", "After adding, start OAuth2 device-code login."); + var interactiveOpt = new Option("--interactive", "Prompt for all values interactively."); + var noProbeOpt = new Option("--no-probe", + "Skip the default remote-server reachability check before writing config."); + var noBrowserOpt = new Option("--no-browser", + "For MCP OAuth: display auth URL instead of opening browser."); + var nonInteractiveOpt = new Option("--non-interactive", + "Fail with exit code 4 if interactive OAuth or prompts are required."); + var jsonOpt = new Option("--json", "Output result as JSON."); + + var cmd = new Command("add", "Add a new upstream MCP server to configuration."); + cmd.AddArgument(nameArg); + cmd.AddOption(transportOpt); + cmd.AddOption(endpointOpt); + cmd.AddOption(authOpt); + cmd.AddOption(tokenRefOpt); + cmd.AddOption(headerNameOpt); + cmd.AddOption(valueRefOpt); + cmd.AddOption(inQueryStringOpt); + cmd.AddOption(usernameRefOpt); + cmd.AddOption(passwordRefOpt); + cmd.AddOption(tokenUrlOpt); + cmd.AddOption(clientIdRefOpt); + cmd.AddOption(clientSecretRefOpt); + cmd.AddOption(scopesOpt); + cmd.AddOption(authUrlOpt); + cmd.AddOption(clientIdOpt); + cmd.AddOption(clientCertRefOpt); + cmd.AddOption(clientKeyRefOpt); + cmd.AddOption(caCertPathOpt); + cmd.AddOption(clientCertPathOpt); + cmd.AddOption(clientKeyPathOpt); + cmd.AddOption(connectTimeoutOpt); + cmd.AddOption(requestTimeoutOpt); + cmd.AddOption(replaceOpt); + cmd.AddOption(dryRunOpt); + cmd.AddOption(loginOpt); + cmd.AddOption(interactiveOpt); + cmd.AddOption(noProbeOpt); + cmd.AddOption(noBrowserOpt); + cmd.AddOption(nonInteractiveOpt); + cmd.AddOption(jsonOpt); + + cmd.SetHandler(async context => + { + var pr = context.ParseResult; + var ct = context.GetCancellationToken(); + + var dryRun = pr.GetValueForOption(dryRunOpt); + var login = pr.GetValueForOption(loginOpt); + var interactive = pr.GetValueForOption(interactiveOpt); + var noProbe = pr.GetValueForOption(noProbeOpt); + var noBrowser = pr.GetValueForOption(noBrowserOpt); + var nonInteractive = pr.GetValueForOption(nonInteractiveOpt); + var json = pr.GetValueForOption(jsonOpt); + + if (dryRun && login) + { + await Console.Error.WriteLineAsync("error: InvalidOption: --dry-run cannot be combined with --login."); + context.ExitCode = 1; + return; + } + + if (interactive && nonInteractive) + { + await Console.Error.WriteLineAsync("error: InvalidOption: --interactive cannot be combined with --non-interactive."); + context.ExitCode = 1; + return; + } + + var authType = pr.GetValueForOption(authOpt); + if (login && !string.IsNullOrWhiteSpace(authType) && + !string.Equals(authType, "oauth2DeviceCode", StringComparison.OrdinalIgnoreCase) && + !string.Equals(authType, "oauth2devicecode", StringComparison.OrdinalIgnoreCase)) + { + await Console.Error.WriteLineAsync("error: InvalidOption: --login is only valid with --auth oauth2DeviceCode."); + context.ExitCode = 1; + return; + } + + bool isInteractive = !nonInteractive && (interactive || !Console.IsInputRedirected); + + string? name = pr.GetValueForArgument(nameArg); + string? transport = pr.GetValueForOption(transportOpt); + string? endpoint = pr.GetValueForOption(endpointOpt); + string? tokenRef = pr.GetValueForOption(tokenRefOpt); + string? headerName = pr.GetValueForOption(headerNameOpt); + string? valueRef = pr.GetValueForOption(valueRefOpt); + bool inQueryString = pr.GetValueForOption(inQueryStringOpt); + string? usernameRef = pr.GetValueForOption(usernameRefOpt); + string? passwordRef = pr.GetValueForOption(passwordRefOpt); + string? tokenUrl = pr.GetValueForOption(tokenUrlOpt); + string? clientIdRef = pr.GetValueForOption(clientIdRefOpt); + string? clientSecret = pr.GetValueForOption(clientSecretRefOpt); + string? scopes = pr.GetValueForOption(scopesOpt); + string? authUrl = pr.GetValueForOption(authUrlOpt); + string? clientId = pr.GetValueForOption(clientIdOpt); + string? clientCertRef = pr.GetValueForOption(clientCertRefOpt); + string? clientKeyRef = pr.GetValueForOption(clientKeyRefOpt); + string? caCertPath = pr.GetValueForOption(caCertPathOpt); + string? clientCertPath = pr.GetValueForOption(clientCertPathOpt); + string? clientKeyPath = pr.GetValueForOption(clientKeyPathOpt); + int? connectTimeout = pr.GetValueForOption(connectTimeoutOpt); + int? requestTimeout = pr.GetValueForOption(requestTimeoutOpt); + bool replace = pr.GetValueForOption(replaceOpt); + + var authOptionsProvided = + !string.IsNullOrWhiteSpace(tokenRef) || + !string.IsNullOrWhiteSpace(headerName) || + !string.IsNullOrWhiteSpace(valueRef) || + inQueryString || + !string.IsNullOrWhiteSpace(usernameRef) || + !string.IsNullOrWhiteSpace(passwordRef) || + !string.IsNullOrWhiteSpace(tokenUrl) || + !string.IsNullOrWhiteSpace(clientIdRef) || + !string.IsNullOrWhiteSpace(clientSecret) || + !string.IsNullOrWhiteSpace(scopes) || + !string.IsNullOrWhiteSpace(authUrl) || + !string.IsNullOrWhiteSpace(clientId) || + !string.IsNullOrWhiteSpace(clientCertRef) || + !string.IsNullOrWhiteSpace(clientKeyRef); + + if (string.IsNullOrWhiteSpace(authType) && !authOptionsProvided) + authType = "none"; + + // Normalize transport alias + if (string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase)) + transport = "httpAutoDetect"; + + if (isInteractive) + { + if (string.IsNullOrWhiteSpace(name)) + name = Prompt("Server name"); + if (string.IsNullOrWhiteSpace(transport)) + transport = Prompt("Transport [stdio/streamableHttp/sse/httpAutoDetect]"); + if (string.IsNullOrWhiteSpace(endpoint)) + endpoint = Prompt("Endpoint"); + if (string.IsNullOrWhiteSpace(authType)) + authType = Prompt("Auth [none/bearer/apiKey/basic/oauth2ClientCredentials/oauth2DeviceCode/mtls]"); + + // Auth-mode-specific prompts + switch (authType?.ToLowerInvariant()) + { + case "bearer": + if (string.IsNullOrWhiteSpace(tokenRef)) + tokenRef = PromptRef("Token ref (e.g. env:MY_TOKEN)", "--token-ref"); + break; + case "apikey": + if (string.IsNullOrWhiteSpace(headerName)) + headerName = Prompt("Header name"); + if (string.IsNullOrWhiteSpace(valueRef)) + valueRef = PromptRef("Value ref (e.g. env:MY_KEY)", "--value-ref"); + break; + case "basic": + if (string.IsNullOrWhiteSpace(usernameRef)) + usernameRef = PromptRef("Username ref (e.g. env:MY_USER)", "--username-ref"); + if (string.IsNullOrWhiteSpace(passwordRef)) + passwordRef = PromptRef("Password ref (e.g. env:MY_PASS)", "--password-ref"); + break; + case "oauth2clientcredentials": + if (string.IsNullOrWhiteSpace(tokenUrl)) + tokenUrl = Prompt("Token URL"); + if (string.IsNullOrWhiteSpace(clientIdRef)) + clientIdRef = PromptRef("Client ID ref (e.g. env:CLIENT_ID)", "--client-id-ref"); + if (string.IsNullOrWhiteSpace(clientSecret)) + clientSecret = PromptRef("Client secret ref (e.g. env:CLIENT_SECRET)", "--client-secret-ref"); + if (interactive && string.IsNullOrWhiteSpace(scopes)) + scopes = Prompt("Scopes (comma-separated, optional)", required: false); + break; + case "oauth2devicecode": + if (string.IsNullOrWhiteSpace(authUrl)) + authUrl = Prompt("Authorization URL"); + if (string.IsNullOrWhiteSpace(tokenUrl)) + tokenUrl = Prompt("Token URL"); + if (string.IsNullOrWhiteSpace(clientId)) + clientId = Prompt("Client ID"); + if (interactive && string.IsNullOrWhiteSpace(scopes)) + scopes = Prompt("Scopes (comma-separated, optional)", required: false); + if (interactive && !login) + { + Console.Write("Start OAuth2 login now? [Y/n]: "); + var loginAnswer = Console.ReadLine()?.Trim().ToLowerInvariant(); + login = loginAnswer != "n" && loginAnswer != "no"; + } + break; + case "mtls": + if (string.IsNullOrWhiteSpace(clientCertRef)) + clientCertRef = PromptRef("Client cert ref (e.g. env:CLIENT_CERT)", "--client-cert-ref"); + if (string.IsNullOrWhiteSpace(clientKeyRef)) + clientKeyRef = PromptRef("Client key ref (e.g. env:CLIENT_KEY)", "--client-key-ref"); + break; + } + + // TLS prompts — only when explicitly requested via --interactive + if (interactive) + { + var t = transport?.ToLowerInvariant(); + if (t is "streamablehttp" or "sse" or "httpautodetect" or "http") + { + if (string.IsNullOrWhiteSpace(caCertPath)) + caCertPath = Prompt("CA cert path (optional)", required: false); + if (string.IsNullOrWhiteSpace(clientCertPath)) + clientCertPath = Prompt("TLS client cert path (optional)", required: false); + if (!string.IsNullOrWhiteSpace(clientCertPath) && string.IsNullOrWhiteSpace(clientKeyPath)) + clientKeyPath = Prompt("TLS client key path"); + } + } + } + else + { + // Non-interactive: validate required fields are present + if (string.IsNullOrWhiteSpace(name)) + { + await Console.Error.WriteLineAsync("error: MissingOption: name is required when stdin is not interactive."); + context.ExitCode = 1; + return; + } + if (string.IsNullOrWhiteSpace(transport)) + { + await Console.Error.WriteLineAsync("error: MissingOption: --transport is required when stdin is not interactive."); + context.ExitCode = 1; + return; + } + if (string.IsNullOrWhiteSpace(endpoint)) + { + await Console.Error.WriteLineAsync("error: MissingOption: --endpoint is required when stdin is not interactive."); + context.ExitCode = 1; + return; + } + if (string.IsNullOrWhiteSpace(authType)) + { + await Console.Error.WriteLineAsync("error: MissingOption: --auth is required when stdin is not interactive."); + context.ExitCode = 1; + return; + } + } + + if (login && !string.Equals(authType, "oauth2DeviceCode", StringComparison.OrdinalIgnoreCase) && + !string.Equals(authType, "oauth2devicecode", StringComparison.OrdinalIgnoreCase)) + { + await Console.Error.WriteLineAsync("error: InvalidOption: --login is only valid with --auth oauth2DeviceCode."); + context.ExitCode = 1; + return; + } + + var scopeArray = string.IsNullOrWhiteSpace(scopes) + ? null + : scopes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var normCa = string.IsNullOrWhiteSpace(caCertPath) ? null : caCertPath; + var normCert = string.IsNullOrWhiteSpace(clientCertPath) ? null : clientCertPath; + var normKey = string.IsNullOrWhiteSpace(clientKeyPath) ? null : clientKeyPath; + McpServerAddTlsOptions? tlsOptions = null; + if (normCa is not null || normCert is not null || normKey is not null) + tlsOptions = new McpServerAddTlsOptions(normCa, normCert, normKey); + + bool skipProbeForLogin = login && + string.Equals(authType, "oauth2DeviceCode", StringComparison.OrdinalIgnoreCase); + + var request = new McpServerAddRequest( + Name: name ?? string.Empty, + Transport: transport ?? string.Empty, + Endpoint: endpoint ?? string.Empty, + AuthType: authType ?? "none", + Auth: new McpServerAddAuthOptions( + TokenRef: tokenRef, + HeaderName: headerName, + ValueRef: valueRef, + InQueryString: inQueryString ? true : null, + UsernameRef: usernameRef, + PasswordRef: passwordRef, + TokenUrl: tokenUrl, + ClientIdRef: clientIdRef, + ClientSecretRef: clientSecret, + Scopes: scopeArray, + AuthUrl: authUrl, + ClientId: clientId, + ClientCertRef: clientCertRef, + ClientKeyRef: clientKeyRef), + Tls: tlsOptions, + ConnectTimeoutSeconds: connectTimeout, + RequestTimeoutSeconds: requestTimeout, + Replace: replace, + DryRun: dryRun, + SkipProbe: noProbe || skipProbeForLogin, + ForceProbeInDryRun: dryRun && browserOAuthFlowProvider is not null); + + var result = await mcpServerConfigService.AddAsync(request, ct); + + // Interactive: prompt to replace if duplicate (explicit --interactive only) + if (!result.Success + && interactive + && result.Errors.Count == 1 + && result.Errors[0].StartsWith("DuplicateServer", StringComparison.Ordinal)) + { + Console.Write($"Server '{request.Name}' already exists. Replace it? [y/N]: "); + var replaceAnswer = Console.ReadLine()?.Trim().ToLowerInvariant(); + if (replaceAnswer is "y" or "yes") + { + request = request with { Replace = true }; + result = await mcpServerConfigService.AddAsync(request, ct); + } + } + + // MCP OAuth two-call onboarding flow + if (!result.Success + && result.Probe?.Status == McpServerProbeStatus.AuthRequired + && result.Probe.AuthGuidance?.SuggestedAuthMode == "mcpOAuth" + && browserOAuthFlowProvider is not null) + { + if (nonInteractive) + { + if (json) + { + var g = result.Probe?.AuthGuidance; + var authRequiredJson = new McpAddResultJson( + Success: false, + Error: "AuthRequired", + Guidance: new McpAddGuidanceJson( + SuggestedAuthMode: "mcpOAuth", + AuthorizationUrl: g?.AuthorizationUrl, + NextCommands: g?.NextCommands)); + Console.WriteLine(JsonSerializer.Serialize(authRequiredJson, McpDryRunJsonContext.Default.McpAddResultJson)); + } + else + { + await Console.Error.WriteLineAsync( + "error: AuthRequired: server requires OAuth login. Re-run without --non-interactive or supply --client-id."); + } + context.ExitCode = 4; + return; + } + + if (dryRun) + { + if (json) + { + var g = result.Probe?.AuthGuidance; + var dryRunOAuthJson = new McpAddResultJson( + Success: false, + Name: name, + Transport: NormalizeTransportForOutput(transport ?? string.Empty), + Endpoint: endpoint, + Auth: "mcpOAuth", + Error: "dry-run: would start browser OAuth flow", + Guidance: new McpAddGuidanceJson( + SuggestedAuthMode: "mcpOAuth")); + Console.WriteLine(JsonSerializer.Serialize(dryRunOAuthJson, McpDryRunJsonContext.Default.McpAddResultJson)); + } + else + { + Console.WriteLine($"[dry-run] Would probe {endpoint}"); + Console.WriteLine("[dry-run] Probe result: AuthRequired (MCP OAuth 2.0)"); + Console.WriteLine("[dry-run] Would start browser OAuth flow (skipped in dry-run)"); + Console.WriteLine($"[dry-run] Would write config: type=mcpOAuth, endpoint={endpoint}"); + Console.WriteLine("No changes made."); + } + context.ExitCode = 0; + return; + } + + if (!json) + Console.WriteLine("This server requires OAuth authorization (MCP OAuth 2.0)."); + + try + { + var oauthConfig = new McpOAuthConfig( + ClientId: clientId, + ClientSecretRef: clientSecret, + Scopes: string.IsNullOrWhiteSpace(scopes) + ? null + : scopes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + + var flowOptions = new McpBrowserOAuthOptions( + NoBrowser: noBrowser, + Interactive: !json, + Tls: tlsOptions is null ? null : new McpTlsConfig( + tlsOptions.CaCertPath, tlsOptions.ClientCertPath, tlsOptions.ClientKeyPath)); + var oauthProgress = json ? null : new Progress(Console.WriteLine); + var flowResult = await browserOAuthFlowProvider.StartFlowAsync( + serverName: name ?? string.Empty, + endpoint: endpoint ?? string.Empty, + config: oauthConfig, + options: flowOptions, + ct: ct, + progress: oauthProgress); + + if (!flowResult.Succeeded) + { + if (json) + { + var failJson = new McpAddResultJson( + Success: false, + Error: flowResult.Error ?? "Authorization did not complete."); + Console.WriteLine(JsonSerializer.Serialize(failJson, McpDryRunJsonContext.Default.McpAddResultJson)); + } + else + { + await Console.Error.WriteLineAsync( + $"error: OAuthFailed: {FormatOAuthError(flowResult.Error)}"); + } + context.ExitCode = 1; + return; + } + + if (!json) + Console.WriteLine("Authorization complete."); + + // Second AddAsync call: write config with mcpOAuth, skip probe. + // Use CompletedConfig from the flow result (may contain server-supplied ClientId from DCR). + var persistedConfig = flowResult.CompletedConfig ?? oauthConfig; + var oauthRequest = new McpServerAddRequest( + Name: name ?? string.Empty, + Transport: transport ?? string.Empty, + Endpoint: endpoint ?? string.Empty, + AuthType: "mcpOAuth", + Auth: new McpServerAddAuthOptions( + ClientId: persistedConfig.ClientId, + ClientSecretRef: persistedConfig.ClientSecretRef, + Scopes: persistedConfig.Scopes), + Tls: tlsOptions, + ConnectTimeoutSeconds: pr.GetValueForOption(connectTimeoutOpt), + RequestTimeoutSeconds: pr.GetValueForOption(requestTimeoutOpt), + Replace: replace, + DryRun: false, + SkipProbe: true); + + var oauthAddResult = await mcpServerConfigService.AddAsync(oauthRequest, ct); + if (!oauthAddResult.Success) + { + if (json) + { + var errJson = new McpAddResultJson( + Success: false, + Error: oauthAddResult.Errors.Count > 0 ? oauthAddResult.Errors[0] : "AddFailed"); + Console.WriteLine(JsonSerializer.Serialize(errJson, McpDryRunJsonContext.Default.McpAddResultJson)); + } + else + { + foreach (var err in oauthAddResult.Errors) + await Console.Error.WriteLineAsync($"error: {err}"); + } + context.ExitCode = 1; + return; + } + + if (json) + { + var successJson = new McpAddResultJson( + Success: true, + Name: name, + Transport: NormalizeTransportForOutput(transport ?? string.Empty), + Endpoint: endpoint, + Auth: "mcpOAuth", + ToolCount: flowResult.ToolCount); + Console.WriteLine(JsonSerializer.Serialize(successJson, McpDryRunJsonContext.Default.McpAddResultJson)); + } + else + { + Console.WriteLine($"{name} added ({flowResult.ToolCount} tools available)"); + Console.WriteLine($" Transport : {NormalizeTransportForOutput(transport ?? string.Empty)}"); + Console.WriteLine($" Endpoint : {endpoint}"); + Console.WriteLine($" Auth : MCP OAuth 2.0"); + } + } + catch (OperationCanceledException) + { + await Console.Error.WriteLineAsync("Authorization cancelled."); + context.ExitCode = 130; + return; + } + + return; + } + + if (!result.Success) + { + if (json) + { + var g = result.Probe?.AuthGuidance; + var errJson = new McpAddResultJson( + Success: false, + Error: result.Errors.Count > 0 ? result.Errors[0] : "AddFailed", + Guidance: g is not null ? new McpAddGuidanceJson( + SuggestedAuthMode: g.SuggestedAuthMode, + AuthorizationUrl: g.AuthorizationUrl, + NextCommands: g.NextCommands) : null); + Console.WriteLine(JsonSerializer.Serialize(errJson, McpDryRunJsonContext.Default.McpAddResultJson)); + context.ExitCode = 1; + return; + } + + if (result.Probe is not null) + { + foreach (var err in result.Errors) + await Console.Error.WriteLineAsync($"error: {err}"); + await Console.Error.WriteLineAsync("No config was written."); + + var guidance = result.Probe.AuthGuidance; + if (guidance?.NextCommands is { Count: > 0 }) + { + await Console.Error.WriteLineAsync(string.Empty); + await Console.Error.WriteLineAsync("Try one of:"); + foreach (var cmd2 in guidance.NextCommands) + await Console.Error.WriteLineAsync($" {cmd2}"); + } + + var status = result.Probe.Status; + if (status is McpServerProbeStatus.Timeout + or McpServerProbeStatus.ConnectionFailed + or McpServerProbeStatus.Unknown) + { + await Console.Error.WriteLineAsync( + "Retry with --no-probe to persist the config offline."); + } + else if (status != McpServerProbeStatus.InvalidConfig) + { + await Console.Error.WriteLineAsync(string.Empty); + await Console.Error.WriteLineAsync( + "Use --no-probe only if you want to save the config before credentials are available."); + } + } + else + { + foreach (var err in result.Errors) + await Console.Error.WriteLineAsync($"error: {err}"); + } + context.ExitCode = 1; + return; + } + + if (dryRun) + { + var dryRunJson = BuildDryRunJson(request); + Console.WriteLine(JsonSerializer.Serialize(dryRunJson, McpDryRunJsonContext.Default.McpAddDryRunJson)); + return; + } + + if (login) + { + if (json) + { + // Inline device-code login without human-readable output to keep stdout clean JSON. + var loadResult = await serverDefinitionRepository.LoadAsync(ct); + if (!loadResult.IsOk) + { + var errJson = new McpAddResultJson(Success: false, Name: request.Name, Error: "ConfigLoadFailed"); + Console.WriteLine(JsonSerializer.Serialize(errJson, McpDryRunJsonContext.Default.McpAddResultJson)); + context.ExitCode = 1; + return; + } + + var loginDef = loadResult.Value.FirstOrDefault(s => + string.Equals(s.Name, request.Name, StringComparison.OrdinalIgnoreCase)); + if (loginDef is null) + { + var errJson = new McpAddResultJson(Success: false, Name: request.Name, Error: "ServerNotFound"); + Console.WriteLine(JsonSerializer.Serialize(errJson, McpDryRunJsonContext.Default.McpAddResultJson)); + context.ExitCode = 1; + return; + } + + if (loginDef.Auth is OAuth2DeviceCodeConfig) + { + var errJson = new McpAddResultJson( + Success: false, + Name: request.Name, + Error: $"AuthLoginRequired: device-code login requires interaction. Run: hypa mcp auth login --server {request.Name}"); + Console.WriteLine(JsonSerializer.Serialize(errJson, McpDryRunJsonContext.Default.McpAddResultJson)); + context.ExitCode = 1; + return; + } + + try + { + await authProvider.GetAuthContextAsync(loginDef, ct); + var successJson = new McpAddResultJson( + Success: true, + Name: request.Name, + Transport: NormalizeTransportForOutput(request.Transport), + Endpoint: request.Endpoint, + Auth: request.AuthType); + Console.WriteLine(JsonSerializer.Serialize(successJson, McpDryRunJsonContext.Default.McpAddResultJson)); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch + { + var errJson = new McpAddResultJson(Success: false, Name: request.Name, Error: "AuthLoginFailed"); + Console.WriteLine(JsonSerializer.Serialize(errJson, McpDryRunJsonContext.Default.McpAddResultJson)); + context.ExitCode = 1; + } + + return; + } + + Console.WriteLine($"Added MCP server: {request.Name}"); + Console.WriteLine($"Starting OAuth2 device-code login for {request.Name}..."); + var loginError = await DoAuthLoginAsync(request.Name, context, ct); + if (loginError is null) + { + Console.WriteLine($"Authenticated: {request.Name}"); + Console.WriteLine($"Run: hypa mcp schema --server {request.Name}"); + } + else + { + await Console.Error.WriteLineAsync($"error: AuthLoginFailed: {loginError}"); + await Console.Error.WriteLineAsync($"Run: hypa mcp auth login --server {request.Name}"); + context.ExitCode = 1; + } + return; + } + + if (json) + { + var successJson = new McpAddResultJson( + Success: true, + Name: request.Name, + Transport: NormalizeTransportForOutput(request.Transport), + Endpoint: request.Endpoint, + Auth: request.AuthType); + Console.WriteLine(JsonSerializer.Serialize(successJson, McpDryRunJsonContext.Default.McpAddResultJson)); + return; + } + + Console.WriteLine($"Added MCP server: {request.Name}"); + Console.WriteLine($"Run: hypa mcp auth check --server {request.Name}"); + Console.WriteLine($"Run: hypa mcp schema --server {request.Name}"); + }); + + return cmd; + } + + private static string Prompt(string label, bool required = true) + { + while (true) + { + Console.Write($"{label}: "); + var value = Console.ReadLine()?.Trim(); + if (!string.IsNullOrWhiteSpace(value)) + return value; + if (!required) + return string.Empty; + } + } + + private static string PromptRef(string label, string optionName, bool required = true) + { + while (true) + { + Console.Write($"{label}: "); + var value = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(value)) + { + if (!required) return string.Empty; + continue; + } + if (value.StartsWith("env:", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + return value; + Console.Error.WriteLine($"error: InvalidSecretRef: {optionName} must use an explicit resolver prefix such as env: or file:."); + } + } + + private static McpAddDryRunJson BuildDryRunJson(McpServerAddRequest request) + { + var auth = BuildAuthDryRunJson(request.AuthType, request.Auth); + McpAddTlsDryRunJson? tls = request.Tls is { } t + ? new McpAddTlsDryRunJson(t.CaCertPath, t.ClientCertPath, t.ClientKeyPath) + : null; + + return new McpAddDryRunJson( + request.Name, + NormalizeTransportForOutput(request.Transport), + request.Endpoint, + auth, + tls, + request.ConnectTimeoutSeconds, + request.RequestTimeoutSeconds); + } + + private static McpAddAuthDryRunJson BuildAuthDryRunJson(string authType, McpServerAddAuthOptions a) + { + return authType.ToLowerInvariant() switch + { + "bearer" => new McpAddAuthDryRunJson("bearer", TokenRef: a.TokenRef), + "apikey" => new McpAddAuthDryRunJson("apiKey", + HeaderName: a.HeaderName, ValueRef: a.ValueRef, InQueryString: a.InQueryString), + "basic" => new McpAddAuthDryRunJson("basic", + UsernameRef: a.UsernameRef, PasswordRef: a.PasswordRef), + "oauth2clientcredentials" => new McpAddAuthDryRunJson("oauth2ClientCredentials", + TokenUrl: a.TokenUrl, ClientIdRef: a.ClientIdRef, ClientSecretRef: a.ClientSecretRef, Scopes: a.Scopes), + "oauth2devicecode" => new McpAddAuthDryRunJson("oauth2DeviceCode", + AuthUrl: a.AuthUrl, TokenUrl: a.TokenUrl, ClientId: a.ClientId, Scopes: a.Scopes), + "mtls" => new McpAddAuthDryRunJson("mtls", + ClientCertRef: a.ClientCertRef, ClientKeyRef: a.ClientKeyRef), + _ => new McpAddAuthDryRunJson("none"), + }; + } + + private static string NormalizeTransportForOutput(string transport) => + transport.ToLowerInvariant() switch + { + "streamablehttp" => "streamableHttp", + "httpautodetect" or "http" => "httpAutoDetect", + "sse" => "sse", + _ => transport, + }; + + private Command BuildInvoke() + { + var serverOpt = new Option("--server", "Upstream server name.") { IsRequired = true }; + var toolOpt = new Option("--tool", "Tool name on the server.") { IsRequired = true }; + var argumentsOpt = new Option("--arguments", "Tool arguments as a JSON object string."); + var hintOpt = new Option("--hint", "Compression hint: raw | summary | structured."); + var jsonOpt = new Option("--json", "Output result as JSON."); + + var cmd = new Command("invoke", "Invoke a tool on an upstream MCP server."); + cmd.AddOption(serverOpt); + cmd.AddOption(toolOpt); + cmd.AddOption(argumentsOpt); + cmd.AddOption(hintOpt); + cmd.AddOption(jsonOpt); + + cmd.SetHandler(async context => + { + var server = context.ParseResult.GetValueForOption(serverOpt)!; + var tool = context.ParseResult.GetValueForOption(toolOpt)!; + var arguments = context.ParseResult.GetValueForOption(argumentsOpt); + var hint = context.ParseResult.GetValueForOption(hintOpt); + var json = context.ParseResult.GetValueForOption(jsonOpt); + var ct = context.GetCancellationToken(); + + var compressionHint = ParseHint(hint); + var request = new McpProxyRequest(server, tool, new JsonPayload(arguments ?? "{}"), compressionHint); + + McpResult result; + try + { + result = await proxyService.InvokeAsync(request, ct); + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"error: {ex.Message}"); + context.ExitCode = 1; + return; + } + + if (json) + { + Console.WriteLine(JsonSerializer.Serialize(result, McpJsonContext.Default.McpResult)); + } + else + { + if (result.IsError) + { + var code = result.Error?.Code ?? McpErrorCodes.ToolInvocationFailed; + await Console.Error.WriteLineAsync($"error ({code}): {result.Error?.Message}"); + context.ExitCode = 1; + return; + } + + Console.WriteLine(result.CompressedResponse); + Console.Error.WriteLine($"duration: {result.Latency.Elapsed.TotalMilliseconds:F0}ms"); + } + }); + + return cmd; + } + + private Command BuildBatch() + { + var serverOpt = new Option("--server", "Default server name for requests that omit it.") { IsRequired = false }; + var fileOpt = new Option("--file", "Path to a JSON file containing a batch requests array.") { IsRequired = true }; + var jsonOpt = new Option("--json", "Output results as JSON."); + + var cmd = new Command("batch", "Invoke multiple tools in parallel."); + cmd.AddOption(serverOpt); + cmd.AddOption(fileOpt); + cmd.AddOption(jsonOpt); + + cmd.SetHandler(async context => + { + var defaultServer = context.ParseResult.GetValueForOption(serverOpt); + var file = context.ParseResult.GetValueForOption(fileOpt)!; + var json = context.ParseResult.GetValueForOption(jsonOpt); + var ct = context.GetCancellationToken(); + + if (!file.Exists) + { + await Console.Error.WriteLineAsync($"error: file not found: {file.FullName}"); + context.ExitCode = 1; + return; + } + + IReadOnlyList batch; + try + { + var fileContent = await File.ReadAllTextAsync(file.FullName, ct); + batch = ParseBatchFile(fileContent, defaultServer); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to parse batch file '{File}'", file.FullName); + await Console.Error.WriteLineAsync($"error ({McpErrorCodes.InvalidRequest}): failed to parse batch file."); + context.ExitCode = 1; + return; + } + + if (batch.Count == 0) + { + await Console.Error.WriteLineAsync("error: batch file contains no requests."); + context.ExitCode = 1; + return; + } + + IReadOnlyList results; + try + { + results = await proxyService.InvokeBatchAsync(batch, ct); + } + catch + { + await Console.Error.WriteLineAsync($"error ({McpErrorCodes.ToolInvocationFailed}): batch invocation failed."); + context.ExitCode = 1; + return; + } + + if (json) + { + Console.WriteLine(JsonSerializer.Serialize(results, McpJsonContext.Default.IReadOnlyListMcpResult)); + } + else + { + var succeeded = results.Count(r => !r.IsError); + var failed = results.Count(r => r.IsError); + Console.WriteLine($"batch: {results.Count} request(s) — {succeeded} succeeded, {failed} failed."); + foreach (var r in results) + { + var status = r.IsError ? "ERROR" : "OK"; + Console.WriteLine($" {r.ServerName}/{r.ToolName} {status} ({r.Latency.Elapsed.TotalMilliseconds:F0}ms)"); + if (r.IsError && r.Error is not null) + Console.WriteLine($" {r.Error.Code}: {r.Error.Message}"); + } + if (failed > 0) + context.ExitCode = 1; + } + }); + + return cmd; + } + + private Command BuildSchema() + { + var serverOpt = new Option("--server", "Filter schema to a single server."); + var jsonOpt = new Option("--json", "Output schema as JSON."); + + var cmd = new Command("schema", "Show tool schemas for configured MCP servers."); + cmd.AddOption(serverOpt); + cmd.AddOption(jsonOpt); + + cmd.SetHandler(async context => + { + var server = context.ParseResult.GetValueForOption(serverOpt); + var json = context.ParseResult.GetValueForOption(jsonOpt); + var ct = context.GetCancellationToken(); + + McpSchemaManifest manifest; + try + { + manifest = await proxyService.GetSchemaAsync(ct); + } + catch + { + await Console.Error.WriteLineAsync($"error ({McpErrorCodes.SchemaUnavailable}): failed to retrieve schema."); + context.ExitCode = 1; + return; + } + + var servers = string.IsNullOrWhiteSpace(server) + ? manifest.Servers + : manifest.Servers.Where(s => string.Equals(s.ServerName, server, StringComparison.OrdinalIgnoreCase)).ToList(); + + var errors = string.IsNullOrWhiteSpace(server) + ? manifest.Errors + : manifest.Errors?.Where(e => string.Equals(e.ServerName, server, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (json) + { + var filtered = new McpSchemaManifest(servers, errors); + Console.WriteLine(JsonSerializer.Serialize(filtered, McpJsonContext.Default.McpSchemaManifest)); + return; + } + + if (servers.Count == 0 && (errors is null || errors.Count == 0)) + { + Console.WriteLine(string.IsNullOrWhiteSpace(server) + ? "No MCP servers configured." + : $"Server '{server}' not found."); + return; + } + + foreach (var srv in servers) + { + Console.WriteLine($"{srv.ServerName} ({srv.Tools.Count} tool(s)):"); + foreach (var t in srv.Tools) + Console.WriteLine($" {t.Name}: {t.Description}"); + } + + if (errors is { Count: > 0 }) + { + foreach (var e in errors) + await Console.Error.WriteLineAsync($"warning ({e.Code}): {e.ServerName}: {e.Message}"); + } + }); + + return cmd; + } + + private Command BuildSearch() + { + var queryOpt = new Option("--query", "Search query text.") { IsRequired = true }; + var jsonOpt = new Option("--json", "Output results as JSON."); + + var cmd = new Command("search", "Search for tools across configured MCP servers."); + cmd.AddOption(queryOpt); + cmd.AddOption(jsonOpt); + + cmd.SetHandler(async context => + { + var query = context.ParseResult.GetValueForOption(queryOpt)!; + var json = context.ParseResult.GetValueForOption(jsonOpt); + var ct = context.GetCancellationToken(); + + IReadOnlyList results; + try + { + results = await proxyService.SearchToolsAsync(query, ct); + } + catch + { + await Console.Error.WriteLineAsync($"error ({McpErrorCodes.SchemaUnavailable}): failed to search tools."); + context.ExitCode = 1; + return; + } + + if (json) + { + Console.WriteLine(JsonSerializer.Serialize(results, McpJsonContext.Default.IReadOnlyListMcpToolSearchResult)); + return; + } + + if (results.Count == 0) + { + Console.WriteLine($"No tools matching '{query}'."); + return; + } + + Console.WriteLine($"Found {results.Count} tool(s) matching '{query}':"); + foreach (var r in results) + Console.WriteLine($" {r.ServerName}/{r.ToolName} (score={r.Score:F2}): {r.Description}"); + }); + + return cmd; + } + + private Command BuildList() + { + var jsonOpt = new Option("--json", "Output server list as JSON."); + + var cmd = new Command("list", "List configured upstream MCP servers."); + cmd.AddOption(jsonOpt); + + cmd.SetHandler(async context => + { + var json = context.ParseResult.GetValueForOption(jsonOpt); + var ct = context.GetCancellationToken(); + + var loadResult = await serverDefinitionRepository.LoadAsync(ct); + if (!loadResult.IsOk) + { + await Console.Error.WriteLineAsync($"error ({McpErrorCodes.ServerUnavailable}): failed to load server configuration: {loadResult.Error.Message}"); + context.ExitCode = 1; + return; + } + + var servers = loadResult.Value; + + if (json) + { + var items = servers + .Select(s => new McpServerListItemJson( + s.Name, + s.Transport.Kind.ToString(), + s.Transport.Endpoint, + s.Auth.GetType().Name.Replace("Config", string.Empty, StringComparison.Ordinal), + s.Tls is not null)) + .ToList(); + Console.WriteLine(JsonSerializer.Serialize( + (IReadOnlyList)items, + McpJsonContext.Default.IReadOnlyListMcpServerListItemJson)); + return; + } + + if (servers.Count == 0) + { + Console.WriteLine("No MCP servers configured."); + return; + } + + foreach (var s in servers) + { + var auth = s.Auth.GetType().Name.Replace("Config", string.Empty, StringComparison.Ordinal); + var endpoint = s.Transport.Endpoint ?? "—"; + Console.WriteLine($" {s.Name} {s.Transport.Kind} {endpoint} {auth}"); + } + }); + + return cmd; + } + + private Command BuildTools() + { + var serverOpt = new Option("--server", "Filter tools to a single server."); + var jsonOpt = new Option("--json", "Output tool list as JSON."); + + var cmd = new Command("tools", "List available tools across configured MCP servers."); + cmd.AddOption(serverOpt); + cmd.AddOption(jsonOpt); + + cmd.SetHandler(async context => + { + var server = context.ParseResult.GetValueForOption(serverOpt); + var json = context.ParseResult.GetValueForOption(jsonOpt); + var ct = context.GetCancellationToken(); + + McpSchemaManifest manifest; + try + { + manifest = await proxyService.GetSchemaAsync(ct); + } + catch + { + await Console.Error.WriteLineAsync($"error ({McpErrorCodes.SchemaUnavailable}): failed to retrieve tool list."); + context.ExitCode = 1; + return; + } + + var servers = string.IsNullOrWhiteSpace(server) + ? manifest.Servers + : manifest.Servers.Where(s => string.Equals(s.ServerName, server, StringComparison.OrdinalIgnoreCase)).ToList(); + + var errors = string.IsNullOrWhiteSpace(server) + ? manifest.Errors + : manifest.Errors?.Where(e => string.Equals(e.ServerName, server, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (json) + { + var entries = servers + .SelectMany(s => s.Tools.Select(t => new McpToolListEntryJson(s.ServerName, t.Name, t.Description))) + .ToList(); + Console.WriteLine(JsonSerializer.Serialize( + (IReadOnlyList)entries, + McpJsonContext.Default.IReadOnlyListMcpToolListEntryJson)); + + if (errors is { Count: > 0 }) + foreach (var e in errors) + await Console.Error.WriteLineAsync($"warning ({e.Code}): {e.ServerName}: {e.Message}"); + + return; + } + + if (servers.Count == 0 && (errors is null || errors.Count == 0)) + { + Console.WriteLine(string.IsNullOrWhiteSpace(server) + ? "No tools found." + : $"Server '{server}' not found."); + return; + } + + foreach (var srv in servers) + foreach (var t in srv.Tools) + Console.WriteLine($" {srv.ServerName}/{t.Name} — {TruncateDescription(t.Description)}"); + + if (errors is { Count: > 0 }) + foreach (var e in errors) + await Console.Error.WriteLineAsync($"warning ({e.Code}): {e.ServerName}: {e.Message}"); + }); + + return cmd; + } + + private static string TruncateDescription(string? desc, int max = 100) => + desc is null ? string.Empty : + desc.Length <= max ? desc : + string.Concat(desc.AsSpan(0, max), "…"); + + private Command BuildAuth() + { + var authCmd = new Command("auth", "Authentication operations for upstream MCP servers."); + authCmd.AddCommand(BuildAuthCheck()); + authCmd.AddCommand(BuildAuthLogin()); + return authCmd; + } + + private Command BuildAuthCheck() + { + var serverOpt = new Option("--server", "Server name to check.") { IsRequired = true }; + var jsonOpt = new Option("--json", "Output result as JSON."); + + var cmd = new Command("check", "Validate credentials for a configured MCP server."); + cmd.AddOption(serverOpt); + cmd.AddOption(jsonOpt); + + cmd.SetHandler(async context => + { + var server = context.ParseResult.GetValueForOption(serverOpt)!; + var json = context.ParseResult.GetValueForOption(jsonOpt); + var ct = context.GetCancellationToken(); + + var loadResult = await serverDefinitionRepository.LoadAsync(ct); + if (!loadResult.IsOk) + { + await Console.Error.WriteLineAsync($"error ({McpErrorCodes.SchemaUnavailable}): failed to load server configuration."); + context.ExitCode = 1; + return; + } + + var definition = loadResult.Value.FirstOrDefault(s => + string.Equals(s.Name, server, StringComparison.OrdinalIgnoreCase)); + + if (definition is null) + { + await Console.Error.WriteLineAsync($"error ({McpErrorCodes.UnknownServer}): server '{server}' not found in configuration."); + context.ExitCode = 1; + return; + } + + try + { + var authContext = await authProvider.GetAuthContextAsync(definition, ct); + var authMode = definition.Auth.GetType().Name.Replace("Config", string.Empty, StringComparison.Ordinal); + + if (json) + { + var checkResult = new AuthCheckResult(server, authMode, Passed: true); + Console.WriteLine(JsonSerializer.Serialize(checkResult, McpJsonContext.Default.AuthCheckResult)); + } + else + { + Console.WriteLine($"Auth check passed for '{server}'."); + Console.WriteLine($" mode: {authMode}"); + Console.WriteLine($" headers: {authContext.Headers.Count}"); + Console.WriteLine($" bearer: {(authContext.BearerToken is not null ? "present" : "absent")}"); + Console.WriteLine($" certificate: {(authContext.ClientCertificatePath is not null ? "configured" : "none")}"); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch + { + if (json) + { + var checkResult = new AuthCheckResult(server, AuthMode: "unknown", Passed: false, Error: $"Auth check failed for '{server}'."); + Console.WriteLine(JsonSerializer.Serialize(checkResult, McpJsonContext.Default.AuthCheckResult)); + } + else + { + await Console.Error.WriteLineAsync($"error ({McpErrorCodes.AuthRequired}): auth check failed for '{server}'."); + } + context.ExitCode = 1; + } + }); + + return cmd; + } + + private Command BuildAuthLogin() + { + var serverOpt = new Option("--server", "Server name to authenticate.") { IsRequired = true }; + + var cmd = new Command("login", "Initiate OAuth2 device-code login for a server."); + cmd.AddOption(serverOpt); + + cmd.SetHandler(async context => + { + var server = context.ParseResult.GetValueForOption(serverOpt)!; + var ct = context.GetCancellationToken(); + var loginError = await DoAuthLoginAsync(server, context, ct); + if (loginError is not null) + await Console.Error.WriteLineAsync($"error ({McpErrorCodes.AuthRequired}): {loginError}"); + }); + + return cmd; + } + + private async Task DoAuthLoginAsync(string server, InvocationContext context, CancellationToken ct) + { + var loadResult = await serverDefinitionRepository.LoadAsync(ct); + if (!loadResult.IsOk) + { + await Console.Error.WriteLineAsync($"error ({McpErrorCodes.SchemaUnavailable}): failed to load server configuration."); + context.ExitCode = 1; + return "failed to load server configuration."; + } + + var definition = loadResult.Value.FirstOrDefault(s => + string.Equals(s.Name, server, StringComparison.OrdinalIgnoreCase)); + + if (definition is null) + { + await Console.Error.WriteLineAsync($"error: server '{server}' not found in configuration."); + context.ExitCode = 1; + return $"server '{server}' not found in configuration."; + } + + if (definition.Auth is McpOAuthConfig oauthConfig) + { + if (browserOAuthFlowProvider is null) + { + await Console.Error.WriteLineAsync("error: OAuth browser flow provider not available."); + context.ExitCode = 1; + return "OAuth browser flow provider not available."; + } + + var oauthProgress = new Progress(Console.WriteLine); + McpBrowserOAuthFlowResult flowResult; + try + { + flowResult = await browserOAuthFlowProvider.StartFlowAsync( + serverName: server, + endpoint: definition.Transport.Endpoint ?? string.Empty, + config: oauthConfig, + options: new McpBrowserOAuthOptions(Tls: definition.Tls), + ct: ct, + progress: oauthProgress); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + context.ExitCode = 1; + return $"auth login failed for '{server}': {ex.Message}"; + } + + if (flowResult.Succeeded) + { + Console.WriteLine($"Login successful for '{server}'."); + return null; + } + + context.ExitCode = 1; + return FormatOAuthError(flowResult.Error) ?? $"auth login failed for '{server}'."; + } + + if (definition.Auth is not OAuth2DeviceCodeConfig) + { + var mode = definition.Auth.GetType().Name.Replace("Config", string.Empty, StringComparison.Ordinal); + await Console.Error.WriteLineAsync( + $"error: server '{server}' uses '{mode}' auth. 'auth login' only applies to oauth2DeviceCode and mcpOAuth servers."); + context.ExitCode = 1; + return $"server '{server}' uses '{mode}' auth."; + } + + try + { + Console.WriteLine($"Initiating device-code login for '{server}'..."); + await authProvider.GetAuthContextAsync(definition, ct); + Console.WriteLine($"Login successful for '{server}'."); + return null; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch + { + context.ExitCode = 1; + return $"auth login failed for '{server}'."; + } + } + + private static IReadOnlyList ParseBatchFile(string json, string? defaultServer) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Array) + throw new InvalidOperationException("Batch file must contain a JSON array."); + + var requests = new List(root.GetArrayLength()); + foreach (var element in root.EnumerateArray()) + { + var server = (element.TryGetProperty("server", out var sEl) ? sEl.GetString() : null) + ?? defaultServer + ?? string.Empty; + var tool = element.GetProperty("tool").GetString() ?? string.Empty; + var argumentsJson = element.TryGetProperty("arguments", out var argsEl) + ? argsEl.GetRawText() + : "{}"; + var hintStr = element.TryGetProperty("hint", out var hintEl) ? hintEl.GetString() : null; + requests.Add(new McpProxyRequest(server, tool, new JsonPayload(argumentsJson), ParseHint(hintStr))); + } + return requests; + } + + private static CompressionHint? ParseHint(string? hint) => + hint?.ToLowerInvariant() switch + { + "raw" => CompressionHint.Raw, + "summary" => CompressionHint.Summary, + "structured" => CompressionHint.Structured, + _ => null + }; + + // The MCP .NET SDK enforces RFC 9728: the `resource` field in the server's + // Protected Resource Metadata must exactly match the endpoint URI. Some servers + // declare the base domain (e.g. https://example.com) while their MCP path is + // /mcp, causing this mismatch. It is a server-side spec compliance issue and + // cannot be bypassed client-side. + private static string FormatOAuthError(string? error) + { + if (error is not null && error.Contains("Resource URI in metadata", StringComparison.OrdinalIgnoreCase)) + { + return $"{error}\n" + + "hint: The server's OAuth metadata declares a resource URI that does not match your endpoint.\n" + + " This is a server-side spec compliance issue (RFC 9728). Contact the server operator."; + } + + return error ?? "Authorization did not complete."; + } +} diff --git a/src/Hypa.Cli/Commands/ServeCommand.cs b/src/Hypa.Cli/Commands/ServeCommand.cs index ce2fc40..ecc5917 100644 --- a/src/Hypa.Cli/Commands/ServeCommand.cs +++ b/src/Hypa.Cli/Commands/ServeCommand.cs @@ -64,6 +64,8 @@ public Command Build() mcpBuilder.WithTools(); if (filter is null || filter.Contains("hypa_compress")) mcpBuilder.WithTools(); + if (filter is null || filter.Contains("hypa_mcp")) + mcpBuilder.WithTools(); await builder.Build().RunAsync(ct); }); diff --git a/src/Hypa.Cli/DI/CliServiceExtensions.cs b/src/Hypa.Cli/DI/CliServiceExtensions.cs index 3630ac0..c484073 100644 --- a/src/Hypa.Cli/DI/CliServiceExtensions.cs +++ b/src/Hypa.Cli/DI/CliServiceExtensions.cs @@ -57,6 +57,7 @@ private static void AddCommands(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => { @@ -76,11 +77,13 @@ private static void AddCommands(IServiceCollection services) root.AddCommand(sp.GetRequiredService().Build()); root.AddCommand(sp.GetRequiredService().Build()); root.AddCommand(sp.GetRequiredService().Build()); + root.AddCommand(sp.GetRequiredService().BuildMd()); root.AddCommand(sp.GetRequiredService().Build()); root.AddCommand(sp.GetRequiredService().Build()); root.AddCommand(sp.GetRequiredService().Build()); root.AddCommand(sp.GetRequiredService().Build()); root.AddCommand(sp.GetRequiredService().Build()); + root.AddCommand(sp.GetRequiredService().Build()); sp.GetRequiredService().AttachTo(root); return root; }); diff --git a/src/Hypa.Cli/Json/CodeJsonContext.cs b/src/Hypa.Cli/Json/CodeJsonContext.cs index 7e526b4..8a4c983 100644 --- a/src/Hypa.Cli/Json/CodeJsonContext.cs +++ b/src/Hypa.Cli/Json/CodeJsonContext.cs @@ -1,11 +1,16 @@ using System.Text.Json.Serialization; +using Hypa.Cli.Commands; using Hypa.Sdk.CodeIntelligence; namespace Hypa.Cli.Json; +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] [JsonSerializable(typeof(CodeIndexResult))] [JsonSerializable(typeof(CodeGraphResult))] [JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(IReadOnlyList))] [JsonSerializable(typeof(IReadOnlyList))] [JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(MarkdownQueryJsonResult))] internal sealed partial class CodeJsonContext : JsonSerializerContext; diff --git a/src/Hypa.Cli/Json/McpJsonContext.cs b/src/Hypa.Cli/Json/McpJsonContext.cs new file mode 100644 index 0000000..2c17095 --- /dev/null +++ b/src/Hypa.Cli/Json/McpJsonContext.cs @@ -0,0 +1,100 @@ +using System.Text.Json.Serialization; +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Cli.Json; + +[JsonSerializable(typeof(McpResult))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(McpSchemaManifest))] +[JsonSerializable(typeof(McpServerSchema))] +[JsonSerializable(typeof(McpToolSchema))] +[JsonSerializable(typeof(McpSchemaError))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(McpToolSearchResult))] +[JsonSerializable(typeof(McpProxyError))] +[JsonSerializable(typeof(McpLatencyMetadata))] +[JsonSerializable(typeof(JsonPayload))] +[JsonSerializable(typeof(AuthCheckResult))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true, + WriteIndented = true)] +internal sealed partial class McpJsonContext : JsonSerializerContext { } + +internal sealed record AuthCheckResult( + string Server, + string AuthMode, + bool Passed, + string? Error = null); + +internal sealed record McpServerListItemJson( + string Name, + string Transport, + string? Endpoint, + string Auth, + bool HasTls); + +internal sealed record McpToolListEntryJson( + string ServerName, + string ToolName, + string Description); + +internal sealed record McpAddDryRunJson( + string Name, + string Transport, + string? Endpoint, + McpAddAuthDryRunJson? Auth, + McpAddTlsDryRunJson? Tls, + int? ConnectTimeoutSeconds, + int? RequestTimeoutSeconds); + +internal sealed record McpAddAuthDryRunJson( + string Type, + string? TokenRef = null, + string? HeaderName = null, + string? ValueRef = null, + bool? InQueryString = null, + string? UsernameRef = null, + string? PasswordRef = null, + string? TokenUrl = null, + string? ClientIdRef = null, + string? ClientSecretRef = null, + string[]? Scopes = null, + string? AuthUrl = null, + string? ClientId = null, + string? ClientCertRef = null, + string? ClientKeyRef = null); + +internal sealed record McpAddTlsDryRunJson( + string? CaCertPath, + string? ClientCertPath, + string? ClientKeyPath); + +internal sealed record McpAddResultJson( + bool Success, + string? Name = null, + string? Transport = null, + string? Endpoint = null, + string? Auth = null, + int? ToolCount = null, + string? Error = null, + McpAddGuidanceJson? Guidance = null); + +internal sealed record McpAddGuidanceJson( + string? SuggestedAuthMode = null, + string? AuthorizationUrl = null, + IReadOnlyList? NextCommands = null); + +[JsonSerializable(typeof(McpAddDryRunJson))] +[JsonSerializable(typeof(McpAddAuthDryRunJson))] +[JsonSerializable(typeof(McpAddTlsDryRunJson))] +[JsonSerializable(typeof(McpAddResultJson))] +[JsonSerializable(typeof(McpAddGuidanceJson))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] +internal sealed partial class McpDryRunJsonContext : JsonSerializerContext { } diff --git a/src/Hypa.Infrastructure/CodeIntelligence/CodePatternExtractor.cs b/src/Hypa.Infrastructure/CodeIntelligence/CodePatternExtractor.cs index c877156..26b0f9a 100644 --- a/src/Hypa.Infrastructure/CodeIntelligence/CodePatternExtractor.cs +++ b/src/Hypa.Infrastructure/CodeIntelligence/CodePatternExtractor.cs @@ -521,4 +521,300 @@ private sealed record GraphFacts(IReadOnlyList References, IReadO private sealed record TypeRelationshipCapture(string SourceName, string TargetName, string EdgeKind, string ReferenceKind, int StartByte); private sealed record ReferenceCapture(string TargetName, int StartByte); + + private static readonly Regex MarkdownAtxHeading = new(@"^(#{1,6})\s+([^#\n]+)$", RegexOptions.Multiline); + private static readonly Regex MarkdownCodeBlock = new(@"```(\w*)\n?([\s\S]*?)```", RegexOptions.Multiline); + private static readonly Regex MarkdownFrontmatter = new(@"^---\r?\n([\s\S]*?)\r?\n---", RegexOptions.Multiline); + private static readonly Regex MarkdownFrontmatterKey = new(@"^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.+)$", RegexOptions.Multiline); + private static readonly Regex MarkdownIdentifier = new(@"\b[a-zA-Z][a-zA-Z0-9_-]*\b", RegexOptions.Multiline); + private static readonly Regex MarkdownHeadingLevelAtLine = new(@"^(#{1,6})\s+"); + private static readonly Regex MarkdownLink = new(@"\[([^\]]+)\]\([^\)]+\)", RegexOptions.Multiline); + private static readonly Regex MarkdownInlineCode = new(@"`([^`]+)`", RegexOptions.Multiline); + private static readonly Regex MarkdownHeadingMarker = new(@"^\s{0,3}#{1,6}\s+", RegexOptions.Multiline); + private static readonly Regex MarkdownEmphasisMarkers = new(@"\*\*|\*|__|_", RegexOptions.Multiline); + private static readonly Regex MarkdownNonAnchor = new(@"[^a-z0-9-]", RegexOptions.Multiline); + private static readonly Regex MarkdownWhitespace = new(@"\s+", RegexOptions.Multiline); + private static readonly Regex MarkdownCodeFenceLine = new(@"^```.*$", RegexOptions.Multiline); + private static readonly Regex MarkdownBlankLines = new(@"(\r?\n){3,}", RegexOptions.Multiline); + + /// + /// Extracts Markdown structure: headings, code blocks, frontmatter. + /// + public static CodeStructureDocument ExtractMarkdown(CodeFileIdentity file, string content, ProviderProvenance provenance) + { + var headings = ExtractMarkdownHeadings(file, content, provenance).ToArray(); + var codeBlocks = ExtractMarkdownCodeBlocks(file, content, provenance).ToArray(); + var symbols = headings.Concat(codeBlocks).ToArray(); + + var references = ExtractIdentifierReferencesMarkdown(file, content, provenance) + .Concat(ExtractMarkdownFrontmatter(file, content, provenance)) + .ToArray(); + + var edges = ExtractMarkdownEdges(content, symbols).ToArray(); + var sections = ExtractMarkdownSections(file, content, headings, provenance); + var frontmatterYaml = ExtractMarkdownFrontmatterYaml(content); + var plainText = ToMarkdownPlainText(content, removeFrontmatter: true); + + return new CodeStructureDocument + { + File = file, + Provenance = provenance, + Symbols = symbols, + References = references, + DependencyEdges = edges, + Sections = sections, + FrontmatterYaml = frontmatterYaml, + PlainText = plainText, + }; + } + + private static IReadOnlyList ExtractMarkdownSections(CodeFileIdentity file, string content, IReadOnlyList headings, ProviderProvenance provenance) + { + var ordered = headings.OrderBy(h => h.Span.StartByte).ToList(); + var sections = new List(ordered.Count); + + for (var i = 0; i < ordered.Count; i++) + { + var heading = ordered[i]; + var headingLevel = GetMarkdownHeadingLevelAtOffset(content, heading.Span.StartByte); + var endBoundary = content.Length; + for (var j = i + 1; j < ordered.Count; j++) + { + var next = ordered[j]; + var nextLevel = GetMarkdownHeadingLevelAtOffset(content, next.Span.StartByte); + if (nextLevel <= headingLevel) + { + endBoundary = next.Span.StartByte; + break; + } + } + + var headingPath = BuildMarkdownHeadingPath(content, ordered, heading); + var sectionText = content[heading.Span.StartByte..Math.Max(heading.Span.StartByte, endBoundary)]; + var endPos = LineColumn(content, endBoundary); + + sections.Add(new MarkdownSection + { + Id = CodeStableId.ForSymbol(file.RelativePath, "section", headingPath, heading.Span.StartByte), + FilePath = file.RelativePath, + HeadingText = heading.Name, + HeadingLevel = headingLevel, + HeadingPath = headingPath, + HeadingAnchor = ToMarkdownAnchor(heading.Name), + StartLine = heading.Span.StartLine, + EndLine = endPos.Line, + StartByte = heading.Span.StartByte, + EndByte = endBoundary, + Text = sectionText, + PlainText = ToMarkdownPlainText(sectionText, removeFrontmatter: false), + Provenance = provenance, + }); + } + + return sections; + } + + private static string BuildMarkdownHeadingPath(string content, IReadOnlyList allHeadings, CodeSymbol heading) + { + var stack = new Stack(); + var cursor = heading; + while (cursor is not null) + { + stack.Push(cursor.Name); + cursor = FindClosestAncestor(content, allHeadings, cursor); + } + + return string.Join('/', stack); + } + + private static string ToMarkdownAnchor(string headingText) + { + var lower = headingText.ToLowerInvariant().Trim(); + var hyphenated = MarkdownWhitespace.Replace(lower, "-"); + var filtered = MarkdownNonAnchor.Replace(hyphenated, string.Empty); + return filtered.Trim('-'); + } + + private static string? ExtractMarkdownFrontmatterYaml(string content) + { + var match = MarkdownFrontmatter.Match(content); + return match.Success ? match.Groups[1].Value : null; + } + + private static string ToMarkdownPlainText(string markdown, bool removeFrontmatter) + { + var text = markdown; + if (removeFrontmatter) + text = MarkdownFrontmatter.Replace(text, string.Empty); + + text = MarkdownHeadingMarker.Replace(text, string.Empty); + text = MarkdownCodeFenceLine.Replace(text, string.Empty); + text = MarkdownLink.Replace(text, "$1"); + text = MarkdownInlineCode.Replace(text, "$1"); + text = MarkdownEmphasisMarkers.Replace(text, string.Empty); + text = MarkdownBlankLines.Replace(text, Environment.NewLine + Environment.NewLine); + + return text.Trim(); + } + + private static IEnumerable ExtractMarkdownHeadings(CodeFileIdentity file, string content, ProviderProvenance provenance) + { + foreach (Match match in MarkdownAtxHeading.Matches(content)) + { + var headingText = match.Groups[2].Value.Trim(); + if (string.IsNullOrWhiteSpace(headingText)) + continue; + + yield return new CodeSymbol + { + Id = CodeStableId.ForSymbol(file.RelativePath, "heading", headingText, match.Index), + FilePath = file.RelativePath, + Language = file.Language, + Name = headingText, + Kind = "heading", + Span = SpanFor(content, match.Index, match.Length), + Provenance = provenance with { Confidence = Math.Min(provenance.Confidence, 0.85) }, + }; + } + } + + private static IEnumerable ExtractMarkdownCodeBlocks(CodeFileIdentity file, string content, ProviderProvenance provenance) + { + foreach (Match match in MarkdownCodeBlock.Matches(content)) + { + var languageInfo = match.Groups[1].Value.Trim(); + var blockLanguage = string.IsNullOrWhiteSpace(languageInfo) ? "unknown" : languageInfo; + var representativeName = blockLanguage == "unknown" ? "code-block" : $"{blockLanguage}-block"; + + yield return new CodeSymbol + { + Id = CodeStableId.ForSymbol(file.RelativePath, "code-block", representativeName, match.Index), + FilePath = file.RelativePath, + Language = file.Language, + Name = representativeName, + Kind = "code-block", + Span = SpanFor(content, match.Index, match.Length), + Provenance = provenance with { Confidence = Math.Min(provenance.Confidence, 0.8) }, + }; + } + } + + private static IEnumerable ExtractMarkdownFrontmatter(CodeFileIdentity file, string content, ProviderProvenance provenance) + { + var frontmatterMatch = MarkdownFrontmatter.Match(content); + if (!frontmatterMatch.Success) + yield break; + + var yamlContent = frontmatterMatch.Groups[1].Value; + foreach (Match keyMatch in MarkdownFrontmatterKey.Matches(yamlContent)) + { + var key = keyMatch.Groups[1].Value; + if (string.IsNullOrWhiteSpace(key)) + continue; + + yield return new CodeReference + { + Id = CodeStableId.ForReference(file.RelativePath, "frontmatter", key, frontmatterMatch.Index + keyMatch.Index), + FilePath = file.RelativePath, + Kind = "frontmatter", + Target = key, + Span = SpanFor(content, frontmatterMatch.Index + keyMatch.Index, keyMatch.Length), + Provenance = provenance with { FactKind = "heuristic", Confidence = 0.85 }, + }; + } + } + + private static IEnumerable ExtractIdentifierReferencesMarkdown(CodeFileIdentity file, string content, ProviderProvenance provenance) + { + var declarationIndices = ExtractMarkdownHeadings(file, content, provenance) + .Select(h => h.Span.StartByte) + .ToHashSet(); + + foreach (Match match in MarkdownIdentifier.Matches(content)) + { + var name = match.Value; + if (declarationIndices.Contains(match.Index)) + continue; + + if (name.Length > 1 && char.IsLower(name[0])) + { + yield return new CodeReference + { + Id = CodeStableId.ForReference(file.RelativePath, "identifier", name, match.Index), + FilePath = file.RelativePath, + Kind = "identifier", + Target = name, + Span = SpanFor(content, match.Index, match.Length), + Provenance = provenance with { Confidence = Math.Min(provenance.Confidence, 0.5) }, + }; + } + } + } + + private static IEnumerable ExtractMarkdownEdges(string content, IReadOnlyList symbols) + { + var headingSymbols = symbols.Where(s => s.Kind == "heading").OrderBy(s => s.Span.StartByte).ToList(); + + var byLevel = headingSymbols + .GroupBy(s => GetMarkdownHeadingLevelAtOffset(content, s.Span.StartByte)) + .ToDictionary(g => g.Key, g => g.ToList()); + + foreach (var levelGroup in byLevel.Where(g => g.Key > 1)) + { + foreach (var heading in levelGroup.Value) + { + var ancestor = FindClosestAncestor(content, headingSymbols, heading); + if (ancestor is null) + continue; + + yield return new CodeDependencyEdge + { + Id = CodeStableId.ForEdge(ancestor.Id, heading.Id, "child-of", heading.Span.StartByte), + SourceId = ancestor.Id, + TargetId = heading.Id, + Kind = "child-of", + Provenance = heading.Provenance, + }; + } + } + } + + private static CodeSymbol? FindClosestAncestor(string content, IReadOnlyList allHeadings, CodeSymbol child) + { + var childIndex = -1; + for (var i = 0; i < allHeadings.Count; i++) + { + if (allHeadings[i].Id == child.Id) + { + childIndex = i; + break; + } + } + + if (childIndex <= 0) + return null; + + var childLevel = GetMarkdownHeadingLevelAtOffset(content, child.Span.StartByte); + + for (var i = childIndex - 1; i >= 0; i--) + { + var ancestor = allHeadings[i]; + var ancestorLevel = GetMarkdownHeadingLevelAtOffset(content, ancestor.Span.StartByte); + if (ancestorLevel < childLevel) + return ancestor; + } + + return null; + } + + private static int GetMarkdownHeadingLevelAtOffset(string content, int startByte) + { + var lineEnd = content.IndexOf('\n', Math.Max(0, startByte)); + var line = lineEnd >= 0 + ? content[startByte..lineEnd] + : content[startByte..]; + + var match = MarkdownHeadingLevelAtLine.Match(line); + return match.Success ? match.Groups[1].Value.Length : 1; + } } diff --git a/src/Hypa.Infrastructure/CodeIntelligence/GitFileStateProvider.cs b/src/Hypa.Infrastructure/CodeIntelligence/GitFileStateProvider.cs new file mode 100644 index 0000000..cc384ce --- /dev/null +++ b/src/Hypa.Infrastructure/CodeIntelligence/GitFileStateProvider.cs @@ -0,0 +1,108 @@ +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Runner; + +namespace Hypa.Infrastructure.CodeIntelligence; + +public sealed class GitFileStateProvider(ICommandRunner commandRunner) : IGitFileStateProvider +{ + public async Task?> GetCleanBlobOidsAsync( + string projectRoot, CancellationToken ct) + { + try + { + var trackedOutput = await RunGitAsync(projectRoot, ["ls-files", "-s"], ct); + if (trackedOutput is null) + return null; + + var modifiedOutput = await RunGitAsync(projectRoot, ["ls-files", "--modified"], ct); + if (modifiedOutput is null) + return null; + + var modifiedPaths = ParsePathLines(modifiedOutput); + var result = new Dictionary(StringComparer.Ordinal); + + foreach (var (path, oid) in ParseTrackedFiles(trackedOutput)) + { + if (!modifiedPaths.Contains(path)) + result[path] = oid; + } + + return result; + } + catch + { + return null; + } + } + + public async Task GetCleanBlobOidAsync( + string absolutePath, string projectRoot, CancellationToken ct) + { + try + { + var relativePath = ToGitRelativePath(projectRoot, absolutePath); + var trackedOutput = await RunGitAsync(projectRoot, ["ls-files", "-s", "--", relativePath], ct); + if (trackedOutput is null) + return null; + + var modifiedOutput = await RunGitAsync(projectRoot, ["ls-files", "--modified", "--", relativePath], ct); + if (modifiedOutput is null) + return null; + + if (ParsePathLines(modifiedOutput).Contains(relativePath)) + return null; + + var trackedFile = ParseTrackedFiles(trackedOutput).FirstOrDefault(); + return trackedFile.Path is null ? null : trackedFile.Oid; + } + catch + { + return null; + } + } + + private async Task RunGitAsync(string projectRoot, IReadOnlyList arguments, CancellationToken ct) + { + var invocation = CommandInvocation.Buffered("git", arguments, $"git {string.Join(' ', arguments)}") with + { + WorkingDirectory = projectRoot, + }; + + var result = await commandRunner.RunAsync(invocation, ct); + if (!result.IsOk || result.Value.ExitCode != 0) + return null; + + return result.Value.Stdout; + } + + private static HashSet ParsePathLines(string output) => + output.Split('\n') + .Select(line => line.TrimEnd('\r')) + .Where(line => line.Length > 0) + .ToHashSet(StringComparer.Ordinal); + + private static IEnumerable<(string Path, string Oid)> ParseTrackedFiles(string output) + { + foreach (var rawLine in output.Split('\n')) + { + var line = rawLine.TrimEnd('\r'); + if (line.Length == 0) + continue; + + var parts = line.Split('\t', 2); + if (parts.Length != 2) + throw new FormatException("git ls-files -s output did not contain a tab separator."); + + var metadata = parts[0].Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (metadata.Length < 3) + throw new FormatException("git ls-files -s output did not contain mode, oid, and stage."); + + yield return (parts[1], metadata[1]); + } + } + + private static string ToGitRelativePath(string projectRoot, string absolutePath) => + Path.GetRelativePath(projectRoot, absolutePath) + .Replace(Path.DirectorySeparatorChar, '/') + .Replace(Path.AltDirectorySeparatorChar, '/'); +} diff --git a/src/Hypa.Infrastructure/CodeIntelligence/MarkdownStructureProvider.cs b/src/Hypa.Infrastructure/CodeIntelligence/MarkdownStructureProvider.cs new file mode 100644 index 0000000..95f2fc4 --- /dev/null +++ b/src/Hypa.Infrastructure/CodeIntelligence/MarkdownStructureProvider.cs @@ -0,0 +1,140 @@ +using Hypa.Runtime.Application.Ports; +using Hypa.Sdk.CodeIntelligence; +using System.Collections.Concurrent; +using System.Text.RegularExpressions; +using TreeSitter; + +namespace Hypa.Infrastructure.CodeIntelligence; + +/// +/// Provider for Markdown code structure extraction using tree-sitter. +/// Implements ADR-0006 parser tiers and extracts headings, code blocks, and frontmatter. +/// +public sealed class MarkdownStructureProvider : ICodeStructureProvider +{ + // Markdown grammar availability tracking for health checks + private static readonly ConcurrentDictionary GrammarAvailability = new(StringComparer.OrdinalIgnoreCase); + + public string Id => "markdown"; + public string Version => "1.0.0"; + public string QueryVersion => TreeSitterQueryRegistry.QueryVersion; + + /// + /// Determines if this provider can handle the given language. + /// Only handles "markdown" language. + /// + public bool CanHandle(string language) => + language.Equals("markdown", StringComparison.OrdinalIgnoreCase) && + GrammarAvailability.GetOrAdd("markdown", CanCreateLanguage); + + /// + /// Performs health check by parsing a sample Markdown document. + /// + public CodeProviderHealth CheckHealth() + { + try + { + using var language = CreateLanguage("markdown"); + using var parser = CreateParser(language); + using var tree = Parse(parser, "# Hello\n\nThis is a **test**."); + var available = new[] { "markdown" } + .Where(CanHandle) + .Order(StringComparer.OrdinalIgnoreCase) + .Select(l => $"{l}:grammar"); + return new CodeProviderHealth + { + ProviderId = Id, + Status = "ok", + Message = "Tree-sitter markdown loaded. " + string.Join(", ", available), + }; + } + catch (Exception ex) + { + return new CodeProviderHealth + { + ProviderId = Id, + Status = "warn", + Message = ex.Message, + }; + } + } + + /// + /// Parses a Markdown document and extracts structure. + /// Uses CodePatternExtractor with markdown-specific patterns for headings and code blocks. + /// + public Task ParseAsync(CodeFileIdentity file, string content, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + try + { + using var language = CreateLanguage(file.Language); + using var parser = CreateParser(language); + using var _ = Parse(parser, content); + + var syntacticProvenance = new ProviderProvenance + { + ProviderId = Id, + ProviderVersion = Version, + QueryVersion = QueryVersion, + FactKind = "syntactic", + Confidence = 0.75, + }; + + return Task.FromResult(CodePatternExtractor.ExtractMarkdown(file, content, syntacticProvenance)); + } + catch (Exception) when (!ct.IsCancellationRequested) + { + var heuristicProvenance = new ProviderProvenance + { + ProviderId = Id, + ProviderVersion = Version, + QueryVersion = QueryVersion, + FactKind = "heuristic", + Confidence = 0.45, + }; + + return Task.FromResult(CodePatternExtractor.ExtractMarkdown(file, content, heuristicProvenance)); + } + } + + /// + /// Creates a tree-sitter language for Markdown. + /// + private static Language CreateLanguage(string language) + { + if (!TreeSitterQueryRegistry.Grammars.TryGetValue(language, out var grammar)) + throw new NotSupportedException($"Tree-sitter grammar is not registered for language '{language}'."); + + return new Language(grammar.Library, grammar.Function); + } + + /// + /// Creates a parser for the given language. + /// + private static Parser CreateParser(Language language) => new(language); + + /// + /// Parses content using the tree-sitter parser. + /// + private static Tree Parse(Parser parser, string content) => + parser.Parse(content) ?? throw new InvalidOperationException("Tree-sitter parse returned null."); + + /// + /// Checks if a language can be created (grammar/parser available). + /// Used for health check availability tracking. + /// + private static bool CanCreateLanguage(string language) + { + try + { + using var _ = CreateLanguage(language); + return true; + } + catch + { + return false; + } + } +} diff --git a/src/Hypa.Infrastructure/CodeIntelligence/TreeSitterCodeStructureProvider.cs b/src/Hypa.Infrastructure/CodeIntelligence/TreeSitterCodeStructureProvider.cs index 7916af6..57784a4 100644 --- a/src/Hypa.Infrastructure/CodeIntelligence/TreeSitterCodeStructureProvider.cs +++ b/src/Hypa.Infrastructure/CodeIntelligence/TreeSitterCodeStructureProvider.cs @@ -14,7 +14,10 @@ public sealed class TreeSitterCodeStructureProvider : ICodeStructureProvider public string QueryVersion => TreeSitterQueryRegistry.QueryVersion; public bool CanHandle(string language) => - TreeSitterQueryRegistry.Grammars.ContainsKey(language) && GrammarAvailability.GetOrAdd(language, CanCreateLanguage); + // markdown has its own dedicated MarkdownStructureProvider + !language.Equals("markdown", StringComparison.OrdinalIgnoreCase) && + TreeSitterQueryRegistry.Grammars.ContainsKey(language) && + GrammarAvailability.GetOrAdd(language, CanCreateLanguage); public CodeProviderHealth CheckHealth() { diff --git a/src/Hypa.Infrastructure/CodeIntelligence/TreeSitterQueryRegistry.cs b/src/Hypa.Infrastructure/CodeIntelligence/TreeSitterQueryRegistry.cs index 13fee14..2c48c7f 100644 --- a/src/Hypa.Infrastructure/CodeIntelligence/TreeSitterQueryRegistry.cs +++ b/src/Hypa.Infrastructure/CodeIntelligence/TreeSitterQueryRegistry.cs @@ -21,6 +21,7 @@ internal static class TreeSitterQueryRegistry ["json"] = new("tree-sitter-json", "tree_sitter_json"), ["yaml"] = new("tree-sitter-yaml", "tree_sitter_yaml"), ["toml"] = new("tree-sitter-toml", "tree_sitter_toml"), + ["markdown"] = new("tree-sitter-markdown", "tree_sitter_markdown"), }; public static readonly IReadOnlyDictionary QueryPacks = new Dictionary(StringComparer.OrdinalIgnoreCase) @@ -40,6 +41,7 @@ internal static class TreeSitterQueryRegistry ["json"] = SyntacticQueryPack.Config, ["yaml"] = SyntacticQueryPack.Config, ["toml"] = SyntacticQueryPack.Config, + ["markdown"] = SyntacticQueryPack.Markdown, }; } @@ -50,4 +52,5 @@ internal sealed record SyntacticQueryPack(bool Symbols, bool Imports, bool Calls public static SyntacticQueryPack Full { get; } = new(true, true, true, true, true, true, true); public static SyntacticQueryPack CallsAndReferences { get; } = new(true, true, true, true, false, false, false); public static SyntacticQueryPack Config { get; } = new(true, true, false, false, false, false, false); + public static SyntacticQueryPack Markdown { get; } = new(true, true, false, true, false, false, false); } diff --git a/src/Hypa.Infrastructure/DI/InfrastructureServiceExtensions.cs b/src/Hypa.Infrastructure/DI/InfrastructureServiceExtensions.cs index f800339..c0f962e 100644 --- a/src/Hypa.Infrastructure/DI/InfrastructureServiceExtensions.cs +++ b/src/Hypa.Infrastructure/DI/InfrastructureServiceExtensions.cs @@ -7,6 +7,11 @@ using Hypa.Infrastructure.Hooks; using Hypa.Infrastructure.Hooks.Adapters; using Hypa.Infrastructure.Mcp; +using Hypa.Infrastructure.Mcp.Auth; +using Hypa.Infrastructure.Mcp.Config; +using Hypa.Infrastructure.Mcp.Connection; +using Hypa.Infrastructure.Mcp.Import; +using Hypa.Infrastructure.Mcp.Secrets; using Hypa.Infrastructure.Parsers; using Hypa.Infrastructure.ProjectRoot; using Hypa.Infrastructure.Reducers; @@ -23,6 +28,7 @@ using Hypa.Runtime.Domain.Parsers.Canonical; using Hypa.Runtime.Domain.Runner; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Hypa.Infrastructure.DI; @@ -42,6 +48,8 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(sp => new McpOAuthTokenFilePermissionsCheck( + sp.GetRequiredService().DataDirectory)); services.AddSingleton(); services.AddSingleton(); @@ -122,7 +130,9 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -153,6 +163,53 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + + services.AddSingleton(_ => + { + var claudeHome = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude"); + return new ClaudeMcpConnectionImportSource(claudeHome); + }); + services.AddSingleton(_ => + new CodexMcpConnectionImportSource(CodexConfigPaths.ResolveConfigPath())); + services.AddSingleton(); + services.AddSingleton(sp => + sp.GetRequiredService()); + + services.AddHttpClient("mcp-oauth"); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => new EnvironmentSecretResolver( + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(sp => new McpOAuthTokenStoreFactory( + sp.GetRequiredService().DataDirectory, + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Hypa.Infrastructure/Doctor/McpOAuthTokenFilePermissionsCheck.cs b/src/Hypa.Infrastructure/Doctor/McpOAuthTokenFilePermissionsCheck.cs new file mode 100644 index 0000000..eaec870 --- /dev/null +++ b/src/Hypa.Infrastructure/Doctor/McpOAuthTokenFilePermissionsCheck.cs @@ -0,0 +1,69 @@ +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Hooks; + +namespace Hypa.Infrastructure.Doctor; + +public sealed class McpOAuthTokenFilePermissionsCheck : IDoctorCheck +{ + private readonly string _tokenFilePath; + + public McpOAuthTokenFilePermissionsCheck(string dataDirectory) + { + _tokenFilePath = Path.Combine(dataDirectory, "mcp-oauth-tokens.json"); + } + + public string Category => "MCP"; + + public DoctorCheckResult Run() + { + if (!File.Exists(_tokenFilePath)) + return new DoctorCheckResult( + "OAuth token permissions", + "not present", + DoctorStatus.Ok); + + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + return new DoctorCheckResult( + "OAuth token permissions", + "platform skipped", + DoctorStatus.Ok); + + UnixFileMode mode; + try + { + mode = File.GetUnixFileMode(_tokenFilePath); + } + catch (Exception ex) + { + return new DoctorCheckResult( + "OAuth token permissions", + "unreadable", + DoctorStatus.Ok, + $"Could not read file permissions: {ex.Message}"); + } + + const UnixFileMode groupOrOtherBits = + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + + if ((mode & groupOrOtherBits) != 0) + return new DoctorCheckResult( + "OAuth token permissions", + $"insecure ({FormatMode(mode)})", + DoctorStatus.Warn, + $"Token file is group/world-readable. Run: chmod 600 {_tokenFilePath}"); + + return new DoctorCheckResult( + "OAuth token permissions", + $"secure ({FormatMode(mode)})", + DoctorStatus.Ok); + } + + private static string FormatMode(UnixFileMode mode) + { + var u = ((int)mode >> 6) & 7; + var g = ((int)mode >> 3) & 7; + var o = (int)mode & 7; + return $"{u}{g}{o}"; + } +} diff --git a/src/Hypa.Infrastructure/Hooks/ReadRedirector.cs b/src/Hypa.Infrastructure/Hooks/ReadRedirector.cs index 8fa2592..c89c36b 100644 --- a/src/Hypa.Infrastructure/Hooks/ReadRedirector.cs +++ b/src/Hypa.Infrastructure/Hooks/ReadRedirector.cs @@ -65,10 +65,13 @@ public sealed class ReadRedirector( var provider = providerRegistry.Select(lang); var doc = await provider.ParseAsync(fileId, content, ct); - if (doc.Symbols.Count == 0) + var hasContent = lang == "markdown" ? doc.Sections.Count > 0 : doc.Symbols.Count > 0; + if (!hasContent) return null; - var outline = BuildOutline(content, doc, resolvedPath); + var outline = lang == "markdown" + ? BuildMarkdownOutline(doc, resolvedPath) + : BuildOutline(content, doc, resolvedPath); // Only redirect if we achieved meaningful compression (>20% reduction). if (outline.Length >= content.Length * 0.8) @@ -84,6 +87,17 @@ public sealed class ReadRedirector( } } + private static string BuildMarkdownOutline(CodeStructureDocument doc, string path) + { + var sb = new StringBuilder(); + sb.AppendLine($"// hypa: outline of {Path.GetFileName(path)} ({doc.Sections.Count} sections)"); + sb.AppendLine("// Full content elided. Use hypa_read MCP tool for full content or specific sections."); + sb.AppendLine(); + foreach (var s in doc.Sections) + sb.AppendLine($"{new string('#', s.HeadingLevel)} {s.HeadingText} (line {s.StartLine})"); + return sb.ToString(); + } + private static string BuildOutline(string content, CodeStructureDocument doc, string path) { var sb = new StringBuilder(); @@ -119,6 +133,7 @@ private static string DetectLanguage(string path) => ".py" => "python", ".rs" => "rust", ".go" => "go", + ".md" => "markdown", _ => "text", }; diff --git a/src/Hypa.Infrastructure/Hypa.Infrastructure.csproj b/src/Hypa.Infrastructure/Hypa.Infrastructure.csproj index 1125165..df396de 100644 --- a/src/Hypa.Infrastructure/Hypa.Infrastructure.csproj +++ b/src/Hypa.Infrastructure/Hypa.Infrastructure.csproj @@ -12,6 +12,12 @@ <_Parameter1>Hypa.UnitTests + + <_Parameter1>Hypa.GoldenTests + + + <_Parameter1>DynamicProxyGenAssembly2 + @@ -37,4 +43,5 @@ + diff --git a/src/Hypa.Infrastructure/Mcp/Auth/BrowserLauncherAdapter.cs b/src/Hypa.Infrastructure/Mcp/Auth/BrowserLauncherAdapter.cs new file mode 100644 index 0000000..0f2f2b1 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/BrowserLauncherAdapter.cs @@ -0,0 +1,72 @@ +using System.ComponentModel; +using System.Diagnostics; +using Hypa.Runtime.Application.Ports; + +namespace Hypa.Infrastructure.Mcp.Auth; + +internal sealed class BrowserLauncherAdapter : IBrowserLauncher +{ + private readonly string? _overrideCommand; + + public BrowserLauncherAdapter(string? overrideCommand = null) + { + _overrideCommand = overrideCommand; + } + + public bool TryOpen(string url) + { + var command = _overrideCommand ?? GetBrowserCommand(DetectWsl()); + return TryLaunch(command, url); + } + + internal static string GetBrowserCommand(bool isWsl) + { + if (OperatingSystem.IsWindows()) + return "explorer"; + + if (OperatingSystem.IsMacOS()) + return "open"; + + // Linux + return isWsl ? "wslview" : "xdg-open"; + } + + private static bool DetectWsl() + { + if (!OperatingSystem.IsLinux()) + return false; + + try + { + var version = File.ReadAllText("/proc/version"); + return version.Contains("microsoft", StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private static bool TryLaunch(string command, string argument) + { + try + { + var psi = new ProcessStartInfo + { + FileName = command, + Arguments = argument, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + using var process = Process.Start(psi); + return process is not null; + } + catch (Exception ex) when (ex is Win32Exception or FileNotFoundException) + { + return false; + } + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Auth/DeviceTokenStore.cs b/src/Hypa.Infrastructure/Mcp/Auth/DeviceTokenStore.cs new file mode 100644 index 0000000..a0ec5e6 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/DeviceTokenStore.cs @@ -0,0 +1,54 @@ +using System.Text.Json; + +namespace Hypa.Infrastructure.Mcp.Auth; + +internal sealed record DeviceTokenStoreJson( + Dictionary Tokens); + +internal sealed record DeviceTokenEntryJson( + string AccessToken, + long ExpiresAtUnixMs); + +internal sealed class DeviceTokenStore +{ + private readonly string _filePath; + + public DeviceTokenStore(string storagePath) + { + _filePath = Path.Combine(storagePath, "mcp-tokens.json"); + } + + public async Task> LoadAsync(CancellationToken ct) + { + if (!File.Exists(_filePath)) + return new Dictionary(); + + try + { + await using var stream = File.OpenRead(_filePath); + var store = await JsonSerializer.DeserializeAsync( + stream, + OAuthTokenJsonContext.Default.DeviceTokenStoreJson, + ct); + return store?.Tokens ?? new Dictionary(); + } + catch + { + return new Dictionary(); + } + } + + public async Task SaveAsync(Dictionary tokens, CancellationToken ct) + { + var dir = Path.GetDirectoryName(_filePath); + if (dir is not null && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + await using var stream = File.Create(_filePath); + await JsonSerializer.SerializeAsync( + stream, + new DeviceTokenStoreJson(tokens), + OAuthTokenJsonContext.Default.DeviceTokenStoreJson, + ct); + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Auth/HypaBrowserOAuthDelegate.cs b/src/Hypa.Infrastructure/Mcp/Auth/HypaBrowserOAuthDelegate.cs new file mode 100644 index 0000000..8ca6ed1 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/HypaBrowserOAuthDelegate.cs @@ -0,0 +1,205 @@ +using System.Web; +using Hypa.Runtime.Application.Ports; + +namespace Hypa.Infrastructure.Mcp.Auth; + +internal sealed class HypaBrowserOAuthDelegate +{ + private readonly IBrowserLauncher _browserLauncher; + private readonly IOAuthCallbackListener _callbackListener; + private readonly IProgress? _progress; + private readonly TimeSpan _callbackTimeout; + private readonly bool _interactive; + private bool _noBrowser; + + public HypaBrowserOAuthDelegate( + IBrowserLauncher browserLauncher, + IOAuthCallbackListener callbackListener, + IProgress? progress = null, + bool noBrowser = false, + TimeSpan? callbackTimeout = null, + bool interactive = true) + { + _browserLauncher = browserLauncher; + _callbackListener = callbackListener; + _progress = progress; + _noBrowser = noBrowser; + _callbackTimeout = callbackTimeout ?? TimeSpan.FromMinutes(5); + _interactive = interactive; + } + + public async Task HandleAsync(Uri authorizationUri, Uri redirectUri, CancellationToken ct) + { + // The listener must be started before this delegate is called from the SDK; + // in the McpTransportBuilder / McpBrowserOAuthFlowProvider paths StartAsync is + // called before ClientOAuthOptions is constructed. Guard here for safety. + await _callbackListener.StartAsync(ct); + + var expectedState = ExtractQueryParam(authorizationUri, "state"); + + try + { + var authUrl = authorizationUri.ToString(); + var redirectUrl = redirectUri.ToString(); + + if (!_noBrowser) + { + bool browserOpened = _browserLauncher.TryOpen(authUrl); + if (!browserOpened) + _noBrowser = true; + } + + if (_noBrowser) + { + _progress?.Report("Authorization URL:"); + _progress?.Report($" {authUrl}"); + _progress?.Report(string.Empty); + _progress?.Report($"After authorizing, your browser will redirect to {redirectUrl}"); + if (!_interactive) + throw new InvalidOperationException( + "Browser OAuth requires interaction. Omit --no-browser or run without --json."); + } + else + { + _progress?.Report("Opening browser for authorization..."); + _progress?.Report("Authorization URL (if browser didn't open):"); + _progress?.Report($" {authUrl}"); + } + + _progress?.Report("Waiting for authorization (Ctrl+C to cancel)..."); + + if (_noBrowser && _interactive && !Console.IsInputRedirected) + return await RacePasteAndListenerAsync(redirectUri, expectedState, ct); + + var callbackResult = await _callbackListener.WaitForCallbackAsync(_callbackTimeout, ct); + return ValidateAndExtractCode(callbackResult, expectedState); + } + finally + { + await _callbackListener.StopAsync(); + } + } + + private async Task RacePasteAndListenerAsync( + Uri redirectUri, string? expectedState, CancellationToken ct) + { + _progress?.Report("If the redirect doesn't fire, paste the full callback URL here:"); + + using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + raceCts.CancelAfter(_callbackTimeout); + + var listenerTask = _callbackListener.WaitForCallbackAsync(_callbackTimeout, ct); + + while (!raceCts.Token.IsCancellationRequested) + { + var pasteTask = ReadPastedInputAsync(raceCts.Token); + var completed = await Task.WhenAny(pasteTask, listenerTask); + + if (completed == listenerTask) + { + // Automatic redirect completed. + var listenerResult = await listenerTask; + return ValidateAndExtractCode(listenerResult, expectedState); + } + + // Paste attempt arrived first. + string? line; + try { line = await pasteTask; } + catch (OperationCanceledException) { break; } + + if (string.IsNullOrWhiteSpace(line)) + { + _progress?.Report("Input was empty — still waiting."); + continue; + } + + if (!Uri.TryCreate(line.Trim(), UriKind.Absolute, out var pastedUri)) + { + _progress?.Report("Not a valid URL — still waiting."); + continue; + } + + if (!MatchesRedirectUri(pastedUri, redirectUri)) + { + _progress?.Report($"URL does not match expected redirect URI — still waiting."); + continue; + } + + var rawQs = pastedUri.Query.TrimStart('?'); + OAuthCallbackListener.ParseQueryString(rawQs, out var code, out _, out var receivedState); + + var stateError = ValidateState(expectedState, receivedState); + if (stateError is not null) + { + _progress?.Report($"{stateError} — still waiting."); + continue; + } + + if (string.IsNullOrEmpty(code)) + { + _progress?.Report("No authorization code in URL — still waiting."); + continue; + } + + return code; + } + + // Timed out or cancelled; let the listener result determine the final outcome. + if (listenerTask.IsCompletedSuccessfully) + return ValidateAndExtractCode(await listenerTask, expectedState); + + return null; + } + + private static string? ValidateAndExtractCode(OAuthCallbackResult result, string? expectedState) + { + if (string.IsNullOrEmpty(result.Code)) + return null; + + var stateError = ValidateState(expectedState, result.State); + if (stateError is not null) + return null; + + return result.Code; + } + + private static string? ValidateState(string? expected, string? received) + { + if (string.IsNullOrEmpty(expected)) + return null; // No state in auth URL; nothing to validate. + + if (string.IsNullOrEmpty(received)) + return "OAuth state missing from callback"; + + if (!string.Equals(expected, received, StringComparison.Ordinal)) + return "OAuth state mismatch"; + + return null; + } + + private static bool MatchesRedirectUri(Uri pasted, Uri expected) => + string.Equals(pasted.Scheme, expected.Scheme, StringComparison.OrdinalIgnoreCase) && + string.Equals(pasted.Host, expected.Host, StringComparison.OrdinalIgnoreCase) && + pasted.Port == expected.Port && + string.Equals(pasted.AbsolutePath.TrimEnd('/'), expected.AbsolutePath.TrimEnd('/'), + StringComparison.OrdinalIgnoreCase); + + private static string? ExtractQueryParam(Uri uri, string name) + { + var qs = uri.Query.TrimStart('?'); + OAuthCallbackListener.ParseQueryString(qs, out _, out _, out var state); + // state is the only param we currently need; for other params extend ParseQueryString. + if (name == "state") return state; + + // Fallback for any other param via HttpUtility (no trim loss of state special-casing). + var parsed = HttpUtility.ParseQueryString(qs); + return parsed[name]; + } + + private static Task ReadPastedInputAsync(CancellationToken ct) => + Task.Run(() => + { + Console.Write("> "); + return Console.ReadLine(); + }, ct); +} diff --git a/src/Hypa.Infrastructure/Mcp/Auth/IOAuthTokenService.cs b/src/Hypa.Infrastructure/Mcp/Auth/IOAuthTokenService.cs new file mode 100644 index 0000000..ee1b472 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/IOAuthTokenService.cs @@ -0,0 +1,9 @@ +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Infrastructure.Mcp.Auth; + +internal interface IOAuthTokenService +{ + Task GetClientCredentialsTokenAsync(OAuth2ClientCredentialsConfig config, CancellationToken ct); + Task GetDeviceCodeTokenAsync(OAuth2DeviceCodeConfig config, CancellationToken ct); +} diff --git a/src/Hypa.Infrastructure/Mcp/Auth/McpAuthProviderService.cs b/src/Hypa.Infrastructure/Mcp/Auth/McpAuthProviderService.cs new file mode 100644 index 0000000..24e9a2c --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/McpAuthProviderService.cs @@ -0,0 +1,145 @@ +using System.Text; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Mcp; +using Microsoft.Extensions.Logging; + +namespace Hypa.Infrastructure.Mcp.Auth; + +internal sealed class McpAuthProviderService : IMcpAuthProvider +{ + private static readonly IReadOnlyDictionary EmptyHeaders = + new Dictionary(); + + private readonly ISecretResolver _secretResolver; + private readonly IOAuthTokenService _oauthTokenService; + private readonly SecretRedactionRegistry _redactionRegistry; + private readonly ILogger _logger; + + public McpAuthProviderService( + ISecretResolver secretResolver, + IOAuthTokenService oauthTokenService, + SecretRedactionRegistry redactionRegistry, + ILogger logger) + { + _secretResolver = secretResolver; + _oauthTokenService = oauthTokenService; + _redactionRegistry = redactionRegistry; + _logger = logger; + } + + public async ValueTask GetAuthContextAsync(McpServerDefinition server, CancellationToken ct) + { + return server.Auth switch + { + NoneAuthConfig => new McpAuthContext(EmptyHeaders), + + BearerAuthConfig bearer => await ResolveBearerAsync(bearer, ct), + + ApiKeyAuthConfig apiKey => await ResolveApiKeyAsync(apiKey, ct), + + BasicAuthConfig basic => await ResolveBasicAsync(basic, ct), + + OAuth2ClientCredentialsConfig clientCreds => await ResolveClientCredentialsAsync(clientCreds, ct), + + OAuth2DeviceCodeConfig deviceCode => await ResolveDeviceCodeAsync(deviceCode, ct), + + MtlsConfig mtls => await ResolveMtlsAsync(mtls, ct), + + UnknownAuthConfig unknown => HandleUnknown(unknown), + + _ => new McpAuthContext(EmptyHeaders), + }; + } + + private async ValueTask ResolveBearerAsync(BearerAuthConfig config, CancellationToken ct) + { + var token = await _secretResolver.ResolveAsync(config.TokenRef, ct) + ?? throw new McpCredentialResolutionException( + $"Secret reference '{config.TokenRef}' resolved to null. Verify the secret is set."); + _redactionRegistry.Register(token); + return new McpAuthContext( + Headers: new Dictionary { ["Authorization"] = $"Bearer {token}" }); + } + + private async ValueTask ResolveApiKeyAsync(ApiKeyAuthConfig config, CancellationToken ct) + { + var value = await _secretResolver.ResolveAsync(config.ValueRef, ct) + ?? throw new McpCredentialResolutionException( + $"Secret reference '{config.ValueRef}' resolved to null. Verify the secret is set."); + _redactionRegistry.Register(value); + + if (config.InQueryString) + { + return new McpAuthContext( + Headers: EmptyHeaders, + QueryParameters: new Dictionary { [config.HeaderName] = value }); + } + + return new McpAuthContext( + Headers: new Dictionary { [config.HeaderName] = value }); + } + + private async ValueTask ResolveBasicAsync(BasicAuthConfig config, CancellationToken ct) + { + var username = await _secretResolver.ResolveAsync(config.UsernameRef, ct) + ?? throw new McpCredentialResolutionException( + $"Secret reference '{config.UsernameRef}' resolved to null. Verify the secret is set."); + var password = await _secretResolver.ResolveAsync(config.PasswordRef, ct) + ?? throw new McpCredentialResolutionException( + $"Secret reference '{config.PasswordRef}' resolved to null. Verify the secret is set."); + _redactionRegistry.Register(password); + + var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); + return new McpAuthContext( + Headers: new Dictionary { ["Authorization"] = $"Basic {encoded}" }, + Username: username, + Password: password); + } + + private async ValueTask ResolveClientCredentialsAsync( + OAuth2ClientCredentialsConfig config, CancellationToken ct) + { + var token = await _oauthTokenService.GetClientCredentialsTokenAsync(config, ct); + _redactionRegistry.Register(token); + return new McpAuthContext( + Headers: new Dictionary { ["Authorization"] = $"Bearer {token}" }); + } + + private async ValueTask ResolveDeviceCodeAsync( + OAuth2DeviceCodeConfig config, CancellationToken ct) + { + var token = await _oauthTokenService.GetDeviceCodeTokenAsync(config, ct); + if (token is null) + { + return new McpAuthContext( + Headers: EmptyHeaders, + BearerToken: null); + } + + _redactionRegistry.Register(token); + return new McpAuthContext( + Headers: new Dictionary { ["Authorization"] = $"Bearer {token}" }, + BearerToken: token); + } + + private async ValueTask ResolveMtlsAsync(MtlsConfig config, CancellationToken ct) + { + var certPath = config.ClientCertRef is not null + ? await _secretResolver.ResolveAsync(config.ClientCertRef, ct) + : null; + var keyPath = config.ClientKeyRef is not null + ? await _secretResolver.ResolveAsync(config.ClientKeyRef, ct) + : null; + + return new McpAuthContext( + Headers: EmptyHeaders, + ClientCertificatePath: certPath, + ClientKeyPath: keyPath); + } + + private McpAuthContext HandleUnknown(UnknownAuthConfig config) + { + _logger.LogWarning("Unknown auth type '{Type}' — returning empty auth context", config.Type); + return new McpAuthContext(EmptyHeaders); + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Auth/McpBrowserOAuthFlowProvider.cs b/src/Hypa.Infrastructure/Mcp/Auth/McpBrowserOAuthFlowProvider.cs new file mode 100644 index 0000000..795a881 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/McpBrowserOAuthFlowProvider.cs @@ -0,0 +1,156 @@ +using Hypa.Infrastructure.Mcp.Connection; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Mcp; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; + +namespace Hypa.Infrastructure.Mcp.Auth; + +internal sealed class McpBrowserOAuthFlowProvider : IMcpBrowserOAuthFlowProvider +{ + private readonly IBrowserLauncher _browserLauncher; + private readonly McpOAuthTokenStoreFactory _tokenStoreFactory; + private readonly ISecretResolver _secretResolver; + private readonly ILogger _logger; + + public McpBrowserOAuthFlowProvider( + IBrowserLauncher browserLauncher, + McpOAuthTokenStoreFactory tokenStoreFactory, + ISecretResolver secretResolver, + ILogger logger) + { + _browserLauncher = browserLauncher; + _tokenStoreFactory = tokenStoreFactory; + _secretResolver = secretResolver; + _logger = logger; + } + + public async Task StartFlowAsync( + string serverName, + string endpoint, + McpOAuthConfig config, + McpBrowserOAuthOptions options, + CancellationToken ct, + IProgress? progress = null) + { + var callbackListener = new OAuthCallbackListener(); + + var oauthDelegate = new HypaBrowserOAuthDelegate( + _browserLauncher, + callbackListener, + progress, + options.NoBrowser, + options.CallbackTimeout, + options.Interactive); + + await callbackListener.StartAsync(ct); + + var tokenStore = _tokenStoreFactory.For(serverName); + + var clientSecret = config.ClientSecretRef is not null + ? await _secretResolver.ResolveAsync(config.ClientSecretRef, ct) + : null; + + // Capture DCR-issued credentials when no ClientId was provided. + string? dcrClientId = null; + string? dcrClientSecret = null; + var isDcrPath = config.ClientId is null; + + var oauthOptions = new ClientOAuthOptions + { + RedirectUri = callbackListener.GetRedirectUri(), + Scopes = config.Scopes, + AuthorizationRedirectDelegate = oauthDelegate.HandleAsync, + TokenCache = tokenStore, + }; + + if (config.ClientId is not null) + oauthOptions.ClientId = config.ClientId; + + if (clientSecret is not null) + oauthOptions.ClientSecret = clientSecret; + + // Wire up DCR response capture so the flow result contains the server-assigned credentials. + if (isDcrPath) + { + oauthOptions.DynamicClientRegistration = new DynamicClientRegistrationOptions + { + ResponseDelegate = (response, _) => + { + dcrClientId = response.ClientId; + dcrClientSecret = response.ClientSecret; + _logger.LogInformation( + "Dynamic client registration succeeded for server '{Server}': clientId={ClientId}", + serverName, dcrClientId); + return Task.CompletedTask; + } + }; + } + + var transportOptions = new HttpClientTransportOptions + { + Endpoint = new Uri(endpoint), + TransportMode = HttpTransportMode.AutoDetect, + Name = serverName, + OAuth = oauthOptions, + }; + + var tlsHandler = McpTransportBuilder.BuildHttpClientHandler(options.Tls); + + McpClient? client = null; + try + { + IClientTransport transport = tlsHandler is not null + ? new HttpClientTransport(transportOptions, new HttpClient(tlsHandler)) + : new HttpClientTransport(transportOptions); + client = await McpClient.CreateAsync(transport, new McpClientOptions(), null, ct); + + var tools = await client.ListToolsAsync(cancellationToken: ct); + + // Persist DCR credentials so they can be resolved later. + if (dcrClientSecret is not null && dcrClientId is not null) + { + await tokenStore.StoreDcrCredentialsAsync(dcrClientId, dcrClientSecret, ct); + } + + // Use DCR-issued credentials when available; otherwise fall back to the original config. + var resolvedClientId = dcrClientId ?? config.ClientId; + var resolvedSecretRef = dcrClientSecret is not null + ? $"hypa:dcr:{serverName}" + : config.ClientSecretRef; + var completedConfig = new McpOAuthConfig( + ClientId: resolvedClientId, + ClientSecretRef: resolvedSecretRef, + Scopes: config.Scopes); + return new McpBrowserOAuthFlowResult( + Succeeded: true, + CompletedConfig: completedConfig, + ToolCount: tools.Count); + } + catch (OperationCanceledException) + { + return new McpBrowserOAuthFlowResult( + Succeeded: false, + CompletedConfig: null, + ToolCount: null, + Error: "Cancelled"); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "OAuth browser flow failed for server '{Server}'", serverName); + return new McpBrowserOAuthFlowResult( + Succeeded: false, + CompletedConfig: null, + ToolCount: null, + Error: ex.Message); + } + finally + { + if (client is not null) + await client.DisposeAsync(); + await callbackListener.StopAsync(); + } + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Auth/McpCredentialResolutionException.cs b/src/Hypa.Infrastructure/Mcp/Auth/McpCredentialResolutionException.cs new file mode 100644 index 0000000..d976cd8 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/McpCredentialResolutionException.cs @@ -0,0 +1,3 @@ +namespace Hypa.Infrastructure.Mcp.Auth; + +internal sealed class McpCredentialResolutionException(string message) : Exception(message); diff --git a/src/Hypa.Infrastructure/Mcp/Auth/McpOAuthTokenJsonContext.cs b/src/Hypa.Infrastructure/Mcp/Auth/McpOAuthTokenJsonContext.cs new file mode 100644 index 0000000..b2dc9e7 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/McpOAuthTokenJsonContext.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace Hypa.Infrastructure.Mcp.Auth; + +internal sealed record McpOAuthTokenFileJson( + int Version, + Dictionary Tokens); + +internal sealed record McpOAuthTokenEntryJson( + string TokenType, + string AccessToken, + string? RefreshToken, + int? ExpiresIn, + string ObtainedAt, + string? Scope, + string? DcrClientId = null, + string? DcrClientSecret = null); + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(McpOAuthTokenFileJson))] +[JsonSerializable(typeof(McpOAuthTokenEntryJson))] +[JsonSerializable(typeof(Dictionary))] +internal sealed partial class McpOAuthTokenJsonContext : JsonSerializerContext; diff --git a/src/Hypa.Infrastructure/Mcp/Auth/McpOAuthTokenStore.cs b/src/Hypa.Infrastructure/Mcp/Auth/McpOAuthTokenStore.cs new file mode 100644 index 0000000..fa5ca22 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/McpOAuthTokenStore.cs @@ -0,0 +1,264 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Authentication; + +namespace Hypa.Infrastructure.Mcp.Auth; + +internal sealed class McpOAuthTokenStore : ITokenCache +{ + private const int CurrentVersion = 2; + + private readonly string _serverName; + private readonly string _filePath; + private readonly SecretRedactionRegistry _redactionRegistry; + private readonly ILogger _logger; + + public McpOAuthTokenStore(string serverName, string storagePath) + : this(serverName, storagePath, new SecretRedactionRegistry(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance) + { } + + public McpOAuthTokenStore( + string serverName, + string storagePath, + SecretRedactionRegistry redactionRegistry, + ILogger logger) + { + _serverName = serverName; + _filePath = Path.Combine(storagePath, "mcp-oauth-tokens.json"); + _redactionRegistry = redactionRegistry; + _logger = logger; + } + + public async ValueTask GetTokensAsync(CancellationToken cancellationToken) + { + if (!File.Exists(_filePath)) + return null; + + McpOAuthTokenFileJson? file; + try + { + await using var stream = File.OpenRead(_filePath); + file = await JsonSerializer.DeserializeAsync( + stream, + McpOAuthTokenJsonContext.Default.McpOAuthTokenFileJson, + cancellationToken); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to read mcp-oauth-tokens.json"); + return null; + } + + if (file is null) + return null; + + if (file.Version > CurrentVersion) + { + _logger.LogWarning("mcp-oauth-tokens.json version {Version} is newer than supported {Supported}; ignoring tokens", + file.Version, CurrentVersion); + return null; + } + + if (!file.Tokens.TryGetValue(_serverName, out var entry)) + return null; + + var token = ToTokenContainer(entry); + + if (IsExpired(token)) + return null; + + if (!string.IsNullOrEmpty(token.AccessToken)) + _redactionRegistry.Register(token.AccessToken); + if (!string.IsNullOrEmpty(token.RefreshToken)) + _redactionRegistry.Register(token.RefreshToken); + + return token; + } + + public async ValueTask StoreTokensAsync(TokenContainer tokens, CancellationToken cancellationToken) + { + var dir = Path.GetDirectoryName(_filePath)!; + Directory.CreateDirectory(dir); + + var existing = await LoadFileAsync(cancellationToken); + existing[_serverName] = ToEntry(tokens); + + var file = new McpOAuthTokenFileJson(CurrentVersion, existing); + var tempPath = Path.Combine(dir, $"mcp-oauth-tokens.{Guid.NewGuid():N}.tmp"); + + try + { + await using (var stream = new FileStream( + tempPath, FileMode.Create, FileAccess.Write, FileShare.None, + bufferSize: 4096, useAsync: true)) + { + await JsonSerializer.SerializeAsync( + stream, + file, + McpOAuthTokenJsonContext.Default.McpOAuthTokenFileJson, + cancellationToken); + await stream.FlushAsync(cancellationToken); + } + + SetSecurePermissions(tempPath); + File.Move(tempPath, _filePath, overwrite: true); + } + catch + { + TryDelete(tempPath); + throw; + } + + if (!string.IsNullOrEmpty(tokens.AccessToken)) + _redactionRegistry.Register(tokens.AccessToken); + if (!string.IsNullOrEmpty(tokens.RefreshToken)) + _redactionRegistry.Register(tokens.RefreshToken); + } + + private async Task> LoadFileAsync(CancellationToken ct) + { + if (!File.Exists(_filePath)) + return new Dictionary(); + + try + { + await using var stream = File.OpenRead(_filePath); + var file = await JsonSerializer.DeserializeAsync( + stream, + McpOAuthTokenJsonContext.Default.McpOAuthTokenFileJson, + ct); + + if (file is null || file.Version > CurrentVersion) + return new Dictionary(); + + return file.Tokens; + } + catch + { + return new Dictionary(); + } + } + + private static bool IsExpired(TokenContainer token) + { + if (token.ExpiresIn is null) + return false; + + var expiresAt = token.ObtainedAt.AddSeconds(token.ExpiresIn.Value); + return DateTimeOffset.UtcNow >= expiresAt; + } + + private static void SetSecurePermissions(string path) + { + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + return; + + try + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch + { + // Best effort; file permissions are not critical for correctness + } + } + + private static void TryDelete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { } + } + + private static TokenContainer ToTokenContainer(McpOAuthTokenEntryJson entry) => new() + { + TokenType = entry.TokenType, + AccessToken = entry.AccessToken, + RefreshToken = entry.RefreshToken!, + ExpiresIn = entry.ExpiresIn, + ObtainedAt = DateTimeOffset.TryParse(entry.ObtainedAt, out var dt) ? dt : DateTimeOffset.UtcNow, + Scope = entry.Scope!, + }; + + private static McpOAuthTokenEntryJson ToEntry(TokenContainer t) => new( + t.TokenType ?? "Bearer", + t.AccessToken, + t.RefreshToken, + t.ExpiresIn, + t.ObtainedAt.ToString("O"), + t.Scope); + + public async Task StoreDcrCredentialsAsync(string clientId, string clientSecret, CancellationToken ct) + { + var dir = Path.GetDirectoryName(_filePath)!; + Directory.CreateDirectory(dir); + + var existing = await LoadFileAsync(ct); + + if (!existing.TryGetValue(_serverName, out var entry)) + { + _logger.LogWarning( + "Cannot persist DCR credentials for server '{Server}': no token entry found. " + + "The SDK may not have stored tokens yet.", + _serverName); + return; + } + + entry = entry with { DcrClientId = clientId, DcrClientSecret = clientSecret }; + existing[_serverName] = entry; + + var file = new McpOAuthTokenFileJson(CurrentVersion, existing); + var tempPath = Path.Combine(dir, $"mcp-oauth-tokens.{Guid.NewGuid():N}.tmp"); + + try + { + await using (var stream = new FileStream( + tempPath, FileMode.Create, FileAccess.Write, FileShare.None, + bufferSize: 4096, useAsync: true)) + { + await JsonSerializer.SerializeAsync( + stream, + file, + McpOAuthTokenJsonContext.Default.McpOAuthTokenFileJson, + ct); + await stream.FlushAsync(ct); + } + + SetSecurePermissions(tempPath); + File.Move(tempPath, _filePath, overwrite: true); + } + catch + { + TryDelete(tempPath); + throw; + } + + _redactionRegistry.Register(clientSecret); + } + + public async ValueTask<(string? ClientId, string? Secret)> GetDcrCredentialsAsync(CancellationToken ct) + { + if (!File.Exists(_filePath)) + return (null, null); + + McpOAuthTokenFileJson? file; + try + { + await using var stream = File.OpenRead(_filePath); + file = await JsonSerializer.DeserializeAsync( + stream, + McpOAuthTokenJsonContext.Default.McpOAuthTokenFileJson, + ct); + } + catch + { + return (null, null); + } + + if (file is null || !file.Tokens.TryGetValue(_serverName, out var entry)) + return (null, null); + + if (!string.IsNullOrEmpty(entry.DcrClientSecret)) + _redactionRegistry.Register(entry.DcrClientSecret); + + return (entry.DcrClientId, entry.DcrClientSecret); + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Auth/McpOAuthTokenStoreFactory.cs b/src/Hypa.Infrastructure/Mcp/Auth/McpOAuthTokenStoreFactory.cs new file mode 100644 index 0000000..cb4800c --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/McpOAuthTokenStoreFactory.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Logging; + +namespace Hypa.Infrastructure.Mcp.Auth; + +internal sealed class McpOAuthTokenStoreFactory +{ + private readonly string _storagePath; + private readonly SecretRedactionRegistry _redactionRegistry; + private readonly ILogger _logger; + + public McpOAuthTokenStoreFactory( + string storagePath, + SecretRedactionRegistry redactionRegistry, + ILogger logger) + { + _storagePath = storagePath; + _redactionRegistry = redactionRegistry; + _logger = logger; + } + + public McpOAuthTokenStore For(string serverName) => + new(serverName, _storagePath, _redactionRegistry, _logger); +} diff --git a/src/Hypa.Infrastructure/Mcp/Auth/OAuthCallbackListener.cs b/src/Hypa.Infrastructure/Mcp/Auth/OAuthCallbackListener.cs new file mode 100644 index 0000000..99cb327 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/OAuthCallbackListener.cs @@ -0,0 +1,143 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Hypa.Runtime.Application.Ports; + +namespace Hypa.Infrastructure.Mcp.Auth; + +internal sealed class OAuthCallbackListener : IOAuthCallbackListener +{ + private TcpListener? _portHolder; + private HttpListener? _listener; + private readonly int _port; + private bool _started; + + private static readonly string SuccessHtml = + "

Authorization complete

" + + "

You may close this window and return to the terminal.

"; + + public OAuthCallbackListener() + { + // Bind a TcpListener on port 0 to obtain and hold a free port. + // The port remains bound (by TcpListener) until StartAsync switches to HttpListener, + // satisfying the "publish only a bound URI" invariant. There is still a brief + // stop/start transition inside StartAsync, but no other process can steal the port + // between construction and that point. + _portHolder = new TcpListener(IPAddress.Loopback, 0); + _portHolder.Start(); + _port = ((IPEndPoint)_portHolder.LocalEndpoint).Port; + } + + public Task StartAsync(CancellationToken ct) + { + if (_started) + return Task.CompletedTask; + + // Release the TcpListener and immediately bind HttpListener on the same port. + _portHolder?.Stop(); + _portHolder = null; + + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://127.0.0.1:{_port}/callback/"); + _listener.Start(); + _started = true; + + return Task.CompletedTask; + } + + public Uri GetRedirectUri() + { + if (!_started) + throw new InvalidOperationException( + "GetRedirectUri() called before StartAsync(). " + + "The listener must be started to publish a bound redirect URI."); + + return new($"http://127.0.0.1:{_port}/callback"); + } + + public async Task WaitForCallbackAsync(TimeSpan timeout, CancellationToken ct) + { + if (_listener is null) + throw new InvalidOperationException("Listener not started."); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(timeout); + + try + { + var contextTask = _listener.GetContextAsync(); + await Task.WhenAny(contextTask, Task.Delay(Timeout.Infinite, timeoutCts.Token)); + + if (!contextTask.IsCompletedSuccessfully) + { + // Timeout: stop the listener to release resources and force the + // abandoned GetContextAsync() to fault (its exception is observed + // by the HttpListener infrastructure rather than left dangling). + _listener?.Stop(); + return new OAuthCallbackResult(null, null, null); + } + + var context = await contextTask; + var rawQuery = context.Request.Url?.Query ?? string.Empty; + var rawQs = rawQuery.TrimStart('?'); + + ParseQueryString(rawQs, out var code, out var error, out var state); + + // Serve success page + var response = context.Response; + var bytes = Encoding.UTF8.GetBytes(SuccessHtml); + response.ContentType = "text/html; charset=utf-8"; + response.ContentLength64 = bytes.Length; + await response.OutputStream.WriteAsync(bytes, CancellationToken.None); + response.Close(); + + return new OAuthCallbackResult(code, error, state); + } + catch (OperationCanceledException) + { + return new OAuthCallbackResult(null, null, null); + } + catch (HttpListenerException) + { + return new OAuthCallbackResult(null, null, null); + } + } + + public Task StopAsync() + { + try { _portHolder?.Stop(); } catch { } + _portHolder = null; + try { _listener?.Stop(); } catch { } + try { _listener?.Close(); } catch { } + _listener = null; + _started = false; + return Task.CompletedTask; + } + + internal static void ParseQueryString(string rawQs, out string? code, out string? error, out string? state) + { + code = null; + error = null; + state = null; + + if (string.IsNullOrEmpty(rawQs)) + return; + + foreach (var part in rawQs.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var eqIdx = part.IndexOf('='); + if (eqIdx < 0) continue; + + var key = Uri.UnescapeDataString(part[..eqIdx]); + var value = Uri.UnescapeDataString(part[(eqIdx + 1)..]); + + if (string.Equals(key, "code", StringComparison.OrdinalIgnoreCase)) + code = value; + else if (string.Equals(key, "error", StringComparison.OrdinalIgnoreCase)) + error = value; + else if (string.Equals(key, "state", StringComparison.OrdinalIgnoreCase)) + state = value; + } + } + +} diff --git a/src/Hypa.Infrastructure/Mcp/Auth/OAuthDeviceCodeResponse.cs b/src/Hypa.Infrastructure/Mcp/Auth/OAuthDeviceCodeResponse.cs new file mode 100644 index 0000000..582ff92 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/OAuthDeviceCodeResponse.cs @@ -0,0 +1,8 @@ +namespace Hypa.Infrastructure.Mcp.Auth; + +internal sealed record OAuthDeviceCodeResponse( + string DeviceCode, + string UserCode, + string VerificationUri, + int ExpiresIn, + int? Interval); diff --git a/src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenCache.cs b/src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenCache.cs new file mode 100644 index 0000000..3c2e7f9 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenCache.cs @@ -0,0 +1,31 @@ +using System.Collections.Concurrent; + +namespace Hypa.Infrastructure.Mcp.Auth; + +internal sealed class OAuthTokenCache +{ + private readonly ConcurrentDictionary _cache = new(); + + public string? TryGet(string key, int skewSeconds = 60) + { + if (_cache.TryGetValue(key, out var cached)) + { + if (cached.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(skewSeconds)) + return cached.AccessToken; + } + return null; + } + + public void Set(string key, string accessToken, int? expiresIn) + { + var expiresAt = expiresIn.HasValue + ? DateTimeOffset.UtcNow.AddSeconds(expiresIn.Value) + : DateTimeOffset.UtcNow.AddHours(1); + + _cache[key] = new CachedOAuthToken(accessToken, expiresAt); + } + + public void Remove(string key) => _cache.TryRemove(key, out _); + + internal record CachedOAuthToken(string AccessToken, DateTimeOffset ExpiresAt); +} diff --git a/src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenJsonContext.cs b/src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenJsonContext.cs new file mode 100644 index 0000000..6ba7614 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenJsonContext.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Hypa.Infrastructure.Mcp.Auth; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(OAuthTokenResponse))] +[JsonSerializable(typeof(OAuthDeviceCodeResponse))] +[JsonSerializable(typeof(DeviceTokenStoreJson))] +internal sealed partial class OAuthTokenJsonContext : JsonSerializerContext; diff --git a/src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenResponse.cs b/src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenResponse.cs new file mode 100644 index 0000000..00c6ee5 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenResponse.cs @@ -0,0 +1,7 @@ +namespace Hypa.Infrastructure.Mcp.Auth; + +internal sealed record OAuthTokenResponse( + string AccessToken, + string TokenType, + int? ExpiresIn, + string? Scope); diff --git a/src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenService.cs b/src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenService.cs new file mode 100644 index 0000000..5225ac7 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/OAuthTokenService.cs @@ -0,0 +1,195 @@ +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using System.Text.Json; +using Hypa.Infrastructure.Mcp.Secrets; +using Hypa.Infrastructure.Storage; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Mcp; +using Microsoft.Extensions.Logging; + +namespace Hypa.Infrastructure.Mcp.Auth; + +internal sealed class OAuthTokenService : IOAuthTokenService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ISecretResolver _secretResolver; + private readonly OAuthTokenCache _cache; + private readonly DeviceTokenStore _deviceTokenStore; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _locks = new(); + + public OAuthTokenService( + IHttpClientFactory httpClientFactory, + ISecretResolver secretResolver, + OAuthTokenCache cache, + HypaDataOptions dataOptions, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _secretResolver = secretResolver; + _cache = cache; + _deviceTokenStore = new DeviceTokenStore(dataOptions.DataDirectory); + _logger = logger; + } + + public async Task GetClientCredentialsTokenAsync( + OAuth2ClientCredentialsConfig config, + CancellationToken ct) + { + var key = $"{config.ClientIdRef}@{config.TokenUrl}"; + + var cached = _cache.TryGet(key); + if (cached is not null) + return cached; + + var sem = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await sem.WaitAsync(ct); + try + { + cached = _cache.TryGet(key); + if (cached is not null) + return cached; + + var clientId = await _secretResolver.ResolveAsync(config.ClientIdRef, ct) ?? string.Empty; + var clientSecret = await _secretResolver.ResolveAsync(config.ClientSecretRef, ct) ?? string.Empty; + + var formFields = new List> + { + new("grant_type", "client_credentials"), + new("client_id", clientId), + new("client_secret", clientSecret), + }; + + if (config.Scopes is { Length: > 0 }) + formFields.Add(new("scope", string.Join(' ', config.Scopes))); + + var http = _httpClientFactory.CreateClient("mcp-oauth"); + using var response = await http.PostAsync( + config.TokenUrl, + new FormUrlEncodedContent(formFields), + ct); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(ct); + var tokenResponse = await JsonSerializer.DeserializeAsync( + stream, + OAuthTokenJsonContext.Default.OAuthTokenResponse, + ct); + + var token = tokenResponse?.AccessToken + ?? throw new InvalidOperationException("OAuth token response missing access_token"); + + _cache.Set(key, token, tokenResponse.ExpiresIn); + return token; + } + finally + { + sem.Release(); + } + } + + public async Task GetDeviceCodeTokenAsync( + OAuth2DeviceCodeConfig config, + CancellationToken ct) + { + var key = $"device:{config.ClientId}@{config.TokenUrl}"; + + var cached = _cache.TryGet(key); + if (cached is not null) + return cached; + + var stored = await _deviceTokenStore.LoadAsync(ct); + if (stored.TryGetValue(key, out var entry)) + { + var expiresAt = DateTimeOffset.FromUnixTimeMilliseconds(entry.ExpiresAtUnixMs); + if (expiresAt > DateTimeOffset.UtcNow.AddSeconds(60)) + { + var remaining = (int)(expiresAt - DateTimeOffset.UtcNow).TotalSeconds; + _cache.Set(key, entry.AccessToken, remaining); + return entry.AccessToken; + } + } + + return null; + } + + public async Task InitiateDeviceCodeFlowAsync( + OAuth2DeviceCodeConfig config, + CancellationToken ct) + { + var http = _httpClientFactory.CreateClient("mcp-oauth"); + + var deviceFormFields = new List> + { + new("client_id", config.ClientId), + }; + + if (config.Scopes is { Length: > 0 }) + deviceFormFields.Add(new("scope", string.Join(' ', config.Scopes))); + + using var deviceResponse = await http.PostAsync( + config.AuthUrl, + new FormUrlEncodedContent(deviceFormFields), + ct); + deviceResponse.EnsureSuccessStatusCode(); + + await using var deviceStream = await deviceResponse.Content.ReadAsStreamAsync(ct); + var deviceCode = await JsonSerializer.DeserializeAsync( + deviceStream, + OAuthTokenJsonContext.Default.OAuthDeviceCodeResponse, + ct); + + if (deviceCode is null) + throw new InvalidOperationException("Invalid device code response"); + + await Console.Error.WriteLineAsync($"Open {deviceCode.VerificationUri} and enter code: {deviceCode.UserCode}"); + + var pollInterval = deviceCode.Interval ?? 5; + var deadline = DateTimeOffset.UtcNow.AddSeconds(deviceCode.ExpiresIn); + + while (DateTimeOffset.UtcNow < deadline) + { + await Task.Delay(TimeSpan.FromSeconds(pollInterval), ct); + + var pollFields = new List> + { + new("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + new("device_code", deviceCode.DeviceCode), + new("client_id", config.ClientId), + }; + + using var pollResponse = await http.PostAsync( + config.TokenUrl, + new FormUrlEncodedContent(pollFields), + ct); + + if (!pollResponse.IsSuccessStatusCode) + continue; + + await using var pollStream = await pollResponse.Content.ReadAsStreamAsync(ct); + var tokenResponse = await JsonSerializer.DeserializeAsync( + pollStream, + OAuthTokenJsonContext.Default.OAuthTokenResponse, + ct); + + if (tokenResponse?.AccessToken is null) + continue; + + var key = $"device:{config.ClientId}@{config.TokenUrl}"; + _cache.Set(key, tokenResponse.AccessToken, tokenResponse.ExpiresIn); + + var expiresAt = tokenResponse.ExpiresIn.HasValue + ? DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn.Value) + : DateTimeOffset.UtcNow.AddHours(1); + + var stored = await _deviceTokenStore.LoadAsync(ct); + stored[key] = new DeviceTokenEntryJson(tokenResponse.AccessToken, expiresAt.ToUnixTimeMilliseconds()); + await _deviceTokenStore.SaveAsync(stored, ct); + + _logger.LogInformation("Device code authentication succeeded for client {ClientId}", config.ClientId); + return; + } + + throw new InvalidOperationException("Device code flow timed out"); + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Auth/SecretRedactionRegistry.cs b/src/Hypa.Infrastructure/Mcp/Auth/SecretRedactionRegistry.cs new file mode 100644 index 0000000..e8a3523 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Auth/SecretRedactionRegistry.cs @@ -0,0 +1,26 @@ +namespace Hypa.Infrastructure.Mcp.Auth; + +public sealed class SecretRedactionRegistry +{ + private readonly HashSet _secrets = new(); + private readonly object _lock = new(); + + public void Register(string secret) + { + if (string.IsNullOrEmpty(secret)) + return; + + lock (_lock) + _secrets.Add(secret); + } + + public string Redact(string text) + { + lock (_lock) + { + foreach (var secret in _secrets) + text = text.Replace(secret, "[REDACTED]", StringComparison.Ordinal); + } + return text; + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Config/McpServerConfigLoader.cs b/src/Hypa.Infrastructure/Mcp/Config/McpServerConfigLoader.cs new file mode 100644 index 0000000..196ca69 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Config/McpServerConfigLoader.cs @@ -0,0 +1,128 @@ +using System.Text.Json; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Config; +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Infrastructure.Mcp.Config; + +public sealed class McpServerConfigLoader : IMcpServerDefinitionRepository +{ + private readonly string _configFilePath; + + public McpServerConfigLoader(IConfigLoader configLoader) + : this(ResolveStoragePath(configLoader)) + { } + + internal McpServerConfigLoader(string storagePath) + { + _configFilePath = Path.Combine(storagePath, "mcp-servers.json"); + } + + public async Task, Error>> LoadAsync(CancellationToken ct) + { + if (!File.Exists(_configFilePath)) + return Result, Error>.Ok(Array.Empty()); + + try + { + await using var stream = File.OpenRead(_configFilePath); + var file = await JsonSerializer.DeserializeAsync( + stream, + McpServersJsonContext.Default.McpServersFileJson, + ct); + + if (file?.Servers is null or { Count: 0 }) + return Result, Error>.Ok(Array.Empty()); + + var definitions = file.Servers + .Where(s => s is not null) + .Select(MapToDefinition) + .ToList(); + + return Result, Error>.Ok(definitions); + } + catch (Exception ex) + { + return Result, Error>.Fail( + new Error("ParseFailed", $"Failed to parse mcp-servers.json: {ex.Message}")); + } + } + + private static McpServerDefinition MapToDefinition(McpServerJson json) + { + var name = json.Name ?? string.Empty; + var transport = MapTransport(json); + var auth = MapAuth(json.Auth); + var tls = MapTls(json.Tls); + var connectTimeout = json.ConnectTimeoutSeconds.HasValue + ? TimeSpan.FromSeconds(json.ConnectTimeoutSeconds.Value) + : (TimeSpan?)null; + var requestTimeout = json.RequestTimeoutSeconds.HasValue + ? TimeSpan.FromSeconds(json.RequestTimeoutSeconds.Value) + : (TimeSpan?)null; + + return new McpServerDefinition(name, transport, auth, tls, connectTimeout, requestTimeout); + } + + private static McpTransportConfig MapTransport(McpServerJson json) + { + var kind = json.Transport?.ToLowerInvariant() switch + { + null or "" or "stdio" => McpTransportKind.Stdio, + "streamablehttp" => McpTransportKind.Http, + "sse" => McpTransportKind.Sse, + "http" or "httpautodetect" => McpTransportKind.HttpAutoDetect, + _ => McpTransportKind.Unknown, + }; + return new McpTransportConfig(kind, json.Endpoint); + } + + private static McpAuthConfig MapAuth(McpAuthJson? auth) + { + if (auth is null) + return new NoneAuthConfig(); + + if (string.IsNullOrWhiteSpace(auth.Type)) + return new UnknownAuthConfig(string.Empty); + + return auth.Type.ToLowerInvariant() switch + { + "none" => new NoneAuthConfig(), + "bearer" => new BearerAuthConfig(auth.TokenRef ?? string.Empty), + "apikey" => new ApiKeyAuthConfig( + auth.HeaderName ?? string.Empty, + auth.ValueRef ?? string.Empty, + auth.InQueryString ?? false), + "basic" => new BasicAuthConfig( + auth.UsernameRef ?? string.Empty, + auth.PasswordRef ?? string.Empty), + "oauth2clientcredentials" => new OAuth2ClientCredentialsConfig( + auth.TokenUrl ?? string.Empty, + auth.ClientIdRef ?? string.Empty, + auth.ClientSecretRef ?? string.Empty, + auth.Scopes), + "oauth2devicecode" => new OAuth2DeviceCodeConfig( + auth.AuthUrl ?? string.Empty, + auth.TokenUrl ?? string.Empty, + auth.ClientId ?? string.Empty, + auth.Scopes), + "mtls" => new MtlsConfig(auth.ClientCertRef, auth.ClientKeyRef), + "mcpoauth" => new McpOAuthConfig(auth.ClientId, auth.ClientSecretRef, auth.Scopes), + _ => new UnknownAuthConfig(auth.Type), + }; + } + + private static McpTlsConfig? MapTls(McpTlsJson? tls) + { + if (tls is null) + return null; + return new McpTlsConfig(tls.CaCertPath, tls.ClientCertPath, tls.ClientKeyPath); + } + + private static string ResolveStoragePath(IConfigLoader configLoader) + { + var result = configLoader.LoadAsync(CancellationToken.None).GetAwaiter().GetResult(); + return result.IsOk ? result.Value.StoragePath : HypaConfig.Default.StoragePath; + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Config/McpServerConfigWriter.cs b/src/Hypa.Infrastructure/Mcp/Config/McpServerConfigWriter.cs new file mode 100644 index 0000000..f6446d2 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Config/McpServerConfigWriter.cs @@ -0,0 +1,201 @@ +using System.Text.Json; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Config; +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Infrastructure.Mcp.Config; + +public sealed class McpServerConfigWriter : IMcpServerConfigReader, IMcpServerConfigWriter +{ + private readonly string _configFilePath; + private readonly string _configDir; + + public McpServerConfigWriter(IConfigLoader configLoader) + : this(ResolveStoragePath(configLoader)) + { } + + internal McpServerConfigWriter(string storagePath) + { + _configDir = storagePath; + _configFilePath = Path.Combine(storagePath, "mcp-servers.json"); + } + + public async Task, Error>> ReadEditableAsync(CancellationToken ct) + { + if (!File.Exists(_configFilePath)) + return Result, Error>.Ok(Array.Empty()); + + try + { + await using var stream = File.OpenRead(_configFilePath); + var file = await JsonSerializer.DeserializeAsync( + stream, + McpServersJsonContext.Default.McpServersFileJson, + ct); + + if (file?.Servers is null or { Count: 0 }) + return Result, Error>.Ok(Array.Empty()); + + var definitions = file.Servers + .Where(s => s is not null) + .Select(MapToDefinition) + .ToList(); + + return Result, Error>.Ok(definitions); + } + catch (Exception ex) + { + return Result, Error>.Fail( + new Error("ParseFailed", $"Failed to parse mcp-servers.json: {ex.Message}")); + } + } + + public async Task> WriteAsync(IReadOnlyList servers, CancellationToken ct) + { + Directory.CreateDirectory(_configDir); + + var tempPath = Path.Combine(_configDir, $"mcp-servers.{Guid.NewGuid():N}.tmp"); + try + { + var jsonList = servers.Select(MapToJson).ToList(); + var file = new McpServersFileJson(jsonList); + + await using (var stream = new FileStream( + tempPath, FileMode.Create, FileAccess.Write, FileShare.None, + bufferSize: 4096, useAsync: true)) + { + await JsonSerializer.SerializeAsync( + stream, + file, + McpServersJsonContext.Default.McpServersFileJson, + ct); + await stream.FlushAsync(ct); + } + + File.Move(tempPath, _configFilePath, overwrite: true); + return Result.Ok(Unit.Value); + } + catch (Exception ex) + { + TryDelete(tempPath); + return Result.Fail( + new Error("WriteFailed", $"Failed to write mcp-servers.json: {ex.Message}")); + } + } + + private static void TryDelete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } + catch { /* best effort */ } + } + + private static McpServerDefinition MapToDefinition(McpServerJson json) + { + var transportKind = json.Transport?.ToLowerInvariant() switch + { + null or "" or "stdio" => McpTransportKind.Stdio, + "streamablehttp" => McpTransportKind.Http, + "sse" => McpTransportKind.Sse, + "http" or "httpautodetect" => McpTransportKind.HttpAutoDetect, + _ => McpTransportKind.Unknown, + }; + var transport = new McpTransportConfig(transportKind, json.Endpoint); + + McpAuthConfig auth = json.Auth is null + ? new NoneAuthConfig() + : string.IsNullOrWhiteSpace(json.Auth.Type) + ? new UnknownAuthConfig(string.Empty) + : json.Auth.Type.ToLowerInvariant() switch + { + "none" => (McpAuthConfig)new NoneAuthConfig(), + "bearer" => new BearerAuthConfig(json.Auth.TokenRef ?? string.Empty), + "apikey" => new ApiKeyAuthConfig( + json.Auth.HeaderName ?? string.Empty, + json.Auth.ValueRef ?? string.Empty, + json.Auth.InQueryString ?? false), + "basic" => new BasicAuthConfig( + json.Auth.UsernameRef ?? string.Empty, + json.Auth.PasswordRef ?? string.Empty), + "oauth2clientcredentials" => new OAuth2ClientCredentialsConfig( + json.Auth.TokenUrl ?? string.Empty, + json.Auth.ClientIdRef ?? string.Empty, + json.Auth.ClientSecretRef ?? string.Empty, + json.Auth.Scopes), + "oauth2devicecode" => new OAuth2DeviceCodeConfig( + json.Auth.AuthUrl ?? string.Empty, + json.Auth.TokenUrl ?? string.Empty, + json.Auth.ClientId ?? string.Empty, + json.Auth.Scopes), + "mtls" => new MtlsConfig(json.Auth.ClientCertRef, json.Auth.ClientKeyRef), + "mcpoauth" => new McpOAuthConfig(json.Auth.ClientId, json.Auth.ClientSecretRef, json.Auth.Scopes), + _ => new UnknownAuthConfig(json.Auth.Type), + }; + + McpTlsConfig? tls = json.Tls is null + ? null + : new McpTlsConfig(json.Tls.CaCertPath, json.Tls.ClientCertPath, json.Tls.ClientKeyPath); + + var connectTimeout = json.ConnectTimeoutSeconds.HasValue + ? TimeSpan.FromSeconds(json.ConnectTimeoutSeconds.Value) + : (TimeSpan?)null; + var requestTimeout = json.RequestTimeoutSeconds.HasValue + ? TimeSpan.FromSeconds(json.RequestTimeoutSeconds.Value) + : (TimeSpan?)null; + + return new McpServerDefinition( + json.Name ?? string.Empty, transport, auth, tls, connectTimeout, requestTimeout); + } + + internal static McpServerJson MapToJson(McpServerDefinition def) + { + var transport = def.Transport.Kind switch + { + McpTransportKind.Stdio => "stdio", + McpTransportKind.Http => "streamableHttp", + McpTransportKind.Sse => "sse", + McpTransportKind.HttpAutoDetect => "httpAutoDetect", + _ => def.Transport.Kind.ToString().ToLowerInvariant(), + }; + + McpAuthJson? auth = def.Auth switch + { + NoneAuthConfig => new McpAuthJson( + "none", null, null, null, null, null, null, null, null, null, null, null, null, null, null), + BearerAuthConfig b => new McpAuthJson( + "bearer", b.TokenRef, null, null, null, null, null, null, null, null, null, null, null, null, null), + ApiKeyAuthConfig ak => new McpAuthJson( + "apiKey", null, ak.HeaderName, ak.ValueRef, ak.InQueryString, null, null, null, null, null, null, null, null, null, null), + BasicAuthConfig ba => new McpAuthJson( + "basic", null, null, null, null, ba.UsernameRef, ba.PasswordRef, null, null, null, null, null, null, null, null), + OAuth2ClientCredentialsConfig cc => new McpAuthJson( + "oauth2ClientCredentials", null, null, null, null, null, null, cc.TokenUrl, cc.ClientIdRef, cc.ClientSecretRef, null, null, cc.Scopes, null, null), + OAuth2DeviceCodeConfig dc => new McpAuthJson( + "oauth2DeviceCode", null, null, null, null, null, null, dc.TokenUrl, null, null, dc.AuthUrl, dc.ClientId, dc.Scopes, null, null), + MtlsConfig m => new McpAuthJson( + "mtls", null, null, null, null, null, null, null, null, null, null, null, null, m.ClientCertRef, m.ClientKeyRef), + McpOAuthConfig oauth => new McpAuthJson( + "mcpOAuth", null, null, null, null, null, null, null, null, oauth.ClientSecretRef, null, oauth.ClientId, oauth.Scopes, null, null), + _ => null, + }; + + McpTlsJson? tls = def.Tls is null + ? null + : new McpTlsJson(def.Tls.CaCertPath, def.Tls.ClientCertPath, def.Tls.ClientKeyPath); + + return new McpServerJson( + def.Name, + transport, + def.Transport.Endpoint, + auth, + tls, + def.ConnectTimeout.HasValue ? (int)def.ConnectTimeout.Value.TotalSeconds : null, + def.RequestTimeout.HasValue ? (int)def.RequestTimeout.Value.TotalSeconds : null); + } + + private static string ResolveStoragePath(IConfigLoader configLoader) + { + var result = configLoader.LoadAsync(CancellationToken.None).GetAwaiter().GetResult(); + return result.IsOk ? result.Value.StoragePath : HypaConfig.Default.StoragePath; + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Config/McpServerJson.cs b/src/Hypa.Infrastructure/Mcp/Config/McpServerJson.cs new file mode 100644 index 0000000..a101979 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Config/McpServerJson.cs @@ -0,0 +1,34 @@ +namespace Hypa.Infrastructure.Mcp.Config; + +internal sealed record McpServersFileJson(IReadOnlyList? Servers); + +internal sealed record McpServerJson( + string? Name, + string? Transport, + string? Endpoint, + McpAuthJson? Auth, + McpTlsJson? Tls, + int? ConnectTimeoutSeconds, + int? RequestTimeoutSeconds); + +internal sealed record McpAuthJson( + string? Type, + string? TokenRef, + string? HeaderName, + string? ValueRef, + bool? InQueryString, + string? UsernameRef, + string? PasswordRef, + string? TokenUrl, + string? ClientIdRef, + string? ClientSecretRef, + string? AuthUrl, + string? ClientId, + string[]? Scopes, + string? ClientCertRef, + string? ClientKeyRef); + +internal sealed record McpTlsJson( + string? CaCertPath, + string? ClientCertPath, + string? ClientKeyPath); diff --git a/src/Hypa.Infrastructure/Mcp/Config/McpServersJsonContext.cs b/src/Hypa.Infrastructure/Mcp/Config/McpServersJsonContext.cs new file mode 100644 index 0000000..5269252 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Config/McpServersJsonContext.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Hypa.Infrastructure.Mcp.Config; + +[JsonSerializable(typeof(McpServersFileJson))] +[JsonSerializable(typeof(McpServerJson))] +[JsonSerializable(typeof(McpAuthJson))] +[JsonSerializable(typeof(McpTlsJson))] +[JsonSerializable(typeof(List))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true)] +internal sealed partial class McpServersJsonContext : JsonSerializerContext { } diff --git a/src/Hypa.Infrastructure/Mcp/Connection/DirectMcpDispatcher.cs b/src/Hypa.Infrastructure/Mcp/Connection/DirectMcpDispatcher.cs new file mode 100644 index 0000000..f528c3b --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Connection/DirectMcpDispatcher.cs @@ -0,0 +1,285 @@ +using System.Text.Json.Nodes; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Mcp; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Hypa.Infrastructure.Mcp.Connection; + +internal sealed class DirectMcpDispatcher : IMcpDispatcher +{ + private readonly IMcpServerDefinitionRepository _serverRepo; + private readonly IMcpClientConnectionFactory _factory; + private readonly McpConfigValidationService _validator; + private readonly IClock _clock; + private readonly ILogger _logger; + + public DirectMcpDispatcher( + IMcpServerDefinitionRepository serverRepo, + IMcpClientConnectionFactory factory, + McpConfigValidationService validator, + IClock clock, + ILogger logger) + { + _serverRepo = serverRepo; + _factory = factory; + _validator = validator; + _clock = clock; + _logger = logger; + } + + public async Task GetSchemaAsync(CancellationToken ct) + { + var serversResult = await _serverRepo.LoadAsync(ct); + if (!serversResult.IsOk) + { + _logger.LogError("Failed to load server definitions: {Error}", serversResult.Error.Message); + return new McpSchemaManifest( + [], + [new McpSchemaError("(config)", McpErrorCodes.SchemaUnavailable, "Failed to load server configuration.")]); + } + + var serverSchemas = new List(); + var schemaErrors = new List(); + + foreach (var server in serversResult.Value) + { + var validation = _validator.Validate([server]); + if (!validation.IsOk) + { + var msg = string.Join("; ", validation.Error.Select(e => $"{e.Field}: {e.Message}")); + _logger.LogWarning("Skipping invalid server config '{Server}': {Errors}", server.Name, msg); + schemaErrors.Add(new McpSchemaError(server.Name, McpErrorCodes.InvalidRequest, msg)); + continue; + } + + var clientResult = await _factory.GetOrCreateAsync(server, ct); + if (!clientResult.IsOk) + { + _logger.LogWarning("Skipping schema for '{Server}': {Error}", + server.Name, clientResult.Error.Message); + schemaErrors.Add(new McpSchemaError(server.Name, clientResult.Error.Code, clientResult.Error.Message)); + continue; + } + + try + { + var tools = await clientResult.Value.ListToolsAsync(ct); + var toolSchemas = tools + .Select(t => new McpToolSchema( + t.Name, + t.Description ?? string.Empty, + new JsonPayload(t.ProtocolTool.InputSchema.GetRawText()))) + .ToList(); + + serverSchemas.Add(new McpServerSchema(server.Name, toolSchemas)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to list tools for server '{Server}'", server.Name); + await _factory.InvalidateAsync(server.Name); + schemaErrors.Add(new McpSchemaError(server.Name, McpErrorCodes.SchemaUnavailable, $"Failed to retrieve tools from server '{server.Name}'.")); + } + } + + return new McpSchemaManifest(serverSchemas, schemaErrors.Count > 0 ? schemaErrors : null); + } + + public async Task> SearchToolsAsync(string query, CancellationToken ct) + { + var schema = await GetSchemaAsync(ct); + var lower = query.ToLowerInvariant(); + + return schema.Servers + .SelectMany(s => s.Tools.Select(t => (Server: s, Tool: t))) + .Where(x => + x.Tool.Name.Contains(lower, StringComparison.OrdinalIgnoreCase) || + x.Tool.Description.Contains(lower, StringComparison.OrdinalIgnoreCase)) + .Select(x => new McpToolSearchResult( + x.Server.ServerName, + x.Tool.Name, + x.Tool.Description, + Score: 1.0)) + .ToList(); + } + + public async Task InvokeAsync(McpProxyRequest request, CancellationToken ct) + { + var requestStart = _clock.UtcNow; + + var serversResult = await _serverRepo.LoadAsync(ct); + if (!serversResult.IsOk) + return ErrorResult(request, McpErrorCodes.UnknownServer, "Failed to load server definitions.", requestStart); + + var server = serversResult.Value.FirstOrDefault(s => s.Name == request.ServerName); + if (server is null) + return ErrorResult(request, McpErrorCodes.UnknownServer, + $"No server named '{request.ServerName}' is configured.", requestStart); + + var validation = _validator.Validate([server]); + if (!validation.IsOk) + { + var message = string.Join("; ", validation.Error.Select(e => $"{e.Field}: {e.Message}")); + return ErrorResult(request, McpErrorCodes.InvalidRequest, message, requestStart); + } + + var clientResult = await _factory.GetOrCreateAsync(server, ct); + if (!clientResult.IsOk) + return ErrorResult(request, clientResult.Error.Code, clientResult.Error.Message, requestStart); + + using var timeoutCts = server.RequestTimeout.HasValue + ? CancellationTokenSource.CreateLinkedTokenSource(ct) + : null; + + if (timeoutCts is not null && server.RequestTimeout.HasValue) + timeoutCts.CancelAfter(server.RequestTimeout.Value); + + var effectiveCt = timeoutCts?.Token ?? ct; + var start = requestStart; + + Dictionary args; + try + { + args = ParseArguments(request.Arguments.RawJson); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Invalid arguments for '{Server}/{Tool}'", + request.ServerName, request.ToolName); + return ErrorResult(request, McpErrorCodes.InvalidRequest, $"Invalid arguments: {ex.Message}", requestStart); + } + + try + { + var sdkResult = await clientResult.Value.CallToolAsync( + request.ToolName, + args, + effectiveCt); + + var elapsed = _clock.UtcNow - start; + var text = sdkResult.Content is not null + ? string.Concat(sdkResult.Content.OfType().Select(c => c.Text)) + : string.Empty; + var raw = BuildRawJson(sdkResult); + + if (sdkResult.IsError == true) + { + return new McpResult( + request.ServerName, + request.ToolName, + new JsonPayload(raw), + text, + new McpLatencyMetadata(start, elapsed), + IsError: true, + new McpProxyError(McpErrorCodes.RemoteToolError, text, request.ServerName, request.ToolName)); + } + + return new McpResult( + request.ServerName, + request.ToolName, + new JsonPayload(raw), + text, + new McpLatencyMetadata(start, elapsed), + IsError: false, + Error: null); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + await _factory.InvalidateAsync(request.ServerName); + return ErrorResult(request, McpErrorCodes.Timeout, + $"Request to '{request.ServerName}' timed out.", start, _clock.UtcNow - start); + } + catch (Exception ex) + { + _logger.LogError(ex, "Tool invocation failed for '{Server}/{Tool}'", + request.ServerName, request.ToolName); + await _factory.InvalidateAsync(request.ServerName); + return ErrorResult(request, McpErrorCodes.ToolInvocationFailed, + $"Tool invocation failed for '{request.ServerName}/{request.ToolName}'.", + start, _clock.UtcNow - start); + } + } + + public async Task> InvokeBatchAsync( + IReadOnlyList requests, + CancellationToken ct) + { + var tasks = requests.Select(r => InvokeAsync(r, ct)); + return await Task.WhenAll(tasks); + } + + private static McpResult ErrorResult( + McpProxyRequest request, + string code, + string message, + DateTimeOffset startedAt, + TimeSpan? elapsed = null) + { + var latency = new McpLatencyMetadata(startedAt, elapsed ?? TimeSpan.Zero); + return new McpResult( + request.ServerName, + request.ToolName, + new JsonPayload("{}"), + string.Empty, + latency, + IsError: true, + new McpProxyError(code, message, request.ServerName, request.ToolName)); + } + + private static Dictionary ParseArguments(string rawJson) + { + if (string.IsNullOrWhiteSpace(rawJson)) + return []; + + var node = JsonNode.Parse(rawJson); + if (node is not JsonObject obj) + return []; + + return obj.ToDictionary(kvp => kvp.Key, kvp => ToObject(kvp.Value)); + } + + private static object? ToObject(JsonNode? node) => node switch + { + null => null, + JsonValue v when v.TryGetValue(out var s) => s, + JsonValue v when v.TryGetValue(out var l) => l, + JsonValue v when v.TryGetValue(out var d) => d, + JsonValue v when v.TryGetValue(out var b) => b, + JsonObject o => o.ToDictionary(kvp => kvp.Key, kvp => ToObject(kvp.Value)), + JsonArray a => a.Select(ToObject).ToList(), + _ => node.ToString(), + }; + + private static string BuildRawJson(CallToolResult result) + { + var arr = new JsonArray(); + if (result.Content is null) + return arr.ToJsonString(); + + foreach (var block in result.Content) + { + JsonNode item = block switch + { + TextContentBlock text => new JsonObject { ["type"] = "text", ["text"] = text.Text }, + ImageContentBlock image => new JsonObject + { + ["type"] = "image", + ["data"] = Convert.ToBase64String(image.Data.Span), + ["mimeType"] = image.MimeType, + }, + AudioContentBlock audio => new JsonObject + { + ["type"] = "audio", + ["data"] = Convert.ToBase64String(audio.Data.Span), + ["mimeType"] = audio.MimeType, + }, + _ => new JsonObject { ["type"] = block.Type }, + }; + arr.Add(item); + } + + return arr.ToJsonString(); + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Connection/IMcpClientConnectionFactory.cs b/src/Hypa.Infrastructure/Mcp/Connection/IMcpClientConnectionFactory.cs new file mode 100644 index 0000000..a587f31 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Connection/IMcpClientConnectionFactory.cs @@ -0,0 +1,13 @@ +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Infrastructure.Mcp.Connection; + +internal interface IMcpClientConnectionFactory +{ + Task> GetOrCreateAsync( + McpServerDefinition server, + CancellationToken ct); + + Task InvalidateAsync(string serverName); +} diff --git a/src/Hypa.Infrastructure/Mcp/Connection/IMcpClientFacade.cs b/src/Hypa.Infrastructure/Mcp/Connection/IMcpClientFacade.cs new file mode 100644 index 0000000..aee8470 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Connection/IMcpClientFacade.cs @@ -0,0 +1,25 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Hypa.Infrastructure.Mcp.Connection; + +internal interface IMcpClientFacade +{ + ValueTask> ListToolsAsync(CancellationToken ct); + ValueTask CallToolAsync( + string toolName, + IReadOnlyDictionary arguments, + CancellationToken ct); +} + +internal sealed class McpClientFacade(McpClient client) : IMcpClientFacade +{ + public ValueTask> ListToolsAsync(CancellationToken ct) => + client.ListToolsAsync(cancellationToken: ct); + + public ValueTask CallToolAsync( + string toolName, + IReadOnlyDictionary arguments, + CancellationToken ct) => + client.CallToolAsync(toolName, arguments, cancellationToken: ct); +} diff --git a/src/Hypa.Infrastructure/Mcp/Connection/IMcpSdkBridge.cs b/src/Hypa.Infrastructure/Mcp/Connection/IMcpSdkBridge.cs new file mode 100644 index 0000000..7031121 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Connection/IMcpSdkBridge.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; + +namespace Hypa.Infrastructure.Mcp.Connection; + +// One-shot client façade used exclusively by the probe adapter. +// Owns the underlying McpClient and disposes it when finished. +internal interface IProbeClientFacade : IAsyncDisposable +{ + ValueTask> ListToolsAsync(CancellationToken ct); +} + +internal sealed class McpProbeFacade(McpClient client) : IProbeClientFacade +{ + public ValueTask> ListToolsAsync(CancellationToken ct) => + client.ListToolsAsync(cancellationToken: ct); + + public ValueTask DisposeAsync() => client.DisposeAsync(); +} + +internal interface IMcpSdkBridge +{ + Task CreateClientAsync( + IClientTransport transport, + McpClientOptions options, + ILoggerFactory? loggerFactory, + CancellationToken ct); + + IClientTransport CreateStdioTransport(StdioClientTransportOptions options); + IClientTransport CreateHttpTransport(HttpClientTransportOptions options, HttpClient? httpClient); + + // Creates and wraps a one-shot MCP client owned by the probe adapter. + Task CreateProbeClientAsync( + IClientTransport transport, + McpClientOptions options, + ILoggerFactory? loggerFactory, + CancellationToken ct); +} + +internal sealed class McpSdkBridge : IMcpSdkBridge +{ + public Task CreateClientAsync( + IClientTransport transport, + McpClientOptions options, + ILoggerFactory? loggerFactory, + CancellationToken ct) => + McpClient.CreateAsync(transport, options, loggerFactory, ct); + + public IClientTransport CreateStdioTransport(StdioClientTransportOptions options) => + new StdioClientTransport(options); + + public IClientTransport CreateHttpTransport(HttpClientTransportOptions options, HttpClient? httpClient) => + httpClient is null + ? new HttpClientTransport(options) + : new HttpClientTransport(options, httpClient, null, ownsHttpClient: true); + + public async Task CreateProbeClientAsync( + IClientTransport transport, + McpClientOptions options, + ILoggerFactory? loggerFactory, + CancellationToken ct) + { + var client = await McpClient.CreateAsync(transport, options, loggerFactory, ct); + return new McpProbeFacade(client); + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Connection/McpClientConnectionFactory.cs b/src/Hypa.Infrastructure/Mcp/Connection/McpClientConnectionFactory.cs new file mode 100644 index 0000000..2068dd1 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Connection/McpClientConnectionFactory.cs @@ -0,0 +1,116 @@ +using System.Collections.Concurrent; +using Hypa.Infrastructure.Mcp.Auth; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; + +namespace Hypa.Infrastructure.Mcp.Connection; + +internal sealed class McpClientConnectionFactory : IMcpClientConnectionFactory, IAsyncDisposable +{ + private readonly McpTransportBuilder _transportBuilder; + private readonly IMcpSdkBridge _sdk; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _cache = new(); + private readonly ConcurrentDictionary _locks = new(); + + public McpClientConnectionFactory( + McpTransportBuilder transportBuilder, + IMcpSdkBridge sdk, + ILoggerFactory loggerFactory, + ILogger logger) + { + _transportBuilder = transportBuilder; + _sdk = sdk; + _loggerFactory = loggerFactory; + _logger = logger; + } + + public async Task> GetOrCreateAsync( + McpServerDefinition server, + CancellationToken ct) + { + if (_cache.TryGetValue(server.Name, out var existing)) + return Result.Ok(new McpClientFacade(existing.Client)); + + var sem = _locks.GetOrAdd(server.Name, _ => new SemaphoreSlim(1, 1)); + await sem.WaitAsync(ct); + try + { + if (_cache.TryGetValue(server.Name, out existing)) + return Result.Ok(new McpClientFacade(existing.Client)); + + return await CreateAndCacheAsync(server, ct); + } + finally + { + sem.Release(); + } + } + + public async Task InvalidateAsync(string serverName) + { + if (_cache.TryRemove(serverName, out var entry)) + await entry.DisposeAsync(); + } + + public async ValueTask DisposeAsync() + { + foreach (var key in _cache.Keys.ToArray()) + await InvalidateAsync(key); + } + + private async Task> CreateAndCacheAsync( + McpServerDefinition server, + CancellationToken ct) + { + using var timeoutCts = server.ConnectTimeout.HasValue + ? CancellationTokenSource.CreateLinkedTokenSource(ct) + : null; + if (timeoutCts is not null) + timeoutCts.CancelAfter(server.ConnectTimeout!.Value); + + var connectCt = timeoutCts?.Token ?? ct; + + try + { + var transport = await _transportBuilder.BuildAsync(server, connectCt); + var client = await _sdk.CreateClientAsync( + transport, + new McpClientOptions(), + _loggerFactory, + connectCt); + + _cache[server.Name] = new McpClientEntry(client, DateTimeOffset.UtcNow); + return Result.Ok(new McpClientFacade(client)); + } + catch (McpCredentialResolutionException ex) + { + _logger.LogWarning("Credential resolution failed for server '{Server}': {Message}", + server.Name, ex.Message); + return Result.Fail(new McpProxyError( + McpErrorCodes.AuthRequired, + $"Credential resolution failed for server '{server.Name}'.", + server.Name)); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + _logger.LogWarning("Connection to MCP server '{Server}' timed out after {Timeout}", + server.Name, server.ConnectTimeout); + return Result.Fail(new McpProxyError( + McpErrorCodes.Timeout, + $"Connection to '{server.Name}' timed out.", + server.Name)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create MCP client for server '{Server}'", server.Name); + return Result.Fail(new McpProxyError( + McpErrorCodes.ConnectionFailed, + $"Failed to connect to server '{server.Name}'.", + server.Name)); + } + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Connection/McpClientEntry.cs b/src/Hypa.Infrastructure/Mcp/Connection/McpClientEntry.cs new file mode 100644 index 0000000..92ebde8 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Connection/McpClientEntry.cs @@ -0,0 +1,8 @@ +using ModelContextProtocol.Client; + +namespace Hypa.Infrastructure.Mcp.Connection; + +internal sealed record McpClientEntry(McpClient Client, DateTimeOffset CreatedAt) : IAsyncDisposable +{ + public async ValueTask DisposeAsync() => await Client.DisposeAsync(); +} diff --git a/src/Hypa.Infrastructure/Mcp/Connection/McpServerProbeAdapter.cs b/src/Hypa.Infrastructure/Mcp/Connection/McpServerProbeAdapter.cs new file mode 100644 index 0000000..da69a49 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Connection/McpServerProbeAdapter.cs @@ -0,0 +1,313 @@ +using System.Net; +using Hypa.Infrastructure.Mcp.Auth; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Mcp; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; + +namespace Hypa.Infrastructure.Mcp.Connection; + +internal sealed class McpServerProbeAdapter : IMcpServerProbe +{ + private readonly McpTransportBuilder _transportBuilder; + private readonly IMcpSdkBridge _sdk; + private readonly McpConfigValidationService _validator; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + + public McpServerProbeAdapter( + McpTransportBuilder transportBuilder, + IMcpSdkBridge sdk, + McpConfigValidationService validator, + ILoggerFactory loggerFactory, + ILogger logger) + { + _transportBuilder = transportBuilder; + _sdk = sdk; + _validator = validator; + _loggerFactory = loggerFactory; + _logger = logger; + } + + // Overrides WWW-Authenticate bearer challenge detection for unit tests; null = use captured value. + internal bool? BearerChallengeOverride { get; set; } + + public async Task ProbeAsync(McpServerDefinition server, CancellationToken ct) + { + // Pre-validate defensively; when called via McpServerConfigService this branch is + // unreachable (service validates before calling ProbeAsync), but guards direct consumers. + var validation = _validator.Validate([server]); + if (!validation.IsOk) + return new McpServerProbeResult( + McpServerProbeStatus.InvalidConfig, + "Invalid server config: " + + string.Join("; ", validation.Error.Select(e => $"{e.Field}: {e.Message}"))); + + using var timeoutCts = server.ConnectTimeout.HasValue + ? CancellationTokenSource.CreateLinkedTokenSource(ct) + : null; + if (timeoutCts is not null) + timeoutCts.CancelAfter(server.ConnectTimeout!.Value); + var probeCt = timeoutCts?.Token ?? ct; + + WwwAuthenticateCapture? wwwCapture = null; + IProbeClientFacade? probeClient = null; + try + { + var (transport, capture) = await _transportBuilder.BuildForProbeAsync(server, probeCt); + wwwCapture = capture; + // The probe intentionally triggers a 401 on OAuth-protected servers; the SDK logs + // that as Error before throwing. Raise the minimum level for SDK categories to Critical + // so expected 401 noise is suppressed while truly catastrophic SDK failures remain + // visible. All exceptions are caught below, so no diagnostic information is lost. + probeClient = await _sdk.CreateProbeClientAsync( + transport, new McpClientOptions(), + new CategoryMinLevelLoggerFactory(_loggerFactory, "ModelContextProtocol", LogLevel.Critical), + probeCt); + _ = await probeClient.ListToolsAsync(probeCt); + return new McpServerProbeResult( + McpServerProbeStatus.Reachable, + "Reachable: tools/list succeeded."); + } + catch (McpCredentialResolutionException ex) + { + return new McpServerProbeResult( + McpServerProbeStatus.AuthRequired, + "Credential resolution failed: " + ex.Message, + BuildGuidance(server)); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) + { + return new McpServerProbeResult( + McpServerProbeStatus.Timeout, + $"Connection to '{server.Name}' timed out."); + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized + && server.Auth is NoneAuthConfig) + { + // Bearer challenge on a NoneAuth server → mcpOAuth guidance; SDK handles discovery. + // BearerChallengeOverride is a test seam only. + var hasBearerChallenge = BearerChallengeOverride ?? (wwwCapture?.HasBearerChallenge == true); + return new McpServerProbeResult( + McpServerProbeStatus.AuthRequired, + $"Server returned {(int)ex.StatusCode!.Value} {ex.StatusCode}.", + hasBearerChallenge ? BuildMcpOAuthGuidance(server) : BuildGuidance(server)); + } + catch (HttpRequestException ex) when (IsAuthStatus(ex)) + { + var hasBearerChallenge = BearerChallengeOverride ?? (wwwCapture?.HasBearerChallenge == true); + return new McpServerProbeResult( + McpServerProbeStatus.AuthRequired, + $"Server returned {(int)ex.StatusCode!.Value} {ex.StatusCode}.", + server.Auth is NoneAuthConfig && hasBearerChallenge + ? BuildMcpOAuthGuidance(server) + : BuildGuidance(server)); + } + catch (HttpRequestException ex) when (!ex.StatusCode.HasValue && IsAuthSemantic(ex)) + { + var hasBearerChallenge = BearerChallengeOverride ?? (wwwCapture?.HasBearerChallenge == true); + return new McpServerProbeResult( + McpServerProbeStatus.AuthRequired, + ExtractSafeMessage(ex), + server.Auth is NoneAuthConfig && hasBearerChallenge + ? BuildMcpOAuthGuidance(server) + : BuildGuidance(server)); + } + catch (Exception ex) when (ex is not HttpRequestException && IsAuthSemantic(ex)) + { + var hasBearerChallenge = BearerChallengeOverride ?? (wwwCapture?.HasBearerChallenge == true); + return new McpServerProbeResult( + McpServerProbeStatus.AuthRequired, + ExtractSafeMessage(ex), + server.Auth is NoneAuthConfig && hasBearerChallenge + ? BuildMcpOAuthGuidance(server) + : BuildGuidance(server)); + } + catch (HttpRequestException ex) + { + _logger.LogDebug(ex, "Probe HTTP failure for server '{Server}'", server.Name); + return new McpServerProbeResult( + McpServerProbeStatus.ConnectionFailed, + $"Failed to reach '{server.Name}': {ex.GetType().Name}"); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Probe failure for server '{Server}'", server.Name); + return new McpServerProbeResult( + McpServerProbeStatus.Unknown, + $"Probe of '{server.Name}' failed: {ex.GetType().Name}"); + } + finally + { + if (probeClient is not null) + await probeClient.DisposeAsync(); + } + } + + private static McpAuthGuidance BuildMcpOAuthGuidance(McpServerDefinition server) => + new( + SuggestedAuthMode: "mcpOAuth", + AuthorizationUrl: null, + TokenUrl: null, + ClientId: null, + Scopes: null, + NextCommands: [$"hypa mcp auth login --server {server.Name}"]); + + private static bool IsAuthStatus(HttpRequestException ex) => + ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden; + + private static bool IsAuthSemantic(Exception ex) + { + var current = ex; + while (current is not null) + { + if (ContainsAuthKeyword(current.Message)) + return true; + current = current.InnerException; + } + return false; + } + + private static bool ContainsAuthKeyword(string message) => + message.Contains("401", StringComparison.OrdinalIgnoreCase) || + message.Contains("403", StringComparison.OrdinalIgnoreCase) || + message.Contains("unauthorized", StringComparison.OrdinalIgnoreCase) || + message.Contains("forbidden", StringComparison.OrdinalIgnoreCase); + + private static string ExtractSafeMessage(Exception ex) => ex.GetType().Name; + + private static string TransportCliName(McpTransportKind kind) => kind switch + { + McpTransportKind.Http => "streamableHttp", + McpTransportKind.Sse => "sse", + _ => "http", + }; + + private static string ToEnvVarName(string name) => + string.Concat(name.ToUpperInvariant().Select(c => char.IsAsciiLetterOrDigit(c) ? c : '_')); + + // SuggestedAuthMode uses lowercase CLI flag aliases (e.g. "bearer", "oauth2DeviceCode"), + // not McpAuthMode enum member names, so programmatic comparison must use the same aliases. + private static McpAuthGuidance BuildGuidance(McpServerDefinition server) => + server.Auth switch + { + McpOAuthConfig => new McpAuthGuidance( + SuggestedAuthMode: "mcpOAuth", + AuthorizationUrl: null, + TokenUrl: null, + ClientId: null, + Scopes: null, + NextCommands: [$"hypa mcp auth login --server {server.Name}"]), + + NoneAuthConfig => new McpAuthGuidance( + SuggestedAuthMode: null, + AuthorizationUrl: null, + TokenUrl: null, + ClientId: null, + Scopes: null, + NextCommands: + [ + $"hypa mcp add {server.Name} --transport {TransportCliName(server.Transport.Kind)} --endpoint {server.Transport.Endpoint} --auth bearer --token-ref env:{ToEnvVarName(server.Name)}_TOKEN", + $"hypa mcp add {server.Name} --transport {TransportCliName(server.Transport.Kind)} --endpoint {server.Transport.Endpoint} --auth oauth2DeviceCode --auth-url --token-url --client-id --login", + ]), + + BearerAuthConfig => new McpAuthGuidance( + SuggestedAuthMode: "bearer", + AuthorizationUrl: null, + TokenUrl: null, + ClientId: null, + Scopes: null, + NextCommands: [$"hypa mcp auth check --server {server.Name}"]), + + OAuth2DeviceCodeConfig dc => new McpAuthGuidance( + SuggestedAuthMode: "oauth2DeviceCode", + AuthorizationUrl: dc.AuthUrl, + TokenUrl: dc.TokenUrl, + ClientId: dc.ClientId, + Scopes: dc.Scopes, + NextCommands: [$"hypa mcp auth login --server {server.Name}"]), + + OAuth2ClientCredentialsConfig cc => new McpAuthGuidance( + SuggestedAuthMode: "oauth2ClientCredentials", + AuthorizationUrl: null, + TokenUrl: cc.TokenUrl, + ClientId: null, + Scopes: null, + NextCommands: [$"hypa mcp auth check --server {server.Name}"]), + + ApiKeyAuthConfig => new McpAuthGuidance( + SuggestedAuthMode: "apiKey", + AuthorizationUrl: null, + TokenUrl: null, + ClientId: null, + Scopes: null, + NextCommands: [$"hypa mcp auth check --server {server.Name}"]), + + BasicAuthConfig => new McpAuthGuidance( + SuggestedAuthMode: "basic", + AuthorizationUrl: null, + TokenUrl: null, + ClientId: null, + Scopes: null, + NextCommands: [$"hypa mcp auth check --server {server.Name}"]), + + MtlsConfig => new McpAuthGuidance( + SuggestedAuthMode: "mtls", + AuthorizationUrl: null, + TokenUrl: null, + ClientId: null, + Scopes: null, + NextCommands: [$"hypa mcp auth check --server {server.Name}"]), + + _ => new McpAuthGuidance( + SuggestedAuthMode: null, + AuthorizationUrl: null, + TokenUrl: null, + ClientId: null, + Scopes: null, + NextCommands: null), + }; +} + +/// +/// Wraps a logger factory and applies a minimum log level to all categories that start +/// with . Used to suppress expected SDK Error noise +/// (e.g. 401 responses) while keeping Critical failures visible. +/// +internal sealed class CategoryMinLevelLoggerFactory( + ILoggerFactory inner, + string categoryPrefix, + LogLevel minimumLevel) : ILoggerFactory +{ + public ILogger CreateLogger(string categoryName) + { + var logger = inner.CreateLogger(categoryName); + return categoryName.StartsWith(categoryPrefix, StringComparison.Ordinal) + ? new MinLevelLogger(logger, minimumLevel) + : logger; + } + + public void AddProvider(ILoggerProvider provider) => inner.AddProvider(provider); + public void Dispose() { } // inner is owned by the DI container +} + +internal sealed class MinLevelLogger(ILogger inner, LogLevel minimum) : ILogger +{ + public IDisposable? BeginScope(TState state) where TState : notnull => + inner.BeginScope(state); + + public bool IsEnabled(LogLevel logLevel) => + logLevel >= minimum && inner.IsEnabled(logLevel); + + public void Log(LogLevel logLevel, EventId eventId, TState state, + Exception? exception, Func formatter) + { + if (logLevel >= minimum) + inner.Log(logLevel, eventId, state, exception, formatter); + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Connection/McpTransportBuilder.cs b/src/Hypa.Infrastructure/Mcp/Connection/McpTransportBuilder.cs new file mode 100644 index 0000000..36be17a --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Connection/McpTransportBuilder.cs @@ -0,0 +1,282 @@ +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Hypa.Infrastructure.Mcp.Auth; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Mcp; +using Hypa.Runtime.Domain.Rewrite; +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; + +namespace Hypa.Infrastructure.Mcp.Connection; + +internal sealed class McpTransportBuilder +{ + private readonly IMcpAuthProvider _authProvider; + private readonly IMcpSdkBridge _sdk; + private readonly IShellLexer _shellLexer; + private readonly IBrowserLauncher _browserLauncher; + private readonly McpOAuthTokenStoreFactory _tokenStoreFactory; + private readonly ISecretResolver _secretResolver; + + public McpTransportBuilder( + IMcpAuthProvider authProvider, + IMcpSdkBridge sdk, + IShellLexer shellLexer, + IBrowserLauncher browserLauncher, + McpOAuthTokenStoreFactory tokenStoreFactory, + ISecretResolver secretResolver) + { + _authProvider = authProvider; + _sdk = sdk; + _shellLexer = shellLexer; + _browserLauncher = browserLauncher; + _tokenStoreFactory = tokenStoreFactory; + _secretResolver = secretResolver; + } + + public Task BuildAsync(McpServerDefinition server, CancellationToken ct) => + server.Transport.Kind switch + { + McpTransportKind.Stdio => Task.FromResult(BuildStdio(server)), + _ => BuildHttpAsync(server, ct), + }; + + private IClientTransport BuildStdio(McpServerDefinition server) + { + var endpoint = server.Transport.Endpoint ?? string.Empty; + var tokens = _shellLexer.Lex(endpoint); + var args = tokens + .Where(t => t.Kind is TokenKind.Arg or TokenKind.QuotedArg) + .Select(t => t.Value) + .ToArray(); + + var command = args.Length > 0 ? args[0] : endpoint; + var arguments = args.Length > 1 ? args[1..] : []; + + return _sdk.CreateStdioTransport(new StdioClientTransportOptions + { + Command = command, + Arguments = arguments, + Name = server.Name, + }); + } + + private async Task BuildHttpAsync(McpServerDefinition server, CancellationToken ct) + { + var auth = await _authProvider.GetAuthContextAsync(server, ct); + var endpoint = BuildEndpointUri(server.Transport.Endpoint!, auth); + + var options = new HttpClientTransportOptions + { + Endpoint = endpoint, + TransportMode = MapTransportMode(server.Transport.Kind), + Name = server.Name, + }; + + if (server.ConnectTimeout.HasValue) + options.ConnectionTimeout = server.ConnectTimeout.Value; + + var headers = auth.Headers.Count > 0 + ? auth.Headers.ToDictionary() + : new Dictionary(); + + if (server.Auth is McpOAuthConfig) + await InjectCachedOAuthTokenAsync(server.Name, headers, ct); + + if (headers.Count > 0) + options.AdditionalHeaders = headers; + + var httpClient = BuildHttpClient(server, auth); + return _sdk.CreateHttpTransport(options, httpClient); + } + + // Non-interactive operations (schema, tools, invoke, etc.) must never start an interactive + // OAuth flow. Inject the cached token as a plain bearer header; fail fast with AuthRequired + // when the token is absent or expired so the caller surfaces a clear error instead of hanging. + // The interactive flow lives exclusively in McpBrowserOAuthFlowProvider (mcp auth login). + private async Task InjectCachedOAuthTokenAsync( + string serverName, Dictionary headers, CancellationToken ct) + { + var cached = await _tokenStoreFactory.For(serverName).GetTokensAsync(ct); + if (cached is not null && !string.IsNullOrEmpty(cached.AccessToken)) + headers["Authorization"] = $"Bearer {cached.AccessToken}"; + else + throw new McpCredentialResolutionException( + $"No valid OAuth token for '{serverName}'. Run: hypa mcp auth login --server {serverName}"); + } + + private static HttpTransportMode MapTransportMode(McpTransportKind kind) => kind switch + { + McpTransportKind.Sse => HttpTransportMode.Sse, + McpTransportKind.Http => HttpTransportMode.StreamableHttp, + _ => HttpTransportMode.AutoDetect, + }; + + private static Uri BuildEndpointUri(string baseEndpoint, McpAuthContext auth) + { + var uri = new Uri(baseEndpoint); + if (auth.QueryParameters is not { Count: > 0 } qp) + return uri; + + var qs = string.Join("&", qp.Select( + kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}")); + + var existing = uri.Query.TrimStart('?'); + var separator = existing.Length > 0 ? "&" : ""; + return new Uri($"{uri.GetLeftPart(UriPartial.Path)}?{existing}{separator}{qs}"); + } + + public async Task<(IClientTransport Transport, WwwAuthenticateCapture? Capture)> BuildForProbeAsync( + McpServerDefinition server, CancellationToken ct) + { + if (server.Transport.Kind == McpTransportKind.Stdio) + return (await BuildAsync(server, ct), null); + + // McpOAuthConfig: never set HttpClientTransportOptions.OAuth in probe mode. + // Use a cached token as a plain bearer header when available; otherwise probe unauthenticated. + if (server.Auth is McpOAuthConfig) + return (await BuildHttpForOAuthProbeAsync(server, ct), null); + + // NoneAuth: inject capture handler so the probe can detect WWW-Authenticate: Bearer. + if (server.Auth is not NoneAuthConfig) + return (await BuildAsync(server, ct), null); + + var auth = await _authProvider.GetAuthContextAsync(server, ct); + var endpoint = BuildEndpointUri(server.Transport.Endpoint!, auth); + + var options = new HttpClientTransportOptions + { + Endpoint = endpoint, + TransportMode = MapTransportMode(server.Transport.Kind), + Name = server.Name, + }; + + if (server.ConnectTimeout.HasValue) + options.ConnectionTimeout = server.ConnectTimeout.Value; + + if (auth.Headers.Count > 0) + options.AdditionalHeaders = auth.Headers.ToDictionary(); + + var capture = new WwwAuthenticateCapture(); + capture.InnerHandler = BuildHttpClientHandler(server, auth) ?? new HttpClientHandler(); + var httpClient = new HttpClient(capture); + + return (_sdk.CreateHttpTransport(options, httpClient), capture); + } + + private async Task BuildHttpForOAuthProbeAsync( + McpServerDefinition server, CancellationToken ct) + { + var auth = await _authProvider.GetAuthContextAsync(server, ct); + var endpoint = BuildEndpointUri(server.Transport.Endpoint!, auth); + + var options = new HttpClientTransportOptions + { + Endpoint = endpoint, + TransportMode = MapTransportMode(server.Transport.Kind), + Name = server.Name, + }; + + if (server.ConnectTimeout.HasValue) + options.ConnectionTimeout = server.ConnectTimeout.Value; + + var headers = auth.Headers.Count > 0 + ? auth.Headers.ToDictionary() + : new Dictionary(); + + // Inject cached token as a plain bearer header when valid; never construct ClientOAuthOptions. + var cachedToken = await _tokenStoreFactory.For(server.Name).GetTokensAsync(ct); + if (cachedToken is not null && !string.IsNullOrEmpty(cachedToken.AccessToken)) + headers["Authorization"] = $"Bearer {cachedToken.AccessToken}"; + + if (headers.Count > 0) + options.AdditionalHeaders = headers; + + var httpClient = BuildHttpClient(server, auth); + return _sdk.CreateHttpTransport(options, httpClient); + } + + internal static HttpClientHandler? BuildHttpClientHandler(McpTlsConfig? tls) + { + var certPath = tls?.ClientCertPath; + var keyPath = tls?.ClientKeyPath; + var caCertPath = tls?.CaCertPath; + + if (certPath is null && keyPath is null && caCertPath is null) + return null; + + var handler = new HttpClientHandler(); + + if (certPath is not null && keyPath is not null) + { + var cert = X509Certificate2.CreateFromPemFile(certPath, keyPath); + handler.ClientCertificates.Add(cert); + } + + if (caCertPath is not null) + { + var caCert = X509CertificateLoader.LoadCertificateFromFile(caCertPath); + handler.ServerCertificateCustomValidationCallback = + (_, serverCert, chain, errors) => ValidateWithCustomCa(serverCert, chain, caCert, errors); + } + + return handler; + } + + private static HttpClientHandler? BuildHttpClientHandler(McpServerDefinition server, McpAuthContext auth) + { + var certPath = server.Tls?.ClientCertPath ?? auth.ClientCertificatePath; + var keyPath = server.Tls?.ClientKeyPath ?? auth.ClientKeyPath; + var caCertPath = server.Tls?.CaCertPath; + + if (certPath is null && keyPath is null && caCertPath is null) + return null; + + var handler = new HttpClientHandler(); + + if (certPath is not null && keyPath is not null) + { + var cert = X509Certificate2.CreateFromPemFile(certPath, keyPath); + handler.ClientCertificates.Add(cert); + } + + if (caCertPath is not null) + { + var caCert = X509CertificateLoader.LoadCertificateFromFile(caCertPath); + handler.ServerCertificateCustomValidationCallback = + (_, serverCert, chain, errors) => ValidateWithCustomCa(serverCert, chain, caCert, errors); + } + + return handler; + } + + private static HttpClient? BuildHttpClient(McpServerDefinition server, McpAuthContext auth) + { + var handler = BuildHttpClientHandler(server, auth); + return handler is null ? null : new HttpClient(handler); + } + + private static bool ValidateWithCustomCa( + X509Certificate? serverCert, + X509Chain? chain, + X509Certificate2 caCert, + SslPolicyErrors errors) + { + if (serverCert is null) + return false; + + if ((errors & ~SslPolicyErrors.RemoteCertificateChainErrors) != SslPolicyErrors.None) + return false; + + using var customChain = new X509Chain(); + customChain.ChainPolicy.ExtraStore.Add(caCert); + customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority; + customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + + if (!customChain.Build(new X509Certificate2(serverCert))) + return false; + + var root = customChain.ChainElements[^1].Certificate; + return root.Thumbprint.Equals(caCert.Thumbprint, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Connection/WwwAuthenticateCapture.cs b/src/Hypa.Infrastructure/Mcp/Connection/WwwAuthenticateCapture.cs new file mode 100644 index 0000000..49509ef --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Connection/WwwAuthenticateCapture.cs @@ -0,0 +1,19 @@ +namespace Hypa.Infrastructure.Mcp.Connection; + +internal sealed class WwwAuthenticateCapture : DelegatingHandler +{ + public bool HasBearerChallenge { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = await base.SendAsync(request, cancellationToken); + if ((int)response.StatusCode == 401 + && response.Headers.WwwAuthenticate.Any( + h => h.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase))) + { + HasBearerChallenge = true; + } + return response; + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Import/ClaudeMcpConnectionImportSource.cs b/src/Hypa.Infrastructure/Mcp/Import/ClaudeMcpConnectionImportSource.cs new file mode 100644 index 0000000..f73468c --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Import/ClaudeMcpConnectionImportSource.cs @@ -0,0 +1,222 @@ +using System.Text.Json; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Infrastructure.Mcp.Import; + +public sealed class ClaudeMcpConnectionImportSource(string globalHome) : IMcpConnectionImportSource +{ + public string AgentKey => "claude"; + + public bool SupportsScope(McpImportScope scope) => true; + + public async Task> DiscoverAsync( + McpImportDiscoveryRequest request, CancellationToken ct) + { + var results = new List(); + + if (request.Scope is McpImportScope.Global or McpImportScope.All) + { + var globalPath = Path.Combine(globalHome, "settings.json"); + await ParseFileAsync(globalPath, "global", results, ct); + } + + if (request.Scope is McpImportScope.Project or McpImportScope.All) + { + if (!string.IsNullOrWhiteSpace(request.ProjectRoot)) + { + var projectPath = Path.Combine(request.ProjectRoot, ".claude", "settings.local.json"); + await ParseFileAsync(projectPath, "project", results, ct); + } + } + + return results; + } + + private async Task ParseFileAsync( + string filePath, + string scopeLabel, + List results, + CancellationToken ct) + { + if (!File.Exists(filePath)) + return; + + ClaudeSettingsJson? settings; + try + { + var json = await File.ReadAllTextAsync(filePath, ct); + settings = JsonSerializer.Deserialize(json, ClaudeSettingsJsonContext.Default.ClaudeSettingsJson); + } + catch (Exception ex) + { + results.Add(new McpImportedConnection( + AgentKey, scopeLabel, "unknown", null, string.Empty, + McpImportCandidateStatus.ParseError, + ex.Message)); + return; + } + + if (settings?.McpServers is null) + return; + + foreach (var (name, entry) in settings.McpServers) + { + if (entry is null) + continue; + + results.Add(ClassifyEntry(name, scopeLabel, entry)); + } + } + + private McpImportedConnection ClassifyEntry(string name, string scopeLabel, ClaudeMcpServerEntry entry) + { + // Skip Hypa self-entry: name is "hypa", command is bare "hypa", or command starts with "hypa serve". + if (string.Equals(name, "hypa", StringComparison.OrdinalIgnoreCase) || + string.Equals(entry.Command?.Trim(), "hypa", StringComparison.OrdinalIgnoreCase) || + IsHypaServeCommand(entry.Command)) + { + return new McpImportedConnection( + AgentKey, scopeLabel, name, null, string.Empty, + McpImportCandidateStatus.SkippedSelf, "Hypa self-entry"); + } + + // Check for unsafe raw secrets in env dict. + if (entry.Env is { Count: > 0 } env) + { + foreach (var value in env.Values) + { + if (!string.IsNullOrEmpty(value) && + !value.StartsWith("env:", StringComparison.OrdinalIgnoreCase) && + !value.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + return new McpImportedConnection( + AgentKey, scopeLabel, name, null, string.Empty, + McpImportCandidateStatus.SkippedUnsafeSecret, + "env map contains raw values that cannot be imported safely"); + } + } + } + + McpTransportKind transportKind; + string? endpoint; + + var typeStr = (entry.Type ?? string.Empty).ToLowerInvariant(); + + if (typeStr == "stdio" || (string.IsNullOrEmpty(typeStr) && entry.Command is not null)) + { + if (string.IsNullOrWhiteSpace(entry.Command)) + { + return new McpImportedConnection( + AgentKey, scopeLabel, name, null, string.Empty, + McpImportCandidateStatus.SkippedIncomplete, + "stdio entry missing command"); + } + + transportKind = McpTransportKind.Stdio; + var args = entry.Args is { Length: > 0 } + ? " " + string.Join(" ", entry.Args) + : string.Empty; + endpoint = (entry.Command + args).Trim(); + } + else if (typeStr is "streamablehttp" or "streamable_http") + { + var url = entry.Url ?? entry.Endpoint; + if (string.IsNullOrWhiteSpace(url)) + return new McpImportedConnection( + AgentKey, scopeLabel, name, null, string.Empty, + McpImportCandidateStatus.SkippedIncomplete, + "remote entry missing url/endpoint"); + transportKind = McpTransportKind.Http; + endpoint = url; + } + else if (typeStr == "sse") + { + var url = entry.Url ?? entry.Endpoint; + if (string.IsNullOrWhiteSpace(url)) + return new McpImportedConnection( + AgentKey, scopeLabel, name, null, string.Empty, + McpImportCandidateStatus.SkippedIncomplete, + "remote entry missing url/endpoint"); + transportKind = McpTransportKind.Sse; + endpoint = url; + } + else if (typeStr is "httpautodetect" or "http") + { + var url = entry.Url ?? entry.Endpoint; + if (string.IsNullOrWhiteSpace(url)) + return new McpImportedConnection( + AgentKey, scopeLabel, name, null, string.Empty, + McpImportCandidateStatus.SkippedIncomplete, + "remote entry missing url/endpoint"); + transportKind = McpTransportKind.HttpAutoDetect; + endpoint = url; + } + else + { + return new McpImportedConnection( + AgentKey, scopeLabel, name, null, string.Empty, + McpImportCandidateStatus.SkippedUnsupported, + $"unsupported transport type: {entry.Type}"); + } + + var authResult = ExtractAuthConfig(entry.Env); + if (!authResult.IsOk) + return new McpImportedConnection( + AgentKey, scopeLabel, name, null, string.Empty, + McpImportCandidateStatus.SkippedUnsupported, + authResult.Error); + + var server = new McpServerDefinition( + name, + new McpTransportConfig(transportKind, endpoint), + authResult.Value, + Tls: null, + ConnectTimeout: null, + RequestTimeout: null); + + var fingerprint = McpServerImportService.ComputeFingerprint(server); + + return new McpImportedConnection( + AgentKey, scopeLabel, name, server, fingerprint, + McpImportCandidateStatus.Importable, null); + } + + private static bool IsHypaServeCommand(string? command) + { + if (string.IsNullOrWhiteSpace(command)) return false; + var trimmed = command.Trim(); + return string.Equals(trimmed, "hypa serve", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("hypa serve ", StringComparison.OrdinalIgnoreCase); + } + + private static Result ExtractAuthConfig(Dictionary? env) + { + if (env is null || env.Count == 0) + return Result.Ok(new NoneAuthConfig()); + + // Look for Authorization header (Bearer token). + if (env.TryGetValue("Authorization", out var authValue) && !string.IsNullOrWhiteSpace(authValue)) + { + if (authValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + var tokenRef = authValue["Bearer ".Length..].Trim(); + if (!string.IsNullOrWhiteSpace(tokenRef) && + (tokenRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase) || + tokenRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))) + { + return Result.Ok(new BearerAuthConfig(tokenRef)); + } + } + else if (authValue.StartsWith("env:", StringComparison.OrdinalIgnoreCase) || + authValue.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + return Result.Ok(new BearerAuthConfig(authValue)); + } + } + + return Result.Ok(new NoneAuthConfig()); + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Import/ClaudeSettingsJsonContext.cs b/src/Hypa.Infrastructure/Mcp/Import/ClaudeSettingsJsonContext.cs new file mode 100644 index 0000000..9268a4d --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Import/ClaudeSettingsJsonContext.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace Hypa.Infrastructure.Mcp.Import; + +internal sealed record ClaudeSettingsJson( + [property: JsonPropertyName("mcpServers")] + Dictionary? McpServers); + +internal sealed record ClaudeMcpServerEntry( + [property: JsonPropertyName("type")] string? Type, + [property: JsonPropertyName("command")] string? Command, + [property: JsonPropertyName("args")] string[]? Args, + [property: JsonPropertyName("url")] string? Url, + [property: JsonPropertyName("endpoint")] string? Endpoint, + [property: JsonPropertyName("env")] Dictionary? Env); + +[JsonSerializable(typeof(ClaudeSettingsJson))] +[JsonSerializable(typeof(ClaudeMcpServerEntry))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(string[]))] +[JsonSourceGenerationOptions( + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] +internal sealed partial class ClaudeSettingsJsonContext : JsonSerializerContext; diff --git a/src/Hypa.Infrastructure/Mcp/Import/CodexMcpConnectionImportSource.cs b/src/Hypa.Infrastructure/Mcp/Import/CodexMcpConnectionImportSource.cs new file mode 100644 index 0000000..e7d6a89 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Import/CodexMcpConnectionImportSource.cs @@ -0,0 +1,234 @@ +using Hypa.Infrastructure.Hooks; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Infrastructure.Mcp.Import; + +public sealed class CodexMcpConnectionImportSource(string globalConfigPath) : IMcpConnectionImportSource +{ + public string AgentKey => "codex"; + + public bool SupportsScope(McpImportScope scope) => true; + + public async Task> DiscoverAsync( + McpImportDiscoveryRequest request, CancellationToken ct) + { + var results = new List(); + + if (request.Scope is McpImportScope.Global or McpImportScope.All) + await ParseFileAsync(globalConfigPath, "global", results, ct); + + if (request.Scope is McpImportScope.Project or McpImportScope.All) + { + if (!string.IsNullOrWhiteSpace(request.ProjectRoot)) + { + var projectPath = Path.Combine(request.ProjectRoot, ".codex", "config.toml"); + await ParseFileAsync(projectPath, "project", results, ct); + } + } + + return results; + } + + private static async Task ParseFileAsync( + string filePath, + string scopeLabel, + List results, + CancellationToken ct) + { + if (!File.Exists(filePath)) + return; + + List lines; + try + { + var content = await File.ReadAllTextAsync(filePath, ct); + lines = [.. content.Split('\n')]; + } + catch (Exception ex) + { + results.Add(new McpImportedConnection( + "codex", scopeLabel, "unknown", null, string.Empty, + McpImportCandidateStatus.ParseError, ex.Message)); + return; + } + + for (var i = 0; i < lines.Count; i++) + { + var line = lines[i]; + if (!TomlSectionHelper.TryParseHeaderPath(line, out var headerPath)) + continue; + + if (!headerPath.StartsWith("mcp_servers.", StringComparison.OrdinalIgnoreCase)) + continue; + + var serverName = headerPath["mcp_servers.".Length..]; + if (string.IsNullOrWhiteSpace(serverName) || serverName.Contains('.')) + continue; + + var endIdx = TomlSectionHelper.FindNextNonDescendantSection(lines, i + 1, headerPath); + var bodyLines = endIdx >= 0 + ? lines.GetRange(i + 1, endIdx - (i + 1)) + : lines.GetRange(i + 1, lines.Count - (i + 1)); + + var conn = ParseEntry(serverName, scopeLabel, bodyLines); + results.Add(conn); + } + } + + private static McpImportedConnection ParseEntry( + string name, string scopeLabel, List bodyLines) + { + string? command = null; + string? args = null; + string? url = null; + string? endpoint = null; + string? bearerToken = null; + + foreach (var rawLine in bodyLines) + { + var line = rawLine.Trim(); + if (string.IsNullOrEmpty(line) || line.StartsWith('#')) + continue; + + var eqIdx = line.IndexOf('='); + if (eqIdx < 0) continue; + + var key = line[..eqIdx].Trim().ToLowerInvariant(); + var rawValue = line[(eqIdx + 1)..].Trim(); + + switch (key) + { + case "command": + command = UnquoteToml(rawValue); + break; + case "args": + // Multiline arrays are not supported. + if (!rawValue.StartsWith('[') || !rawValue.EndsWith(']')) + { + return new McpImportedConnection( + "codex", scopeLabel, name, null, string.Empty, + McpImportCandidateStatus.ParseError, + "multiline args array is not supported; use inline array form: args = [\"a\", \"b\"]"); + } + args = ParseInlineArray(rawValue); + break; + case "url": + url = UnquoteToml(rawValue); + break; + case "endpoint": + endpoint = UnquoteToml(rawValue); + break; + case "bearer_token": + bearerToken = UnquoteToml(rawValue); + break; + } + } + + // Skip Hypa self-entry. + if (string.Equals(name, "hypa", StringComparison.OrdinalIgnoreCase)) + return new McpImportedConnection("codex", scopeLabel, name, null, string.Empty, + McpImportCandidateStatus.SkippedSelf, "Hypa self-entry"); + + if (command is not null && IsHypaServeCommand(command)) + return new McpImportedConnection("codex", scopeLabel, name, null, string.Empty, + McpImportCandidateStatus.SkippedSelf, "Hypa self-entry"); + + // Determine transport. + if (command is not null) + { + var authConfigResult = ExtractAuthConfig(bearerToken); + if (!authConfigResult.IsOk) + { + return new McpImportedConnection( + "codex", scopeLabel, name, null, string.Empty, + McpImportCandidateStatus.SkippedUnsupported, + authConfigResult.Error); + } + + var ep = args is not null ? $"{command} {args}".Trim() : command; + var server = new McpServerDefinition( + name, + new McpTransportConfig(McpTransportKind.Stdio, ep), + authConfigResult.Value!, + Tls: null, ConnectTimeout: null, RequestTimeout: null); + return new McpImportedConnection( + "codex", scopeLabel, name, server, + McpServerImportService.ComputeFingerprint(server), + McpImportCandidateStatus.Importable, null); + } + + var remoteUrl = url ?? endpoint; + if (!string.IsNullOrWhiteSpace(remoteUrl)) + { + var authConfigResult = ExtractAuthConfig(bearerToken); + if (!authConfigResult.IsOk) + { + return new McpImportedConnection( + "codex", scopeLabel, name, null, string.Empty, + McpImportCandidateStatus.SkippedUnsupported, + authConfigResult.Error); + } + + var server = new McpServerDefinition( + name, + new McpTransportConfig(McpTransportKind.HttpAutoDetect, remoteUrl), + authConfigResult.Value!, + Tls: null, ConnectTimeout: null, RequestTimeout: null); + return new McpImportedConnection( + "codex", scopeLabel, name, server, + McpServerImportService.ComputeFingerprint(server), + McpImportCandidateStatus.Importable, null); + } + + return new McpImportedConnection("codex", scopeLabel, name, null, string.Empty, + McpImportCandidateStatus.SkippedIncomplete, + "no command or url/endpoint found"); + } + + private static string UnquoteToml(string value) + { + // Strip surrounding quotes (single or double). + if (value.Length >= 2 && + ((value[0] == '"' && value[^1] == '"') || + (value[0] == '\'' && value[^1] == '\''))) + { + return value[1..^1]; + } + return value; + } + + private static string ParseInlineArray(string raw) + { + var inner = raw[1..^1]; // strip [ and ] + if (string.IsNullOrWhiteSpace(inner)) + return string.Empty; + + var parts = inner.Split(','); + return string.Join(" ", parts.Select(p => UnquoteToml(p.Trim()))); + } + + private static bool IsHypaServeCommand(string command) + { + var trimmed = command.Trim(); + return string.Equals(trimmed, "hypa", StringComparison.OrdinalIgnoreCase) || + string.Equals(trimmed, "hypa serve", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("hypa serve ", StringComparison.OrdinalIgnoreCase); + } + + private static (bool IsOk, McpAuthConfig? Value, string? Error) ExtractAuthConfig(string? bearerToken) + { + if (string.IsNullOrWhiteSpace(bearerToken)) + return (true, new NoneAuthConfig(), null); + + if (bearerToken.StartsWith("env:", StringComparison.OrdinalIgnoreCase) || + bearerToken.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + return (true, new BearerAuthConfig(bearerToken), null); + } + + // Raw secrets (not env:/ or file:/) are unsafe and must not be imported + return (false, null, "bearer_token contains a raw secret; use env:VAR_NAME or file:/path/to/file instead"); + } +} diff --git a/src/Hypa.Infrastructure/Mcp/McpToolResult.cs b/src/Hypa.Infrastructure/Mcp/McpToolResult.cs index 8919139..300d346 100644 --- a/src/Hypa.Infrastructure/Mcp/McpToolResult.cs +++ b/src/Hypa.Infrastructure/Mcp/McpToolResult.cs @@ -37,6 +37,9 @@ internal static string BuildArgsJson(params (string Key, string? Value)[] pairs) Content = [new TextContentBlock { Text = message }] }; + internal static CallToolResult Err(string code, string message) => + Err($"SUMMARY\nError ({code}): {message}"); + internal static string TextOf(CallToolResult result) => string.Concat(result.Content.OfType().Select(c => c.Text)); } diff --git a/src/Hypa.Infrastructure/Mcp/Secrets/EnvironmentSecretResolver.cs b/src/Hypa.Infrastructure/Mcp/Secrets/EnvironmentSecretResolver.cs new file mode 100644 index 0000000..edffd8d --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Secrets/EnvironmentSecretResolver.cs @@ -0,0 +1,61 @@ +using Hypa.Infrastructure.Mcp.Auth; +using Hypa.Runtime.Application.Ports; +using Microsoft.Extensions.Logging; + +namespace Hypa.Infrastructure.Mcp.Secrets; + +internal sealed class EnvironmentSecretResolver : ISecretResolver +{ + private readonly McpOAuthTokenStoreFactory _tokenStoreFactory; + private readonly ILogger _logger; + + public EnvironmentSecretResolver( + McpOAuthTokenStoreFactory tokenStoreFactory, + ILogger logger) + { + _tokenStoreFactory = tokenStoreFactory; + _logger = logger; + } + + public async ValueTask ResolveAsync(string reference, CancellationToken ct) + { + if (reference.StartsWith("env:", StringComparison.Ordinal)) + { + var varName = reference["env:".Length..]; + return Environment.GetEnvironmentVariable(varName); + } + + if (reference.StartsWith("file:", StringComparison.Ordinal)) + { + var filePath = reference["file:".Length..]; + try + { + var content = await File.ReadAllTextAsync(filePath, ct); + return content.Trim(); + } + catch (Exception ex) + { + _logger.LogInformation("Failed to read secret file {Path}: {Message}", filePath, ex.Message); + return null; + } + } + + if (reference.StartsWith("hypa:dcr:", StringComparison.Ordinal)) + { + var serverName = reference["hypa:dcr:".Length..]; + try + { + var store = _tokenStoreFactory.For(serverName); + var (_, secret) = await store.GetDcrCredentialsAsync(ct); + return secret; + } + catch (Exception ex) + { + _logger.LogInformation("Failed to resolve DCR credential for server {Server}: {Message}", serverName, ex.Message); + return null; + } + } + + return reference; + } +} diff --git a/src/Hypa.Infrastructure/Mcp/Tools/HypaMcpTool.cs b/src/Hypa.Infrastructure/Mcp/Tools/HypaMcpTool.cs new file mode 100644 index 0000000..263d351 --- /dev/null +++ b/src/Hypa.Infrastructure/Mcp/Tools/HypaMcpTool.cs @@ -0,0 +1,322 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Hypa.Infrastructure.Mcp.Auth; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Mcp; +using Hypa.Runtime.Domain.Sessions; +using Microsoft.Extensions.Logging; + +namespace Hypa.Infrastructure.Mcp.Tools; + +[McpServerToolType] +public sealed class HypaMcpTool +{ + [McpServerTool(Name = "hypa_mcp"), Description("MCP proxy operations across configured upstream servers. Actions: invoke, batch, schema, search, auth_check.")] + public static async Task ExecuteAsync( + McpProxyService proxyService, + IMcpServerDefinitionRepository serverDefinitionRepository, + IMcpAuthProvider authProvider, + IEvidenceLedger evidenceLedger, + ISessionResolver sessionResolver, + SecretRedactionRegistry redactionRegistry, + ILogger logger, + CancellationToken cancellationToken, + [Description("Action: invoke | batch | schema | search | auth_check")] string action, + [Description("Upstream server name (required for: invoke, auth_check; optional filter for: schema)")] string? server = null, + [Description("Tool name on the upstream server (required for: invoke)")] string? tool = null, + [Description("Tool arguments as a JSON object string (for: invoke; default: {})")] string? arguments = null, + [Description("Compression hint: raw | summary | structured (for: invoke)")] string? hint = null, + [Description("Batch requests as a JSON array of {server,tool,arguments?,hint?} objects (for: batch)")] string? requests = null, + [Description("Free-text search query (required for: search)")] string? query = null) + { + var sw = Stopwatch.StartNew(); + + CallToolResult toolResult; + try + { + toolResult = action switch + { + "invoke" => await InvokeAsync(proxyService, server, tool, arguments, hint, cancellationToken), + "batch" => await BatchAsync(proxyService, requests, cancellationToken), + "schema" => await SchemaAsync(proxyService, server, cancellationToken), + "search" => await SearchAsync(proxyService, query, cancellationToken), + "auth_check" => await AuthCheckAsync(serverDefinitionRepository, authProvider, server, logger, cancellationToken), + _ => McpToolResult.Err(McpErrorCodes.InvalidRequest, $"Unknown action '{action}'. Valid actions: invoke, batch, schema, search, auth_check.") + }; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "hypa_mcp unexpected exception for action '{Action}'", action); + toolResult = McpToolResult.Err(McpErrorCodes.InvalidRequest, $"Unexpected error processing action '{action}'."); + } + + var argsJson = McpToolResult.BuildArgsJson( + ("action", action), ("server", server), ("tool", tool), ("hint", hint), ("query", query)); + var resultText = McpToolResult.TextOf(toolResult); + var redactedResult = redactionRegistry.Redact(resultText); + + try + { + var sessionResult = await sessionResolver.ResolveAsync(new SessionResolveOptions(), cancellationToken); + if (!sessionResult.IsOk) + logger.LogWarning("hypa_mcp session not resolved: {Error}", sessionResult.Error.Message); + await evidenceLedger.RecordToolCallAsync(new ToolCallRecord + { + SessionId = sessionResult.IsOk ? sessionResult.Value.Id : Guid.Empty, + ToolName = "hypa_mcp", + Args = argsJson, + ArgsHash = HashString(argsJson), + Result = redactedResult[..Math.Min(200, redactedResult.Length)], + OutputHash = HashString(redactedResult), + DurationMs = sw.ElapsedMilliseconds + }, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "hypa_mcp evidence recording failed"); + } + + return toolResult; + } + + private static async Task InvokeAsync( + McpProxyService proxyService, + string? server, string? tool, string? arguments, string? hint, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(server)) + return McpToolResult.Err(McpErrorCodes.InvalidRequest, "'server' is required for invoke."); + if (string.IsNullOrWhiteSpace(tool)) + return McpToolResult.Err(McpErrorCodes.InvalidRequest, "'tool' is required for invoke."); + + var request = new McpProxyRequest(server, tool, new JsonPayload(arguments ?? "{}"), ParseHint(hint)); + var result = await proxyService.InvokeAsync(request, ct); + return FormatInvokeResult(result); + } + + private static async Task BatchAsync( + McpProxyService proxyService, string? requestsJson, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(requestsJson)) + return McpToolResult.Err(McpErrorCodes.InvalidRequest, "'requests' is required for batch."); + + IReadOnlyList batch; + try + { + batch = ParseBatchRequests(requestsJson); + } + catch + { + return McpToolResult.Err(McpErrorCodes.InvalidRequest, "Failed to parse batch requests. Ensure 'requests' is a JSON array of {server,tool,arguments?,hint?} objects."); + } + + if (batch.Count == 0) + return McpToolResult.Err(McpErrorCodes.InvalidRequest, "Batch requests array is empty."); + + var results = await proxyService.InvokeBatchAsync(batch, ct); + + var succeeded = results.Count(r => !r.IsError); + var failed = results.Count(r => r.IsError); + + var sb = new StringBuilder(); + sb.AppendLine("SUMMARY"); + sb.AppendLine($"Batch completed: {results.Count} request(s) — {succeeded} succeeded, {failed} failed."); + sb.AppendLine(); + sb.AppendLine("RESULTS"); + for (var i = 0; i < results.Count; i++) + { + var r = results[i]; + sb.AppendLine($" [{i}] {r.ServerName}/{r.ToolName} {(r.IsError ? "ERROR" : "OK")} ({r.Latency.Elapsed.TotalMilliseconds:F0}ms)"); + if (r.IsError && r.Error is not null) + sb.AppendLine($" {r.Error.Code}: {r.Error.Message}"); + else + sb.AppendLine($" {Truncate(r.CompressedResponse, 200)}"); + } + + return McpToolResult.Ok(sb.ToString().TrimEnd()); + } + + private static async Task SchemaAsync( + McpProxyService proxyService, string? server, CancellationToken ct) + { + var manifest = await proxyService.GetSchemaAsync(ct); + + var servers = string.IsNullOrWhiteSpace(server) + ? manifest.Servers + : manifest.Servers.Where(s => string.Equals(s.ServerName, server, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (servers.Count == 0) + { + return McpToolResult.Ok(string.IsNullOrWhiteSpace(server) + ? "SUMMARY\nNo MCP servers configured." + : $"SUMMARY\nServer '{server}' not found."); + } + + var sb = new StringBuilder(); + sb.AppendLine("SUMMARY"); + sb.AppendLine($"Schema: {servers.Count} server(s), {servers.Sum(s => s.Tools.Count)} tool(s)."); + sb.AppendLine(); + sb.AppendLine("SCHEMA"); + foreach (var srv in servers) + { + sb.AppendLine($" {srv.ServerName} ({srv.Tools.Count} tool(s)):"); + foreach (var t in srv.Tools) + sb.AppendLine($" {t.Name}: {t.Description}"); + } + + if (manifest.Errors is { Count: > 0 } errors) + { + sb.AppendLine(); + sb.AppendLine("WARNINGS"); + foreach (var e in errors) + sb.AppendLine($" {e.ServerName} [{e.Code}]: {e.Message}"); + } + + return McpToolResult.Ok(sb.ToString().TrimEnd()); + } + + private static async Task SearchAsync( + McpProxyService proxyService, string? query, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(query)) + return McpToolResult.Err(McpErrorCodes.InvalidRequest, "'query' is required for search."); + + var results = await proxyService.SearchToolsAsync(query, ct); + + if (results.Count == 0) + return McpToolResult.Ok($"SUMMARY\nNo tools matching '{query}'."); + + var sb = new StringBuilder(); + sb.AppendLine("SUMMARY"); + sb.AppendLine($"Found {results.Count} tool(s) matching '{query}'."); + sb.AppendLine(); + sb.AppendLine("RESULTS"); + foreach (var r in results) + sb.AppendLine($" {r.ServerName}/{r.ToolName} (score={r.Score:F2}): {r.Description}"); + + return McpToolResult.Ok(sb.ToString().TrimEnd()); + } + + private static async Task AuthCheckAsync( + IMcpServerDefinitionRepository serverDefinitionRepository, + IMcpAuthProvider authProvider, + string? server, + ILogger logger, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(server)) + return McpToolResult.Err(McpErrorCodes.InvalidRequest, "'server' is required for auth_check."); + + var loadResult = await serverDefinitionRepository.LoadAsync(ct); + if (!loadResult.IsOk) + { + logger.LogError("auth_check: failed to load server definitions: {Error}", loadResult.Error.Message); + return McpToolResult.Err(McpErrorCodes.SchemaUnavailable, "Failed to load server configuration."); + } + + var definition = loadResult.Value.FirstOrDefault(s => + string.Equals(s.Name, server, StringComparison.OrdinalIgnoreCase)); + + if (definition is null) + return McpToolResult.Err(McpErrorCodes.UnknownServer, $"Server '{server}' not found in configuration."); + + try + { + var authContext = await authProvider.GetAuthContextAsync(definition, ct); + var authMode = definition.Auth.GetType().Name.Replace("Config", string.Empty, StringComparison.Ordinal); + + var sb = new StringBuilder(); + sb.AppendLine("SUMMARY"); + sb.AppendLine($"Auth check passed for '{server}'."); + sb.AppendLine(); + sb.AppendLine("DETAILS"); + sb.AppendLine($" Auth mode: {authMode}"); + sb.AppendLine($" Headers resolved: {authContext.Headers.Count}"); + sb.AppendLine($" Bearer token: {(authContext.BearerToken is not null ? "present" : "absent")}"); + sb.AppendLine($" Client certificate: {(authContext.ClientCertificatePath is not null ? "configured" : "none")}"); + + return McpToolResult.Ok(sb.ToString().TrimEnd()); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger.LogDebug(ex, "Auth check failed for server '{Server}'", server); + return McpToolResult.Err(McpErrorCodes.AuthRequired, $"Auth check failed for '{server}'."); + } + } + + private static CallToolResult FormatInvokeResult(McpResult result) + { + if (result.IsError) + { + var code = result.Error?.Code ?? McpErrorCodes.ToolInvocationFailed; + var message = result.Error?.Message ?? "Tool invocation failed."; + return McpToolResult.Err($"SUMMARY\nError ({code}): {message}"); + } + + var sb = new StringBuilder(); + sb.AppendLine("SUMMARY"); + sb.AppendLine($"Tool '{result.ToolName}' on '{result.ServerName}' completed in {result.Latency.Elapsed.TotalMilliseconds:F0}ms."); + sb.AppendLine(); + sb.AppendLine("DETAILS"); + sb.AppendLine(result.CompressedResponse); + sb.AppendLine("STATS"); + sb.Append($"duration={result.Latency.Elapsed.TotalMilliseconds:F0}ms"); + + return McpToolResult.Ok(sb.ToString().TrimEnd()); + } + + private static IReadOnlyList ParseBatchRequests(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Array) + throw new InvalidOperationException("Batch requests must be a JSON array."); + + var requests = new List(root.GetArrayLength()); + foreach (var element in root.EnumerateArray()) + { + var serverName = element.GetProperty("server").GetString() ?? string.Empty; + var toolName = element.GetProperty("tool").GetString() ?? string.Empty; + var argumentsJson = element.TryGetProperty("arguments", out var argsEl) + ? ArgumentsJson(argsEl) + : "{}"; + var hintStr = element.TryGetProperty("hint", out var hintEl) ? hintEl.GetString() : null; + requests.Add(new McpProxyRequest(serverName, toolName, new JsonPayload(argumentsJson), ParseHint(hintStr))); + } + return requests; + } + + private static string ArgumentsJson(JsonElement element) => + element.ValueKind == JsonValueKind.String + ? element.GetString() ?? "{}" + : element.GetRawText(); + + private static CompressionHint? ParseHint(string? hint) => + hint?.ToLowerInvariant() switch + { + "raw" => CompressionHint.Raw, + "summary" => CompressionHint.Summary, + "structured" => CompressionHint.Structured, + _ => null + }; + + private static string Truncate(string text, int maxLength) => + text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "..."); + + private static string HashString(string input) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input))).ToLowerInvariant(); +} diff --git a/src/Hypa.Infrastructure/Storage/SqliteCodeIndexRepository.cs b/src/Hypa.Infrastructure/Storage/SqliteCodeIndexRepository.cs index 0defbcd..ec25ce8 100644 --- a/src/Hypa.Infrastructure/Storage/SqliteCodeIndexRepository.cs +++ b/src/Hypa.Infrastructure/Storage/SqliteCodeIndexRepository.cs @@ -3,6 +3,7 @@ using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using System.Text; namespace Hypa.Infrastructure.Storage; @@ -33,9 +34,13 @@ public async Task SaveDocumentsAsync(IReadOnlyList docume await DeleteFileFactsAsync(conn, document.File.RelativePath, ct); await ExecuteAsync(conn, """ INSERT OR REPLACE INTO code_files - (path, project_root, absolute_path, language, content_hash, size_bytes, indexed_at, provider_id, provider_version, query_version) + (path, project_root, absolute_path, language, content_hash, size_bytes, indexed_at, + provider_id, provider_version, query_version, frontmatter_yaml, plain_text, fact_kind, confidence, + git_blob_oid, mtime_ms) VALUES - (@path, @projectRoot, @absolutePath, @language, @contentHash, @sizeBytes, @indexedAt, @providerId, @providerVersion, @queryVersion) + (@path, @projectRoot, @absolutePath, @language, @contentHash, @sizeBytes, @indexedAt, + @providerId, @providerVersion, @queryVersion, @frontmatterYaml, @plainText, @factKind, @confidence, + @gitBlobOid, @mtimeMs) """, ct, ("@path", document.File.RelativePath), ("@projectRoot", document.File.ProjectRoot), @@ -46,7 +51,13 @@ INSERT OR REPLACE INTO code_files ("@indexedAt", document.File.IndexedAt.ToString("O")), ("@providerId", document.Provenance.ProviderId), ("@providerVersion", document.Provenance.ProviderVersion), - ("@queryVersion", document.Provenance.QueryVersion)); + ("@queryVersion", document.Provenance.QueryVersion), + ("@frontmatterYaml", document.FrontmatterYaml), + ("@plainText", document.PlainText), + ("@factKind", document.Provenance.FactKind), + ("@confidence", document.Provenance.Confidence), + ("@gitBlobOid", document.File.GitBlobOid), + ("@mtimeMs", document.File.MTimeMs)); foreach (var symbol in document.Symbols) await ExecuteAsync(conn, """ @@ -81,6 +92,18 @@ INSERT OR REPLACE INTO code_diagnostics VALUES (@id, @filePath, @severity, @code, @message, @startLine, @startColumn, @endLine, @endColumn, @startByte, @endByte, @providerId, @providerVersion, @queryVersion, @factKind, @confidence) """, ct, DiagnosticParams(diagnostic)); + + foreach (var section in document.Sections) + await ExecuteAsync(conn, """ + INSERT OR REPLACE INTO markdown_sections + (id, file_path, heading_level, heading_text, heading_path, + start_line, end_line, start_byte, end_byte, + text, plain_text, provider_id, provider_version, query_version, fact_kind, confidence) + VALUES + (@id, @filePath, @headingLevel, @headingText, @headingPath, + @startLine, @endLine, @startByte, @endByte, + @text, @plainText, @providerId, @providerVersion, @queryVersion, @factKind, @confidence) + """, ct, SectionParams(section)); } await tx.CommitAsync(ct); @@ -231,6 +254,84 @@ LIMIT 500 } } + public async Task QueryMarkdownAsync(string filePath, CancellationToken ct) + { + try + { + var init = await schema.InitAsync(ct); + if (!init.IsOk) return null; + + await using var conn = OpenConnection(); + await conn.OpenAsync(ct); + + var document = await QueryMarkdownDocumentAsync(conn, filePath, ct); + if (document is null) return null; + + return document with + { + Symbols = await QuerySymbolsForFileAsync(conn, filePath, ct), + References = await QueryReferencesForFileAsync(conn, filePath, ct), + Diagnostics = await QueryDiagnosticsForFileAsync(conn, filePath, ct), + Sections = await QueryMarkdownSectionsAsync(conn, filePath, ct), + }; + } + catch (Exception ex) when (StorageFailure.IsExpected(ex)) + { + _logger.LogDebug(ex, "Failed to query markdown document"); + return null; + } + } + + public async Task> QueryMarkdownSectionsAsync(string filePath, CancellationToken ct) + { + try + { + var init = await schema.InitAsync(ct); + if (!init.IsOk) return []; + + await using var conn = OpenConnection(); + await conn.OpenAsync(ct); + return await QueryMarkdownSectionsAsync(conn, filePath, ct); + } + catch (Exception ex) when (StorageFailure.IsExpected(ex)) + { + _logger.LogDebug(ex, "Failed to query markdown sections"); + return []; + } + } + + public async Task> QueryReferencesAsync(string filePath, string kind, CancellationToken ct) + { + try + { + var init = await schema.InitAsync(ct); + if (!init.IsOk) return []; + + await using var conn = OpenConnection(); + await conn.OpenAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, file_path, kind, target, start_line, start_column, end_line, end_column, + start_byte, end_byte, provider_id, provider_version, query_version, fact_kind, confidence + FROM code_references + WHERE file_path = @filePath AND kind = @kind + ORDER BY start_byte + """; + cmd.Parameters.AddWithValue("@filePath", filePath); + cmd.Parameters.AddWithValue("@kind", kind); + await using var reader = await cmd.ExecuteReaderAsync(ct); + var references = new List(); + while (await reader.ReadAsync(ct)) + references.Add(ReadReference(reader)); + return references; + } + catch (Exception ex) when (StorageFailure.IsExpected(ex)) + { + _logger.LogDebug(ex, "Failed to query code references"); + return []; + } + } + public async Task SaveProviderHealthAsync(IReadOnlyList health, CancellationToken ct) { try @@ -290,6 +391,109 @@ public async Task> GetProviderHealthAsync(Canc } } + public async Task> QueryFileStatesAsync( + string projectRoot, CancellationToken ct) + { + try + { + var init = await schema.InitAsync(ct); + if (!init.IsOk) return new Dictionary(); + + await using var conn = OpenConnection(); + await conn.OpenAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT absolute_path, git_blob_oid, mtime_ms, size_bytes + FROM code_files + WHERE project_root = @projectRoot + """; + cmd.Parameters.AddWithValue("@projectRoot", projectRoot); + await using var reader = await cmd.ExecuteReaderAsync(ct); + var result = new Dictionary(); + while (await reader.ReadAsync(ct)) + { + var state = new FileIndexState + { + AbsolutePath = reader.GetString(0), + GitBlobOid = reader.IsDBNull(1) ? null : reader.GetString(1), + MTimeMs = reader.GetInt64(2), + SizeBytes = reader.GetInt64(3), + }; + result[state.AbsolutePath] = state; + } + return result; + } + catch (Exception ex) when (StorageFailure.IsExpected(ex)) + { + _logger.LogDebug(ex, "Failed to query file states"); + return new Dictionary(); + } + } + + public async Task QueryFileStateAsync(string absolutePath, CancellationToken ct) + { + try + { + var init = await schema.InitAsync(ct); + if (!init.IsOk) return null; + + await using var conn = OpenConnection(); + await conn.OpenAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT absolute_path, git_blob_oid, mtime_ms, size_bytes + FROM code_files + WHERE absolute_path = @absolutePath + """; + cmd.Parameters.AddWithValue("@absolutePath", absolutePath); + await using var reader = await cmd.ExecuteReaderAsync(ct); + if (!await reader.ReadAsync(ct)) return null; + return new FileIndexState + { + AbsolutePath = reader.GetString(0), + GitBlobOid = reader.IsDBNull(1) ? null : reader.GetString(1), + MTimeMs = reader.GetInt64(2), + SizeBytes = reader.GetInt64(3), + }; + } + catch (Exception ex) when (StorageFailure.IsExpected(ex)) + { + _logger.LogDebug(ex, "Failed to query file state"); + return null; + } + } + + public async Task DeleteFileAsync(string absolutePath, CancellationToken ct) + { + try + { + var init = await schema.InitAsync(ct); + if (!init.IsOk) return; + + await using var conn = OpenConnection(); + await conn.OpenAsync(ct); + + string? relativePath; + await using (var lookup = conn.CreateCommand()) + { + lookup.CommandText = "SELECT path FROM code_files WHERE absolute_path = @absolutePath"; + lookup.Parameters.AddWithValue("@absolutePath", absolutePath); + relativePath = (string?)await lookup.ExecuteScalarAsync(ct); + } + + if (relativePath is null) return; + + await using var tx = await conn.BeginTransactionAsync(ct); + await DeleteFileFactsAsync(conn, relativePath, ct); + await ExecuteAsync(conn, "DELETE FROM code_files WHERE path = @path", ct, ("@path", relativePath)); + await tx.CommitAsync(ct); + } + catch (Exception ex) when (StorageFailure.IsExpected(ex)) + { + _logger.LogDebug(ex, "Failed to delete file from code index"); + } + } + private async Task DeleteFileFactsAsync(SqliteConnection conn, string filePath, CancellationToken ct) { var symbolIds = new List(); @@ -307,11 +511,99 @@ private async Task DeleteFileFactsAsync(SqliteConnection conn, string filePath, foreach (var table in new[] { "code_symbols", "code_references", "code_diagnostics" }) await ExecuteAsync(conn, $"DELETE FROM {table} WHERE file_path = @filePath", ct, ("@filePath", filePath)); + await ExecuteAsync(conn, "DELETE FROM markdown_sections WHERE file_path = @filePath", ct, ("@filePath", filePath)); await ExecuteAsync(conn, "DELETE FROM code_dependency_edges WHERE source_id = @filePath OR target_id = @filePath", ct, ("@filePath", filePath)); } private SqliteConnection OpenConnection() => new($"Data Source={options.DatabasePath}"); + private static async Task QueryMarkdownDocumentAsync(SqliteConnection conn, string filePath, CancellationToken ct) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT path, project_root, absolute_path, language, content_hash, size_bytes, indexed_at, + provider_id, provider_version, query_version, fact_kind, confidence, frontmatter_yaml, plain_text + FROM code_files + WHERE path = @filePath AND language = 'markdown' + """; + cmd.Parameters.AddWithValue("@filePath", filePath); + await using var reader = await cmd.ExecuteReaderAsync(ct); + return await reader.ReadAsync(ct) ? ReadMarkdownDocument(reader) : null; + } + + private static async Task> QuerySymbolsForFileAsync(SqliteConnection conn, string filePath, CancellationToken ct) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, file_path, language, name, kind, parent_id, start_line, start_column, end_line, end_column, start_byte, end_byte, + provider_id, provider_version, query_version, fact_kind, confidence + FROM code_symbols + WHERE file_path = @filePath + ORDER BY start_byte + """; + cmd.Parameters.AddWithValue("@filePath", filePath); + await using var reader = await cmd.ExecuteReaderAsync(ct); + var symbols = new List(); + while (await reader.ReadAsync(ct)) + symbols.Add(ReadSymbol(reader)); + return symbols; + } + + private static async Task> QueryReferencesForFileAsync(SqliteConnection conn, string filePath, CancellationToken ct) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, file_path, kind, target, start_line, start_column, end_line, end_column, + start_byte, end_byte, provider_id, provider_version, query_version, fact_kind, confidence + FROM code_references + WHERE file_path = @filePath + ORDER BY start_byte + """; + cmd.Parameters.AddWithValue("@filePath", filePath); + await using var reader = await cmd.ExecuteReaderAsync(ct); + var references = new List(); + while (await reader.ReadAsync(ct)) + references.Add(ReadReference(reader)); + return references; + } + + private static async Task> QueryDiagnosticsForFileAsync(SqliteConnection conn, string filePath, CancellationToken ct) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, file_path, severity, code, message, start_line, start_column, end_line, end_column, start_byte, end_byte, + provider_id, provider_version, query_version, fact_kind, confidence + FROM code_diagnostics + WHERE file_path = @filePath + ORDER BY start_byte + """; + cmd.Parameters.AddWithValue("@filePath", filePath); + await using var reader = await cmd.ExecuteReaderAsync(ct); + var diagnostics = new List(); + while (await reader.ReadAsync(ct)) + diagnostics.Add(ReadDiagnostic(reader)); + return diagnostics; + } + + private static async Task> QueryMarkdownSectionsAsync(SqliteConnection conn, string filePath, CancellationToken ct) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, file_path, heading_level, heading_text, heading_path, + start_line, end_line, start_byte, end_byte, + text, plain_text, provider_id, provider_version, query_version, fact_kind, confidence + FROM markdown_sections + WHERE file_path = @filePath + ORDER BY start_byte + """; + cmd.Parameters.AddWithValue("@filePath", filePath); + await using var reader = await cmd.ExecuteReaderAsync(ct); + var sections = new List(); + while (await reader.ReadAsync(ct)) + sections.Add(ReadSection(reader)); + return sections; + } + private static async Task> QueryReferencesAsync(SqliteConnection conn, string target, string? path, CancellationToken ct) { await using var cmd = conn.CreateCommand(); @@ -375,11 +667,37 @@ private static (string, object?)[] DiagnosticParams(CodeDiagnostic d) => .. ProvenanceParams(d.Provenance), ]; + private static (string, object?)[] SectionParams(MarkdownSection s) => + [ + ("@id", s.Id), ("@filePath", s.FilePath), ("@headingLevel", s.HeadingLevel), ("@headingText", s.HeadingText), ("@headingPath", s.HeadingPath), + ("@startLine", s.StartLine), ("@endLine", s.EndLine), ("@startByte", s.StartByte), ("@endByte", s.EndByte), + ("@text", s.Text), ("@plainText", s.PlainText), + ("@providerId", s.Provenance.ProviderId), ("@providerVersion", s.Provenance.ProviderVersion), ("@queryVersion", s.Provenance.QueryVersion), + ("@factKind", s.Provenance.FactKind), ("@confidence", s.Provenance.Confidence), + ]; + private static (string, object?)[] ProvenanceParams(ProviderProvenance p) => [ ("@providerId", p.ProviderId), ("@providerVersion", p.ProviderVersion), ("@queryVersion", p.QueryVersion), ("@factKind", p.FactKind), ("@confidence", p.Confidence), ]; + private static CodeStructureDocument ReadMarkdownDocument(SqliteDataReader r) => new() + { + File = new CodeFileIdentity + { + RelativePath = r.GetString(0), + ProjectRoot = r.GetString(1), + Path = r.GetString(2), + Language = r.GetString(3), + ContentHash = r.GetString(4), + SizeBytes = r.GetInt64(5), + IndexedAt = DateTimeOffset.Parse(r.GetString(6)), + }, + Provenance = ReadProvenance(r, 7), + FrontmatterYaml = r.IsDBNull(12) ? null : r.GetString(12), + PlainText = r.IsDBNull(13) ? null : r.GetString(13), + }; + private static CodeSymbol ReadSymbol(SqliteDataReader r) => new() { Id = r.GetString(0), @@ -425,6 +743,47 @@ private static (string, object?)[] ProvenanceParams(ProviderProvenance p) => Provenance = ReadProvenance(r, 11), }; + private static MarkdownSection ReadSection(SqliteDataReader r) => new() + { + Id = r.GetString(0), + FilePath = r.GetString(1), + HeadingLevel = r.GetInt32(2), + HeadingText = r.GetString(3), + HeadingPath = r.GetString(4), + HeadingAnchor = ToAnchor(r.GetString(3)), + StartLine = r.GetInt32(5), + EndLine = r.GetInt32(6), + StartByte = r.GetInt32(7), + EndByte = r.GetInt32(8), + Text = r.IsDBNull(9) ? null : r.GetString(9), + PlainText = r.IsDBNull(10) ? null : r.GetString(10), + Provenance = ReadProvenance(r, 11), + }; + + private static string ToAnchor(string text) + { + var lower = text.ToLowerInvariant().Trim(); + var builder = new StringBuilder(lower.Length); + var previousWhitespace = false; + foreach (var c in lower) + { + if (char.IsWhiteSpace(c)) + { + if (!previousWhitespace) + builder.Append('-'); + previousWhitespace = true; + } + else + { + previousWhitespace = false; + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') + builder.Append(c); + } + } + + return builder.ToString().Trim('-'); + } + private static SourceSpan ReadSpan(SqliteDataReader r, int start) => new() { StartLine = r.GetInt32(start), diff --git a/src/Hypa.Infrastructure/Storage/SqliteSchemaInitializer.cs b/src/Hypa.Infrastructure/Storage/SqliteSchemaInitializer.cs index 6b349aa..f53b46f 100644 --- a/src/Hypa.Infrastructure/Storage/SqliteSchemaInitializer.cs +++ b/src/Hypa.Infrastructure/Storage/SqliteSchemaInitializer.cs @@ -14,7 +14,7 @@ public sealed class SqliteSchemaInitializer(HypaDataOptions options) "sessions", "evidence_records", "artifact_refs", "command_metrics", "trust_records", "parse_metrics", "code_files", "code_symbols", "code_references", "code_dependency_edges", "code_diagnostics", - "code_provider_health", "project_registrations" + "code_provider_health", "project_registrations", "markdown_sections" ]; // All columns added via AddColumnIfMissingAsync — must mirror those calls exactly. @@ -29,6 +29,17 @@ private static readonly (string Table, string Column)[] RequiredColumns = ("code_dependency_edges", "end_column"), ("code_dependency_edges", "start_byte"), ("code_dependency_edges", "end_byte"), + ("code_files", "frontmatter_yaml"), + ("code_files", "plain_text"), + ("code_files", "fact_kind"), + ("code_files", "confidence"), + ("code_symbols", "heading_level"), + ("code_symbols", "heading_path"), + ("code_symbols", "document_type"), + ("markdown_sections", "fact_kind"), + ("markdown_sections", "confidence"), + ("code_files", "git_blob_oid"), + ("code_files", "mtime_ms"), ]; public async Task> InitAsync(CancellationToken ct) @@ -100,7 +111,7 @@ private async Task IsCompatibleAsync(CancellationToken ct) return true; } - private const int CurrentSchemaVersion = 1; + private const int CurrentSchemaVersion = 3; // Phase 1 of 2: read schema_version via a read-only connection so that future-version // detection works even when the database or filesystem is read-only (e.g. Codex sandbox). @@ -257,7 +268,13 @@ CREATE TABLE IF NOT EXISTS code_files ( indexed_at TEXT NOT NULL, provider_id TEXT NOT NULL, provider_version TEXT NOT NULL, - query_version TEXT NOT NULL + query_version TEXT NOT NULL, + frontmatter_yaml TEXT, + plain_text TEXT, + fact_kind TEXT NOT NULL DEFAULT 'syntactic', + confidence REAL NOT NULL DEFAULT 0.0, + git_blob_oid TEXT, + mtime_ms INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS code_symbols ( id TEXT PRIMARY KEY, @@ -355,6 +372,27 @@ CREATE TABLE IF NOT EXISTS project_registrations ( UNIQUE(root_path, agent_key) ); CREATE INDEX IF NOT EXISTS ix_project_registrations_agent ON project_registrations(agent_key); + CREATE TABLE IF NOT EXISTS markdown_sections ( + id TEXT PRIMARY KEY, + file_path TEXT NOT NULL REFERENCES code_files(path), + heading_level INTEGER NOT NULL, + heading_text TEXT NOT NULL, + heading_path TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + start_byte INTEGER NOT NULL, + end_byte INTEGER NOT NULL, + text TEXT, + plain_text TEXT, + provider_id TEXT NOT NULL, + provider_version TEXT NOT NULL, + query_version TEXT NOT NULL, + fact_kind TEXT NOT NULL DEFAULT 'syntactic', + confidence REAL NOT NULL DEFAULT 0.0 + ); + CREATE INDEX IF NOT EXISTS idx_markdown_sections_file_path ON markdown_sections(file_path); + CREATE INDEX IF NOT EXISTS idx_markdown_sections_heading_path ON markdown_sections(heading_path); + CREATE INDEX IF NOT EXISTS idx_markdown_sections_level ON markdown_sections(heading_level); CREATE TABLE IF NOT EXISTS schema_metadata ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -370,14 +408,87 @@ value TEXT NOT NULL await AddColumnIfMissingAsync(conn, "code_dependency_edges", "end_column", "INTEGER", ct); await AddColumnIfMissingAsync(conn, "code_dependency_edges", "start_byte", "INTEGER", ct); await AddColumnIfMissingAsync(conn, "code_dependency_edges", "end_byte", "INTEGER", ct); + await AddColumnIfMissingAsync(conn, "code_files", "frontmatter_yaml", "TEXT", ct); + await AddColumnIfMissingAsync(conn, "code_files", "plain_text", "TEXT", ct); + await AddColumnIfMissingAsync(conn, "code_files", "fact_kind", "TEXT NOT NULL DEFAULT 'syntactic'", ct); + await AddColumnIfMissingAsync(conn, "code_files", "confidence", "REAL NOT NULL DEFAULT 0.0", ct); + await AddColumnIfMissingAsync(conn, "code_symbols", "heading_level", "INTEGER", ct); + await AddColumnIfMissingAsync(conn, "code_symbols", "heading_path", "TEXT", ct); + await AddColumnIfMissingAsync(conn, "code_symbols", "document_type", "TEXT", ct); + await AddColumnIfMissingAsync(conn, "markdown_sections", "fact_kind", "TEXT NOT NULL DEFAULT 'syntactic'", ct); + await AddColumnIfMissingAsync(conn, "markdown_sections", "confidence", "REAL NOT NULL DEFAULT 0.0", ct); + await AddColumnIfMissingAsync(conn, "code_files", "git_blob_oid", "TEXT", ct); + await AddColumnIfMissingAsync(conn, "code_files", "mtime_ms", "INTEGER NOT NULL DEFAULT 0", ct); + if (await MarkdownSectionsHasHeadingPathUniqueAsync(conn, ct)) + { + await RebuildMarkdownSectionsWithoutHeadingPathUniqueAsync(conn, ct); + await CreateMarkdownSectionIndexesAsync(conn, ct); + } await UpsertSchemaVersionAsync(conn, ct); } + private static async Task MarkdownSectionsHasHeadingPathUniqueAsync(SqliteConnection conn, CancellationToken ct) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT sql FROM sqlite_master WHERE type='table' AND name='markdown_sections'"; + var sql = (string?)await cmd.ExecuteScalarAsync(ct); + return sql?.Contains("UNIQUE(file_path, heading_path)", StringComparison.OrdinalIgnoreCase) is true; + } + + private static async Task RebuildMarkdownSectionsWithoutHeadingPathUniqueAsync(SqliteConnection conn, CancellationToken ct) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + PRAGMA foreign_keys=OFF; + CREATE TABLE markdown_sections_new ( + id TEXT PRIMARY KEY, + file_path TEXT NOT NULL REFERENCES code_files(path), + heading_level INTEGER NOT NULL, + heading_text TEXT NOT NULL, + heading_path TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + start_byte INTEGER NOT NULL, + end_byte INTEGER NOT NULL, + text TEXT, + plain_text TEXT, + provider_id TEXT NOT NULL, + provider_version TEXT NOT NULL, + query_version TEXT NOT NULL, + fact_kind TEXT NOT NULL DEFAULT 'syntactic', + confidence REAL NOT NULL DEFAULT 0.0 + ); + INSERT INTO markdown_sections_new + (id, file_path, heading_level, heading_text, heading_path, + start_line, end_line, start_byte, end_byte, + text, plain_text, provider_id, provider_version, query_version, fact_kind, confidence) + SELECT id, file_path, heading_level, heading_text, heading_path, + start_line, end_line, start_byte, end_byte, + text, plain_text, provider_id, provider_version, query_version, fact_kind, confidence + FROM markdown_sections; + DROP TABLE markdown_sections; + ALTER TABLE markdown_sections_new RENAME TO markdown_sections; + PRAGMA foreign_keys=ON; + """; + await cmd.ExecuteNonQueryAsync(ct); + } + + private static async Task CreateMarkdownSectionIndexesAsync(SqliteConnection conn, CancellationToken ct) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE INDEX IF NOT EXISTS idx_markdown_sections_file_path ON markdown_sections(file_path); + CREATE INDEX IF NOT EXISTS idx_markdown_sections_heading_path ON markdown_sections(heading_path); + CREATE INDEX IF NOT EXISTS idx_markdown_sections_level ON markdown_sections(heading_level); + """; + await cmd.ExecuteNonQueryAsync(ct); + } + private static async Task UpsertSchemaVersionAsync(SqliteConnection conn, CancellationToken ct) { await using var cmd = conn.CreateCommand(); cmd.CommandText = """ - INSERT INTO schema_metadata (key, value) VALUES ('schema_version', '1') + INSERT INTO schema_metadata (key, value) VALUES ('schema_version', '3') ON CONFLICT(key) DO UPDATE SET value = excluded.value """; await cmd.ExecuteNonQueryAsync(ct); diff --git a/src/Hypa.Runtime/Application/Ports/IBrowserLauncher.cs b/src/Hypa.Runtime/Application/Ports/IBrowserLauncher.cs new file mode 100644 index 0000000..968a283 --- /dev/null +++ b/src/Hypa.Runtime/Application/Ports/IBrowserLauncher.cs @@ -0,0 +1,6 @@ +namespace Hypa.Runtime.Application.Ports; + +public interface IBrowserLauncher +{ + bool TryOpen(string url); +} diff --git a/src/Hypa.Runtime/Application/Ports/ICodeIndexRepository.cs b/src/Hypa.Runtime/Application/Ports/ICodeIndexRepository.cs index 9ace494..f37b757 100644 --- a/src/Hypa.Runtime/Application/Ports/ICodeIndexRepository.cs +++ b/src/Hypa.Runtime/Application/Ports/ICodeIndexRepository.cs @@ -8,6 +8,19 @@ public interface ICodeIndexRepository Task> QuerySymbolsAsync(CodeSymbolQuery query, CancellationToken ct); Task QueryGraphAsync(CodeGraphQuery query, CancellationToken ct); Task> QueryDiagnosticsAsync(CancellationToken ct); + Task QueryMarkdownAsync(string filePath, CancellationToken ct); + Task> QueryMarkdownSectionsAsync(string filePath, CancellationToken ct); + Task> QueryReferencesAsync(string filePath, string kind, CancellationToken ct); Task SaveProviderHealthAsync(IReadOnlyList health, CancellationToken ct); Task> GetProviderHealthAsync(CancellationToken ct); + + /// Stored freshness manifest for all files under a project root. + Task> QueryFileStatesAsync( + string projectRoot, CancellationToken ct); + + /// Stored freshness state for a single file. Null if not indexed. + Task QueryFileStateAsync(string absolutePath, CancellationToken ct); + + /// Remove all index records for a file and its derived facts. + Task DeleteFileAsync(string absolutePath, CancellationToken ct); } diff --git a/src/Hypa.Runtime/Application/Ports/IGitFileStateProvider.cs b/src/Hypa.Runtime/Application/Ports/IGitFileStateProvider.cs new file mode 100644 index 0000000..7c54819 --- /dev/null +++ b/src/Hypa.Runtime/Application/Ports/IGitFileStateProvider.cs @@ -0,0 +1,10 @@ +namespace Hypa.Runtime.Application.Ports; + +public interface IGitFileStateProvider +{ + Task?> GetCleanBlobOidsAsync( + string projectRoot, CancellationToken ct); + + Task GetCleanBlobOidAsync( + string absolutePath, string projectRoot, CancellationToken ct); +} diff --git a/src/Hypa.Runtime/Application/Ports/IMcpAuthProvider.cs b/src/Hypa.Runtime/Application/Ports/IMcpAuthProvider.cs new file mode 100644 index 0000000..871d692 --- /dev/null +++ b/src/Hypa.Runtime/Application/Ports/IMcpAuthProvider.cs @@ -0,0 +1,8 @@ +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Ports; + +public interface IMcpAuthProvider +{ + ValueTask GetAuthContextAsync(McpServerDefinition server, CancellationToken ct); +} diff --git a/src/Hypa.Runtime/Application/Ports/IMcpBrowserOAuthFlowProvider.cs b/src/Hypa.Runtime/Application/Ports/IMcpBrowserOAuthFlowProvider.cs new file mode 100644 index 0000000..d45cfe8 --- /dev/null +++ b/src/Hypa.Runtime/Application/Ports/IMcpBrowserOAuthFlowProvider.cs @@ -0,0 +1,15 @@ +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Ports; + +public interface IMcpBrowserOAuthFlowProvider +{ + Task StartFlowAsync( + string serverName, + string endpoint, + McpOAuthConfig config, + McpBrowserOAuthOptions options, + CancellationToken ct, + IProgress? progress = null); +} diff --git a/src/Hypa.Runtime/Application/Ports/IMcpConnectionImportSource.cs b/src/Hypa.Runtime/Application/Ports/IMcpConnectionImportSource.cs new file mode 100644 index 0000000..62c807d --- /dev/null +++ b/src/Hypa.Runtime/Application/Ports/IMcpConnectionImportSource.cs @@ -0,0 +1,40 @@ +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Ports; + +public interface IMcpConnectionImportSource +{ + string AgentKey { get; } + bool SupportsScope(McpImportScope scope); + Task> DiscoverAsync(McpImportDiscoveryRequest request, CancellationToken ct); +} + +public sealed record McpImportDiscoveryRequest(McpImportScope Scope, string? ProjectRoot); + +public enum McpImportScope +{ + Global, + Project, + All, +} + +public sealed record McpImportedConnection( + string SourceAgent, + string SourceScope, + string SourceName, + McpServerDefinition? Server, + string Fingerprint, + McpImportCandidateStatus Status, + string? Detail); + +public enum McpImportCandidateStatus +{ + Importable, + SkippedSelf, + SkippedUnsafeSecret, + SkippedIncomplete, + SkippedUnsupported, + SkippedDuplicate, + SkippedConflict, + ParseError, +} diff --git a/src/Hypa.Runtime/Application/Ports/IMcpDispatcher.cs b/src/Hypa.Runtime/Application/Ports/IMcpDispatcher.cs new file mode 100644 index 0000000..6214617 --- /dev/null +++ b/src/Hypa.Runtime/Application/Ports/IMcpDispatcher.cs @@ -0,0 +1,11 @@ +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Ports; + +public interface IMcpDispatcher +{ + Task InvokeAsync(McpProxyRequest request, CancellationToken ct); + Task> InvokeBatchAsync(IReadOnlyList requests, CancellationToken ct); + Task GetSchemaAsync(CancellationToken ct); + Task> SearchToolsAsync(string query, CancellationToken ct); +} diff --git a/src/Hypa.Runtime/Application/Ports/IMcpServerConfigReader.cs b/src/Hypa.Runtime/Application/Ports/IMcpServerConfigReader.cs new file mode 100644 index 0000000..feb248e --- /dev/null +++ b/src/Hypa.Runtime/Application/Ports/IMcpServerConfigReader.cs @@ -0,0 +1,9 @@ +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Ports; + +public interface IMcpServerConfigReader +{ + Task, Error>> ReadEditableAsync(CancellationToken ct); +} diff --git a/src/Hypa.Runtime/Application/Ports/IMcpServerConfigWriter.cs b/src/Hypa.Runtime/Application/Ports/IMcpServerConfigWriter.cs new file mode 100644 index 0000000..868bb0e --- /dev/null +++ b/src/Hypa.Runtime/Application/Ports/IMcpServerConfigWriter.cs @@ -0,0 +1,9 @@ +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Ports; + +public interface IMcpServerConfigWriter +{ + Task> WriteAsync(IReadOnlyList servers, CancellationToken ct); +} diff --git a/src/Hypa.Runtime/Application/Ports/IMcpServerDefinitionRepository.cs b/src/Hypa.Runtime/Application/Ports/IMcpServerDefinitionRepository.cs new file mode 100644 index 0000000..05cb149 --- /dev/null +++ b/src/Hypa.Runtime/Application/Ports/IMcpServerDefinitionRepository.cs @@ -0,0 +1,9 @@ +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Ports; + +public interface IMcpServerDefinitionRepository +{ + Task, Error>> LoadAsync(CancellationToken ct); +} diff --git a/src/Hypa.Runtime/Application/Ports/IMcpServerImportService.cs b/src/Hypa.Runtime/Application/Ports/IMcpServerImportService.cs new file mode 100644 index 0000000..e12d89e --- /dev/null +++ b/src/Hypa.Runtime/Application/Ports/IMcpServerImportService.cs @@ -0,0 +1,9 @@ +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Common; + +namespace Hypa.Runtime.Application.Ports; + +public interface IMcpServerImportService +{ + Task> ImportAsync(McpImportRequest request, CancellationToken ct); +} diff --git a/src/Hypa.Runtime/Application/Ports/IMcpServerProbe.cs b/src/Hypa.Runtime/Application/Ports/IMcpServerProbe.cs new file mode 100644 index 0000000..a4644d0 --- /dev/null +++ b/src/Hypa.Runtime/Application/Ports/IMcpServerProbe.cs @@ -0,0 +1,8 @@ +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Ports; + +public interface IMcpServerProbe +{ + Task ProbeAsync(McpServerDefinition server, CancellationToken ct); +} diff --git a/src/Hypa.Runtime/Application/Ports/IOAuthCallbackListener.cs b/src/Hypa.Runtime/Application/Ports/IOAuthCallbackListener.cs new file mode 100644 index 0000000..eb7b917 --- /dev/null +++ b/src/Hypa.Runtime/Application/Ports/IOAuthCallbackListener.cs @@ -0,0 +1,11 @@ +namespace Hypa.Runtime.Application.Ports; + +public interface IOAuthCallbackListener +{ + Task StartAsync(CancellationToken ct); + Uri GetRedirectUri(); + Task WaitForCallbackAsync(TimeSpan timeout, CancellationToken ct); + Task StopAsync(); +} + +public sealed record OAuthCallbackResult(string? Code, string? Error, string? State); diff --git a/src/Hypa.Runtime/Application/Ports/ISecretResolver.cs b/src/Hypa.Runtime/Application/Ports/ISecretResolver.cs new file mode 100644 index 0000000..1fd0f7a --- /dev/null +++ b/src/Hypa.Runtime/Application/Ports/ISecretResolver.cs @@ -0,0 +1,6 @@ +namespace Hypa.Runtime.Application.Ports; + +public interface ISecretResolver +{ + ValueTask ResolveAsync(string reference, CancellationToken ct); +} diff --git a/src/Hypa.Runtime/Application/Services/CodeIndexService.cs b/src/Hypa.Runtime/Application/Services/CodeIndexService.cs index b75e1fe..447869d 100644 --- a/src/Hypa.Runtime/Application/Services/CodeIndexService.cs +++ b/src/Hypa.Runtime/Application/Services/CodeIndexService.cs @@ -7,7 +7,8 @@ namespace Hypa.Runtime.Application.Services; public sealed class CodeIndexService( IProjectRootDetector rootDetector, CodeStructureProviderRegistry providers, - ICodeIndexRepository repository) + ICodeIndexRepository repository, + IGitFileStateProvider gitProvider) { private static readonly HashSet IgnoredDirectories = new(StringComparer.OrdinalIgnoreCase) { @@ -16,12 +17,15 @@ public sealed class CodeIndexService( private const long MaxFileBytes = 1_000_000; - public async Task IndexAsync(string? path, CancellationToken ct) + private readonly record struct StaleFileEntry(string AbsolutePath, FileInfo Info, string? CurrentOid); + + public async Task IndexFullAsync(string? path, CancellationToken ct) { var requestedPath = string.IsNullOrWhiteSpace(path) ? Directory.GetCurrentDirectory() : Path.GetFullPath(path); var detectorStart = Directory.Exists(requestedPath) ? requestedPath : Path.GetDirectoryName(requestedPath) ?? requestedPath; var root = rootDetector.Detect(detectorStart) ?? detectorStart; var files = Directory.Exists(requestedPath) ? EnumerateFiles(requestedPath).ToArray() : [requestedPath]; + var cleanOids = await gitProvider.GetCleanBlobOidsAsync(root, ct); var documents = new List(); var skipped = 0; @@ -47,15 +51,22 @@ public async Task IndexAsync(string? path, CancellationToken ct continue; } + var absolutePath = Path.GetFullPath(filePath); + var relativePath = Path.GetRelativePath(root, absolutePath); + string? gitBlobOid = null; + cleanOids?.TryGetValue(relativePath, out gitBlobOid); + var identity = new CodeFileIdentity { ProjectRoot = root, - Path = Path.GetFullPath(filePath), - RelativePath = Path.GetRelativePath(root, filePath), + Path = absolutePath, + RelativePath = relativePath, Language = language, ContentHash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(content))).ToLowerInvariant(), SizeBytes = info.Length, IndexedAt = DateTimeOffset.UtcNow, + GitBlobOid = gitBlobOid, + MTimeMs = new DateTimeOffset(info.LastWriteTimeUtc).ToUnixTimeMilliseconds(), }; try @@ -99,6 +110,185 @@ public async Task IndexAsync(string? path, CancellationToken ct }; } + public async Task IndexIncrementalAsync(string? path, CancellationToken ct) + { + var requestedPath = string.IsNullOrWhiteSpace(path) ? Directory.GetCurrentDirectory() : Path.GetFullPath(path); + var detectorStart = Directory.Exists(requestedPath) ? requestedPath : Path.GetDirectoryName(requestedPath) ?? requestedPath; + var root = rootDetector.Detect(detectorStart) ?? detectorStart; + + var storedStates = await repository.QueryFileStatesAsync(root, ct); + var cleanOids = await gitProvider.GetCleanBlobOidsAsync(root, ct); + + var onDiskAbsolutePaths = new HashSet(StringComparer.Ordinal); + var staleFiles = new List(); + var skipped = 0; + + foreach (var absolutePath in EnumerateFiles(root)) + { + ct.ThrowIfCancellationRequested(); + var language = CodeLanguageRegistry.GetLanguage(absolutePath); + var info = new FileInfo(absolutePath); + if (language is null || !info.Exists || info.Length > MaxFileBytes) + { + skipped++; + continue; + } + + onDiskAbsolutePaths.Add(absolutePath); + var relativePath = Path.GetRelativePath(root, absolutePath); + var stored = storedStates.GetValueOrDefault(absolutePath); + + bool stale; + string? currentOid = null; + if (cleanOids is not null && cleanOids.TryGetValue(relativePath, out var oid)) + { + currentOid = oid; + stale = stored is null || stored.GitBlobOid != currentOid; + } + else + { + var currentMtime = new DateTimeOffset(info.LastWriteTimeUtc).ToUnixTimeMilliseconds(); + stale = stored is null + || stored.MTimeMs != currentMtime + || stored.SizeBytes != info.Length; + } + + if (stale) + staleFiles.Add(new StaleFileEntry(absolutePath, info, currentOid)); + } + + var deletedCount = 0; + foreach (var storedPath in storedStates.Keys) + { + if (!onDiskAbsolutePaths.Contains(storedPath)) + { + await repository.DeleteFileAsync(storedPath, ct); + deletedCount++; + } + } + + var documents = new List(); + foreach (var entry in staleFiles) + { + ct.ThrowIfCancellationRequested(); + string content; + try { content = await File.ReadAllTextAsync(entry.AbsolutePath, ct); } + catch { skipped++; continue; } + + var language = CodeLanguageRegistry.GetLanguage(entry.AbsolutePath)!; + var identity = new CodeFileIdentity + { + ProjectRoot = root, + Path = entry.AbsolutePath, + RelativePath = Path.GetRelativePath(root, entry.AbsolutePath), + Language = language, + ContentHash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(content))).ToLowerInvariant(), + SizeBytes = entry.Info.Length, + IndexedAt = DateTimeOffset.UtcNow, + GitBlobOid = entry.CurrentOid, + MTimeMs = new DateTimeOffset(entry.Info.LastWriteTimeUtc).ToUnixTimeMilliseconds(), + }; + + try + { + documents.Add(await providers.Select(language).ParseAsync(identity, content, ct)); + } + catch (Exception ex) + { + var fallback = providers.Providers.First(p => p.Id == "regex-fallback"); + var fallbackDocument = await fallback.ParseAsync(identity, content, ct); + documents.Add(fallbackDocument with + { + Diagnostics = fallbackDocument.Diagnostics.Concat([ + new CodeDiagnostic + { + Id = CodeStableId.ForDiagnostic(identity.RelativePath, "provider-fallback", 0), + FilePath = identity.RelativePath, + Severity = "warning", + Code = "provider-fallback", + Message = ex.InnerException?.Message ?? ex.Message, + Provenance = fallbackDocument.Provenance, + }, + ]).ToArray(), + }); + } + } + + await repository.SaveDocumentsAsync(documents, ct); + var health = providers.Providers.Select(p => p.CheckHealth()).ToArray(); + await repository.SaveProviderHealthAsync(health, ct); + + return new CodeIndexResult + { + FilesIndexed = staleFiles.Count, + FilesSkipped = skipped, + FilesDeleted = deletedCount, + SymbolCount = documents.Sum(d => d.Symbols.Count), + ReferenceCount = documents.Sum(d => d.References.Count), + EdgeCount = documents.Sum(d => d.DependencyEdges.Count), + DiagnosticCount = documents.Sum(d => d.Diagnostics.Count), + ProviderHealth = health, + }; + } + + public async Task EnsureFreshAsync(string absolutePath, CancellationToken ct) + { + if (!File.Exists(absolutePath)) return; + + var dir = Path.GetDirectoryName(absolutePath) ?? absolutePath; + var root = rootDetector.Detect(dir) ?? dir; + + var currentOid = await gitProvider.GetCleanBlobOidAsync(absolutePath, root, ct); + if (currentOid is not null) + { + var stored = await repository.QueryFileStateAsync(absolutePath, ct); + if (stored?.GitBlobOid == currentOid) return; + await ReIndexFileAsync(absolutePath, root, currentOid, ct); + return; + } + + var info = new FileInfo(absolutePath); + var currentMtime = new DateTimeOffset(info.LastWriteTimeUtc).ToUnixTimeMilliseconds(); + var storedFallback = await repository.QueryFileStateAsync(absolutePath, ct); + if (storedFallback is not null + && storedFallback.MTimeMs == currentMtime + && storedFallback.SizeBytes == info.Length) + return; + + await ReIndexFileAsync(absolutePath, root, null, ct); + } + + private async Task ReIndexFileAsync(string absolutePath, string root, string? knownGitBlobOid, CancellationToken ct) + { + var info = new FileInfo(absolutePath); + var language = CodeLanguageRegistry.GetLanguage(absolutePath); + if (language is null || !info.Exists || info.Length > MaxFileBytes) return; + + string content; + try { content = await File.ReadAllTextAsync(absolutePath, ct); } + catch { return; } + + var identity = new CodeFileIdentity + { + ProjectRoot = root, + Path = absolutePath, + RelativePath = Path.GetRelativePath(root, absolutePath), + Language = language, + ContentHash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(content))).ToLowerInvariant(), + SizeBytes = info.Length, + IndexedAt = DateTimeOffset.UtcNow, + GitBlobOid = knownGitBlobOid, + MTimeMs = new DateTimeOffset(info.LastWriteTimeUtc).ToUnixTimeMilliseconds(), + }; + + try + { + var doc = await providers.Select(language).ParseAsync(identity, content, ct); + await repository.SaveDocumentsAsync([doc], ct); + } + catch { } + } + private static IEnumerable EnumerateFiles(string root) { var pending = new Stack(); diff --git a/src/Hypa.Runtime/Application/Services/CodeLanguageRegistry.cs b/src/Hypa.Runtime/Application/Services/CodeLanguageRegistry.cs index 95e1f67..d9dec65 100644 --- a/src/Hypa.Runtime/Application/Services/CodeLanguageRegistry.cs +++ b/src/Hypa.Runtime/Application/Services/CodeLanguageRegistry.cs @@ -25,6 +25,7 @@ public static class CodeLanguageRegistry [".yaml"] = "yaml", [".yml"] = "yaml", [".toml"] = "toml", + [".md"] = "markdown", }; public static string? GetLanguage(string path) => diff --git a/src/Hypa.Runtime/Application/Services/CodeQueryService.cs b/src/Hypa.Runtime/Application/Services/CodeQueryService.cs index 1fd671f..da0de1a 100644 --- a/src/Hypa.Runtime/Application/Services/CodeQueryService.cs +++ b/src/Hypa.Runtime/Application/Services/CodeQueryService.cs @@ -10,4 +10,22 @@ public Task> QuerySymbolsAsync(CodeSymbolQuery query, public Task QueryGraphAsync(CodeGraphQuery query, CancellationToken ct) => repository.QueryGraphAsync(query, ct); + + public Task QueryMarkdownAsync(string filePath, CancellationToken ct) => + repository.QueryMarkdownAsync(filePath, ct); + + public Task> QueryMarkdownSectionsAsync(string filePath, CancellationToken ct) => + repository.QueryMarkdownSectionsAsync(filePath, ct); + + public async Task> QueryTocAsync(string filePath, int maxDepth = 3, CancellationToken ct = default) + { + var sections = await repository.QueryMarkdownSectionsAsync(filePath, ct); + return sections.Where(s => s.HeadingLevel <= maxDepth).ToArray(); + } + + public async Task QueryFrontmatterAsync(string filePath, CancellationToken ct) + { + var document = await repository.QueryMarkdownAsync(filePath, ct); + return document?.FrontmatterYaml; + } } diff --git a/src/Hypa.Runtime/Application/Services/CodeStructureProviderRegistry.cs b/src/Hypa.Runtime/Application/Services/CodeStructureProviderRegistry.cs index 230de0d..664bb5b 100644 --- a/src/Hypa.Runtime/Application/Services/CodeStructureProviderRegistry.cs +++ b/src/Hypa.Runtime/Application/Services/CodeStructureProviderRegistry.cs @@ -10,10 +10,7 @@ public sealed class CodeStructureProviderRegistry(IEnumerable p.Id == "tree-sitter" && p.CanHandle(language)); - if (treeSitter is not null) - return treeSitter; - - return _providers.First(p => p.Id == "regex-fallback"); + return _providers.FirstOrDefault(p => p.Id != "regex-fallback" && p.CanHandle(language)) + ?? _providers.First(p => p.Id == "regex-fallback"); } } diff --git a/src/Hypa.Runtime/Application/Services/InitService.cs b/src/Hypa.Runtime/Application/Services/InitService.cs index a7ca983..d931df1 100644 --- a/src/Hypa.Runtime/Application/Services/InitService.cs +++ b/src/Hypa.Runtime/Application/Services/InitService.cs @@ -9,14 +9,16 @@ public sealed class InitService( IHookInstaller installer, IProjectRootDetector projectRootDetector, IProjectRegistry projectRegistry, - IStorageProvisioner storageProvisioner) + IStorageProvisioner storageProvisioner, + IMcpServerImportService? importService = null) { public async Task InstallAsync( InitScope scope, string? agentKey, string? projectRootOverride, bool dryRun, - CancellationToken ct = default) + CancellationToken ct = default, + bool skipMcpImport = false) { var detectedProjectRoot = ResolveProjectRoot(projectRootOverride); @@ -37,6 +39,8 @@ public async Task InstallAsync( return new InitResult([], detectedProjectRoot, ProjectSkipped: false); var reports = new List(); + var importReports = new List(); + var importErrors = new List(); if (!dryRun) { @@ -54,10 +58,26 @@ [new InstallReport("storage", [new InstallEntry("Database provisioning failed", } if (scope is InitScope.Global or InitScope.All) + { reports.AddRange(await InstallForScopeAsync(adapters, global: true, projectRoot: null, agentKey, dryRun, ct)); + if (!skipMcpImport && importService is not null) + { + var importResult = await RunImportAsync(importService, agentKey, McpImportScope.Global, null, dryRun, ct); + if (importResult.IsOk) importReports.Add(importResult.Value); + else importErrors.Add($"Global MCP import: {importResult.Error.Message}"); + } + } if (scope == InitScope.Project) + { reports.AddRange(await InstallForScopeAsync(adapters, global: false, detectedProjectRoot!, agentKey, dryRun, ct)); + if (!skipMcpImport && importService is not null) + { + var importResult = await RunImportAsync(importService, agentKey, McpImportScope.Project, detectedProjectRoot, dryRun, ct); + if (importResult.IsOk) importReports.Add(importResult.Value); + else importErrors.Add($"Project MCP import: {importResult.Error.Message}"); + } + } var projectSkipped = false; if (scope == InitScope.All) @@ -69,10 +89,48 @@ [new InstallReport("storage", [new InstallEntry("Database provisioning failed", else { reports.AddRange(await InstallForScopeAsync(adapters, global: false, detectedProjectRoot, agentKey, dryRun, ct)); + if (!skipMcpImport && importService is not null) + { + var importResult = await RunImportAsync(importService, agentKey, McpImportScope.Project, detectedProjectRoot, dryRun, ct); + if (importResult.IsOk) importReports.Add(importResult.Value); + else importErrors.Add($"Project MCP import: {importResult.Error.Message}"); + } } } - return new InitResult(reports, detectedProjectRoot, projectSkipped); + // Add import errors as warnings to the harness reports without aborting installation. + if (importErrors.Count > 0) + { + reports.Add(new InstallReport( + "mcp-import", + importErrors.Select((err, i) => new InstallEntry( + i == 0 ? "MCP Server Import" : "Additional Import Issue", + InstallStatus.Warning, + err)).ToList())); + } + + McpImportReport? mergedImport = importReports.Count > 0 + ? new McpImportReport( + importReports.SelectMany(r => r.Sources).ToList(), + importReports.Sum(r => r.ImportedCount), + importReports.Sum(r => r.AlreadyPresentCount), + importReports.Sum(r => r.SkippedCount), + importReports.Sum(r => r.ConflictCount)) + : null; + + return new InitResult(reports, detectedProjectRoot, projectSkipped, ImportReport: mergedImport); + } + + private static async Task> RunImportAsync( + IMcpServerImportService importService, + string? agentKey, + McpImportScope scope, + string? projectRoot, + bool dryRun, + CancellationToken ct) + { + return await importService.ImportAsync( + new McpImportRequest(agentKey, scope, projectRoot, Replace: false, DryRun: dryRun), ct); } private async Task> InstallForScopeAsync( @@ -155,4 +213,5 @@ public sealed record InitResult( IReadOnlyList Reports, string? ProjectRoot, bool ProjectSkipped, + McpImportReport? ImportReport = null, string? ErrorMessage = null); diff --git a/src/Hypa.Runtime/Application/Services/McpBrowserOAuthFlowResult.cs b/src/Hypa.Runtime/Application/Services/McpBrowserOAuthFlowResult.cs new file mode 100644 index 0000000..9e2b532 --- /dev/null +++ b/src/Hypa.Runtime/Application/Services/McpBrowserOAuthFlowResult.cs @@ -0,0 +1,9 @@ +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Services; + +public sealed record McpBrowserOAuthFlowResult( + bool Succeeded, + McpOAuthConfig? CompletedConfig, + int? ToolCount, + string? Error = null); diff --git a/src/Hypa.Runtime/Application/Services/McpBrowserOAuthOptions.cs b/src/Hypa.Runtime/Application/Services/McpBrowserOAuthOptions.cs new file mode 100644 index 0000000..76b3a9d --- /dev/null +++ b/src/Hypa.Runtime/Application/Services/McpBrowserOAuthOptions.cs @@ -0,0 +1,9 @@ +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Services; + +public sealed record McpBrowserOAuthOptions( + bool NoBrowser = false, + TimeSpan? CallbackTimeout = null, + bool Interactive = true, + McpTlsConfig? Tls = null); diff --git a/src/Hypa.Runtime/Application/Services/McpConfigValidationService.cs b/src/Hypa.Runtime/Application/Services/McpConfigValidationService.cs new file mode 100644 index 0000000..f20f396 --- /dev/null +++ b/src/Hypa.Runtime/Application/Services/McpConfigValidationService.cs @@ -0,0 +1,158 @@ +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Services; + +public sealed record McpConfigError(string ServerName, string Field, string Message); + +public sealed class McpConfigValidationService +{ + public Result> Validate(IReadOnlyList servers) + { + var errors = new List(); + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var server in servers) + { + if (string.IsNullOrWhiteSpace(server.Name)) + errors.Add(new McpConfigError(server.Name, "Name", "Server name must not be empty.")); + else if (!seen.Add(server.Name)) + errors.Add(new McpConfigError(server.Name, "Name", $"Duplicate server name '{server.Name}'.")); + } + + foreach (var server in servers) + { + ValidateTransport(server, errors); + ValidateAuth(server, errors); + ValidateTimeouts(server, errors); + } + + foreach (var server in servers) + ValidateTls(server, errors); + + return errors.Count == 0 + ? Result>.Ok(Unit.Value) + : Result>.Fail(errors); + } + + private static void ValidateTransport(McpServerDefinition server, List errors) + { + var transport = server.Transport; + switch (transport.Kind) + { + case McpTransportKind.Unknown: + errors.Add(new McpConfigError(server.Name, "Transport.Kind", + "Unknown transport type. Valid values: stdio, streamableHttp, sse, httpAutoDetect.")); + break; + case McpTransportKind.Stdio: + if (string.IsNullOrWhiteSpace(transport.Endpoint)) + errors.Add(new McpConfigError(server.Name, "Transport.Endpoint", + "Stdio transport requires an Endpoint containing the command to execute.")); + break; + case McpTransportKind.Http: + case McpTransportKind.Sse: + case McpTransportKind.HttpAutoDetect: + if (string.IsNullOrWhiteSpace(transport.Endpoint)) + { + errors.Add(new McpConfigError(server.Name, "Transport.Endpoint", + $"{transport.Kind} transport requires an Endpoint.")); + } + else if (!Uri.TryCreate(transport.Endpoint, UriKind.Absolute, out _)) + { + errors.Add(new McpConfigError(server.Name, "Transport.Endpoint", + $"Endpoint '{transport.Endpoint}' is not a valid absolute URI.")); + } + break; + } + } + + private static void ValidateAuth(McpServerDefinition server, List errors) + { + switch (server.Auth) + { + case BearerAuthConfig bearer: + if (string.IsNullOrWhiteSpace(bearer.TokenRef)) + errors.Add(new McpConfigError(server.Name, "Auth.TokenRef", "Bearer auth requires TokenRef.")); + break; + + case ApiKeyAuthConfig apiKey: + if (string.IsNullOrWhiteSpace(apiKey.HeaderName)) + errors.Add(new McpConfigError(server.Name, "Auth.HeaderName", "ApiKey auth requires HeaderName.")); + if (string.IsNullOrWhiteSpace(apiKey.ValueRef)) + errors.Add(new McpConfigError(server.Name, "Auth.ValueRef", "ApiKey auth requires ValueRef.")); + break; + + case BasicAuthConfig basic: + if (string.IsNullOrWhiteSpace(basic.UsernameRef)) + errors.Add(new McpConfigError(server.Name, "Auth.UsernameRef", "Basic auth requires UsernameRef.")); + if (string.IsNullOrWhiteSpace(basic.PasswordRef)) + errors.Add(new McpConfigError(server.Name, "Auth.PasswordRef", "Basic auth requires PasswordRef.")); + break; + + case OAuth2ClientCredentialsConfig oauth2Cc: + if (string.IsNullOrWhiteSpace(oauth2Cc.TokenUrl)) + errors.Add(new McpConfigError(server.Name, "Auth.TokenUrl", "OAuth2ClientCredentials requires TokenUrl.")); + if (string.IsNullOrWhiteSpace(oauth2Cc.ClientIdRef)) + errors.Add(new McpConfigError(server.Name, "Auth.ClientIdRef", "OAuth2ClientCredentials requires ClientIdRef.")); + if (string.IsNullOrWhiteSpace(oauth2Cc.ClientSecretRef)) + errors.Add(new McpConfigError(server.Name, "Auth.ClientSecretRef", "OAuth2ClientCredentials requires ClientSecretRef.")); + break; + + case OAuth2DeviceCodeConfig oauth2Dc: + if (string.IsNullOrWhiteSpace(oauth2Dc.AuthUrl)) + errors.Add(new McpConfigError(server.Name, "Auth.AuthUrl", "OAuth2DeviceCode requires AuthUrl.")); + if (string.IsNullOrWhiteSpace(oauth2Dc.TokenUrl)) + errors.Add(new McpConfigError(server.Name, "Auth.TokenUrl", "OAuth2DeviceCode requires TokenUrl.")); + if (string.IsNullOrWhiteSpace(oauth2Dc.ClientId)) + errors.Add(new McpConfigError(server.Name, "Auth.ClientId", "OAuth2DeviceCode requires ClientId.")); + break; + + case MtlsConfig mtls: + if (string.IsNullOrWhiteSpace(mtls.ClientCertRef)) + errors.Add(new McpConfigError(server.Name, "Auth.ClientCertRef", "mTLS requires ClientCertRef.")); + if (string.IsNullOrWhiteSpace(mtls.ClientKeyRef)) + errors.Add(new McpConfigError(server.Name, "Auth.ClientKeyRef", "mTLS requires ClientKeyRef.")); + break; + + case UnknownAuthConfig unknown: + var authTypeMsg = string.IsNullOrWhiteSpace(unknown.Type) + ? "Auth.Type is required when an auth block is present. Valid values: none, bearer, apikey, basic, oauth2clientcredentials, oauth2devicecode, mtls." + : $"Unknown auth type '{unknown.Type}'. Valid values: none, bearer, apikey, basic, oauth2clientcredentials, oauth2devicecode, mtls."; + errors.Add(new McpConfigError(server.Name, "Auth.Type", authTypeMsg)); + break; + } + } + + private static void ValidateTimeouts(McpServerDefinition server, List errors) + { + if (server.ConnectTimeout is { TotalSeconds: <= 0 }) + errors.Add(new McpConfigError(server.Name, "ConnectTimeout", "ConnectTimeout must be a positive duration.")); + if (server.RequestTimeout is { TotalSeconds: <= 0 }) + errors.Add(new McpConfigError(server.Name, "RequestTimeout", "RequestTimeout must be a positive duration.")); + } + + private static void ValidateTls(McpServerDefinition server, List errors) + { + var tls = server.Tls; + if (tls is null) return; + + var hasTlsMaterial = !string.IsNullOrWhiteSpace(tls.CaCertPath) + || !string.IsNullOrWhiteSpace(tls.ClientCertPath) + || !string.IsNullOrWhiteSpace(tls.ClientKeyPath); + + if (!hasTlsMaterial) return; + + if (server.Transport.Kind == McpTransportKind.Stdio) + { + errors.Add(new McpConfigError(server.Name, "Tls", + "TLS options are not valid for stdio transport.")); + return; + } + + var hasCert = !string.IsNullOrWhiteSpace(tls.ClientCertPath); + var hasKey = !string.IsNullOrWhiteSpace(tls.ClientKeyPath); + if (hasCert != hasKey) + errors.Add(new McpConfigError(server.Name, "Tls.ClientCert", + "TLS ClientCertPath and ClientKeyPath must be supplied together.")); + } +} diff --git a/src/Hypa.Runtime/Application/Services/McpProxyService.cs b/src/Hypa.Runtime/Application/Services/McpProxyService.cs new file mode 100644 index 0000000..1996129 --- /dev/null +++ b/src/Hypa.Runtime/Application/Services/McpProxyService.cs @@ -0,0 +1,52 @@ +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Services; + +public sealed class McpProxyService( + IMcpDispatcher dispatcher, + McpResponseCompressionService compression, + McpToolSearchIndex searchIndex, + IClock clock) +{ + public async Task InvokeAsync(McpProxyRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.ServerName)) + return InvalidRequest(request, "ServerName is required."); + + if (string.IsNullOrWhiteSpace(request.ToolName)) + return InvalidRequest(request, "ToolName is required."); + + var result = await dispatcher.InvokeAsync(request, ct); + return compression.Compress(result, request.CompressionHint); + } + + public async Task> InvokeBatchAsync( + IReadOnlyList requests, + CancellationToken ct) + { + var tasks = requests.Select(r => InvokeAsync(r, ct)); + return await Task.WhenAll(tasks); + } + + public Task GetSchemaAsync(CancellationToken ct) => + dispatcher.GetSchemaAsync(ct); + + public async Task> SearchToolsAsync( + string query, + CancellationToken ct) + { + var manifest = await dispatcher.GetSchemaAsync(ct); + return searchIndex.Search(manifest, query); + } + + private McpResult InvalidRequest(McpProxyRequest request, string message) => + new( + request.ServerName, + request.ToolName, + new JsonPayload("{}"), + string.Empty, + new McpLatencyMetadata(clock.UtcNow, TimeSpan.Zero), + IsError: true, + new McpProxyError(McpErrorCodes.InvalidRequest, message, request.ServerName, request.ToolName)); +} diff --git a/src/Hypa.Runtime/Application/Services/McpResponseCompressionService.cs b/src/Hypa.Runtime/Application/Services/McpResponseCompressionService.cs new file mode 100644 index 0000000..890be89 --- /dev/null +++ b/src/Hypa.Runtime/Application/Services/McpResponseCompressionService.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Nodes; +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Services; + +public sealed class McpResponseCompressionService +{ + public McpResult Compress(McpResult result, CompressionHint? hint) + { + if (result.IsError || hint == CompressionHint.Raw) + return result; + + var compressed = hint == CompressionHint.Structured + ? CompactJson(result.RawResponse.RawJson) + : ExtractAndNormaliseText(result.RawResponse.RawJson); + + return result with { CompressedResponse = compressed }; + } + + private static string CompactJson(string raw) + { + try + { + var node = JsonNode.Parse(raw); + return node?.ToJsonString() ?? raw; + } + catch + { + return ExtractAndNormaliseText(raw); + } + } + + private static string ExtractAndNormaliseText(string raw) + { + try + { + var node = JsonNode.Parse(raw); + if (node is JsonArray arr) + { + var parts = arr + .OfType() + .Where(o => o["type"]?.GetValue() == "text") + .Select(o => o["text"]?.GetValue() ?? string.Empty); + + var joined = string.Join('\n', parts); + if (!string.IsNullOrWhiteSpace(joined)) + return NormaliseWhitespace(joined); + } + } + catch { } + + return NormaliseWhitespace(raw); + } + + private static string NormaliseWhitespace(string text) + { + var lines = text.Split('\n'); + var result = new List(lines.Length); + + foreach (var line in lines) + { + var trimmed = line.TrimEnd(); + if (!string.IsNullOrWhiteSpace(trimmed)) + result.Add(trimmed); + } + + return string.Join('\n', result).Trim(); + } +} diff --git a/src/Hypa.Runtime/Application/Services/McpServerConfigService.cs b/src/Hypa.Runtime/Application/Services/McpServerConfigService.cs new file mode 100644 index 0000000..221957b --- /dev/null +++ b/src/Hypa.Runtime/Application/Services/McpServerConfigService.cs @@ -0,0 +1,270 @@ +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Services; + +public sealed record McpServerAddRequest( + string Name, + string Transport, + string Endpoint, + string AuthType, + McpServerAddAuthOptions Auth, + McpServerAddTlsOptions? Tls, + int? ConnectTimeoutSeconds, + int? RequestTimeoutSeconds, + bool Replace, + bool DryRun, + bool SkipProbe = false, + bool ForceProbeInDryRun = false); + +public sealed record McpServerAddAuthOptions( + string? TokenRef = null, + string? HeaderName = null, + string? ValueRef = null, + bool? InQueryString = null, + string? UsernameRef = null, + string? PasswordRef = null, + string? TokenUrl = null, + string? ClientIdRef = null, + string? ClientSecretRef = null, + string[]? Scopes = null, + string? AuthUrl = null, + string? ClientId = null, + string? ClientCertRef = null, + string? ClientKeyRef = null); + +public sealed record McpServerAddTlsOptions( + string? CaCertPath, + string? ClientCertPath, + string? ClientKeyPath); + +public sealed record McpServerAddResult( + bool Success, + McpServerDefinition? Server, + IReadOnlyList Errors, + McpServerProbeResult? Probe = null); + +public sealed class McpServerConfigService( + IMcpServerConfigReader reader, + IMcpServerConfigWriter writer, + McpConfigValidationService validator, + IMcpServerProbe probe) +{ + private static readonly string[] ValidSecretRefPrefixes = ["env:", "file:"]; + + public async Task AddAsync(McpServerAddRequest request, CancellationToken ct) + { + var errors = new List(); + + ValidateTimeouts(request, errors); + ValidateSecretRefs(request, errors); + ValidateAuthRequiredFields(request, errors); + + if (errors.Count > 0) + return new McpServerAddResult(false, null, errors); + + var readResult = await reader.ReadEditableAsync(ct); + if (!readResult.IsOk) + return new McpServerAddResult(false, null, [$"InvalidConfig: {readResult.Error.Message}"]); + + var existing = readResult.Value; + + var isDuplicate = existing.Any(s => + string.Equals(s.Name, request.Name, StringComparison.OrdinalIgnoreCase)); + + if (isDuplicate && !request.Replace) + return new McpServerAddResult(false, null, + [$"DuplicateServer: MCP server '{request.Name}' already exists. Use --replace to overwrite it."]); + + var newDef = MapToDefinition(request); + + List candidates; + if (request.Replace) + { + candidates = existing + .Where(s => !string.Equals(s.Name, request.Name, StringComparison.OrdinalIgnoreCase)) + .ToList(); + candidates.Add(newDef); + } + else + { + candidates = [.. existing, newDef]; + } + + var validationResult = validator.Validate(candidates); + if (!validationResult.IsOk) + { + var validationErrors = validationResult.Error + .Select(e => $"InvalidConfig: {e.ServerName} {e.Field}: {e.Message}") + .ToList(); + return new McpServerAddResult(false, null, validationErrors); + } + + if (request.DryRun && !request.ForceProbeInDryRun) + return new McpServerAddResult(true, newDef, []); + + var isRemote = newDef.Transport.Kind is + McpTransportKind.Http or McpTransportKind.Sse or McpTransportKind.HttpAutoDetect; + + if (!request.SkipProbe && isRemote) + { + var probeResult = await probe.ProbeAsync(newDef, ct); + if (probeResult.Status != McpServerProbeStatus.Reachable) + { + var msg = probeResult.Status switch + { + McpServerProbeStatus.AuthRequired => $"AuthRequired: {probeResult.Message}", + McpServerProbeStatus.InvalidConfig => $"InvalidConfig: {probeResult.Message}", + McpServerProbeStatus.Timeout => $"Timeout: {probeResult.Message}", + McpServerProbeStatus.ConnectionFailed => $"ConnectionFailed: {probeResult.Message}", + _ => $"ProbeFailed: {probeResult.Message}", + }; + return new McpServerAddResult(false, newDef, [msg], probeResult); + } + + if (request.DryRun) + return new McpServerAddResult(true, newDef, [], probeResult); + + var writeResult = await writer.WriteAsync(candidates, ct); + if (!writeResult.IsOk) + return new McpServerAddResult(false, null, [$"InvalidConfig: {writeResult.Error.Message}"], probeResult); + + return new McpServerAddResult(true, newDef, [], probeResult); + } + + var writeRes = await writer.WriteAsync(candidates, ct); + if (!writeRes.IsOk) + return new McpServerAddResult(false, null, [$"InvalidConfig: {writeRes.Error.Message}"]); + + return new McpServerAddResult(true, newDef, []); + } + + private static void ValidateTimeouts(McpServerAddRequest request, List errors) + { + if (request.ConnectTimeoutSeconds is not null and <= 0) + errors.Add("InvalidOption: --connect-timeout-seconds must be a positive integer."); + if (request.RequestTimeoutSeconds is not null and <= 0) + errors.Add("InvalidOption: --request-timeout-seconds must be a positive integer."); + } + + private static void ValidateSecretRefs(McpServerAddRequest request, List errors) + { + var a = request.Auth; + CheckRef(a.TokenRef, "--token-ref", errors); + CheckRef(a.ValueRef, "--value-ref", errors); + CheckRef(a.UsernameRef, "--username-ref", errors); + CheckRef(a.PasswordRef, "--password-ref", errors); + CheckRef(a.ClientIdRef, "--client-id-ref", errors); + CheckRef(a.ClientSecretRef, "--client-secret-ref", errors); + CheckRef(a.ClientCertRef, "--client-cert-ref", errors); + CheckRef(a.ClientKeyRef, "--client-key-ref", errors); + } + + private static void CheckRef(string? value, string optionName, List errors) + { + if (string.IsNullOrWhiteSpace(value)) return; + if (ValidSecretRefPrefixes.Any(p => value.StartsWith(p, StringComparison.OrdinalIgnoreCase))) return; + errors.Add($"InvalidSecretRef: {optionName} must use an explicit resolver prefix such as env: or file:."); + } + + private static void ValidateAuthRequiredFields(McpServerAddRequest request, List errors) + { + var a = request.Auth; + switch (request.AuthType.ToLowerInvariant()) + { + case "bearer": + if (string.IsNullOrWhiteSpace(a.TokenRef)) + errors.Add("MissingOption: --token-ref is required for bearer auth."); + break; + case "apikey": + if (string.IsNullOrWhiteSpace(a.HeaderName)) + errors.Add("MissingOption: --header-name is required for apiKey auth."); + if (string.IsNullOrWhiteSpace(a.ValueRef)) + errors.Add("MissingOption: --value-ref is required for apiKey auth."); + break; + case "basic": + if (string.IsNullOrWhiteSpace(a.UsernameRef)) + errors.Add("MissingOption: --username-ref is required for basic auth."); + if (string.IsNullOrWhiteSpace(a.PasswordRef)) + errors.Add("MissingOption: --password-ref is required for basic auth."); + break; + case "oauth2clientcredentials": + if (string.IsNullOrWhiteSpace(a.TokenUrl)) + errors.Add("MissingOption: --token-url is required for oauth2ClientCredentials auth."); + if (string.IsNullOrWhiteSpace(a.ClientIdRef)) + errors.Add("MissingOption: --client-id-ref is required for oauth2ClientCredentials auth."); + if (string.IsNullOrWhiteSpace(a.ClientSecretRef)) + errors.Add("MissingOption: --client-secret-ref is required for oauth2ClientCredentials auth."); + break; + case "oauth2devicecode": + if (string.IsNullOrWhiteSpace(a.AuthUrl)) + errors.Add("MissingOption: --auth-url is required for oauth2DeviceCode auth."); + if (string.IsNullOrWhiteSpace(a.TokenUrl)) + errors.Add("MissingOption: --token-url is required for oauth2DeviceCode auth."); + if (string.IsNullOrWhiteSpace(a.ClientId)) + errors.Add("MissingOption: --client-id is required for oauth2DeviceCode auth."); + break; + case "mtls": + if (string.IsNullOrWhiteSpace(a.ClientCertRef)) + errors.Add("MissingOption: --client-cert-ref is required for mtls auth."); + if (string.IsNullOrWhiteSpace(a.ClientKeyRef)) + errors.Add("MissingOption: --client-key-ref is required for mtls auth."); + break; + } + } + + internal static McpServerDefinition MapToDefinition(McpServerAddRequest request) + { + var transportKind = request.Transport.ToLowerInvariant() switch + { + "stdio" => McpTransportKind.Stdio, + "streamablehttp" => McpTransportKind.Http, + "sse" => McpTransportKind.Sse, + "http" or "httpautodetect" => McpTransportKind.HttpAutoDetect, + _ => McpTransportKind.Unknown, + }; + var transport = new McpTransportConfig(transportKind, request.Endpoint); + + McpAuthConfig auth = request.AuthType.ToLowerInvariant() switch + { + "none" => new NoneAuthConfig(), + "bearer" => new BearerAuthConfig(request.Auth.TokenRef ?? string.Empty), + "apikey" => new ApiKeyAuthConfig( + request.Auth.HeaderName ?? string.Empty, + request.Auth.ValueRef ?? string.Empty, + request.Auth.InQueryString ?? false), + "basic" => new BasicAuthConfig( + request.Auth.UsernameRef ?? string.Empty, + request.Auth.PasswordRef ?? string.Empty), + "oauth2clientcredentials" => new OAuth2ClientCredentialsConfig( + request.Auth.TokenUrl ?? string.Empty, + request.Auth.ClientIdRef ?? string.Empty, + request.Auth.ClientSecretRef ?? string.Empty, + request.Auth.Scopes), + "oauth2devicecode" => new OAuth2DeviceCodeConfig( + request.Auth.AuthUrl ?? string.Empty, + request.Auth.TokenUrl ?? string.Empty, + request.Auth.ClientId ?? string.Empty, + request.Auth.Scopes), + "mtls" => new MtlsConfig(request.Auth.ClientCertRef, request.Auth.ClientKeyRef), + "mcpoauth" => new McpOAuthConfig(request.Auth.ClientId, request.Auth.ClientSecretRef, request.Auth.Scopes), + _ => new UnknownAuthConfig(request.AuthType), + }; + + McpTlsConfig? tls = null; + if (request.Tls is { } t) + { + if (t.CaCertPath is not null || t.ClientCertPath is not null || t.ClientKeyPath is not null) + tls = new McpTlsConfig(t.CaCertPath, t.ClientCertPath, t.ClientKeyPath); + } + + var connectTimeout = request.ConnectTimeoutSeconds.HasValue + ? TimeSpan.FromSeconds(request.ConnectTimeoutSeconds.Value) + : (TimeSpan?)null; + var requestTimeout = request.RequestTimeoutSeconds.HasValue + ? TimeSpan.FromSeconds(request.RequestTimeoutSeconds.Value) + : (TimeSpan?)null; + + return new McpServerDefinition(request.Name, transport, auth, tls, connectTimeout, requestTimeout); + } +} diff --git a/src/Hypa.Runtime/Application/Services/McpServerImportService.cs b/src/Hypa.Runtime/Application/Services/McpServerImportService.cs new file mode 100644 index 0000000..e7ac1ec --- /dev/null +++ b/src/Hypa.Runtime/Application/Services/McpServerImportService.cs @@ -0,0 +1,263 @@ +using System.Security.Cryptography; +using System.Text; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Services; + +public sealed record McpImportRequest( + string? AgentKey, + McpImportScope Scope, + string? ProjectRoot, + bool Replace, + bool DryRun); + +public sealed record McpImportReport( + IReadOnlyList Sources, + int ImportedCount, + int AlreadyPresentCount, + int SkippedCount, + int ConflictCount); + +public sealed record McpImportSourceResult( + string Agent, + string Scope, + IReadOnlyList Connections); + +public sealed class McpServerImportService( + IEnumerable sources, + IMcpServerConfigReader reader, + IMcpServerConfigWriter writer, + McpConfigValidationService validator) : IMcpServerImportService +{ + public async Task> ImportAsync(McpImportRequest request, CancellationToken ct) + { + var selectedSources = sources + .Where(s => request.AgentKey is null || + string.Equals(s.AgentKey, request.AgentKey, StringComparison.OrdinalIgnoreCase)) + .Where(s => s.SupportsScope(request.Scope)) + .ToList(); + + if (selectedSources.Count == 0) + return Result.Ok(new McpImportReport([], 0, 0, 0, 0)); + + // Discover candidates from all selected sources (global before project for All scope). + var allDiscovery = new List<(McpImportSourceResult SourceResult, McpImportedConnection Conn)>(); + + McpImportScope[] scopeSequence = request.Scope == McpImportScope.All + ? [McpImportScope.Global, McpImportScope.Project] + : [request.Scope]; + + foreach (var src in selectedSources) + { + foreach (var scopeItem in scopeSequence) + { + var discovered = await src.DiscoverAsync( + new McpImportDiscoveryRequest(scopeItem, request.ProjectRoot), ct); + + var scopeLabel = scopeItem.ToString().ToLowerInvariant(); + var sourceResult = new McpImportSourceResult(src.AgentKey, scopeLabel, discovered); + + foreach (var conn in discovered) + allDiscovery.Add((sourceResult, conn)); + } + } + + // Load existing config once. + var readResult = await reader.ReadEditableAsync(ct); + if (!readResult.IsOk) + return Result.Fail(readResult.Error); + + var existing = readResult.Value; + + var existingByName = existing.ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase); + var seenFingerprints = new HashSet(existing.Select(ComputeFingerprint)); + var acceptedImportNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + var toImport = new List(); + var sourceResultConnections = new Dictionary>(); + int importedCount = 0, alreadyPresentCount = 0, skippedCount = 0, conflictCount = 0; + + foreach (var (sourceResult, conn) in allDiscovery) + { + if (!sourceResultConnections.ContainsKey(sourceResult)) + sourceResultConnections[sourceResult] = []; + + if (conn.Status != McpImportCandidateStatus.Importable || conn.Server is null) + { + sourceResultConnections[sourceResult].Add(conn); + skippedCount++; + continue; + } + + var fp = conn.Fingerprint; + + // Check if name exists in existing config + if (existingByName.TryGetValue(conn.SourceName, out var existingServer)) + { + var existingFp = ComputeFingerprint(existingServer); + if (string.Equals(existingFp, fp, StringComparison.Ordinal)) + { + sourceResultConnections[sourceResult].Add(conn with + { + Status = McpImportCandidateStatus.SkippedDuplicate, + Detail = "already present", + }); + alreadyPresentCount++; + continue; + } + + if (!request.Replace) + { + sourceResultConnections[sourceResult].Add(conn with + { + Status = McpImportCandidateStatus.SkippedConflict, + Detail = "conflict — different configuration already exists", + }); + conflictCount++; + continue; + } + } + + // Check if name was already accepted from a different source in this batch + if (acceptedImportNames.Contains(conn.SourceName)) + { + sourceResultConnections[sourceResult].Add(conn with + { + Status = McpImportCandidateStatus.SkippedConflict, + Detail = "conflict — same name already accepted from another source", + }); + conflictCount++; + continue; + } + + // Track fingerprint for cross-source duplicate detection. + bool isDuplicateConnection = !seenFingerprints.Add(fp); + var importedConn = isDuplicateConnection + ? conn with + { + Status = McpImportCandidateStatus.SkippedDuplicate, + Detail = $"duplicate connection{(conn.Detail is not null ? $"; {conn.Detail}" : string.Empty)}" + } + : conn; + + sourceResultConnections[sourceResult].Add(importedConn); + if (isDuplicateConnection) + { + continue; + } + + toImport.Add(conn.Server); + acceptedImportNames.Add(conn.SourceName); + importedCount++; + } + + if (toImport.Count > 0 && !request.DryRun) + { + List merged; + if (request.Replace) + { + var replacedNames = new HashSet( + toImport.Select(s => s.Name), StringComparer.OrdinalIgnoreCase); + merged = [.. existing.Where(s => !replacedNames.Contains(s.Name)), .. toImport]; + } + else + { + merged = [.. existing, .. toImport]; + } + + // Validate and propagate errors. + var validationResult = validator.Validate(merged); + if (!validationResult.IsOk) + { + var errorMsg = string.Join("; ", validationResult.Error.Select(e => $"{e.ServerName}.{e.Field}: {e.Message}")); + return Result.Fail(new Error("ValidationFailed", errorMsg)); + } + + var writeResult = await writer.WriteAsync(merged, ct); + if (!writeResult.IsOk) + return Result.Fail(writeResult.Error); + } + + var sourceResults = sourceResultConnections + .Select(kv => kv.Key with { Connections = kv.Value }) + .ToList(); + + var report = new McpImportReport(sourceResults, importedCount, alreadyPresentCount, skippedCount, conflictCount); + return Result.Ok(report); + } + + public static string ComputeFingerprint(McpServerDefinition def) + { + var sb = new StringBuilder(); + sb.Append("v1|"); + sb.Append(def.Transport.Kind.ToString().ToLowerInvariant()); + sb.Append('|'); + sb.Append((def.Transport.Endpoint ?? string.Empty).Trim().ToLowerInvariant()); + sb.Append('|'); + AppendAuthMetadata(sb, def.Auth); + sb.Append('|'); + if (def.Tls is { } tls) + { + sb.Append(tls.CaCertPath ?? string.Empty); + sb.Append('|'); + sb.Append(tls.ClientCertPath ?? string.Empty); + sb.Append('|'); + sb.Append(tls.ClientKeyPath ?? string.Empty); + } + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static void AppendAuthMetadata(StringBuilder sb, McpAuthConfig auth) + { + switch (auth) + { + case NoneAuthConfig: + sb.Append("none"); + break; + case BearerAuthConfig: + // TokenRef is a secret ref — excluded from fingerprint. + sb.Append("bearer"); + break; + case ApiKeyAuthConfig ak: + sb.Append("apikey|"); + sb.Append(ak.HeaderName.ToLowerInvariant()); + sb.Append('|'); + sb.Append(ak.InQueryString ? "query" : "header"); + // ValueRef is a secret ref — excluded. + break; + case BasicAuthConfig: + // UsernameRef and PasswordRef are secret refs — excluded. + sb.Append("basic"); + break; + case OAuth2ClientCredentialsConfig oc: + // ClientIdRef and ClientSecretRef are secret refs — excluded. + sb.Append("oauth2clientcredentials|"); + sb.Append(oc.TokenUrl.ToLowerInvariant()); + sb.Append('|'); + sb.Append(string.Join(",", (oc.Scopes ?? []).Select(s => s.ToLowerInvariant()))); + break; + case OAuth2DeviceCodeConfig od: + // ClientId is a public identifier (non-secret). + sb.Append("oauth2devicecode|"); + sb.Append(od.AuthUrl.ToLowerInvariant()); + sb.Append('|'); + sb.Append(od.TokenUrl.ToLowerInvariant()); + sb.Append('|'); + sb.Append(od.ClientId.ToLowerInvariant()); + sb.Append('|'); + sb.Append(string.Join(",", (od.Scopes ?? []).Select(s => s.ToLowerInvariant()))); + break; + case MtlsConfig: + // ClientCertRef and ClientKeyRef are secret refs — excluded. + sb.Append("mtls"); + break; + default: + sb.Append("unknown"); + break; + } + } +} diff --git a/src/Hypa.Runtime/Application/Services/McpToolSearchIndex.cs b/src/Hypa.Runtime/Application/Services/McpToolSearchIndex.cs new file mode 100644 index 0000000..ed7fc7d --- /dev/null +++ b/src/Hypa.Runtime/Application/Services/McpToolSearchIndex.cs @@ -0,0 +1,84 @@ +using Hypa.Runtime.Domain.Mcp; + +namespace Hypa.Runtime.Application.Services; + +public sealed class McpToolSearchIndex +{ + public IReadOnlyList Search(McpSchemaManifest manifest, string query) + { + if (string.IsNullOrWhiteSpace(query)) + return []; + + var queryTokens = Tokenise(query); + if (queryTokens.Count == 0) + return []; + + var results = new List<(McpToolSearchResult Result, double Score)>(); + + foreach (var server in manifest.Servers) + { + foreach (var tool in server.Tools) + { + var score = ScoreTool(tool, server.ServerName, queryTokens, query); + if (score > 0.0) + results.Add((new McpToolSearchResult(server.ServerName, tool.Name, tool.Description, score), score)); + } + } + + return results + .OrderByDescending(x => x.Score) + .Select(x => x.Result with { Score = x.Score }) + .ToList(); + } + + private static double ScoreTool( + McpToolSchema tool, + string serverName, + HashSet queryTokens, + string rawQuery) + { + var toolText = $"{serverName} {tool.Name} {tool.Description} {tool.InputSchema.RawJson}"; + var toolTokens = Tokenise(toolText); + + if (toolTokens.Count == 0) + return 0.0; + + var overlap = queryTokens.Count(t => toolTokens.Contains(t)); + var union = queryTokens.Count + toolTokens.Count - overlap; + var jaccard = union > 0 ? (double)overlap / union : 0.0; + + var nameBonus = 0.0; + var lowerName = tool.Name.ToLowerInvariant(); + var lowerQuery = rawQuery.Trim().ToLowerInvariant(); + + if (string.Equals(lowerName, lowerQuery, StringComparison.Ordinal)) + nameBonus = 0.6; + else if (queryTokens.All(t => Tokenise(tool.Name).Contains(t))) + nameBonus = 0.4; + + return jaccard + nameBonus; + } + + private static HashSet Tokenise(string text) + { + var tokens = new HashSet(StringComparer.OrdinalIgnoreCase); + var span = text.AsSpan(); + var start = -1; + + for (var i = 0; i <= span.Length; i++) + { + var isAlNum = i < span.Length && char.IsLetterOrDigit(span[i]); + if (isAlNum && start < 0) + { + start = i; + } + else if (!isAlNum && start >= 0) + { + tokens.Add(span[start..i].ToString().ToLowerInvariant()); + start = -1; + } + } + + return tokens; + } +} diff --git a/src/Hypa.Runtime/Domain/Mcp/CompressionHint.cs b/src/Hypa.Runtime/Domain/Mcp/CompressionHint.cs new file mode 100644 index 0000000..a4b1aea --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/CompressionHint.cs @@ -0,0 +1,8 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public enum CompressionHint +{ + Raw, + Summary, + Structured, +} diff --git a/src/Hypa.Runtime/Domain/Mcp/JsonPayload.cs b/src/Hypa.Runtime/Domain/Mcp/JsonPayload.cs new file mode 100644 index 0000000..485644f --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/JsonPayload.cs @@ -0,0 +1,3 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public sealed record JsonPayload(string RawJson); diff --git a/src/Hypa.Runtime/Domain/Mcp/McpAuthConfig.cs b/src/Hypa.Runtime/Domain/Mcp/McpAuthConfig.cs new file mode 100644 index 0000000..bbf4c72 --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpAuthConfig.cs @@ -0,0 +1,39 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public abstract record McpAuthConfig; + +public sealed record NoneAuthConfig() : McpAuthConfig; + +public sealed record BearerAuthConfig(string TokenRef) : McpAuthConfig; + +public sealed record ApiKeyAuthConfig( + string HeaderName, + string ValueRef, + bool InQueryString = false) : McpAuthConfig; + +public sealed record BasicAuthConfig( + string UsernameRef, + string PasswordRef) : McpAuthConfig; + +public sealed record OAuth2ClientCredentialsConfig( + string TokenUrl, + string ClientIdRef, + string ClientSecretRef, + string[]? Scopes = null) : McpAuthConfig; + +public sealed record OAuth2DeviceCodeConfig( + string AuthUrl, + string TokenUrl, + string ClientId, + string[]? Scopes = null) : McpAuthConfig; + +public sealed record MtlsConfig( + string? ClientCertRef, + string? ClientKeyRef) : McpAuthConfig; + +public sealed record McpOAuthConfig( + string? ClientId = null, + string? ClientSecretRef = null, + string[]? Scopes = null) : McpAuthConfig; + +public sealed record UnknownAuthConfig(string Type) : McpAuthConfig; diff --git a/src/Hypa.Runtime/Domain/Mcp/McpAuthContext.cs b/src/Hypa.Runtime/Domain/Mcp/McpAuthContext.cs new file mode 100644 index 0000000..c1af650 --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpAuthContext.cs @@ -0,0 +1,10 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public sealed record McpAuthContext( + IReadOnlyDictionary Headers, + IReadOnlyDictionary? QueryParameters = null, + string? BearerToken = null, + string? Username = null, + string? Password = null, + string? ClientCertificatePath = null, + string? ClientKeyPath = null); diff --git a/src/Hypa.Runtime/Domain/Mcp/McpAuthGuidance.cs b/src/Hypa.Runtime/Domain/Mcp/McpAuthGuidance.cs new file mode 100644 index 0000000..bb535e4 --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpAuthGuidance.cs @@ -0,0 +1,9 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public sealed record McpAuthGuidance( + string? SuggestedAuthMode, + string? AuthorizationUrl, + string? TokenUrl, + string? ClientId, + IReadOnlyList? Scopes, + IReadOnlyList? NextCommands); diff --git a/src/Hypa.Runtime/Domain/Mcp/McpAuthMode.cs b/src/Hypa.Runtime/Domain/Mcp/McpAuthMode.cs new file mode 100644 index 0000000..cc8ea3b --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpAuthMode.cs @@ -0,0 +1,13 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public enum McpAuthMode +{ + None, + Bearer, + ApiKey, + Basic, + OAuth2ClientCredentials, + OAuth2DeviceCode, + Mtls, + McpOAuth, +} diff --git a/src/Hypa.Runtime/Domain/Mcp/McpErrorCodes.cs b/src/Hypa.Runtime/Domain/Mcp/McpErrorCodes.cs new file mode 100644 index 0000000..0e78a8e --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpErrorCodes.cs @@ -0,0 +1,14 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public static class McpErrorCodes +{ + public const string UnknownServer = "UnknownServer"; + public const string InvalidRequest = "InvalidRequest"; + public const string ServerUnavailable = "ServerUnavailable"; + public const string ConnectionFailed = "ConnectionFailed"; + public const string ToolInvocationFailed = "ToolInvocationFailed"; + public const string RemoteToolError = "RemoteToolError"; + public const string Timeout = "Timeout"; + public const string SchemaUnavailable = "SchemaUnavailable"; + public const string AuthRequired = "AuthRequired"; +} diff --git a/src/Hypa.Runtime/Domain/Mcp/McpLatencyMetadata.cs b/src/Hypa.Runtime/Domain/Mcp/McpLatencyMetadata.cs new file mode 100644 index 0000000..a73050b --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpLatencyMetadata.cs @@ -0,0 +1,3 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public sealed record McpLatencyMetadata(DateTimeOffset StartedAt, TimeSpan Elapsed); diff --git a/src/Hypa.Runtime/Domain/Mcp/McpProxyError.cs b/src/Hypa.Runtime/Domain/Mcp/McpProxyError.cs new file mode 100644 index 0000000..d102ed6 --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpProxyError.cs @@ -0,0 +1,7 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public sealed record McpProxyError( + string Code, + string Message, + string? ServerName = null, + string? ToolName = null); diff --git a/src/Hypa.Runtime/Domain/Mcp/McpProxyRequest.cs b/src/Hypa.Runtime/Domain/Mcp/McpProxyRequest.cs new file mode 100644 index 0000000..ef328e8 --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpProxyRequest.cs @@ -0,0 +1,7 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public sealed record McpProxyRequest( + string ServerName, + string ToolName, + JsonPayload Arguments, + CompressionHint? CompressionHint = null); diff --git a/src/Hypa.Runtime/Domain/Mcp/McpResult.cs b/src/Hypa.Runtime/Domain/Mcp/McpResult.cs new file mode 100644 index 0000000..1cfba13 --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpResult.cs @@ -0,0 +1,10 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public sealed record McpResult( + string ServerName, + string ToolName, + JsonPayload RawResponse, + string CompressedResponse, + McpLatencyMetadata Latency, + bool IsError, + McpProxyError? Error); diff --git a/src/Hypa.Runtime/Domain/Mcp/McpSchemaManifest.cs b/src/Hypa.Runtime/Domain/Mcp/McpSchemaManifest.cs new file mode 100644 index 0000000..f895d18 --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpSchemaManifest.cs @@ -0,0 +1,11 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public sealed record McpSchemaManifest( + IReadOnlyList Servers, + IReadOnlyList? Errors = null); + +public sealed record McpServerSchema(string ServerName, IReadOnlyList Tools); + +public sealed record McpToolSchema(string Name, string Description, JsonPayload InputSchema); + +public sealed record McpSchemaError(string ServerName, string Code, string Message); diff --git a/src/Hypa.Runtime/Domain/Mcp/McpServerDefinition.cs b/src/Hypa.Runtime/Domain/Mcp/McpServerDefinition.cs new file mode 100644 index 0000000..fe1007a --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpServerDefinition.cs @@ -0,0 +1,9 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public sealed record McpServerDefinition( + string Name, + McpTransportConfig Transport, + McpAuthConfig Auth, + McpTlsConfig? Tls = null, + TimeSpan? ConnectTimeout = null, + TimeSpan? RequestTimeout = null); diff --git a/src/Hypa.Runtime/Domain/Mcp/McpServerProbeResult.cs b/src/Hypa.Runtime/Domain/Mcp/McpServerProbeResult.cs new file mode 100644 index 0000000..85b25d0 --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpServerProbeResult.cs @@ -0,0 +1,6 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public sealed record McpServerProbeResult( + McpServerProbeStatus Status, + string Message, + McpAuthGuidance? AuthGuidance = null); diff --git a/src/Hypa.Runtime/Domain/Mcp/McpServerProbeStatus.cs b/src/Hypa.Runtime/Domain/Mcp/McpServerProbeStatus.cs new file mode 100644 index 0000000..fa61118 --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpServerProbeStatus.cs @@ -0,0 +1,11 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public enum McpServerProbeStatus +{ + Reachable = 0, + AuthRequired = 1, + InvalidConfig = 2, + Timeout = 3, + ConnectionFailed = 4, + Unknown = 5, +} diff --git a/src/Hypa.Runtime/Domain/Mcp/McpTlsConfig.cs b/src/Hypa.Runtime/Domain/Mcp/McpTlsConfig.cs new file mode 100644 index 0000000..3d536f7 --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpTlsConfig.cs @@ -0,0 +1,6 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public sealed record McpTlsConfig( + string? CaCertPath, + string? ClientCertPath, + string? ClientKeyPath); diff --git a/src/Hypa.Runtime/Domain/Mcp/McpToolSearchResult.cs b/src/Hypa.Runtime/Domain/Mcp/McpToolSearchResult.cs new file mode 100644 index 0000000..48b109d --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpToolSearchResult.cs @@ -0,0 +1,7 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public sealed record McpToolSearchResult( + string ServerName, + string ToolName, + string Description, + double Score); diff --git a/src/Hypa.Runtime/Domain/Mcp/McpTransportConfig.cs b/src/Hypa.Runtime/Domain/Mcp/McpTransportConfig.cs new file mode 100644 index 0000000..1c595c8 --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpTransportConfig.cs @@ -0,0 +1,3 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public sealed record McpTransportConfig(McpTransportKind Kind, string? Endpoint); diff --git a/src/Hypa.Runtime/Domain/Mcp/McpTransportKind.cs b/src/Hypa.Runtime/Domain/Mcp/McpTransportKind.cs new file mode 100644 index 0000000..bdac6c1 --- /dev/null +++ b/src/Hypa.Runtime/Domain/Mcp/McpTransportKind.cs @@ -0,0 +1,10 @@ +namespace Hypa.Runtime.Domain.Mcp; + +public enum McpTransportKind +{ + Stdio, + Http, + Sse, + HttpAutoDetect, + Unknown, +} diff --git a/src/Hypa.Sdk/CodeIntelligence/CodeIntelligenceModels.cs b/src/Hypa.Sdk/CodeIntelligence/CodeIntelligenceModels.cs index 8e8d59c..5fbf562 100644 --- a/src/Hypa.Sdk/CodeIntelligence/CodeIntelligenceModels.cs +++ b/src/Hypa.Sdk/CodeIntelligence/CodeIntelligenceModels.cs @@ -9,6 +9,16 @@ public sealed record CodeFileIdentity public required string ContentHash { get; init; } public long SizeBytes { get; init; } public DateTimeOffset IndexedAt { get; init; } = DateTimeOffset.UtcNow; + public string? GitBlobOid { get; init; } + public long MTimeMs { get; init; } +} + +public sealed record FileIndexState +{ + public required string AbsolutePath { get; init; } + public string? GitBlobOid { get; init; } + public required long MTimeMs { get; init; } + public required long SizeBytes { get; init; } } public sealed record CodeStructureDocument @@ -19,6 +29,9 @@ public sealed record CodeStructureDocument public IReadOnlyList References { get; init; } = []; public IReadOnlyList DependencyEdges { get; init; } = []; public IReadOnlyList Diagnostics { get; init; } = []; + public IReadOnlyList Sections { get; init; } = []; + public string? FrontmatterYaml { get; init; } + public string? PlainText { get; init; } } public sealed record CodeSymbol @@ -97,6 +110,7 @@ public sealed record CodeIndexResult { public int FilesIndexed { get; init; } public int FilesSkipped { get; init; } + public int FilesDeleted { get; init; } public int SymbolCount { get; init; } public int ReferenceCount { get; init; } public int EdgeCount { get; init; } @@ -130,3 +144,20 @@ public sealed record CodeGraphResult public IReadOnlyList Edges { get; init; } = []; public IReadOnlyList References { get; init; } = []; } + +public sealed record MarkdownSection +{ + public required string Id { get; init; } + public required string FilePath { get; init; } + public required string HeadingText { get; init; } + public required int HeadingLevel { get; init; } + public required string HeadingPath { get; init; } + public required string HeadingAnchor { get; init; } + public required int StartLine { get; init; } + public required int EndLine { get; init; } + public required int StartByte { get; init; } + public required int EndByte { get; init; } + public string? Text { get; init; } + public string? PlainText { get; init; } + public required ProviderProvenance Provenance { get; init; } +} diff --git a/tests/Hypa.GoldenTests/Fixtures/doctor_output/stdout.verified.txt b/tests/Hypa.GoldenTests/Fixtures/doctor_output/stdout.verified.txt index 84d8915..91c8fd2 100644 --- a/tests/Hypa.GoldenTests/Fixtures/doctor_output/stdout.verified.txt +++ b/tests/Hypa.GoldenTests/Fixtures/doctor_output/stdout.verified.txt @@ -9,6 +9,7 @@ Run: `hypa init --global --agent claude`; `hypa init --agent copilot-vscode`; `hypa init --agent copilot-cli`; `hypa init --global --agent codex` [warn] MCP server settings.json not found Run: `hypa init --global` to install +[ ok] OAuth token permissions not present [ ok] Codex storage /.hypa [ ok] Codex install not configured [ ok] Update diff --git a/tests/Hypa.GoldenTests/Fixtures/markdown/nested-headings/input.md b/tests/Hypa.GoldenTests/Fixtures/markdown/nested-headings/input.md new file mode 100644 index 0000000..2557588 --- /dev/null +++ b/tests/Hypa.GoldenTests/Fixtures/markdown/nested-headings/input.md @@ -0,0 +1,19 @@ +--- +title: API Guide +--- + +# API Basics + +Introduction content. + +## Authentication + +Auth content. + +### Token Auth + +Token details. + +## Rate Limiting + +Rate limit content. diff --git a/tests/Hypa.GoldenTests/Fixtures/markdown/nested-headings/meta.json b/tests/Hypa.GoldenTests/Fixtures/markdown/nested-headings/meta.json new file mode 100644 index 0000000..e1edf89 --- /dev/null +++ b/tests/Hypa.GoldenTests/Fixtures/markdown/nested-headings/meta.json @@ -0,0 +1 @@ +{ "description": "Nested headings with frontmatter" } diff --git a/tests/Hypa.GoldenTests/Fixtures/markdown/nested-headings/nested-headings.verified.txt b/tests/Hypa.GoldenTests/Fixtures/markdown/nested-headings/nested-headings.verified.txt new file mode 100644 index 0000000..695741b --- /dev/null +++ b/tests/Hypa.GoldenTests/Fixtures/markdown/nested-headings/nested-headings.verified.txt @@ -0,0 +1,16 @@ +Frontmatter: +title: API Guide + +Sections: +- L1 API Basics + Path: API Basics + Anchor: api-basics +- L2 Authentication + Path: API Basics/Authentication + Anchor: authentication +- L3 Token Auth + Path: API Basics/Authentication/Token Auth + Anchor: token-auth +- L2 Rate Limiting + Path: API Basics/Rate Limiting + Anchor: rate-limiting diff --git a/tests/Hypa.GoldenTests/Fixtures/markdown/special-chars/input.md b/tests/Hypa.GoldenTests/Fixtures/markdown/special-chars/input.md new file mode 100644 index 0000000..e41cc1c --- /dev/null +++ b/tests/Hypa.GoldenTests/Fixtures/markdown/special-chars/input.md @@ -0,0 +1,7 @@ +# API / REST Basics + +Content with **bold**, `inline code`, and a [link](https://example.com). + +## Setup & Configuration + +Setup content. diff --git a/tests/Hypa.GoldenTests/Fixtures/markdown/special-chars/meta.json b/tests/Hypa.GoldenTests/Fixtures/markdown/special-chars/meta.json new file mode 100644 index 0000000..57772fb --- /dev/null +++ b/tests/Hypa.GoldenTests/Fixtures/markdown/special-chars/meta.json @@ -0,0 +1 @@ +{ "description": "Headings with special characters and inline formatting" } diff --git a/tests/Hypa.GoldenTests/Fixtures/markdown/special-chars/special-chars.verified.txt b/tests/Hypa.GoldenTests/Fixtures/markdown/special-chars/special-chars.verified.txt new file mode 100644 index 0000000..12901e4 --- /dev/null +++ b/tests/Hypa.GoldenTests/Fixtures/markdown/special-chars/special-chars.verified.txt @@ -0,0 +1,10 @@ +Frontmatter: + + +Sections: +- L1 API / REST Basics + Path: API / REST Basics + Anchor: api--rest-basics +- L2 Setup & Configuration + Path: API / REST Basics/Setup & Configuration + Anchor: setup--configuration diff --git a/tests/Hypa.GoldenTests/MarkdownGoldenTests.cs b/tests/Hypa.GoldenTests/MarkdownGoldenTests.cs new file mode 100644 index 0000000..dd3e273 --- /dev/null +++ b/tests/Hypa.GoldenTests/MarkdownGoldenTests.cs @@ -0,0 +1,71 @@ +using System.Reflection; +using System.Text; +using Hypa.Infrastructure.CodeIntelligence; +using Hypa.Sdk.CodeIntelligence; +using Xunit; + +namespace Hypa.GoldenTests; + +public sealed class MarkdownGoldenTests +{ + private static readonly string FixturesPath = Path.Combine( + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, + "Fixtures", "markdown"); + + [Theory] + [InlineData("nested-headings")] + [InlineData("special-chars")] + public async Task ExtractMarkdown(string fixtureName) + { + var dir = Path.Combine(FixturesPath, fixtureName); + var content = await File.ReadAllTextAsync(Path.Combine(dir, "input.md")); + var meta = await File.ReadAllTextAsync(Path.Combine(dir, "meta.json")); + Assert.False(string.IsNullOrWhiteSpace(meta)); + + var identity = MakeFile($"{fixtureName}.md"); + var result = CodePatternExtractor.ExtractMarkdown(identity, content, MakeProvenance()); + + await Verify(Normalize(Render(result))) + .UseDirectory(dir) + .UseFileName(fixtureName); + } + + private static string Render(CodeStructureDocument document) + { + var builder = new StringBuilder(); + builder.AppendLine("Frontmatter:"); + builder.AppendLine(document.FrontmatterYaml is null ? "" : document.FrontmatterYaml.Trim()); + builder.AppendLine(); + builder.AppendLine("Sections:"); + foreach (var section in document.Sections) + { + builder.AppendLine($"- L{section.HeadingLevel} {section.HeadingText}"); + builder.AppendLine($" Path: {section.HeadingPath}"); + builder.AppendLine($" Anchor: {section.HeadingAnchor}"); + } + + return builder.ToString(); + } + + private static string Normalize(string text) => + text.Replace("\r\n", "\n").Replace("\r", "\n").TrimEnd(); + + private static CodeFileIdentity MakeFile(string relativePath) => new() + { + ProjectRoot = "/project", + Path = $"/project/{relativePath}", + RelativePath = relativePath, + Language = "markdown", + ContentHash = "hash", + SizeBytes = 0, + }; + + private static ProviderProvenance MakeProvenance() => new() + { + ProviderId = "markdown", + ProviderVersion = "1", + QueryVersion = "1", + FactKind = "syntactic", + Confidence = 1, + }; +} diff --git a/tests/Hypa.IntegrationTests/McpProxyIntegrationTests.cs b/tests/Hypa.IntegrationTests/McpProxyIntegrationTests.cs new file mode 100644 index 0000000..d392042 --- /dev/null +++ b/tests/Hypa.IntegrationTests/McpProxyIntegrationTests.cs @@ -0,0 +1,401 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Xunit; + +namespace Hypa.IntegrationTests; + +/// +/// End-to-end tests for the MCP proxy layer (hypa_mcp tool). +/// +/// Two-tier setup: +/// Outer — `hypa serve` started with HYPA_storage_path set to a temp directory. +/// Upstream — a second `hypa serve` process registered in the temp mcp-servers.json. +/// +/// The env var override is applied via ProcessStartInfo.Environment so it never +/// touches the real user config at ~/.hypa. +/// +[Trait("Category", "Integration")] +public sealed class McpProxyIntegrationTests : IAsyncLifetime +{ + private string _cliBinary = ""; + private string _tempDataDir = ""; + + public async Task InitializeAsync() + { + var repoRoot = IntegrationTestHelpers.FindRepoRoot(); + _cliBinary = Path.Combine(repoRoot, "src", "Hypa.Cli", "bin", "Debug", "net10.0", "hypa.dll"); + Assert.True(File.Exists(_cliBinary), $"CLI binary not found at: {_cliBinary}"); + + _tempDataDir = Path.Combine(Path.GetTempPath(), $"hypa-proxy-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDataDir); + + // Register a child `hypa serve` process as the upstream MCP server. + // Note: no quotes around _cliBinary — ShellLexer preserves quotes as literal chars, + // which would make dotnet receive `"/path"` (with quotes) as the argument. + var mcpServersJson = $$""" + { + "servers": [ + { + "name": "upstream", + "transport": "stdio", + "endpoint": "dotnet {{_cliBinary}} serve" + } + ] + } + """; + await File.WriteAllTextAsync( + Path.Combine(_tempDataDir, "mcp-servers.json"), + mcpServersJson); + } + + public Task DisposeAsync() + { + if (Directory.Exists(_tempDataDir)) + Directory.Delete(_tempDataDir, recursive: true); + return Task.CompletedTask; + } + + // ------------------------------------------------------------------------- + // Guard test — SDK-first: no custom MCP frame parsing anywhere in the Mcp layer + // ------------------------------------------------------------------------- + + [Fact] + public void McpInfrastructureLayer_ContainsNoCustomMcpFrameParsing() + { + var repoRoot = IntegrationTestHelpers.FindRepoRoot(); + var mcpDir = Path.Combine(repoRoot, "src", "Hypa.Infrastructure", "Mcp"); + + Assert.True(Directory.Exists(mcpDir), $"Mcp infrastructure layer not found: {mcpDir}"); + + // All Mcp infrastructure files must delegate upstream protocol fully to the SDK. + // Strings that indicate home-grown JSON-RPC or transport framing are forbidden + // regardless of which subdirectory (Connection, Auth, Tools, etc.) they appear in. + var forbidden = new[] + { + "ReadLineAsync", "StreamReader", "StreamWriter", + "Content-Length", "Content-Type", + }; + + foreach (var filePath in Directory.EnumerateFiles(mcpDir, "*.cs", SearchOption.AllDirectories)) + { + var source = File.ReadAllText(filePath); + var relPath = Path.GetRelativePath(mcpDir, filePath); + foreach (var token in forbidden) + Assert.False(source.Contains(token, StringComparison.Ordinal), + $"{relPath} contains forbidden token '{token}' — use the SDK client facade instead."); + + Assert.False(source.Contains("jsonrpc", StringComparison.OrdinalIgnoreCase), + $"{relPath} contains 'jsonrpc' — wire protocol must be handled by the SDK."); + } + } + + // ------------------------------------------------------------------------- + // Schema with no upstream configured (SDK client path, no env var needed) + // ------------------------------------------------------------------------- + + [Fact] + public async Task Schema_NoUpstreamServers_ReturnsNoServersConfigured() + { + var emptyDir = Path.Combine(Path.GetTempPath(), $"hypa-empty-{Guid.NewGuid():N}"); + Directory.CreateDirectory(emptyDir); + try + { + var text = await CallHypaMcpAsync( + emptyDir, + new Dictionary { ["action"] = "schema" }, + timeoutSeconds: 30); + + Assert.Contains("No MCP servers configured", text); + } + finally + { + Directory.Delete(emptyDir, recursive: true); + } + } + + // ------------------------------------------------------------------------- + // Proxy schema discovery — outer hypa_mcp proxies to upstream hypa serve + // ------------------------------------------------------------------------- + + [Fact] + public async Task Schema_WithUpstreamHypaServer_ListsUpstreamTools() + { + var ct = new CancellationTokenSource(TimeSpan.FromSeconds(60)).Token; + var text = await CallHypaMcpAsync( + _tempDataDir, + new Dictionary { ["action"] = "schema" }, + timeoutSeconds: 60); + + Assert.Contains("SCHEMA", text); + Assert.Contains("upstream", text); + Assert.Contains("hypa_shell", text); + } + + // ------------------------------------------------------------------------- + // Proxy invoke round-trip + // ------------------------------------------------------------------------- + + [Fact] + public async Task Invoke_WithUpstreamHypaServer_HypaShell_ReturnsOutput() + { + var text = await CallHypaMcpAsync( + _tempDataDir, + new Dictionary + { + ["action"] = "invoke", + ["server"] = "upstream", + ["tool"] = "hypa_shell", + ["arguments"] = """{"command":"echo proxy-round-trip"}""", + }, + timeoutSeconds: 60); + + Assert.Contains("proxy-round-trip", text); + } + + // ------------------------------------------------------------------------- + // Batch with ordered results + // ------------------------------------------------------------------------- + + [Fact] + public async Task Batch_WithUpstreamHypaServer_OrderPreserved() + { + var batchJson = """[{"server":"upstream","tool":"hypa_shell","arguments":"{\"command\":\"echo first\"}"},{"server":"upstream","tool":"hypa_shell","arguments":"{\"command\":\"echo second\"}"},{"server":"upstream","tool":"hypa_shell","arguments":"{\"command\":\"echo third\"}"}]"""; + + var text = await CallHypaMcpAsync( + _tempDataDir, + new Dictionary { ["action"] = "batch", ["requests"] = batchJson }, + timeoutSeconds: 60); + + Assert.Contains("RESULTS", text); + Assert.True(text.IndexOf("[0]", StringComparison.Ordinal) < text.IndexOf("[1]", StringComparison.Ordinal)); + Assert.True(text.IndexOf("[1]", StringComparison.Ordinal) < text.IndexOf("[2]", StringComparison.Ordinal)); + } + + [Fact] + public async Task Batch_PartialFailure_SuccessItemsUnaffected() + { + var batchJson = """[{"server":"upstream","tool":"hypa_shell","arguments":"{\"command\":\"echo ok\"}"},{"server":"no-such-server","tool":"some_tool"}]"""; + + var text = await CallHypaMcpAsync( + _tempDataDir, + new Dictionary { ["action"] = "batch", ["requests"] = batchJson }, + timeoutSeconds: 60); + + Assert.Contains("[0]", text); + Assert.Contains("OK", text); + Assert.Contains("[1]", text); + Assert.Contains("ERROR", text); + } + + // ------------------------------------------------------------------------- + // Unknown server returns structured error + // ------------------------------------------------------------------------- + + [Fact] + public async Task Invoke_UnknownServer_ReturnsUnknownServerError() + { + var text = await CallHypaMcpAsync( + _tempDataDir, + new Dictionary + { + ["action"] = "invoke", + ["server"] = "does-not-exist", + ["tool"] = "echo", + }, + timeoutSeconds: 30); + + Assert.Contains("UnknownServer", text); + } + + // ------------------------------------------------------------------------- + // Remote isError propagation — SDK CallToolAsync isError must surface + // ------------------------------------------------------------------------- + + [Fact] + public async Task Invoke_RemoteToolReturnsIsError_SurfacesRemoteToolError() + { + // Invoke hypa_mcp on the upstream with an unknown action; the upstream + // returns IsError=true. The outer proxy must surface this as RemoteToolError, + // not swallow or misclassify it. + var text = await CallHypaMcpAsync( + _tempDataDir, + new Dictionary + { + ["action"] = "invoke", + ["server"] = "upstream", + ["tool"] = "hypa_mcp", + ["arguments"] = """{"action":"__invalid_action__"}""", + }, + timeoutSeconds: 60); + + Assert.True( + text.Contains("RemoteToolError", StringComparison.Ordinal), + $"Expected RemoteToolError in: {text}"); + } + + // ------------------------------------------------------------------------- + // Cancellation — pre-cancelled token aborts without starting a process + // ------------------------------------------------------------------------- + + [Fact] + public async Task Invoke_PreCancelledToken_ThrowsOperationCancelled() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => + CallHypaMcpAsync( + _tempDataDir, + new Dictionary + { + ["action"] = "invoke", + ["server"] = "upstream", + ["tool"] = "hypa_shell", + ["arguments"] = """{"command":"echo cancel-test"}""", + }, + timeoutSeconds: 30, + externalCt: cts.Token)); + } + + // ------------------------------------------------------------------------- + // Infrastructure-dependent stubs — require external servers or certificates. + // Run manually or in an environment with the required infrastructure. + // ------------------------------------------------------------------------- + + [Fact(Skip = "RequiresExternalInfrastructure: needs an HTTP/SSE MCP test server")] + public Task Invoke_HttpSseUpstream_RoundTrip() => Task.CompletedTask; + + [Fact(Skip = "RequiresExternalInfrastructure: needs OAuth2 authorization server for token refresh")] + public Task Invoke_OAuth2ClientCredentials_TokenRefresh_Succeeds() => Task.CompletedTask; + + [Fact(Skip = "RequiresExternalInfrastructure: needs OAuth2 server with revokable tokens")] + public Task Invoke_OAuth2DeviceCode_RevokedToken_ReturnsAuthRequired() => Task.CompletedTask; + + [Fact(Skip = "RequiresExternalInfrastructure: needs mTLS-enforcing server and client certificates")] + public Task Invoke_Mtls_HandshakeSucceeds() => Task.CompletedTask; + + [Fact(Skip = "RequiresExternalInfrastructure: needs OAuth2 authorization server with SDK ClientOAuthOptions wiring")] + public Task Invoke_SdkOAuthMapping_ClientOAuthOptions_TokenCacheHonoured() => Task.CompletedTask; + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /// + /// Spawns `hypa serve` with HYPA_storage_path set to , + /// performs the MCP initialize handshake, calls `hypa_mcp` with the given + /// arguments, and returns the concatenated text content from the result. + /// + private async Task CallHypaMcpAsync( + string dataDir, + Dictionary arguments, + int timeoutSeconds = 30, + CancellationToken externalCt = default) + { + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, externalCt); + var ct = linked.Token; + + var psi = new ProcessStartInfo("dotnet", $"\"{_cliBinary}\" serve") + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WorkingDirectory = Path.GetDirectoryName(_cliBinary)!, + }; + psi.Environment["HYPA_storage_path"] = dataDir; + + ct.ThrowIfCancellationRequested(); + using var process = Process.Start(psi)!; + process.StandardInput.AutoFlush = true; + + // Drain stderr asynchronously to prevent the pipe buffer from filling up + // when the outer hypa serve writes connection/SDK log messages. + _ = process.StandardError.BaseStream.CopyToAsync(Stream.Null, ct); + + const string initJson = """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"""; + await WriteFrameAsync(process.StandardInput.BaseStream, initJson, ct); + await ReadFrameAsync(process.StandardOutput.BaseStream, ct); // consume init response + + const string initializedNotif = """{"jsonrpc":"2.0","method":"notifications/initialized"}"""; + await WriteFrameAsync(process.StandardInput.BaseStream, initializedNotif, ct); + + // Serialize arguments as a JSON object for tools/call params. + var argsJson = new StringBuilder("{"); + var first = true; + foreach (var (key, value) in arguments) + { + if (!first) argsJson.Append(','); + argsJson.Append($"\"{key}\":\"{EscapeJson(value)}\""); + first = false; + } + argsJson.Append('}'); + + var callJson = $"{{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{{\"name\":\"hypa_mcp\",\"arguments\":{argsJson}}}}}"; + await WriteFrameAsync(process.StandardInput.BaseStream, callJson, ct); + var response = await ReadFrameAsync(process.StandardOutput.BaseStream, ct); + + process.StandardInput.Close(); + await Task.Run(() => process.WaitForExit(10_000)); + + return ExtractTextFromToolCallResponse(response); + } + + private static string ExtractTextFromToolCallResponse(string? responseJson) + { + if (string.IsNullOrWhiteSpace(responseJson)) + return string.Empty; + + try + { + using var doc = JsonDocument.Parse(responseJson); + var root = doc.RootElement; + + // result.content[].text + if (root.TryGetProperty("result", out var result) && + result.TryGetProperty("content", out var content) && + content.ValueKind == JsonValueKind.Array) + { + var sb = new StringBuilder(); + foreach (var block in content.EnumerateArray()) + { + if (block.TryGetProperty("text", out var text)) + sb.Append(text.GetString()); + } + return sb.ToString(); + } + } + catch { } + + return responseJson; + } + + private static string EscapeJson(string value) => + value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static async Task WriteFrameAsync(Stream stream, string json, CancellationToken ct) + { + var payload = Encoding.UTF8.GetBytes(json + "\n"); + await stream.WriteAsync(payload, ct); + await stream.FlushAsync(ct); + } + + private static async Task ReadFrameAsync(Stream stream, CancellationToken ct) + { + var buf = new List(256); + var oneByte = new byte[1]; + while (true) + { + var read = await stream.ReadAsync(oneByte.AsMemory(), ct); + if (read == 0) + return buf.Count == 0 ? null : Encoding.UTF8.GetString([.. buf]).TrimEnd('\r'); + if (oneByte[0] == '\n') + return Encoding.UTF8.GetString([.. buf]).TrimEnd('\r'); + buf.Add(oneByte[0]); + } + } +} diff --git a/tests/Hypa.IntegrationTests/McpRoundTripTests.cs b/tests/Hypa.IntegrationTests/McpRoundTripTests.cs index 46e10fa..7fd35b2 100644 --- a/tests/Hypa.IntegrationTests/McpRoundTripTests.cs +++ b/tests/Hypa.IntegrationTests/McpRoundTripTests.cs @@ -67,12 +67,12 @@ public async Task InitializeAndToolsList_RoundTrip() var listDoc = JsonSerializer.Deserialize(listResponse, McpTestJsonContext.Default.ToolsListResponse); Assert.NotNull(listDoc); var tools = listDoc.Result?.Tools ?? []; - Assert.Equal(6, tools.Length); + Assert.Equal(7, tools.Length); var expectedNames = new HashSet { "hypa_session", "hypa_shell", "hypa_read", - "hypa_search", "hypa_code", "hypa_compress" + "hypa_search", "hypa_code", "hypa_compress", "hypa_mcp" }; foreach (var tool in tools) Assert.Contains(tool.Name, expectedNames); @@ -205,11 +205,11 @@ public async Task ManualJsonLine_ToolsList_ReturnsExpectedTools() var listDoc = JsonSerializer.Deserialize(response, McpTestJsonContext.Default.ToolsListResponse); Assert.NotNull(listDoc); var tools = listDoc.Result?.Tools ?? []; - Assert.Equal(6, tools.Length); + Assert.Equal(7, tools.Length); var expectedNames = new HashSet { "hypa_session", "hypa_shell", "hypa_read", - "hypa_search", "hypa_code", "hypa_compress" + "hypa_search", "hypa_code", "hypa_compress", "hypa_mcp" }; foreach (var tool in tools) Assert.Contains(tool.Name, expectedNames); @@ -291,11 +291,11 @@ public async Task ToolsList_UsingSdkStdioClient_Returns6ToolsWithExpectedNames() await using var client = await McpClient.CreateAsync(transport, cancellationToken: ct); var tools = await client.ListToolsAsync(cancellationToken: ct); - Assert.Equal(6, tools.Count); + Assert.Equal(7, tools.Count); var expectedNames = new HashSet { "hypa_session", "hypa_shell", "hypa_read", - "hypa_search", "hypa_code", "hypa_compress" + "hypa_search", "hypa_code", "hypa_compress", "hypa_mcp" }; foreach (var tool in tools) Assert.Contains(tool.Name, expectedNames); diff --git a/tests/Hypa.UnitTests/Application/CodeIndexServiceIncrementalTests.cs b/tests/Hypa.UnitTests/Application/CodeIndexServiceIncrementalTests.cs new file mode 100644 index 0000000..43aff61 --- /dev/null +++ b/tests/Hypa.UnitTests/Application/CodeIndexServiceIncrementalTests.cs @@ -0,0 +1,423 @@ +using Hypa.Infrastructure.CodeIntelligence; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Sdk.CodeIntelligence; +using Xunit; + +namespace Hypa.UnitTests.Application; + +public sealed class CodeIndexServiceIncrementalTests +{ + // ── IndexIncrementalAsync ──────────────────────────────────────────────── + + [Fact] + public async Task IndexIncrementalAsync_WhenBlobOidUnchanged_SkipsFile() + { + var dir = TempDir(); + try + { + var filePath = Path.GetFullPath(Path.Combine(dir, "code.cs")); + await File.WriteAllTextAsync(filePath, "public class Code {}"); + var relativePath = "code.cs"; + const string oid = "oid_abc"; + + var repo = new TrackingRepository(); + repo.FileStates[filePath] = new FileIndexState + { + AbsolutePath = filePath, + GitBlobOid = oid, + MTimeMs = 0, + SizeBytes = new FileInfo(filePath).Length, + }; + + var git = new FakeGitFileStateProvider + { + BlobOids = new Dictionary { [relativePath] = oid }, + }; + var service = MakeService(dir, repo, git); + + var result = await service.IndexIncrementalAsync(dir, CancellationToken.None); + + Assert.Empty(repo.SavedDocuments); + Assert.Equal(0, result.FilesIndexed); + } + finally { DeleteDir(dir); } + } + + [Fact] + public async Task IndexIncrementalAsync_WhenBlobOidChanged_ReIndexesFile() + { + var dir = TempDir(); + try + { + var filePath = Path.GetFullPath(Path.Combine(dir, "code.cs")); + await File.WriteAllTextAsync(filePath, "public class Code {}"); + var relativePath = "code.cs"; + + var repo = new TrackingRepository(); + repo.FileStates[filePath] = new FileIndexState + { + AbsolutePath = filePath, + GitBlobOid = "old_oid", + MTimeMs = 0, + SizeBytes = new FileInfo(filePath).Length, + }; + + var git = new FakeGitFileStateProvider + { + BlobOids = new Dictionary { [relativePath] = "new_oid" }, + }; + var service = MakeService(dir, repo, git); + + var result = await service.IndexIncrementalAsync(dir, CancellationToken.None); + + Assert.Equal(1, result.FilesIndexed); + Assert.Single(repo.SavedDocuments); + Assert.Equal("new_oid", repo.SavedDocuments[0].File.GitBlobOid); + } + finally { DeleteDir(dir); } + } + + [Fact] + public async Task IndexIncrementalAsync_WhenFileNew_Indexes() + { + var dir = TempDir(); + try + { + var filePath = Path.GetFullPath(Path.Combine(dir, "new.cs")); + await File.WriteAllTextAsync(filePath, "public class New {}"); + var relativePath = "new.cs"; + + var repo = new TrackingRepository(); // empty manifest + var git = new FakeGitFileStateProvider + { + BlobOids = new Dictionary { [relativePath] = "oid_new" }, + }; + var service = MakeService(dir, repo, git); + + var result = await service.IndexIncrementalAsync(dir, CancellationToken.None); + + Assert.Equal(1, result.FilesIndexed); + Assert.Single(repo.SavedDocuments); + } + finally { DeleteDir(dir); } + } + + [Fact] + public async Task IndexIncrementalAsync_WhenFileDeleted_RemovesFromDb() + { + var dir = TempDir(); + try + { + // Path is under the project root but the file does not exist on disk + var ghostPath = Path.GetFullPath(Path.Combine(dir, "ghost.cs")); + + var repo = new TrackingRepository(); + repo.FileStates[ghostPath] = new FileIndexState + { + AbsolutePath = ghostPath, + GitBlobOid = null, + MTimeMs = 999, + SizeBytes = 42, + }; + + var git = new FakeGitFileStateProvider { BlobOids = null }; + var service = MakeService(dir, repo, git); + + var result = await service.IndexIncrementalAsync(dir, CancellationToken.None); + + Assert.Equal(1, result.FilesDeleted); + Assert.Contains(ghostPath, repo.DeletedFiles); + } + finally { DeleteDir(dir); } + } + + [Fact] + public async Task IndexIncrementalAsync_WhenNonGitProject_UsesMtimeForAll() + { + var dir = TempDir(); + try + { + var filePath = Path.GetFullPath(Path.Combine(dir, "code.cs")); + await File.WriteAllTextAsync(filePath, "public class Code {}"); + var info = new FileInfo(filePath); + var mtime = new DateTimeOffset(info.LastWriteTimeUtc).ToUnixTimeMilliseconds(); + + var repo = new TrackingRepository(); + repo.FileStates[filePath] = new FileIndexState + { + AbsolutePath = filePath, + GitBlobOid = null, + MTimeMs = mtime, + SizeBytes = info.Length, + }; + + // git unavailable + var git = new FakeGitFileStateProvider { BlobOids = null }; + var service = MakeService(dir, repo, git); + + var result = await service.IndexIncrementalAsync(dir, CancellationToken.None); + + Assert.Empty(repo.SavedDocuments); + Assert.Equal(0, result.FilesIndexed); + } + finally { DeleteDir(dir); } + } + + // ── EnsureFreshAsync ───────────────────────────────────────────────────── + + [Fact] + public async Task EnsureFreshAsync_WhenTrackedCleanAndOidMatches_IsNoOp() + { + var dir = TempDir(); + try + { + var filePath = Path.GetFullPath(Path.Combine(dir, "code.cs")); + await File.WriteAllTextAsync(filePath, "public class Code {}"); + const string oid = "oid_match"; + + var repo = new TrackingRepository(); + repo.FileStates[filePath] = new FileIndexState + { + AbsolutePath = filePath, + GitBlobOid = oid, + MTimeMs = 0, + SizeBytes = 1, + }; + + var git = new FakeGitFileStateProvider { SingleBlobOid = oid }; + var service = MakeService(dir, repo, git); + + await service.EnsureFreshAsync(filePath, CancellationToken.None); + + Assert.Empty(repo.SavedDocuments); + } + finally { DeleteDir(dir); } + } + + [Fact] + public async Task EnsureFreshAsync_WhenTrackedCleanAndOidDiffers_ReIndexes() + { + var dir = TempDir(); + try + { + var filePath = Path.GetFullPath(Path.Combine(dir, "code.cs")); + await File.WriteAllTextAsync(filePath, "public class Code {}"); + + var repo = new TrackingRepository(); + repo.FileStates[filePath] = new FileIndexState + { + AbsolutePath = filePath, + GitBlobOid = "old_oid", + MTimeMs = 0, + SizeBytes = 1, + }; + + var git = new FakeGitFileStateProvider { SingleBlobOid = "new_oid" }; + var service = MakeService(dir, repo, git); + + await service.EnsureFreshAsync(filePath, CancellationToken.None); + + Assert.NotEmpty(repo.SavedDocuments); + } + finally { DeleteDir(dir); } + } + + [Fact] + public async Task EnsureFreshAsync_WhenFileDoesNotExist_IsNoOp() + { + var dir = TempDir(); + try + { + const string missingPath = "/nonexistent/phantom.cs"; + var repo = new TrackingRepository(); + var git = new FakeGitFileStateProvider { SingleBlobOid = "oid_x" }; + var service = MakeService(dir, repo, git); + + await service.EnsureFreshAsync(missingPath, CancellationToken.None); + + Assert.Empty(repo.SavedDocuments); + } + finally { DeleteDir(dir); } + } + + [Fact] + public async Task EnsureFreshAsync_WhenNotIndexed_IndexesFile() + { + var dir = TempDir(); + try + { + var filePath = Path.GetFullPath(Path.Combine(dir, "code.cs")); + await File.WriteAllTextAsync(filePath, "public class Code {}"); + + var repo = new TrackingRepository(); // empty — no stored state + var git = new FakeGitFileStateProvider { SingleBlobOid = null }; // untracked + var service = MakeService(dir, repo, git); + + await service.EnsureFreshAsync(filePath, CancellationToken.None); + + Assert.NotEmpty(repo.SavedDocuments); + } + finally { DeleteDir(dir); } + } + + [Fact] + public async Task IndexFullAsync_PersistsGitBlobOid_SoSubsequentIncrementalIsNoOp() + { + // Regression: IndexFullAsync must write GitBlobOid so that a subsequent + // IndexIncrementalAsync does not treat every file as stale. + var dir = TempDir(); + try + { + var filePath = Path.GetFullPath(Path.Combine(dir, "code.cs")); + await File.WriteAllTextAsync(filePath, "public class Code {}"); + const string oid = "oid_full"; + + var repo = new TrackingRepository(); + var git = new FakeGitFileStateProvider + { + BlobOids = new Dictionary { ["code.cs"] = oid }, + SingleBlobOid = oid, + }; + var service = MakeService(dir, repo, git); + + await service.IndexFullAsync(dir, CancellationToken.None); + Assert.Equal(oid, repo.SavedDocuments[0].File.GitBlobOid); + + repo.SavedDocuments.Clear(); + var result = await service.IndexIncrementalAsync(dir, CancellationToken.None); + + Assert.Equal(0, result.FilesIndexed); + Assert.Empty(repo.SavedDocuments); + } + finally { DeleteDir(dir); } + } + + [Fact] + public async Task EnsureFreshAsync_AfterReIndex_SecondCallIsNoOp() + { + // Regression: ReIndexFileAsync must persist GitBlobOid so the next + // EnsureFreshAsync call for a tracked+clean file does not re-index again. + var dir = TempDir(); + try + { + var filePath = Path.GetFullPath(Path.Combine(dir, "code.cs")); + await File.WriteAllTextAsync(filePath, "public class Code {}"); + const string oid = "stable_oid"; + + var repo = new TrackingRepository(); // no stored state + var git = new FakeGitFileStateProvider { SingleBlobOid = oid }; + var service = MakeService(dir, repo, git); + + await service.EnsureFreshAsync(filePath, CancellationToken.None); // first call — re-indexes + var savedAfterFirst = repo.SavedDocuments.Count; + Assert.Equal(1, savedAfterFirst); + Assert.Equal(oid, repo.SavedDocuments[0].File.GitBlobOid); + + await service.EnsureFreshAsync(filePath, CancellationToken.None); // second call — must be no-op + Assert.Equal(savedAfterFirst, repo.SavedDocuments.Count); + } + finally { DeleteDir(dir); } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static CodeIndexService MakeService(string root, TrackingRepository repo, FakeGitFileStateProvider git) + { + var rootDetector = new FakeProjectRootDetector(root); + var registry = new CodeStructureProviderRegistry([new RegexFallbackCodeStructureProvider()]); + return new CodeIndexService(rootDetector, registry, repo, git); + } + + private static string TempDir() + { + var path = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"hypa-incr-{Guid.NewGuid():N}")); + Directory.CreateDirectory(path); + return path; + } + + private static void DeleteDir(string path) + { + if (!Directory.Exists(path)) return; + try { Directory.Delete(path, recursive: true); } catch { /* best effort */ } + } + + // ── Fakes ──────────────────────────────────────────────────────────────── + + private sealed class FakeProjectRootDetector(string root) : IProjectRootDetector + { + public string? Detect(string startPath) => root; + } + + private sealed class FakeGitFileStateProvider : IGitFileStateProvider + { + public IReadOnlyDictionary? BlobOids { get; set; } + public string? SingleBlobOid { get; set; } + + public Task?> GetCleanBlobOidsAsync(string root, CancellationToken ct) => + Task.FromResult(BlobOids); + + public Task GetCleanBlobOidAsync(string absolutePath, string projectRoot, CancellationToken ct) => + Task.FromResult(SingleBlobOid); + } + + private sealed class TrackingRepository : ICodeIndexRepository + { + public Dictionary FileStates { get; } = []; + public List SavedDocuments { get; } = []; + public List DeletedFiles { get; } = []; + + public Task SaveDocumentsAsync(IReadOnlyList documents, CancellationToken ct) + { + SavedDocuments.AddRange(documents); + foreach (var doc in documents) + { + FileStates[doc.File.Path] = new FileIndexState + { + AbsolutePath = doc.File.Path, + GitBlobOid = doc.File.GitBlobOid, + MTimeMs = doc.File.MTimeMs, + SizeBytes = doc.File.SizeBytes, + }; + } + return Task.CompletedTask; + } + + public Task> QueryFileStatesAsync(string projectRoot, CancellationToken ct) => + Task.FromResult>(new Dictionary(FileStates)); + + public Task QueryFileStateAsync(string absolutePath, CancellationToken ct) => + Task.FromResult(FileStates.GetValueOrDefault(absolutePath)); + + public Task DeleteFileAsync(string absolutePath, CancellationToken ct) + { + DeletedFiles.Add(absolutePath); + FileStates.Remove(absolutePath); + return Task.CompletedTask; + } + + public Task> QuerySymbolsAsync(CodeSymbolQuery query, CancellationToken ct) => + Task.FromResult>([]); + + public Task QueryGraphAsync(CodeGraphQuery query, CancellationToken ct) => + Task.FromResult(new CodeGraphResult()); + + public Task> QueryDiagnosticsAsync(CancellationToken ct) => + Task.FromResult>([]); + + public Task QueryMarkdownAsync(string filePath, CancellationToken ct) => + Task.FromResult(null); + + public Task> QueryMarkdownSectionsAsync(string filePath, CancellationToken ct) => + Task.FromResult>([]); + + public Task> QueryReferencesAsync(string filePath, string kind, CancellationToken ct) => + Task.FromResult>([]); + + public Task SaveProviderHealthAsync(IReadOnlyList health, CancellationToken ct) => + Task.CompletedTask; + + public Task> GetProviderHealthAsync(CancellationToken ct) => + Task.FromResult>([]); + } +} diff --git a/tests/Hypa.UnitTests/Application/CodeIndexServiceTests.cs b/tests/Hypa.UnitTests/Application/CodeIndexServiceTests.cs index 8ffb523..5d12efe 100644 --- a/tests/Hypa.UnitTests/Application/CodeIndexServiceTests.cs +++ b/tests/Hypa.UnitTests/Application/CodeIndexServiceTests.cs @@ -55,7 +55,7 @@ public void Run() """); var service = MakeService(); - var result = await service.IndexAsync(_projectDir, CancellationToken.None); + var result = await service.IndexFullAsync(_projectDir, CancellationToken.None); var symbols = await _repository.QuerySymbolsAsync(new CodeSymbolQuery { Path = "Sample.cs" }, CancellationToken.None); var graph = await _repository.QueryGraphAsync(new CodeGraphQuery { Path = "Sample.cs" }, CancellationToken.None); @@ -83,7 +83,7 @@ private void Helper() } """); - await MakeService().IndexAsync(_projectDir, CancellationToken.None); + await MakeService().IndexFullAsync(_projectDir, CancellationToken.None); var run = (await _repository.QuerySymbolsAsync(new CodeSymbolQuery { Query = "Run" }, CancellationToken.None)).Single(); var calls = await _repository.QueryGraphAsync(new CodeGraphQuery { EdgeKind = "calls" }, CancellationToken.None); var callees = await _repository.QueryGraphAsync(new CodeGraphQuery { Callees = run.Id }, CancellationToken.None); @@ -117,7 +117,7 @@ private void Three() { } } """); - await MakeService().IndexAsync(_projectDir, CancellationToken.None); + await MakeService().IndexFullAsync(_projectDir, CancellationToken.None); var graph = await _repository.QueryGraphAsync(new CodeGraphQuery(), CancellationToken.None); @@ -133,12 +133,41 @@ public async Task IndexAsync_SkipsIgnoredDirectoriesAndLargeFiles() await File.WriteAllTextAsync(Path.Combine(_projectDir, "bin", "Ignored.cs"), "public class Ignored {}"); await File.WriteAllTextAsync(Path.Combine(_projectDir, "Large.cs"), new string('x', 1_000_001)); - var result = await MakeService().IndexAsync(_projectDir, CancellationToken.None); + var result = await MakeService().IndexFullAsync(_projectDir, CancellationToken.None); Assert.Equal(0, result.FilesIndexed); Assert.Equal(1, result.FilesSkipped); } + [Fact] + public void CodeLanguageRegistry_GetLanguage_MapsMarkdownExtension() + { + var language = CodeLanguageRegistry.GetLanguage("notes.md"); + + Assert.Equal("markdown", language); + } + + [Fact] + public void CodeStructureProviderRegistry_Select_RoutesMarkdownToMarkdownProvider() + { + var markdownProvider = Substitute.For(); + markdownProvider.Id.Returns("markdown"); + markdownProvider.CanHandle("markdown").Returns(true); + + var treeSitterProvider = Substitute.For(); + treeSitterProvider.Id.Returns("tree-sitter"); + treeSitterProvider.CanHandle("markdown").Returns(false); + + var fallbackProvider = Substitute.For(); + fallbackProvider.Id.Returns("regex-fallback"); + + var registry = new CodeStructureProviderRegistry([treeSitterProvider, markdownProvider, fallbackProvider]); + + var selected = registry.Select("markdown"); + + Assert.Equal("markdown", selected.Id); + } + [Fact] public async Task IndexAsync_ReindexesChangedFileWithStableSymbolIds() { @@ -146,11 +175,11 @@ public async Task IndexAsync_ReindexesChangedFileWithStableSymbolIds() await File.WriteAllTextAsync(source, "public class Stable { }"); var service = MakeService(); - await service.IndexAsync(_projectDir, CancellationToken.None); + await service.IndexFullAsync(_projectDir, CancellationToken.None); var first = await _repository.QuerySymbolsAsync(new CodeSymbolQuery { Query = "Stable" }, CancellationToken.None); await File.WriteAllTextAsync(source, "public class Stable { public void Run() { } }"); - await service.IndexAsync(_projectDir, CancellationToken.None); + await service.IndexFullAsync(_projectDir, CancellationToken.None); var second = await _repository.QuerySymbolsAsync(new CodeSymbolQuery { Query = "Stable" }, CancellationToken.None); Assert.Single(first); @@ -219,7 +248,8 @@ private CodeIndexService MakeService() var rootDetector = Substitute.For(); rootDetector.Detect(Arg.Any()).Returns(_projectDir); var registry = new CodeStructureProviderRegistry([new RegexFallbackCodeStructureProvider()]); - return new CodeIndexService(rootDetector, registry, _repository); + var gitProvider = Substitute.For(); + return new CodeIndexService(rootDetector, registry, _repository, gitProvider); } private static CodeStructureDocument MakeDocument() diff --git a/tests/Hypa.UnitTests/Application/CodeQueryServiceTests.cs b/tests/Hypa.UnitTests/Application/CodeQueryServiceTests.cs new file mode 100644 index 0000000..5654e96 --- /dev/null +++ b/tests/Hypa.UnitTests/Application/CodeQueryServiceTests.cs @@ -0,0 +1,175 @@ +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Sdk.CodeIntelligence; +using Xunit; + +namespace Hypa.UnitTests.Application; + +public sealed class CodeQueryServiceTests +{ + [Fact] + public async Task QueryMarkdownSectionsAsync_DelegatesToRepository() + { + var repository = new FakeCodeIndexRepository + { + Sections = [MakeSection("notes.md", "Intro", 1, 0)], + }; + var service = new CodeQueryService(repository); + + var sections = await service.QueryMarkdownSectionsAsync("notes.md", CancellationToken.None); + + Assert.Same(repository.Sections, sections); + Assert.Equal("notes.md", repository.LastMarkdownSectionsPath); + } + + [Fact] + public async Task QueryTocAsync_WhenMaxDepthTwo_FiltersClientSide() + { + var repository = new FakeCodeIndexRepository + { + Sections = + [ + MakeSection("notes.md", "One", 1, 0), + MakeSection("notes.md", "Two", 2, 10), + MakeSection("notes.md", "Three", 3, 20), + ], + }; + var service = new CodeQueryService(repository); + + var sections = await service.QueryTocAsync("notes.md", maxDepth: 2, CancellationToken.None); + + Assert.Equal(["One", "Two"], sections.Select(s => s.HeadingText).ToArray()); + } + + [Fact] + public async Task QueryTocAsync_WhenMaxDepthOmitted_UsesDepthThree() + { + var repository = new FakeCodeIndexRepository + { + Sections = + [ + MakeSection("notes.md", "One", 1, 0), + MakeSection("notes.md", "Three", 3, 10), + MakeSection("notes.md", "Four", 4, 20), + ], + }; + var service = new CodeQueryService(repository); + + var sections = await service.QueryTocAsync("notes.md"); + + Assert.Equal(["One", "Three"], sections.Select(s => s.HeadingText).ToArray()); + } + + [Fact] + public async Task QueryFrontmatterAsync_WhenDocumentHasFrontmatter_ReturnsRawYaml() + { + const string yaml = "title: Test\ntags:\n - docs"; + var repository = new FakeCodeIndexRepository + { + MarkdownDocument = new CodeStructureDocument + { + File = MakeFile("notes.md"), + Provenance = MakeProvenance(), + FrontmatterYaml = yaml, + }, + }; + var service = new CodeQueryService(repository); + + var frontmatter = await service.QueryFrontmatterAsync("notes.md", CancellationToken.None); + + Assert.Equal(yaml, frontmatter); + Assert.Equal("notes.md", repository.LastMarkdownDocumentPath); + } + + private static MarkdownSection MakeSection(string filePath, string headingText, int headingLevel, int startByte) => new() + { + Id = $"sec_{startByte}", + FilePath = filePath, + HeadingText = headingText, + HeadingLevel = headingLevel, + HeadingPath = headingText, + HeadingAnchor = headingText.ToLowerInvariant(), + StartLine = startByte + 1, + EndLine = startByte + 2, + StartByte = startByte, + EndByte = startByte + 5, + Provenance = MakeProvenance(), + }; + + private static CodeFileIdentity MakeFile(string relativePath) => new() + { + ProjectRoot = "/project", + Path = $"/project/{relativePath}", + RelativePath = relativePath, + Language = "markdown", + ContentHash = "hash", + SizeBytes = 0, + }; + + private static ProviderProvenance MakeProvenance() => new() + { + ProviderId = "markdown", + ProviderVersion = "1", + QueryVersion = "1", + FactKind = "syntactic", + Confidence = 1, + }; + + private sealed class FakeCodeIndexRepository : ICodeIndexRepository + { + public IReadOnlyList Sections { get; init; } = []; + public CodeStructureDocument? MarkdownDocument { get; init; } + public string? LastMarkdownSectionsPath { get; private set; } + public string? LastMarkdownDocumentPath { get; private set; } + public Dictionary FileStates { get; init; } = []; + + public Task SaveDocumentsAsync(IReadOnlyList documents, CancellationToken ct) => + Task.CompletedTask; + + public Task> QuerySymbolsAsync(CodeSymbolQuery query, CancellationToken ct) => + Task.FromResult>([]); + + public Task QueryGraphAsync(CodeGraphQuery query, CancellationToken ct) => + Task.FromResult(new CodeGraphResult()); + + public Task> QueryDiagnosticsAsync(CancellationToken ct) => + Task.FromResult>([]); + + public Task QueryMarkdownAsync(string filePath, CancellationToken ct) + { + LastMarkdownDocumentPath = filePath; + return Task.FromResult(MarkdownDocument); + } + + public Task> QueryMarkdownSectionsAsync(string filePath, CancellationToken ct) + { + LastMarkdownSectionsPath = filePath; + return Task.FromResult(Sections); + } + + public Task> QueryReferencesAsync(string filePath, string kind, CancellationToken ct) => + Task.FromResult>([]); + + public Task SaveProviderHealthAsync(IReadOnlyList health, CancellationToken ct) => + Task.CompletedTask; + + public Task> GetProviderHealthAsync(CancellationToken ct) => + Task.FromResult>([]); + + public Task> QueryFileStatesAsync( + string projectRoot, CancellationToken ct) => + Task.FromResult>( + FileStates + .Where(kv => kv.Key.StartsWith(projectRoot, StringComparison.Ordinal)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + + public Task QueryFileStateAsync(string absolutePath, CancellationToken ct) => + Task.FromResult(FileStates.GetValueOrDefault(absolutePath)); + + public Task DeleteFileAsync(string absolutePath, CancellationToken ct) + { + FileStates.Remove(absolutePath); + return Task.CompletedTask; + } + } +} diff --git a/tests/Hypa.UnitTests/Application/InitImportIntegrationTests.cs b/tests/Hypa.UnitTests/Application/InitImportIntegrationTests.cs new file mode 100644 index 0000000..a809cc5 --- /dev/null +++ b/tests/Hypa.UnitTests/Application/InitImportIntegrationTests.cs @@ -0,0 +1,132 @@ +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Hooks; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Application; + +[Trait("Category", "InitImport")] +public sealed class InitImportIntegrationTests +{ + private readonly IHarnessRegistry _registry = Substitute.For(); + private readonly IHookInstaller _installer = Substitute.For(); + private readonly IProjectRootDetector _rootDetector = Substitute.For(); + private readonly IProjectRegistry _projectRegistry = Substitute.For(); + private readonly IStorageProvisioner _provisioner = Substitute.For(); + private readonly IMcpServerImportService _importService = Substitute.For(); + + private InitService Sut(IMcpServerImportService? importService = null) => + new(_registry, _installer, _rootDetector, _projectRegistry, _provisioner, importService); + + private IAgentHarnessAdapter MakeAdapter(string key, bool available = true) + { + var adapter = Substitute.For(); + adapter.Key.Returns(key); + adapter.IsAvailable().Returns(available); + adapter.GetInstallPlan(Arg.Any(), Arg.Any()) + .Returns(new InstallPlan([])); + return adapter; + } + + public InitImportIntegrationTests() + { + _rootDetector.Detect(Arg.Any()).Returns("/repo/root"); + _provisioner.ProvisionAsync(Arg.Any()) + .Returns(Result.Ok(Unit.Value)); + _projectRegistry.RegisterAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Ok(Unit.Value)); + _installer.InstallAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new InstallReport("claude", [new InstallEntry("Installed", InstallStatus.Installed)])); + _importService.ImportAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Ok(new McpImportReport([], 0, 0, 0, 0))); + } + + [Fact] + public async Task InstallAsync_SuccessfulInstall_CallsImportService() + { + var adapter = MakeAdapter("claude"); + _registry.All.Returns([adapter]); + + await Sut(_importService).InstallAsync( + InitScope.Global, agentKey: null, projectRootOverride: null, dryRun: false); + + await _importService.Received(1).ImportAsync( + Arg.Is(r => + r.Scope == McpImportScope.Global && + r.Replace == false && + r.DryRun == false), + Arg.Any()); + } + + [Fact] + public async Task InstallAsync_ImportFails_HarnessInstallStillSucceeds_NoErrorMessage() + { + var adapter = MakeAdapter("claude"); + _registry.All.Returns([adapter]); + _importService.ImportAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Fail(new Error("ImportError", "import boom"))); + + var result = await Sut(_importService).InstallAsync( + InitScope.Global, agentKey: null, projectRootOverride: null, dryRun: false); + + Assert.Null(result.ErrorMessage); + } + + [Fact] + public async Task InstallAsync_DryRun_PassesDryRunToImportService() + { + var adapter = MakeAdapter("claude"); + _registry.All.Returns([adapter]); + + await Sut(_importService).InstallAsync( + InitScope.Global, agentKey: null, projectRootOverride: null, dryRun: true); + + await _importService.Received(1).ImportAsync( + Arg.Is(r => r.DryRun == true), + Arg.Any()); + } + + [Fact] + public async Task InstallAsync_SkipMcpImport_DoesNotCallImportService() + { + var adapter = MakeAdapter("claude"); + _registry.All.Returns([adapter]); + + await Sut(_importService).InstallAsync( + InitScope.Global, agentKey: null, projectRootOverride: null, dryRun: false, + skipMcpImport: true); + + await _importService.DidNotReceive().ImportAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task InstallAsync_NoImportService_DoesNotThrow() + { + var adapter = MakeAdapter("claude"); + _registry.All.Returns([adapter]); + + var result = await Sut(importService: null).InstallAsync( + InitScope.Global, agentKey: null, projectRootOverride: null, dryRun: false); + + Assert.Null(result.ErrorMessage); + } + + [Fact] + public async Task InstallAsync_ProjectScope_PassesProjectRootToImportService() + { + var adapter = MakeAdapter("claude"); + _registry.Find("claude").Returns(adapter); + + await Sut(_importService).InstallAsync( + InitScope.Project, agentKey: "claude", projectRootOverride: "/my/repo", dryRun: false); + + await _importService.Received(1).ImportAsync( + Arg.Is(r => + r.Scope == McpImportScope.Project && + r.ProjectRoot == Path.GetFullPath("/my/repo")), + Arg.Any()); + } +} diff --git a/tests/Hypa.UnitTests/Application/McpServerConfigServiceProbeTests.cs b/tests/Hypa.UnitTests/Application/McpServerConfigServiceProbeTests.cs new file mode 100644 index 0000000..54f1966 --- /dev/null +++ b/tests/Hypa.UnitTests/Application/McpServerConfigServiceProbeTests.cs @@ -0,0 +1,268 @@ +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Application; + +[Trait("Category", "McpServerConfigServiceProbe")] +public sealed class McpServerConfigServiceProbeTests +{ + private readonly IMcpServerConfigReader _reader = Substitute.For(); + private readonly IMcpServerConfigWriter _writer = Substitute.For(); + private readonly McpConfigValidationService _validator = new(); + private readonly IMcpServerProbe _probe = Substitute.For(); + + private McpServerConfigService Sut => new(_reader, _writer, _validator, _probe); + + private static readonly McpServerAddRequest RemoteNoneRequest = new( + Name: "remote", + Transport: "streamableHttp", + Endpoint: "https://example.com", + AuthType: "none", + Auth: new McpServerAddAuthOptions(), + Tls: null, + ConnectTimeoutSeconds: null, + RequestTimeoutSeconds: null, + Replace: false, + DryRun: false); + + private static readonly McpServerAddRequest StdioNoneRequest = new( + Name: "local", + Transport: "stdio", + Endpoint: "hypa serve", + AuthType: "none", + Auth: new McpServerAddAuthOptions(), + Tls: null, + ConnectTimeoutSeconds: null, + RequestTimeoutSeconds: null, + Replace: false, + DryRun: false); + + private static McpServerProbeResult Reachable => + new(McpServerProbeStatus.Reachable, "Reachable: tools/list succeeded."); + + private static McpServerProbeResult AuthRequired => + new(McpServerProbeStatus.AuthRequired, "Server returned 401 Unauthorized.", + new McpAuthGuidance("bearer", null, null, null, null, + ["hypa mcp auth check --server remote"])); + + public McpServerConfigServiceProbeTests() + { + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + _writer.WriteAsync(Arg.Any>(), Arg.Any()) + .Returns(Result.Ok(Unit.Value)); + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(Reachable); + } + + [Fact] + public async Task AddAsync_RemoteWithProbe_ReachableWritesOnce() + { + var result = await Sut.AddAsync(RemoteNoneRequest, default); + + Assert.True(result.Success); + await _writer.Received(1).WriteAsync(Arg.Any>(), default); + Assert.NotNull(result.Probe); + Assert.Equal(McpServerProbeStatus.Reachable, result.Probe.Status); + } + + [Fact] + public async Task AddAsync_RemoteWithProbe_AuthRequiredDoesNotWrite() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(AuthRequired); + + var result = await Sut.AddAsync(RemoteNoneRequest, default); + + Assert.False(result.Success); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), default); + Assert.Contains(result.Errors, e => e.StartsWith("AuthRequired:", StringComparison.Ordinal)); + Assert.NotNull(result.Probe); + } + + [Fact] + public async Task AddAsync_RemoteWithProbe_TimeoutDoesNotWrite() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult(McpServerProbeStatus.Timeout, "Connection timed out.")); + + var result = await Sut.AddAsync(RemoteNoneRequest, default); + + Assert.False(result.Success); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), default); + Assert.Contains(result.Errors, e => e.StartsWith("Timeout:", StringComparison.Ordinal)); + } + + [Fact] + public async Task AddAsync_RemoteWithProbe_ConnectionFailedDoesNotWrite() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult(McpServerProbeStatus.ConnectionFailed, "Failed to reach 'remote'.")); + + var result = await Sut.AddAsync(RemoteNoneRequest, default); + + Assert.False(result.Success); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), default); + Assert.Contains(result.Errors, e => e.StartsWith("ConnectionFailed:", StringComparison.Ordinal)); + } + + [Fact] + public async Task AddAsync_RemoteWithProbe_InvalidConfigDoesNotWrite() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult(McpServerProbeStatus.InvalidConfig, "Invalid config.")); + + var result = await Sut.AddAsync(RemoteNoneRequest, default); + + Assert.False(result.Success); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), default); + Assert.Contains(result.Errors, e => e.StartsWith("InvalidConfig:", StringComparison.Ordinal)); + } + + [Fact] + public async Task AddAsync_RemoteWithProbe_UnknownDoesNotWrite() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult(McpServerProbeStatus.Unknown, "HttpClient")); + + var result = await Sut.AddAsync(RemoteNoneRequest, default); + + Assert.False(result.Success); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), default); + Assert.Contains(result.Errors, e => e.StartsWith("ProbeFailed:", StringComparison.Ordinal)); + } + + [Fact] + public async Task AddAsync_StdioTransport_DoesNotProbe_AndWrites() + { + var result = await Sut.AddAsync(StdioNoneRequest, default); + + Assert.True(result.Success); + await _probe.DidNotReceive().ProbeAsync(Arg.Any(), Arg.Any()); + await _writer.Received(1).WriteAsync(Arg.Any>(), default); + } + + [Fact] + public async Task AddAsync_DryRun_DoesNotProbeOrWrite() + { + var request = RemoteNoneRequest with { DryRun = true }; + + var result = await Sut.AddAsync(request, default); + + Assert.True(result.Success); + await _probe.DidNotReceive().ProbeAsync(Arg.Any(), Arg.Any()); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), default); + } + + [Fact] + public async Task AddAsync_SkipProbe_DoesNotProbe_AndWrites() + { + var request = RemoteNoneRequest with { SkipProbe = true }; + + var result = await Sut.AddAsync(request, default); + + Assert.True(result.Success); + await _probe.DidNotReceive().ProbeAsync(Arg.Any(), Arg.Any()); + await _writer.Received(1).WriteAsync(Arg.Any>(), default); + } + + [Fact] + public async Task AddAsync_InvalidConfig_NeverProbes() + { + var request = new McpServerAddRequest( + "s", "streamableHttp", "https://example.com", "bearer", + new McpServerAddAuthOptions(), + null, null, null, false, false); + + var result = await Sut.AddAsync(request, default); + + Assert.False(result.Success); + await _probe.DidNotReceive().ProbeAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task AddAsync_DuplicateName_NeverProbes() + { + var existing = new McpServerDefinition( + "remote", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new NoneAuthConfig()); + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([existing])); + + var result = await Sut.AddAsync(RemoteNoneRequest, default); + + Assert.False(result.Success); + await _probe.DidNotReceive().ProbeAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task AddAsync_PreservesExistingServers_OnReachableProbe() + { + var existing = new McpServerDefinition( + "existing", + new McpTransportConfig(McpTransportKind.Stdio, "cmd"), + new NoneAuthConfig()); + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([existing])); + + var result = await Sut.AddAsync(RemoteNoneRequest, default); + + Assert.True(result.Success); + await _writer.Received(1).WriteAsync( + Arg.Is>(list => list.Count == 2), + default); + } + + [Fact] + public async Task AddAsync_RemoteWithProbe_AuthRequired_ResultCarriesGuidance() + { + var guidance = new McpAuthGuidance("bearer", null, null, null, null, null); + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult(McpServerProbeStatus.AuthRequired, "auth required", guidance)); + + var result = await Sut.AddAsync(RemoteNoneRequest, default); + + Assert.NotNull(result.Probe); + Assert.Equal("bearer", result.Probe!.AuthGuidance!.SuggestedAuthMode); + } + + [Fact] + public async Task AddAsync_RemoteProbeSuccess_WriteFailure_IncludesProbeResult() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(Reachable); + _writer.WriteAsync(Arg.Any>(), Arg.Any()) + .Returns(Result.Fail(new Error("WriteFailed", "disk full"))); + + var result = await Sut.AddAsync(RemoteNoneRequest, default); + + Assert.False(result.Success); + Assert.NotNull(result.Probe); + Assert.Equal(McpServerProbeStatus.Reachable, result.Probe!.Status); + } + + [Fact] + public async Task AddAsync_RemoteWithProbe_FailureDoesNotMutateExistingConfig() + { + var existing = new McpServerDefinition( + "existing", + new McpTransportConfig(McpTransportKind.Stdio, "cmd"), + new NoneAuthConfig()); + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([existing])); + + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(AuthRequired); + + var request = RemoteNoneRequest with { Replace = true }; + var result = await Sut.AddAsync(request, default); + + Assert.False(result.Success); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), default); + } +} diff --git a/tests/Hypa.UnitTests/Application/McpServerConfigServiceTests.cs b/tests/Hypa.UnitTests/Application/McpServerConfigServiceTests.cs new file mode 100644 index 0000000..df081ef --- /dev/null +++ b/tests/Hypa.UnitTests/Application/McpServerConfigServiceTests.cs @@ -0,0 +1,401 @@ +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Application; + +[Trait("Category", "McpServerConfig")] +public sealed class McpServerConfigServiceTests +{ + private readonly IMcpServerConfigReader _reader = Substitute.For(); + private readonly IMcpServerConfigWriter _writer = Substitute.For(); + private readonly McpConfigValidationService _validator = new(); + private readonly IMcpServerProbe _probe = Substitute.For(); + private McpServerConfigService Sut => new(_reader, _writer, _validator, _probe); + + private static readonly McpServerAddRequest StdioNoneRequest = new( + Name: "local", + Transport: "stdio", + Endpoint: "hypa serve", + AuthType: "none", + Auth: new McpServerAddAuthOptions(), + Tls: null, + ConnectTimeoutSeconds: null, + RequestTimeoutSeconds: null, + Replace: false, + DryRun: false); + + public McpServerConfigServiceTests() + { + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + _writer.WriteAsync(Arg.Any>(), Arg.Any()) + .Returns(Result.Ok(Unit.Value)); + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult(McpServerProbeStatus.Reachable, "ok")); + } + + [Fact] + public async Task AddAsync_NewServer_WritesAndReturnsSuccess() + { + var result = await Sut.AddAsync(StdioNoneRequest, default); + + Assert.True(result.Success); + Assert.NotNull(result.Server); + Assert.Equal("local", result.Server.Name); + await _writer.Received(1).WriteAsync(Arg.Any>(), default); + } + + [Fact] + public async Task AddAsync_AddsFirstServerToEmptyConfig() + { + var result = await Sut.AddAsync(StdioNoneRequest, default); + + Assert.True(result.Success); + await _writer.Received(1).WriteAsync( + Arg.Is>(list => list.Count == 1 && list[0].Name == "local"), + default); + } + + [Fact] + public async Task AddAsync_PreservesExistingServers() + { + var existing = new McpServerDefinition( + "existing", + new McpTransportConfig(McpTransportKind.Stdio, "cmd"), + new NoneAuthConfig()); + _reader.ReadEditableAsync(default).Returns( + Result, Error>.Ok([existing])); + + var result = await Sut.AddAsync(StdioNoneRequest, default); + + Assert.True(result.Success); + await _writer.Received(1).WriteAsync( + Arg.Is>(list => list.Count == 2), + default); + } + + [Fact] + public async Task AddAsync_DuplicateName_WithoutReplace_RejectsDuplicate() + { + var existing = new McpServerDefinition( + "local", + new McpTransportConfig(McpTransportKind.Stdio, "cmd"), + new NoneAuthConfig()); + _reader.ReadEditableAsync(default).Returns( + Result, Error>.Ok([existing])); + + var result = await Sut.AddAsync(StdioNoneRequest, default); + + Assert.False(result.Success); + Assert.Contains(result.Errors, e => e.Contains("DuplicateServer")); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), default); + } + + [Fact] + public async Task AddAsync_DuplicateName_WithReplace_ReplacesServer() + { + var existing = new McpServerDefinition( + "local", + new McpTransportConfig(McpTransportKind.Stdio, "old-cmd"), + new NoneAuthConfig()); + _reader.ReadEditableAsync(default).Returns( + Result, Error>.Ok([existing])); + + var request = StdioNoneRequest with { Replace = true }; + var result = await Sut.AddAsync(request, default); + + Assert.True(result.Success); + await _writer.Received(1).WriteAsync( + Arg.Is>(list => + list.Count == 1 && list[0].Transport.Endpoint == "hypa serve"), + default); + } + + [Fact] + public async Task AddAsync_DryRun_ValidatesButDoesNotWrite() + { + var request = StdioNoneRequest with { DryRun = true }; + var result = await Sut.AddAsync(request, default); + + Assert.True(result.Success); + Assert.NotNull(result.Server); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), default); + } + + // Secret-ref validation + + [Theory] + [InlineData("env:TOKEN")] + [InlineData("file:/run/secrets/token")] + [InlineData("ENV:TOKEN")] + public async Task AddAsync_ValidSecretRef_Accepts(string tokenRef) + { + var request = new McpServerAddRequest( + "s", "streamableHttp", "https://example.com", "bearer", + new McpServerAddAuthOptions(TokenRef: tokenRef), + null, null, null, false, false); + + var result = await Sut.AddAsync(request, default); + Assert.True(result.Success); + } + + [Theory] + [InlineData("MY_RAW_TOKEN")] + [InlineData("TOKEN")] + [InlineData("secret123")] + public async Task AddAsync_BareSecretRef_RejectsWithInvalidSecretRef(string bareRef) + { + var request = new McpServerAddRequest( + "s", "streamableHttp", "https://example.com", "bearer", + new McpServerAddAuthOptions(TokenRef: bareRef), + null, null, null, false, false); + + var result = await Sut.AddAsync(request, default); + Assert.False(result.Success); + Assert.Contains(result.Errors, e => e.Contains("InvalidSecretRef")); + } + + // Auth-mode required field validation + + [Fact] + public async Task AddAsync_Bearer_MissingTokenRef_RejectsMissingOption() + { + var request = new McpServerAddRequest( + "s", "streamableHttp", "https://example.com", "bearer", + new McpServerAddAuthOptions(), + null, null, null, false, false); + + var result = await Sut.AddAsync(request, default); + Assert.False(result.Success); + Assert.Contains(result.Errors, e => e.Contains("MissingOption") && e.Contains("--token-ref")); + } + + [Fact] + public async Task AddAsync_ApiKey_MissingHeaderName_RejectsMissingOption() + { + var request = new McpServerAddRequest( + "s", "streamableHttp", "https://example.com", "apiKey", + new McpServerAddAuthOptions(ValueRef: "env:KEY"), + null, null, null, false, false); + + var result = await Sut.AddAsync(request, default); + Assert.False(result.Success); + Assert.Contains(result.Errors, e => e.Contains("MissingOption") && e.Contains("--header-name")); + } + + [Fact] + public async Task AddAsync_ApiKey_MissingValueRef_RejectsMissingOption() + { + var request = new McpServerAddRequest( + "s", "streamableHttp", "https://example.com", "apiKey", + new McpServerAddAuthOptions(HeaderName: "X-Api-Key"), + null, null, null, false, false); + + var result = await Sut.AddAsync(request, default); + Assert.False(result.Success); + Assert.Contains(result.Errors, e => e.Contains("MissingOption") && e.Contains("--value-ref")); + } + + [Fact] + public async Task AddAsync_Basic_MissingBothRefs_ReportsAllErrors() + { + var request = new McpServerAddRequest( + "s", "streamableHttp", "https://example.com", "basic", + new McpServerAddAuthOptions(), + null, null, null, false, false); + + var result = await Sut.AddAsync(request, default); + Assert.False(result.Success); + Assert.Contains(result.Errors, e => e.Contains("--username-ref")); + Assert.Contains(result.Errors, e => e.Contains("--password-ref")); + } + + [Fact] + public async Task AddAsync_OAuth2ClientCredentials_MissingTokenUrl_Rejects() + { + var request = new McpServerAddRequest( + "s", "streamableHttp", "https://example.com", "oauth2ClientCredentials", + new McpServerAddAuthOptions(ClientIdRef: "env:CID", ClientSecretRef: "env:CS"), + null, null, null, false, false); + + var result = await Sut.AddAsync(request, default); + Assert.False(result.Success); + Assert.Contains(result.Errors, e => e.Contains("--token-url")); + } + + [Fact] + public async Task AddAsync_OAuth2DeviceCode_MissingClientId_Rejects() + { + var request = new McpServerAddRequest( + "s", "streamableHttp", "https://example.com", "oauth2DeviceCode", + new McpServerAddAuthOptions( + AuthUrl: "https://auth/device", + TokenUrl: "https://auth/token"), + null, null, null, false, false); + + var result = await Sut.AddAsync(request, default); + Assert.False(result.Success); + Assert.Contains(result.Errors, e => e.Contains("--client-id")); + } + + // TLS combined with service + + [Fact] + public async Task AddAsync_TlsCertWithoutKey_RejectsInvalidConfig() + { + var request = new McpServerAddRequest( + "s", "streamableHttp", "https://example.com", "none", + new McpServerAddAuthOptions(), + new McpServerAddTlsOptions(null, "/cert.pem", null), + null, null, false, false); + + var result = await Sut.AddAsync(request, default); + Assert.False(result.Success); + Assert.Contains(result.Errors, e => e.Contains("InvalidConfig")); + } + + [Fact] + public async Task AddAsync_TlsOnStdio_RejectsInvalidConfig() + { + var request = new McpServerAddRequest( + "s", "stdio", "hypa serve", "none", + new McpServerAddAuthOptions(), + new McpServerAddTlsOptions("/ca.pem", null, null), + null, null, false, false); + + var result = await Sut.AddAsync(request, default); + Assert.False(result.Success); + Assert.Contains(result.Errors, e => e.Contains("InvalidConfig")); + } + + // Timeout validation via McpConfigValidationService pass-through + + [Fact] + public async Task AddAsync_ValidRequest_MapsTransportCorrectly() + { + var result = await Sut.AddAsync(StdioNoneRequest, default); + + Assert.True(result.Success); + Assert.Equal(McpTransportKind.Stdio, result.Server!.Transport.Kind); + Assert.Equal("hypa serve", result.Server.Transport.Endpoint); + } + + [Fact] + public async Task AddAsync_StreamableHttpTransport_MapsToHttpKind() + { + var request = new McpServerAddRequest( + "s", "streamableHttp", "https://example.com", "none", + new McpServerAddAuthOptions(), + null, null, null, false, false); + + var result = await Sut.AddAsync(request, default); + + Assert.True(result.Success); + Assert.Equal(McpTransportKind.Http, result.Server!.Transport.Kind); + } + + // mTLS required refs + + [Fact] + public async Task AddAsync_Mtls_MissingBothRefs_RejectsMissingOption() + { + var request = new McpServerAddRequest( + "s", "streamableHttp", "https://example.com", "mtls", + new McpServerAddAuthOptions(), + null, null, null, false, false); + + var result = await Sut.AddAsync(request, default); + Assert.False(result.Success); + Assert.Contains(result.Errors, e => e.Contains("MissingOption") && e.Contains("--client-cert-ref")); + Assert.Contains(result.Errors, e => e.Contains("MissingOption") && e.Contains("--client-key-ref")); + } + + [Fact] + public async Task AddAsync_Mtls_WithBothRefs_Succeeds() + { + var request = new McpServerAddRequest( + "s", "streamableHttp", "https://example.com", "mtls", + new McpServerAddAuthOptions(ClientCertRef: "env:CERT", ClientKeyRef: "env:KEY"), + null, null, null, false, false); + + var result = await Sut.AddAsync(request, default); + Assert.True(result.Success); + } + + // Timeout validation + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public async Task AddAsync_NonPositiveConnectTimeout_RejectsInvalidOption(int timeout) + { + var request = StdioNoneRequest with { ConnectTimeoutSeconds = timeout }; + + var result = await Sut.AddAsync(request, default); + Assert.False(result.Success); + Assert.Contains(result.Errors, e => e.Contains("InvalidOption") && e.Contains("connect-timeout-seconds")); + } + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public async Task AddAsync_NonPositiveRequestTimeout_RejectsInvalidOption(int timeout) + { + var request = StdioNoneRequest with { RequestTimeoutSeconds = timeout }; + + var result = await Sut.AddAsync(request, default); + Assert.False(result.Success); + Assert.Contains(result.Errors, e => e.Contains("InvalidOption") && e.Contains("request-timeout-seconds")); + } + + [Fact] + public async Task AddAsync_PositiveTimeouts_Succeeds() + { + var request = StdioNoneRequest with { ConnectTimeoutSeconds = 10, RequestTimeoutSeconds = 30 }; + + var result = await Sut.AddAsync(request, default); + Assert.True(result.Success); + Assert.Equal(TimeSpan.FromSeconds(10), result.Server!.ConnectTimeout); + Assert.Equal(TimeSpan.FromSeconds(30), result.Server.RequestTimeout); + } + + [Fact] + public async Task AddAsync_WriterFailure_ReturnsError() + { + _writer.WriteAsync(Arg.Any>(), Arg.Any()) + .Returns(Result.Fail(new Error("WriteFailed", "disk full"))); + + var result = await Sut.AddAsync(StdioNoneRequest, default); + + Assert.False(result.Success); + Assert.Contains(result.Errors, e => e.Contains("disk full")); + } + + [Fact] + public async Task AddAsync_StdioRequest_DoesNotInvokeProbe() + { + var result = await Sut.AddAsync(StdioNoneRequest, default); + + Assert.True(result.Success); + await _probe.DidNotReceive().ProbeAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task AddAsync_PreValidationFailure_DoesNotInvokeProbe() + { + var request = new McpServerAddRequest( + "s", "streamableHttp", "https://example.com", "bearer", + new McpServerAddAuthOptions(), + null, null, null, false, false); + + var result = await Sut.AddAsync(request, default); + + Assert.False(result.Success); + await _probe.DidNotReceive().ProbeAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/tests/Hypa.UnitTests/Application/McpServerImportServiceTests.cs b/tests/Hypa.UnitTests/Application/McpServerImportServiceTests.cs new file mode 100644 index 0000000..31c6c4e --- /dev/null +++ b/tests/Hypa.UnitTests/Application/McpServerImportServiceTests.cs @@ -0,0 +1,430 @@ +using System.Security.Cryptography; +using System.Text; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Application; + +[Trait("Category", "McpServerImport")] +public sealed class McpServerImportServiceTests +{ + private readonly IMcpServerConfigReader _reader = Substitute.For(); + private readonly IMcpServerConfigWriter _writer = Substitute.For(); + private readonly McpConfigValidationService _validator = new(); + + private McpServerImportService Sut(params IMcpConnectionImportSource[] sources) => + new(sources, _reader, _writer, _validator); + + private static McpServerDefinition StdioServer(string name, string command = "my-server") => + new(name, + new McpTransportConfig(McpTransportKind.Stdio, command), + new NoneAuthConfig(), + Tls: null, + ConnectTimeout: null, + RequestTimeout: null); + + private static IMcpConnectionImportSource SourceReturning( + string agentKey, + McpImportScope scope, + params McpImportedConnection[] connections) + { + var src = Substitute.For(); + src.AgentKey.Returns(agentKey); + src.SupportsScope(Arg.Any()).Returns(true); + src.DiscoverAsync( + Arg.Is(r => r.Scope == scope), + Arg.Any()) + .Returns(connections.ToList()); + return src; + } + + private static McpImportedConnection ImportableConnection(string name, string command = "my-server") + { + var def = StdioServer(name, command); + return new McpImportedConnection( + "claude", "global", name, def, + McpServerImportService.ComputeFingerprint(def), + McpImportCandidateStatus.Importable, null); + } + + private static McpImportedConnection SkippedSelf(string name) => + new("claude", "global", name, null, string.Empty, McpImportCandidateStatus.SkippedSelf, "Hypa self-entry"); + + private static McpImportedConnection SkippedUnsafe(string name) => + new("claude", "global", name, null, string.Empty, McpImportCandidateStatus.SkippedUnsafeSecret, "raw secret"); + + public McpServerImportServiceTests() + { + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + _writer.WriteAsync(Arg.Any>(), Arg.Any()) + .Returns(Result.Ok(Unit.Value)); + } + + [Fact] + public async Task ImportAsync_EmptySources_ReturnsEmptyReport_DoesNotWrite() + { + var result = await Sut().ImportAsync( + new McpImportRequest(null, McpImportScope.Global, null, Replace: false, DryRun: false), default); + + Assert.True(result.IsOk); + var report = result.Value; + Assert.Equal(0, report.ImportedCount); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task ImportAsync_NewServer_ImportsAndWritesOnce() + { + var conn = ImportableConnection("github"); + var src = SourceReturning("claude", McpImportScope.Global, conn); + + var result = await Sut(src).ImportAsync( + new McpImportRequest(null, McpImportScope.Global, null, Replace: false, DryRun: false), default); + + Assert.True(result.IsOk); + var report = result.Value; + Assert.Equal(1, report.ImportedCount); + await _writer.Received(1).WriteAsync( + Arg.Is>(list => list.Any(s => s.Name == "github")), + Arg.Any()); + } + + [Fact] + public async Task ImportAsync_SameNameSameFingerprint_AlreadyPresent_DoesNotWrite() + { + var existing = StdioServer("github"); + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([existing])); + + var conn = ImportableConnection("github"); // same command → same fingerprint + var src = SourceReturning("claude", McpImportScope.Global, conn); + + var result = await Sut(src).ImportAsync( + new McpImportRequest(null, McpImportScope.Global, null, Replace: false, DryRun: false), default); + + Assert.True(result.IsOk); + var report = result.Value; + Assert.Equal(0, report.ImportedCount); + Assert.Equal(1, report.AlreadyPresentCount); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task ImportAsync_SameNameDifferentFingerprint_Conflict_DoesNotWrite() + { + var existing = StdioServer("github", "old-command"); + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([existing])); + + var conn = ImportableConnection("github", "new-command"); + var src = SourceReturning("claude", McpImportScope.Global, conn); + + var result = await Sut(src).ImportAsync( + new McpImportRequest(null, McpImportScope.Global, null, Replace: false, DryRun: false), default); + + Assert.True(result.IsOk); + var report = result.Value; + Assert.Equal(0, report.ImportedCount); + Assert.Equal(1, report.ConflictCount); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task ImportAsync_SameNameDifferentFingerprint_WithReplace_OverwritesAndWrites() + { + var existing = StdioServer("github", "old-command"); + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([existing])); + + var conn = ImportableConnection("github", "new-command"); + var src = SourceReturning("claude", McpImportScope.Global, conn); + + var result = await Sut(src).ImportAsync( + new McpImportRequest(null, McpImportScope.Global, null, Replace: true, DryRun: false), default); + + Assert.True(result.IsOk); + var report = result.Value; + Assert.Equal(1, report.ImportedCount); + await _writer.Received(1).WriteAsync( + Arg.Is>(list => + list.Count == 1 && list[0].Transport.Endpoint == "new-command"), + Arg.Any()); + } + + [Fact] + public async Task ImportAsync_SkippedSelf_NotIncludedInWrite() + { + var src = SourceReturning("claude", McpImportScope.Global, SkippedSelf("hypa")); + + var result = await Sut(src).ImportAsync( + new McpImportRequest(null, McpImportScope.Global, null, Replace: false, DryRun: false), default); + + Assert.True(result.IsOk); + var report = result.Value; + Assert.Equal(0, report.ImportedCount); + Assert.Equal(1, report.SkippedCount); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task ImportAsync_SkippedUnsafeSecret_NotIncludedInWrite() + { + var src = SourceReturning("claude", McpImportScope.Global, SkippedUnsafe("secret-server")); + + var result = await Sut(src).ImportAsync( + new McpImportRequest(null, McpImportScope.Global, null, Replace: false, DryRun: false), default); + + Assert.True(result.IsOk); + var report = result.Value; + Assert.Equal(0, report.ImportedCount); + Assert.Equal(1, report.SkippedCount); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task ImportAsync_DryRun_DoesNotCallWriter() + { + var conn = ImportableConnection("github"); + var src = SourceReturning("claude", McpImportScope.Global, conn); + + var result = await Sut(src).ImportAsync( + new McpImportRequest(null, McpImportScope.Global, null, Replace: false, DryRun: true), default); + + Assert.True(result.IsOk); + var report = result.Value; + Assert.Equal(1, report.ImportedCount); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task ImportAsync_TwoSources_LoadsConfigOnce_WritesOnce() + { + var conn1 = ImportableConnection("github", "gh-mcp"); + var conn2 = ImportableConnection("linear", "linear-mcp"); + var src1 = SourceReturning("claude", McpImportScope.Global, conn1); + var src2 = SourceReturning("codex", McpImportScope.Global, conn2); + + var result = await Sut(src1, src2).ImportAsync( + new McpImportRequest(null, McpImportScope.Global, null, Replace: false, DryRun: false), default); + + Assert.True(result.IsOk); + await _reader.Received(1).ReadEditableAsync(Arg.Any()); + await _writer.Received(1).WriteAsync( + Arg.Is>(list => list.Count == 2), + Arg.Any()); + } + + [Fact] + public async Task ImportAsync_AgentKeyFilter_OnlyCallsMatchingSource() + { + var conn = ImportableConnection("github"); + var claudeSrc = SourceReturning("claude", McpImportScope.Global, conn); + var codexSrc = SourceReturning("codex", McpImportScope.Global); + + codexSrc.AgentKey.Returns("codex"); + + var result = await Sut(claudeSrc, codexSrc).ImportAsync( + new McpImportRequest("claude", McpImportScope.Global, null, Replace: false, DryRun: false), default); + + Assert.True(result.IsOk); + await claudeSrc.Received(1).DiscoverAsync(Arg.Any(), Arg.Any()); + await codexSrc.DidNotReceive().DiscoverAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void ComputeFingerprint_SameDefinition_ReturnsSameHash() + { + var def = StdioServer("github"); + var fp1 = McpServerImportService.ComputeFingerprint(def); + var fp2 = McpServerImportService.ComputeFingerprint(def); + Assert.Equal(fp1, fp2); + } + + [Fact] + public void ComputeFingerprint_DifferentEndpoint_ReturnsDifferentHash() + { + var def1 = StdioServer("github", "command-a"); + var def2 = StdioServer("github", "command-b"); + Assert.NotEqual( + McpServerImportService.ComputeFingerprint(def1), + McpServerImportService.ComputeFingerprint(def2)); + } + + [Fact] + public void ComputeFingerprint_SecretRefChanged_HashUnchanged() + { + var def1 = new McpServerDefinition( + "srv", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new BearerAuthConfig("env:TOKEN_A"), + null, null, null); + var def2 = def1 with { Auth = new BearerAuthConfig("env:TOKEN_B") }; + + Assert.Equal( + McpServerImportService.ComputeFingerprint(def1), + McpServerImportService.ComputeFingerprint(def2)); + } + + [Fact] + public async Task ImportAsync_DifferentNameSameFingerprint_DuplicateReported_Skipped() + { + var conn1 = ImportableConnection("github"); + var conn2 = ImportableConnection("gh-cli", "my-server"); // Same command → same fingerprint, different name + var src = SourceReturning("claude", McpImportScope.Global, conn1, conn2); + + var result = await Sut(src).ImportAsync( + new McpImportRequest(null, McpImportScope.Global, null, Replace: false, DryRun: false), default); + + Assert.True(result.IsOk); + var report = result.Value; + // Only the first one (by order) should be imported. + Assert.Equal(1, report.ImportedCount); + // The second one with the same fingerprint but different name should be reported as duplicate and skipped. + var srcResult = report.Sources.First(); + var duplicateConn = srcResult.Connections.First(c => c.SourceName == "gh-cli"); + Assert.Equal(McpImportCandidateStatus.SkippedDuplicate, duplicateConn.Status); + Assert.Contains("duplicate", duplicateConn.Detail ?? ""); + + await _writer.Received(1).WriteAsync( + Arg.Is>(list => list.Count == 1 && list[0].Name == "github"), + Arg.Any()); + } + + [Fact] + public async Task ImportAsync_AuthenticatedEntry_PreservesBearerToken() + { + var authDef = new McpServerDefinition( + "secure-api", + new McpTransportConfig(McpTransportKind.Http, "https://api.example.com"), + new BearerAuthConfig("env:API_TOKEN"), + null, null, null); + var conn = new McpImportedConnection( + "claude", "global", "secure-api", authDef, + McpServerImportService.ComputeFingerprint(authDef), + McpImportCandidateStatus.Importable, null); + var src = SourceReturning("claude", McpImportScope.Global, conn); + + var result = await Sut(src).ImportAsync( + new McpImportRequest(null, McpImportScope.Global, null, Replace: false, DryRun: false), default); + + Assert.True(result.IsOk); + var report = result.Value; + Assert.Equal(1, report.ImportedCount); + await _writer.Received(1).WriteAsync( + Arg.Is>(list => + list.Count == 1 && + list[0].Name == "secure-api" && + list[0].Auth is BearerAuthConfig && + ((BearerAuthConfig)list[0].Auth).TokenRef == "env:API_TOKEN"), + Arg.Any()); + } + + [Fact] + public async Task ImportAsync_ReadFails_ReturnsError() + { + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Fail( + new Error("ReadFailed", "Config file not found"))); + + var src = SourceReturning("claude", McpImportScope.Global, ImportableConnection("my-server")); + var result = await Sut(src).ImportAsync( + new McpImportRequest(null, McpImportScope.Global, null, Replace: false, DryRun: false), default); + + Assert.False(result.IsOk); + Assert.Equal("ReadFailed", result.Error.Code); + Assert.Equal("Config file not found", result.Error.Message); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task ImportAsync_ValidatorFails_ReturnsError() + { + var badServer = new McpServerDefinition( + string.Empty, // Empty name will fail validation + new McpTransportConfig(McpTransportKind.Stdio, "cmd"), + new NoneAuthConfig(), + null, null, null); + + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + + var src = Substitute.For(); + src.AgentKey.Returns("claude"); + src.SupportsScope(Arg.Any()).Returns(true); + src.DiscoverAsync(Arg.Any(), Arg.Any()) + .Returns(new[] { + new McpImportedConnection( + "claude", "global", "", badServer, + "fp", McpImportCandidateStatus.Importable, null) + }.ToList()); + + var result = await Sut(src).ImportAsync( + new McpImportRequest(null, McpImportScope.Global, null, Replace: false, DryRun: false), default); + + Assert.False(result.IsOk); + Assert.NotNull(result.Error.Code); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task ImportAsync_WriterFails_ReturnsError() + { + _writer.WriteAsync(Arg.Any>(), Arg.Any()) + .Returns(Result.Fail( + new Error("WriteFailed", "Permission denied"))); + + var src = SourceReturning("claude", McpImportScope.Global, ImportableConnection("my-server")); + var result = await Sut(src).ImportAsync( + new McpImportRequest(null, McpImportScope.Global, null, Replace: false, DryRun: false), default); + + Assert.False(result.IsOk); + Assert.Equal("WriteFailed", result.Error.Code); + Assert.Equal("Permission denied", result.Error.Message); + } + + [Fact] + public async Task ImportAsync_CrossSourceSameNameConflict_SkipsSecondAndReports() + { + // Two sources both contribute a server with the same name but different fingerprints + var conn1 = ImportableConnection("shared-server", "cmd1"); + var conn2 = ImportableConnection("shared-server", "cmd2"); + + var src1 = SourceReturning("claude", McpImportScope.Global, conn1); + var src2 = SourceReturning("codex", McpImportScope.Global, conn2); + + var result = await Sut(src1, src2).ImportAsync( + new McpImportRequest(null, McpImportScope.Global, null, Replace: false, DryRun: false), default); + + Assert.True(result.IsOk); + var report = result.Value; + + // Only the first source's server should be imported + Assert.Equal(1, report.ImportedCount); + Assert.Equal(1, report.ConflictCount); + + // Verify that only conn1 is marked as imported and conn2 is marked as conflict + var claudeResult = report.Sources.First(s => s.Agent == "claude"); + var codexResult = report.Sources.First(s => s.Agent == "codex"); + + Assert.True( + claudeResult.Connections.Any(c => c.Status == McpImportCandidateStatus.Importable), + "First source should have importable connection"); + + var conflictConn = codexResult.Connections.First(c => c.SourceName == "shared-server"); + Assert.Equal(McpImportCandidateStatus.SkippedConflict, conflictConn.Status); + Assert.Contains("same name already accepted", conflictConn.Detail ?? ""); + + // Only first server should be written + await _writer.Received(1).WriteAsync( + Arg.Is>(list => + list.Count == 1 && + list[0].Name == "shared-server" && + list[0].Transport.Endpoint == "cmd1"), + Arg.Any()); + } +} diff --git a/tests/Hypa.UnitTests/AssemblyInfo.cs b/tests/Hypa.UnitTests/AssemblyInfo.cs new file mode 100644 index 0000000..2171200 --- /dev/null +++ b/tests/Hypa.UnitTests/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/Hypa.UnitTests/Cli/McpAddCommandTests.cs b/tests/Hypa.UnitTests/Cli/McpAddCommandTests.cs new file mode 100644 index 0000000..25c7bf7 --- /dev/null +++ b/tests/Hypa.UnitTests/Cli/McpAddCommandTests.cs @@ -0,0 +1,1332 @@ +using System.CommandLine; +using Hypa.Cli.Commands; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Cli; + +[Trait("Category", "McpAddCommand")] +[Collection("SequentialEnvTests")] +public sealed class McpAddCommandTests +{ + private readonly IMcpServerConfigReader _reader = Substitute.For(); + private readonly IMcpServerConfigWriter _writer = Substitute.For(); + private readonly IMcpServerDefinitionRepository _serverRepo = Substitute.For(); + private readonly IMcpAuthProvider _authProvider = Substitute.For(); + private readonly IMcpServerProbe _probe = Substitute.For(); + + public McpAddCommandTests() + { + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + _writer.WriteAsync(Arg.Any>(), Arg.Any()) + .Returns(Result.Ok(Unit.Value)); + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult(McpServerProbeStatus.Reachable, "Reachable: tools/list succeeded.")); + } + + private RootCommand BuildRoot() + { + var validator = new McpConfigValidationService(); + var configService = new McpServerConfigService(_reader, _writer, validator, _probe); + var dispatcher = Substitute.For(); + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UtcNow); + var proxyService = new McpProxyService(dispatcher, new McpResponseCompressionService(), new McpToolSearchIndex(), clock); + var command = new McpCommand(proxyService, _serverRepo, _authProvider, configService, NullLogger.Instance); + var root = new RootCommand(); + root.AddCommand(command.Build()); + return root; + } + + // Flag-driven success paths + + [Fact] + public async Task FlagDriven_Stdio_None_Succeeds() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stdout = capture.Stdout; + + var exit = await root.InvokeAsync(["mcp", "add", "local", + "--transport", "stdio", "--endpoint", "hypa serve", "--auth", "none"]); + + Assert.Equal(0, exit); + Assert.Contains("Added MCP server: local", stdout.ToString()); + Assert.Contains("Run: hypa mcp auth check --server local", stdout.ToString()); + } + + [Fact] + public async Task FlagDriven_ApiKey_WithEnvRef_Succeeds() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stdout = capture.Stdout; + + var exit = await root.InvokeAsync(["mcp", "add", "api-server", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "apiKey", + "--header-name", "x-api-key", + "--value-ref", "env:MY_KEY"]); + + Assert.Equal(0, exit); + Assert.Contains("Added MCP server: api-server", stdout.ToString()); + } + + [Fact] + public async Task FlagDriven_Bearer_WithFileRef_Succeeds() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stdout = capture.Stdout; + + var exit = await root.InvokeAsync(["mcp", "add", "bearer-server", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "bearer", + "--token-ref", "file:/run/secrets/token"]); + + Assert.Equal(0, exit); + Assert.Equal(0, exit); + Assert.Contains("Added MCP server: bearer-server", stdout.ToString()); + } + + [Fact] + public async Task FlagDriven_TransportAlias_Http_NormalizedToHttpAutoDetect() + { + var root = BuildRoot(); + + var exit = await root.InvokeAsync(["mcp", "add", "s", + "--transport", "http", + "--endpoint", "https://example.com", + "--auth", "none"]); + + Assert.Equal(0, exit); + await _writer.Received(1).WriteAsync( + Arg.Is>(list => + list[0].Transport.Kind == McpTransportKind.HttpAutoDetect), + Arg.Any()); + } + + // Dry-run output + + [Fact] + public async Task DryRun_PrintsJsonAndDoesNotWrite() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stdout = capture.Stdout; + + var exit = await root.InvokeAsync(["mcp", "add", "preview", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "bearer", + "--token-ref", "env:TOKEN", + "--dry-run"]); + + Assert.Equal(0, exit); + var output = stdout.ToString(); + Assert.Contains("preview", output); + Assert.Contains("streamableHttp", output); + Assert.Contains("bearer", output); + Assert.Contains("env:TOKEN", output); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task DryRun_OutputDoesNotContainNullFields() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stdout = capture.Stdout; + + await root.InvokeAsync(["mcp", "add", "preview", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "bearer", + "--token-ref", "env:TOKEN", + "--dry-run"]); + + Assert.DoesNotContain("null", stdout.ToString()); + } + + // Invalid option combinations + + [Fact] + public async Task DryRunAndLogin_RejectsWithInvalidOption() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + var exit = await root.InvokeAsync(["mcp", "add", "s", + "--transport", "stdio", "--endpoint", "cmd", "--auth", "none", + "--dry-run", "--login"]); + + Assert.Equal(1, exit); + Assert.Contains("InvalidOption", stderr.ToString()); + Assert.Contains("--dry-run", stderr.ToString()); + Assert.Contains("--login", stderr.ToString()); + } + + [Fact] + public async Task LoginWithNonDeviceCodeAuth_RejectsWithInvalidOption() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + var exit = await root.InvokeAsync(["mcp", "add", "s", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "bearer", + "--token-ref", "env:T", + "--login"]); + + Assert.Equal(1, exit); + Assert.Contains("InvalidOption", stderr.ToString()); + Assert.Contains("oauth2DeviceCode", stderr.ToString()); + } + + // Non-interactive missing option errors + + [Fact] + public async Task MissingAuth_DefaultsToNone() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stdout = capture.Stdout; + + var exit = await root.InvokeAsync(["mcp", "add", "s", + "--transport", "stdio", "--endpoint", "cmd"]); + + Assert.Equal(0, exit); + Assert.Contains("Added MCP server: s", stdout.ToString()); + } + + [Fact] + public async Task NonInteractive_MissingTransport_RejectsMissingOption() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + var exit = await root.InvokeAsync(["mcp", "add", "s", + "--endpoint", "cmd", "--auth", "none"]); + + Assert.Equal(1, exit); + Assert.Contains("MissingOption", stderr.ToString()); + Assert.Contains("--transport", stderr.ToString()); + } + + [Fact] + public async Task NonInteractive_MissingEndpoint_RejectsMissingOption() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + var exit = await root.InvokeAsync(["mcp", "add", "s", + "--transport", "stdio", "--auth", "none"]); + + Assert.Equal(1, exit); + Assert.Contains("MissingOption", stderr.ToString()); + Assert.Contains("--endpoint", stderr.ToString()); + } + + // Secret ref validation + + [Fact] + public async Task BareSecretRef_RejectsWithInvalidSecretRef() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + var exit = await root.InvokeAsync(["mcp", "add", "s", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "bearer", + "--token-ref", "MY_RAW_TOKEN"]); + + Assert.Equal(1, exit); + Assert.Contains("InvalidSecretRef", stderr.ToString()); + } + + // Duplicate handling + + [Fact] + public async Task Duplicate_WithoutReplace_RejectsDuplicateServer() + { + var existing = new McpServerDefinition( + "local", + new McpTransportConfig(McpTransportKind.Stdio, "cmd"), + new NoneAuthConfig()); + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([existing])); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + var exit = await root.InvokeAsync(["mcp", "add", "local", + "--transport", "stdio", "--endpoint", "hypa serve", "--auth", "none"]); + + Assert.Equal(1, exit); + Assert.Contains("DuplicateServer", stderr.ToString()); + Assert.Contains("--replace", stderr.ToString()); + } + + [Fact] + public async Task Duplicate_WithReplace_Succeeds() + { + var existing = new McpServerDefinition( + "local", + new McpTransportConfig(McpTransportKind.Stdio, "old-cmd"), + new NoneAuthConfig()); + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([existing])); + + var root = BuildRoot(); + + var exit = await root.InvokeAsync(["mcp", "add", "local", + "--transport", "stdio", "--endpoint", "hypa serve", "--auth", "none", + "--replace"]); + + Assert.Equal(0, exit); + await _writer.Received(1).WriteAsync( + Arg.Is>(list => + list.Count == 1 && list[0].Transport.Endpoint == "hypa serve"), + Arg.Any()); + } + + // mTLS required refs + + [Fact] + public async Task Mtls_MissingBothRefs_RejectsMissingOption() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + var exit = await root.InvokeAsync(["mcp", "add", "s", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "mtls"]); + + Assert.Equal(1, exit); + Assert.Contains("MissingOption", stderr.ToString()); + Assert.Contains("--client-cert-ref", stderr.ToString()); + Assert.Contains("--client-key-ref", stderr.ToString()); + } + + [Fact] + public async Task Mtls_WithBothRefs_Succeeds() + { + var root = BuildRoot(); + + var exit = await root.InvokeAsync(["mcp", "add", "s", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "mtls", + "--client-cert-ref", "env:CERT", + "--client-key-ref", "env:KEY"]); + + Assert.Equal(0, exit); + } + + // Timeout validation + + [Fact] + public async Task NegativeConnectTimeout_Rejects() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + var exit = await root.InvokeAsync(["mcp", "add", "s", + "--transport", "stdio", "--endpoint", "cmd", "--auth", "none", + "--connect-timeout-seconds", "-1"]); + + Assert.Equal(1, exit); + Assert.Contains("connect-timeout-seconds", stderr.ToString()); + } + + [Fact] + public async Task ZeroRequestTimeout_Rejects() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + var exit = await root.InvokeAsync(["mcp", "add", "s", + "--transport", "stdio", "--endpoint", "cmd", "--auth", "none", + "--request-timeout-seconds", "0"]); + + Assert.Equal(1, exit); + Assert.Contains("request-timeout-seconds", stderr.ToString()); + } + + // OAuth2 device-code login delegation + + [Fact] + public async Task Login_OAuth2DeviceCode_DelegatesAuthAfterWrite() + { + var oauth2Def = new McpServerDefinition( + "github", + new McpTransportConfig(McpTransportKind.Http, "https://mcp.github.example.com"), + new OAuth2DeviceCodeConfig( + "https://github.com/login/device/code", + "https://github.com/login/oauth/access_token", + "Iv1.example")); + + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([oauth2Def])); + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(new McpAuthContext(new Dictionary())); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stdout = capture.Stdout; + + var exit = await root.InvokeAsync(["mcp", "add", "github", + "--transport", "streamableHttp", + "--endpoint", "https://mcp.github.example.com", + "--auth", "oauth2DeviceCode", + "--auth-url", "https://github.com/login/device/code", + "--token-url", "https://github.com/login/oauth/access_token", + "--client-id", "Iv1.example", + "--login"]); + + Assert.Equal(0, exit); + await _writer.Received(1).WriteAsync(Arg.Any>(), Arg.Any()); + await _authProvider.Received(1).GetAuthContextAsync( + Arg.Is(d => d.Name == "github"), + Arg.Any()); + var outStr = stdout.ToString(); + Assert.Contains("Added MCP server: github", outStr); + Assert.Contains("Authenticated: github", outStr); + Assert.Contains("Run: hypa mcp schema --server github", outStr); + } + + [Fact] + public async Task Login_OAuth2LoginFailure_KeepsConfigAndPrintsRecovery() + { + var oauth2Def = new McpServerDefinition( + "github", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new OAuth2DeviceCodeConfig( + "https://auth/device", + "https://auth/token", + "client-id")); + + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([oauth2Def])); + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("device flow timed out")); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + var exit = await root.InvokeAsync(["mcp", "add", "github", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "oauth2DeviceCode", + "--auth-url", "https://auth/device", + "--token-url", "https://auth/token", + "--client-id", "client-id", + "--login"]); + + // Config was written even though login failed + await _writer.Received(1).WriteAsync(Arg.Any>(), Arg.Any()); + var errOutput = stderr.ToString(); + Assert.Contains("AuthLoginFailed", errOutput); + Assert.Contains("auth login failed for 'github'.", errOutput); + Assert.Contains("hypa mcp auth login --server github", errOutput); + } + + // Probe-aware tests (Step G) + + [Fact] + public async Task Remote_DefaultProbe_Reachable_Succeeds() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stdout = capture.Stdout; + + var exit = await root.InvokeAsync(["mcp", "add", "remote", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "none"]); + + Assert.Equal(0, exit); + Assert.Contains("Added MCP server: remote", stdout.ToString()); + } + + [Fact] + public async Task Remote_MissingAuth_DefaultsToNoneAndProbes() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stdout = capture.Stdout; + + var exit = await root.InvokeAsync(["mcp", "add", "remote", + "--transport", "streamableHttp", + "--endpoint", "https://example.com"]); + + Assert.Equal(0, exit); + Assert.Contains("Added MCP server: remote", stdout.ToString()); + await _probe.Received(1).ProbeAsync( + Arg.Is(s => s.Auth is NoneAuthConfig), + Arg.Any()); + } + + [Fact] + public async Task Remote_DefaultProbe_AuthRequired_FailsAndPrintsGuidance() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult( + McpServerProbeStatus.AuthRequired, + "Server returned 401 Unauthorized.", + new McpAuthGuidance(null, null, null, null, null, + ["hypa mcp add remote --auth bearer --token-ref env:TOKEN", + "hypa mcp add remote --auth oauth2DeviceCode --login"]))); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + var exit = await root.InvokeAsync(["mcp", "add", "remote", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "none"]); + + Assert.Equal(1, exit); + var errStr = stderr.ToString(); + Assert.Contains("AuthRequired:", errStr); + Assert.Contains("Try one of:", errStr); + Assert.Contains("hypa mcp add remote --auth bearer", errStr); + Assert.Contains("hypa mcp add remote --auth oauth2DeviceCode", errStr); + Assert.Contains("Use --no-probe", errStr); + } + + [Fact] + public async Task Remote_NoProbe_SkipsProbeAndWrites() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("probe must not be called")); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stdout = capture.Stdout; + + var exit = await root.InvokeAsync(["mcp", "add", "remote", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "none", + "--no-probe"]); + + Assert.Equal(0, exit); + Assert.Contains("Added MCP server: remote", stdout.ToString()); + } + + [Fact] + public async Task Remote_Timeout_FailsAndPrintsRetryHint() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult(McpServerProbeStatus.Timeout, "Connection timed out.")); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + var exit = await root.InvokeAsync(["mcp", "add", "remote", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "none"]); + + Assert.Equal(1, exit); + var errStr = stderr.ToString(); + Assert.Contains("Timeout:", errStr); + Assert.Contains("--no-probe", errStr); + } + + [Fact] + public async Task Remote_ConnectionFailed_FailsAndPrintsRetryHint() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult(McpServerProbeStatus.ConnectionFailed, "Failed to reach 'remote'.")); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + var exit = await root.InvokeAsync(["mcp", "add", "remote", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "none"]); + + Assert.Equal(1, exit); + var errStr = stderr.ToString(); + Assert.Contains("ConnectionFailed:", errStr); + Assert.Contains("--no-probe", errStr); + } + + [Fact] + public async Task Stdio_DoesNotProbe() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("probe must not be called")); + + var root = BuildRoot(); + + var exit = await root.InvokeAsync(["mcp", "add", "local", + "--transport", "stdio", + "--endpoint", "hypa serve", + "--auth", "none"]); + + Assert.Equal(0, exit); + } + + [Fact] + public async Task DryRun_DoesNotProbe_AndDoesNotWrite() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("probe must not be called")); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stdout = capture.Stdout; + + var exit = await root.InvokeAsync(["mcp", "add", "remote", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "none", + "--dry-run"]); + + Assert.Equal(0, exit); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task Login_OAuth2DeviceCode_SkipsProbe_AndPersists() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("probe must not be called")); + + var oauth2Def = new McpServerDefinition( + "github", + new McpTransportConfig(McpTransportKind.Http, "https://mcp.github.example.com"), + new OAuth2DeviceCodeConfig( + "https://github.com/login/device/code", + "https://github.com/login/oauth/access_token", + "Iv1.example")); + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([oauth2Def])); + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(new McpAuthContext(new Dictionary())); + + var root = BuildRoot(); + + var exit = await root.InvokeAsync(["mcp", "add", "github", + "--transport", "streamableHttp", + "--endpoint", "https://mcp.github.example.com", + "--auth", "oauth2DeviceCode", + "--auth-url", "https://github.com/login/device/code", + "--token-url", "https://github.com/login/oauth/access_token", + "--client-id", "Iv1.example", + "--login"]); + + Assert.Equal(0, exit); + await _writer.Received(1).WriteAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task Login_NonDeviceCode_RejectedBeforeProbe() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("probe must not be called")); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + var exit = await root.InvokeAsync(["mcp", "add", "s", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "bearer", + "--token-ref", "env:T", + "--login"]); + + Assert.Equal(1, exit); + Assert.Contains("InvalidOption", stderr.ToString()); + } + + [Fact] + public async Task BareSecretRef_RejectedBeforeProbe() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("probe must not be called")); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + var exit = await root.InvokeAsync(["mcp", "add", "s", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "bearer", + "--token-ref", "MY_RAW_TOKEN"]); + + Assert.Equal(1, exit); + Assert.Contains("InvalidSecretRef", stderr.ToString()); + } + + [Fact] + public async Task Probe_FailureDoesNotMutateConfig() + { + var existing = new McpServerDefinition( + "existing", + new McpTransportConfig(McpTransportKind.Stdio, "cmd"), + new NoneAuthConfig()); + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([existing])); + + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult(McpServerProbeStatus.AuthRequired, "auth required")); + + var root = BuildRoot(); + + var exit = await root.InvokeAsync(["mcp", "add", "remote", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "none"]); + + Assert.Equal(1, exit); + await _writer.DidNotReceive().WriteAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task Probe_OutputDoesNotContainNullForGuidanceFields() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult( + McpServerProbeStatus.AuthRequired, + "Server returned 401 Unauthorized.", + new McpAuthGuidance(null, null, null, null, null, null))); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); var stderr = capture.Stderr; + + await root.InvokeAsync(["mcp", "add", "remote", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "none"]); + + Assert.DoesNotContain("null", stderr.ToString(), StringComparison.OrdinalIgnoreCase); + } + +} + +[Trait("Category", "McpAddCommandOAuth")] +[Collection("SequentialEnvTests")] +public sealed class McpAddCommandOAuthTests +{ + private readonly IMcpServerConfigReader _reader = Substitute.For(); + private readonly IMcpServerConfigWriter _writer = Substitute.For(); + private readonly IMcpServerDefinitionRepository _serverRepo = Substitute.For(); + private readonly IMcpAuthProvider _authProvider = Substitute.For(); + private readonly IMcpServerProbe _probe = Substitute.For(); + private readonly IMcpBrowserOAuthFlowProvider _oauthProvider = Substitute.For(); + + private static readonly McpAuthGuidance McpOAuthGuidance = new( + SuggestedAuthMode: "mcpOAuth", + AuthorizationUrl: null, + TokenUrl: null, + ClientId: null, + Scopes: null, + NextCommands: ["hypa mcp auth login --server test-server"]); + + public McpAddCommandOAuthTests() + { + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + _writer.WriteAsync(Arg.Any>(), Arg.Any()) + .Returns(Result.Ok(Unit.Value)); + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + + // Probe returns AuthRequired + mcpOAuth guidance by default + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult( + McpServerProbeStatus.AuthRequired, + "401 Unauthorized", + McpOAuthGuidance)); + } + + private RootCommand BuildRoot() + { + var validator = new McpConfigValidationService(); + var configService = new McpServerConfigService(_reader, _writer, validator, _probe); + var dispatcher = Substitute.For(); + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UtcNow); + var proxyService = new McpProxyService(dispatcher, new McpResponseCompressionService(), new McpToolSearchIndex(), clock); + var command = new McpCommand( + proxyService, _serverRepo, _authProvider, configService, + NullLogger.Instance, + mcpServerImportService: null, + browserOAuthFlowProvider: _oauthProvider); + var root = new RootCommand(); + root.AddCommand(command.Build()); + return root; + } + + [Fact] + public async Task OAuthFlow_NonInteractive_ReturnsExitCode4() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync([ + "mcp", "add", "test-server", + "--transport", "streamableHttp", + "--endpoint", "https://example.com/mcp", + "--auth", "none", + "--non-interactive", + ]); + + Assert.Equal(4, exit); + Assert.Contains("OAuth", capture.Stderr.ToString()); + } + + [Fact] + public async Task OAuthFlow_DryRun_ShowsDryRunOutputAndExits0() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync([ + "mcp", "add", "test-server", + "--transport", "streamableHttp", + "--endpoint", "https://example.com/mcp", + "--auth", "none", + "--dry-run", + ]); + + Assert.Equal(0, exit); + var stdout = capture.Stdout.ToString(); + Assert.Contains("dry-run", stdout); + Assert.Contains("mcpOAuth", stdout); + } + + [Fact] + public async Task OAuthFlow_Success_CallsAddAsyncTwice_AndPrintsSuccess() + { + _oauthProvider.StartFlowAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()) + .Returns(new McpBrowserOAuthFlowResult( + Succeeded: true, + CompletedConfig: new McpOAuthConfig(), + ToolCount: 12)); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync([ + "mcp", "add", "test-server", + "--transport", "streamableHttp", + "--endpoint", "https://example.com/mcp", + "--auth", "none", + ]); + + Assert.Equal(0, exit); + var stdout = capture.Stdout.ToString(); + Assert.Contains("12 tools", stdout); + + // Verify writer was called (second AddAsync persisted config) + await _writer.Received(1).WriteAsync( + Arg.Any>(), + Arg.Any()); + } + + [Fact] + public async Task OAuthFlow_FlowFailed_ReturnsExitCode1() + { + _oauthProvider.StartFlowAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()) + .Returns(new McpBrowserOAuthFlowResult( + Succeeded: false, + CompletedConfig: null, + ToolCount: null, + Error: "Timeout")); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync([ + "mcp", "add", "test-server", + "--transport", "streamableHttp", + "--endpoint", "https://example.com/mcp", + "--auth", "none", + ]); + + Assert.Equal(1, exit); + Assert.Contains("Timeout", capture.Stderr.ToString()); + } + + [Fact] + public async Task OAuthFlow_NoBrowserFlag_PassedToFlowProvider() + { + McpBrowserOAuthOptions? capturedOptions = null; + _oauthProvider.StartFlowAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Do(o => capturedOptions = o), + Arg.Any(), + Arg.Any?>()) + .Returns(new McpBrowserOAuthFlowResult(true, new McpOAuthConfig(), 5)); + + var root = BuildRoot(); + using var _ = new ConsoleCapture(); + + await root.InvokeAsync([ + "mcp", "add", "test-server", + "--transport", "streamableHttp", + "--endpoint", "https://example.com/mcp", + "--auth", "none", + "--no-browser", + ]); + + Assert.True(capturedOptions?.NoBrowser); + } + + [Fact] + public async Task OAuthFlow_Json_Success_OutputsJsonWithToolCount() + { + _oauthProvider.StartFlowAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()) + .Returns(new McpBrowserOAuthFlowResult(true, new McpOAuthConfig(), 7)); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync([ + "mcp", "add", "test-server", + "--transport", "streamableHttp", + "--endpoint", "https://example.com/mcp", + "--auth", "none", + "--json", + ]); + + Assert.Equal(0, exit); + var stdout = capture.Stdout.ToString(); + Assert.Contains("\"success\": true", stdout); + Assert.Contains("\"mcpOAuth\"", stdout); + Assert.Contains("7", stdout); + } + + [Fact] + public async Task OAuthFlow_Json_NonInteractive_OutputsJsonAuthRequired() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync([ + "mcp", "add", "test-server", + "--transport", "streamableHttp", + "--endpoint", "https://example.com/mcp", + "--auth", "none", + "--non-interactive", + "--json", + ]); + + Assert.Equal(4, exit); + var stdout = capture.Stdout.ToString(); + Assert.Contains("\"success\": false", stdout); + Assert.Contains("\"AuthRequired\"", stdout); + Assert.Contains("\"mcpOAuth\"", stdout); + } + + [Fact] + public async Task OAuthFlow_Progress_ReportsAuthUrl() + { + IProgress? capturedProgress = null; + _oauthProvider.StartFlowAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Do?>(p => capturedProgress = p)) + .Returns(new McpBrowserOAuthFlowResult(true, new McpOAuthConfig(), 3)); + + var root = BuildRoot(); + using var _ = new ConsoleCapture(); + + await root.InvokeAsync([ + "mcp", "add", "test-server", + "--transport", "streamableHttp", + "--endpoint", "https://example.com/mcp", + "--auth", "none", + ]); + + Assert.NotNull(capturedProgress); + } + + [Fact] + public async Task OAuthFlow_Json_SetsInteractiveFalse() + { + McpBrowserOAuthOptions? capturedOptions = null; + _oauthProvider.StartFlowAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Do(o => capturedOptions = o), + Arg.Any(), + Arg.Any?>()) + .Returns(new McpBrowserOAuthFlowResult(true, new McpOAuthConfig(), 2)); + + var root = BuildRoot(); + using var _ = new ConsoleCapture(); + + await root.InvokeAsync([ + "mcp", "add", "test-server", + "--transport", "streamableHttp", + "--endpoint", "https://example.com/mcp", + "--auth", "none", + "--json", + ]); + + Assert.NotNull(capturedOptions); + Assert.False(capturedOptions!.Interactive); + } + + [Fact] + public async Task OAuthFlow_NonJson_SetsInteractiveTrue() + { + McpBrowserOAuthOptions? capturedOptions = null; + _oauthProvider.StartFlowAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Do(o => capturedOptions = o), + Arg.Any(), + Arg.Any?>()) + .Returns(new McpBrowserOAuthFlowResult(true, new McpOAuthConfig(), 2)); + + var root = BuildRoot(); + using var _ = new ConsoleCapture(); + + await root.InvokeAsync([ + "mcp", "add", "test-server", + "--transport", "streamableHttp", + "--endpoint", "https://example.com/mcp", + "--auth", "none", + ]); + + Assert.NotNull(capturedOptions); + Assert.True(capturedOptions!.Interactive); + } + + [Fact] + public async Task OAuthFlow_Json_NonInteractive_GuidanceOmitsDiscoveryFields() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + await root.InvokeAsync([ + "mcp", "add", "test-server", + "--transport", "streamableHttp", + "--endpoint", "https://example.com/mcp", + "--auth", "none", + "--non-interactive", + "--json", + ]); + + var stdout = capture.Stdout.ToString(); + Assert.Contains("mcpOAuth", stdout, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("discoveryUrl", stdout, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("supportsDynamicClientRegistration", stdout, StringComparison.OrdinalIgnoreCase); + } +} + +[Trait("Category", "McpAddCommandJson")] +[Collection("SequentialEnvTests")] +public sealed class McpAddCommandJsonTests +{ + private readonly IMcpServerConfigReader _reader = Substitute.For(); + private readonly IMcpServerConfigWriter _writer = Substitute.For(); + private readonly IMcpServerDefinitionRepository _serverRepo = Substitute.For(); + private readonly IMcpAuthProvider _authProvider = Substitute.For(); + private readonly IMcpServerProbe _probe = Substitute.For(); + + public McpAddCommandJsonTests() + { + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + _writer.WriteAsync(Arg.Any>(), Arg.Any()) + .Returns(Result.Ok(Unit.Value)); + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult(McpServerProbeStatus.Reachable, "ok")); + } + + private RootCommand BuildRoot() + { + var validator = new McpConfigValidationService(); + var configService = new McpServerConfigService(_reader, _writer, validator, _probe); + var dispatcher = Substitute.For(); + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UtcNow); + var proxyService = new McpProxyService(dispatcher, new McpResponseCompressionService(), new McpToolSearchIndex(), clock); + var command = new McpCommand(proxyService, _serverRepo, _authProvider, configService, NullLogger.Instance); + var root = new RootCommand(); + root.AddCommand(command.Build()); + return root; + } + + [Fact] + public async Task Json_OrdinarySuccess_OutputsJsonWithNameAndAuth() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync([ + "mcp", "add", "myserver", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "none", + "--json", + ]); + + Assert.Equal(0, exit); + var stdout = capture.Stdout.ToString(); + Assert.Contains("\"success\": true", stdout); + Assert.Contains("\"myserver\"", stdout); + Assert.Contains("\"none\"", stdout); + } + + [Fact] + public async Task Json_ProbeFailure_OutputsJsonError() + { + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult( + McpServerProbeStatus.AuthRequired, + "Server returned 401 Unauthorized.", + new McpAuthGuidance("bearer", null, null, null, null, + ["hypa mcp add myserver --auth bearer --token-ref env:TOKEN"]))); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync([ + "mcp", "add", "myserver", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "none", + "--json", + ]); + + Assert.Equal(1, exit); + var stdout = capture.Stdout.ToString(); + Assert.Contains("\"success\": false", stdout); + Assert.Contains("\"bearer\"", stdout); + } + + [Fact] + public async Task Json_ValidationFailure_OutputsJsonError() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync([ + "mcp", "add", "myserver", + "--transport", "streamableHttp", + "--endpoint", "https://example.com", + "--auth", "bearer", + "--token-ref", "env:TOKEN", + "--json", + ]); + + Assert.Equal(0, exit); + var stdout = capture.Stdout.ToString(); + Assert.Contains("\"success\": true", stdout); + Assert.DoesNotContain("error", stdout, StringComparison.OrdinalIgnoreCase); + } +} + +[Trait("Category", "McpAuthLoginOAuth")] +[Collection("SequentialEnvTests")] +public sealed class McpAuthLoginOAuthTests +{ + private readonly IMcpServerConfigReader _reader = Substitute.For(); + private readonly IMcpServerConfigWriter _writer = Substitute.For(); + private readonly IMcpServerDefinitionRepository _serverRepo = Substitute.For(); + private readonly IMcpAuthProvider _authProvider = Substitute.For(); + private readonly IMcpServerProbe _probe = Substitute.For(); + private readonly IMcpBrowserOAuthFlowProvider _oauthProvider = Substitute.For(); + + private static readonly McpServerDefinition OAuthServer = new( + Name: "my-oauth-server", + Transport: new McpTransportConfig(McpTransportKind.Http, "https://example.com/mcp"), + Auth: new McpOAuthConfig(ClientId: "client-id")); + + public McpAuthLoginOAuthTests() + { + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + _writer.WriteAsync(Arg.Any>(), Arg.Any()) + .Returns(Result.Ok(Unit.Value)); + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([OAuthServer])); + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult(McpServerProbeStatus.Reachable, "ok")); + } + + private RootCommand BuildRoot(IMcpBrowserOAuthFlowProvider? provider = null, bool noProvider = false) + { + var validator = new McpConfigValidationService(); + var configService = new McpServerConfigService(_reader, _writer, validator, _probe); + var dispatcher = Substitute.For(); + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UtcNow); + var proxyService = new McpProxyService(dispatcher, new McpResponseCompressionService(), new McpToolSearchIndex(), clock); + var command = new McpCommand( + proxyService, _serverRepo, _authProvider, configService, + NullLogger.Instance, + mcpServerImportService: null, + browserOAuthFlowProvider: noProvider ? null : (provider ?? _oauthProvider)); + var root = new RootCommand(); + root.AddCommand(command.Build()); + return root; + } + + [Fact] + public async Task AuthLogin_McpOAuth_Success_Prints_LoginSuccessful() + { + _oauthProvider.StartFlowAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), + Arg.Any?>()) + .Returns(new McpBrowserOAuthFlowResult(Succeeded: true, CompletedConfig: new McpOAuthConfig(), ToolCount: 5)); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "auth", "login", "--server", "my-oauth-server"]); + + Assert.Equal(0, exit); + Assert.Contains("Login successful", capture.Stdout.ToString()); + } + + [Fact] + public async Task AuthLogin_McpOAuth_FlowFailure_ReturnsExitCode1_AndPrintsError() + { + _oauthProvider.StartFlowAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), + Arg.Any?>()) + .Returns(new McpBrowserOAuthFlowResult(Succeeded: false, CompletedConfig: null, ToolCount: null, Error: "Timed out")); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "auth", "login", "--server", "my-oauth-server"]); + + Assert.Equal(1, exit); + Assert.Contains("Timed out", capture.Stderr.ToString()); + } + + [Fact] + public async Task AuthLogin_McpOAuth_MissingProvider_ReturnsExitCode1() + { + var root = BuildRoot(noProvider: true); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "auth", "login", "--server", "my-oauth-server"]); + + Assert.Equal(1, exit); + Assert.Contains("not available", capture.Stderr.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task AuthLogin_McpOAuth_PassesTlsFromServerDefinition() + { + var tlsServer = OAuthServer with { Tls = new McpTlsConfig("/ca.pem", null, null) }; + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([tlsServer])); + + McpBrowserOAuthOptions? capturedOptions = null; + _oauthProvider.StartFlowAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Do(o => capturedOptions = o), + Arg.Any(), + Arg.Any?>()) + .Returns(new McpBrowserOAuthFlowResult(true, new McpOAuthConfig(), 3)); + + var root = BuildRoot(); + using var _ = new ConsoleCapture(); + + await root.InvokeAsync(["mcp", "auth", "login", "--server", "my-oauth-server"]); + + Assert.NotNull(capturedOptions); + Assert.Equal("/ca.pem", capturedOptions!.Tls?.CaCertPath); + } + + [Fact] + public async Task AuthLogin_McpOAuth_NonOAuthServer_ReturnsExitCode1_AndMentionsSupportedModes() + { + var bearerServer = OAuthServer with { Name = "bearer-server", Auth = new BearerAuthConfig("env:TOKEN") }; + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([bearerServer])); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "auth", "login", "--server", "bearer-server"]); + + Assert.Equal(1, exit); + Assert.Contains("oauth2DeviceCode", capture.Stderr.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("mcpOAuth", capture.Stderr.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task AuthLogin_McpOAuth_ProgressIsPassedToFlowProvider() + { + IProgress? capturedProgress = null; + _oauthProvider.StartFlowAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Do?>(p => capturedProgress = p)) + .Returns(new McpBrowserOAuthFlowResult(true, new McpOAuthConfig(), 2)); + + var root = BuildRoot(); + using var _ = new ConsoleCapture(); + + await root.InvokeAsync(["mcp", "auth", "login", "--server", "my-oauth-server"]); + + Assert.NotNull(capturedProgress); + } +} + +internal sealed class ConsoleCapture : IDisposable +{ + private readonly TextWriter _origOut = Console.Out; + private readonly TextWriter _origErr = Console.Error; + public readonly System.Text.StringBuilder Stdout = new(); + public readonly System.Text.StringBuilder Stderr = new(); + + public ConsoleCapture() + { + Console.SetOut(new System.IO.StringWriter(Stdout)); + Console.SetError(new System.IO.StringWriter(Stderr)); + } + + public void Dispose() + { + Console.SetOut(_origOut); + Console.SetError(_origErr); + } +} diff --git a/tests/Hypa.UnitTests/Cli/McpCommandRegressionTests.cs b/tests/Hypa.UnitTests/Cli/McpCommandRegressionTests.cs new file mode 100644 index 0000000..ff8f998 --- /dev/null +++ b/tests/Hypa.UnitTests/Cli/McpCommandRegressionTests.cs @@ -0,0 +1,99 @@ +using System.CommandLine; +using Hypa.Cli.Commands; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Cli; + +/// +/// Proves the probe port is wired only to AddAsync — not to auth check, schema, or invoke. +/// +[Trait("Category", "McpCommandRegression")] +public sealed class McpCommandRegressionTests +{ + private readonly IMcpServerConfigReader _reader = Substitute.For(); + private readonly IMcpServerConfigWriter _writer = Substitute.For(); + private readonly IMcpServerDefinitionRepository _serverRepo = Substitute.For(); + private readonly IMcpAuthProvider _authProvider = Substitute.For(); + private readonly IMcpDispatcher _dispatcher = Substitute.For(); + private readonly IMcpServerProbe _probe = Substitute.For(); + + private static readonly McpServerDefinition TestServer = new( + "test-server", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new NoneAuthConfig()); + + public McpCommandRegressionTests() + { + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + _writer.WriteAsync(Arg.Any>(), Arg.Any()) + .Returns(Result.Ok(Unit.Value)); + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([TestServer])); + + // Any call to ProbeAsync during these commands is a test failure. + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("probe must not be called from auth check / schema / invoke")); + } + + private RootCommand BuildRoot() + { + var validator = new McpConfigValidationService(); + var configService = new McpServerConfigService(_reader, _writer, validator, _probe); + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UtcNow); + var proxyService = new McpProxyService(_dispatcher, new McpResponseCompressionService(), new McpToolSearchIndex(), clock); + var command = new McpCommand(proxyService, _serverRepo, _authProvider, configService, NullLogger.Instance); + var root = new RootCommand(); + root.AddCommand(command.Build()); + return root; + } + + [Fact] + public async Task AuthCheck_DoesNotInvokeProbe() + { + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(new McpAuthContext(new Dictionary())); + + var root = BuildRoot(); + var exit = await root.InvokeAsync(["mcp", "auth", "check", "--server", "test-server"]); + + Assert.Equal(0, exit); + await _probe.DidNotReceive().ProbeAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Schema_DoesNotInvokeProbe() + { + _dispatcher.GetSchemaAsync(Arg.Any()) + .Returns(new McpSchemaManifest([], null)); + + var root = BuildRoot(); + var exit = await root.InvokeAsync(["mcp", "schema"]); + + Assert.Equal(0, exit); + await _probe.DidNotReceive().ProbeAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Invoke_DoesNotInvokeProbe() + { + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UtcNow); + var latency = new McpLatencyMetadata(DateTimeOffset.UtcNow, TimeSpan.FromMilliseconds(10)); + _dispatcher.InvokeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpResult("test-server", "some-tool", new JsonPayload("{}"), "ok", latency, IsError: false, Error: null)); + + var root = BuildRoot(); + var exit = await root.InvokeAsync(["mcp", "invoke", "--server", "test-server", "--tool", "some-tool"]); + + Assert.Equal(0, exit); + await _probe.DidNotReceive().ProbeAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/tests/Hypa.UnitTests/Cli/McpDiscoveryCommandTests.cs b/tests/Hypa.UnitTests/Cli/McpDiscoveryCommandTests.cs new file mode 100644 index 0000000..8bcff50 --- /dev/null +++ b/tests/Hypa.UnitTests/Cli/McpDiscoveryCommandTests.cs @@ -0,0 +1,301 @@ +using System.CommandLine; +using System.Text.Json; +using Hypa.Cli.Commands; +using Hypa.Cli.Json; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Cli; + +[Trait("Category", "McpDiscoveryCommand")] +[Collection("SequentialEnvTests")] +public sealed class McpDiscoveryCommandTests +{ + private readonly IMcpServerConfigReader _reader = Substitute.For(); + private readonly IMcpServerConfigWriter _writer = Substitute.For(); + private readonly IMcpServerDefinitionRepository _serverRepo = Substitute.For(); + private readonly IMcpAuthProvider _authProvider = Substitute.For(); + private readonly IMcpServerProbe _probe = Substitute.For(); + private readonly IMcpDispatcher _dispatcher = Substitute.For(); + + public McpDiscoveryCommandTests() + { + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + _writer.WriteAsync(Arg.Any>(), Arg.Any()) + .Returns(Result.Ok(Unit.Value)); + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult(McpServerProbeStatus.Reachable, "ok")); + _dispatcher.GetSchemaAsync(Arg.Any()) + .Returns(new McpSchemaManifest([], null)); + } + + private RootCommand BuildRoot() + { + var validator = new McpConfigValidationService(); + var configService = new McpServerConfigService(_reader, _writer, validator, _probe); + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UtcNow); + var proxyService = new McpProxyService(_dispatcher, new McpResponseCompressionService(), new McpToolSearchIndex(), clock); + var command = new McpCommand(proxyService, _serverRepo, _authProvider, configService, NullLogger.Instance); + var root = new RootCommand(); + root.AddCommand(command.Build()); + return root; + } + + // --- mcp list --- + + [Fact] + public async Task List_ZeroServers_PrintsNoServersConfigured() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "list"]); + + Assert.Equal(0, exit); + Assert.Contains("No MCP servers configured.", capture.Stdout.ToString()); + } + + [Fact] + public async Task List_StdioNoneAuth_PrintsNameTransportAndEndpoint() + { + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([ + new McpServerDefinition( + "my-server", + new McpTransportConfig(McpTransportKind.Stdio, "hypa serve"), + new NoneAuthConfig()), + ])); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "list"]); + + Assert.Equal(0, exit); + var stdout = capture.Stdout.ToString(); + Assert.Contains("my-server", stdout); + Assert.Contains("hypa serve", stdout); + Assert.Contains("None", stdout, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task List_BearerAuth_PrintsBearerLabel() + { + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([ + new McpServerDefinition( + "api-server", + new McpTransportConfig(McpTransportKind.HttpAutoDetect, "https://example.com"), + new BearerAuthConfig("env:TOKEN")), + ])); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "list"]); + + Assert.Equal(0, exit); + Assert.Contains("BearerAuth", capture.Stdout.ToString()); + } + + [Fact] + public async Task List_LoadFailure_WritesErrorToStderrAndExitsNonZero() + { + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Fail( + new Error("LoadFailed", "disk error"))); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "list"]); + + Assert.NotEqual(0, exit); + Assert.Contains("error", capture.Stderr.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task List_Json_ReturnsWellFormedArrayWithCorrectFields() + { + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([ + new McpServerDefinition( + "json-server", + new McpTransportConfig(McpTransportKind.Http, "https://example.com/mcp"), + new BearerAuthConfig("env:TOKEN"), + Tls: new McpTlsConfig("/etc/ssl/ca.crt", null, null)), + ])); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "list", "--json"]); + + Assert.Equal(0, exit); + var items = JsonSerializer.Deserialize>( + capture.Stdout.ToString(), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal("json-server", items[0].Name); + Assert.Equal("BearerAuth", items[0].Auth); + Assert.True(items[0].HasTls); + } + + // --- mcp tools --- + + [Fact] + public async Task Tools_NoTools_PrintsNoToolsFound() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "tools"]); + + Assert.Equal(0, exit); + Assert.Contains("No tools found.", capture.Stdout.ToString()); + } + + [Fact] + public async Task Tools_UnknownServer_PrintsServerNotFoundMessage() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "tools", "--server", "unknown"]); + + Assert.Equal(0, exit); + Assert.Contains("not found", capture.Stdout.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Tools_MultipleServers_ListsAllToolsWithServerPrefix() + { + _dispatcher.GetSchemaAsync(Arg.Any()) + .Returns(new McpSchemaManifest( + [ + new McpServerSchema("server-a", [ + new McpToolSchema("tool-one", "Does the first thing", new JsonPayload("{}")), + ]), + new McpServerSchema("server-b", [ + new McpToolSchema("tool-two", "Does the second thing", new JsonPayload("{}")), + ]), + ], null)); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "tools"]); + + Assert.Equal(0, exit); + var stdout = capture.Stdout.ToString(); + Assert.Contains("server-a/tool-one", stdout); + Assert.Contains("server-b/tool-two", stdout); + } + + [Fact] + public async Task Tools_LongDescription_IsTruncatedInHumanOutput() + { + var longDesc = new string('x', 150); + _dispatcher.GetSchemaAsync(Arg.Any()) + .Returns(new McpSchemaManifest( + [ + new McpServerSchema("srv", [ + new McpToolSchema("big-tool", longDesc, new JsonPayload("{}")), + ]), + ], null)); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + await root.InvokeAsync(["mcp", "tools"]); + + var stdout = capture.Stdout.ToString(); + Assert.DoesNotContain(longDesc, stdout); + Assert.Contains('…', stdout); + } + + [Fact] + public async Task Tools_ServerFilter_ShowsOnlyMatchingServersTools() + { + _dispatcher.GetSchemaAsync(Arg.Any()) + .Returns(new McpSchemaManifest( + [ + new McpServerSchema("target", [ + new McpToolSchema("good-tool", "included", new JsonPayload("{}")), + ]), + new McpServerSchema("other", [ + new McpToolSchema("other-tool", "excluded", new JsonPayload("{}")), + ]), + ], null)); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "tools", "--server", "target"]); + + Assert.Equal(0, exit); + var stdout = capture.Stdout.ToString(); + Assert.Contains("good-tool", stdout); + Assert.DoesNotContain("other-tool", stdout); + } + + [Fact] + public async Task Tools_Json_DescriptionsAreNotTruncated() + { + var fullDesc = new string('d', 200); + _dispatcher.GetSchemaAsync(Arg.Any()) + .Returns(new McpSchemaManifest( + [ + new McpServerSchema("srv", [ + new McpToolSchema("my-tool", fullDesc, new JsonPayload("{}")), + ]), + ], null)); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "tools", "--json"]); + + Assert.Equal(0, exit); + var items = JsonSerializer.Deserialize>( + capture.Stdout.ToString(), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal(fullDesc, items[0].Description); + } + + [Fact] + public async Task Tools_SchemaErrorOnOneServer_ShowsGoodToolsAndWarnsOnStderr() + { + _dispatcher.GetSchemaAsync(Arg.Any()) + .Returns(new McpSchemaManifest( + [ + new McpServerSchema("good-srv", [ + new McpToolSchema("a-tool", "works fine", new JsonPayload("{}")), + ]), + ], + [ + new McpSchemaError("bad-srv", McpErrorCodes.SchemaUnavailable, "connection refused"), + ])); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "tools"]); + + Assert.Equal(0, exit); + Assert.Contains("a-tool", capture.Stdout.ToString()); + Assert.Contains("bad-srv", capture.Stderr.ToString()); + } +} diff --git a/tests/Hypa.UnitTests/Cli/McpImportCommandTests.cs b/tests/Hypa.UnitTests/Cli/McpImportCommandTests.cs new file mode 100644 index 0000000..b5c4733 --- /dev/null +++ b/tests/Hypa.UnitTests/Cli/McpImportCommandTests.cs @@ -0,0 +1,252 @@ +using System.CommandLine; +using System.Text; +using Hypa.Cli.Commands; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Cli; + +[Trait("Category", "McpImportCommand")] +[Collection("SequentialEnvTests")] +public sealed class McpImportCommandTests +{ + private readonly IMcpServerConfigReader _reader = Substitute.For(); + private readonly IMcpServerConfigWriter _writer = Substitute.For(); + private readonly IMcpServerDefinitionRepository _serverRepo = Substitute.For(); + private readonly IMcpAuthProvider _authProvider = Substitute.For(); + private readonly IMcpServerImportService _importService = Substitute.For(); + private readonly IMcpServerProbe _probe = Substitute.For(); + + public McpImportCommandTests() + { + _reader.ReadEditableAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + _writer.WriteAsync(Arg.Any>(), Arg.Any()) + .Returns(Result.Ok(Unit.Value)); + _serverRepo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + _importService.ImportAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Ok(new McpImportReport([], 0, 0, 0, 0))); + _probe.ProbeAsync(Arg.Any(), Arg.Any()) + .Returns(new McpServerProbeResult(McpServerProbeStatus.Reachable, "ok")); + } + + private RootCommand BuildRoot() + { + var validator = new McpConfigValidationService(); + var configService = new McpServerConfigService(_reader, _writer, validator, _probe); + var dispatcher = Substitute.For(); + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UtcNow); + var proxyService = new McpProxyService(dispatcher, new McpResponseCompressionService(), new McpToolSearchIndex(), clock); + var command = new McpCommand(proxyService, _serverRepo, _authProvider, configService, NullLogger.Instance, _importService); + var root = new RootCommand(); + root.AddCommand(command.Build()); + return root; + } + + [Fact] + public async Task ImportCommand_DryRun_PrintsPreviewAndCallsServiceWithDryRun() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "import", "--dry-run"]); + + Assert.Equal(0, exit); + Assert.Contains("Dry run", capture.Stdout.ToString()); + + await _importService.Received(1).ImportAsync( + Arg.Is(r => r.DryRun == true), + Arg.Any()); + } + + [Fact] + public async Task ImportCommand_AgentClaude_PassesAgentKeyToService() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "import", "--agent", "claude"]); + + Assert.Equal(0, exit); + await _importService.Received(1).ImportAsync( + Arg.Is(r => r.AgentKey == "claude"), + Arg.Any()); + } + + [Fact] + public async Task ImportCommand_AgentAll_PassesNullAgentKeyToService() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "import", "--agent", "all"]); + + Assert.Equal(0, exit); + await _importService.Received(1).ImportAsync( + Arg.Is(r => r.AgentKey == null), + Arg.Any()); + } + + [Fact] + public async Task ImportCommand_UnknownAgent_ReturnsExitCode1() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "import", "--agent", "unknown-agent"]); + + Assert.Equal(1, exit); + await _importService.DidNotReceive().ImportAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ImportCommand_NoServersFound_ReturnsSuccess() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "import"]); + + Assert.Equal(0, exit); + } + + [Fact] + public async Task ImportCommand_WithImportedServers_PrintsImportedSymbol() + { + var def = new McpServerDefinition("github", + new McpTransportConfig(McpTransportKind.Stdio, "gh-mcp"), + new NoneAuthConfig(), null, null, null); + var fp = McpServerImportService.ComputeFingerprint(def); + var conn = new McpImportedConnection("claude", "global", "github", def, fp, + McpImportCandidateStatus.Importable, null); + var sourceResult = new McpImportSourceResult("claude", "global", [conn]); + _importService.ImportAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Ok(new McpImportReport([sourceResult], 1, 0, 0, 0))); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "import"]); + + Assert.Equal(0, exit); + var output = capture.Stdout.ToString(); + Assert.Contains("github", output); + Assert.Contains("+", output); + } + + [Fact] + public async Task ImportCommand_WithConflict_PrintsConflictSymbol() + { + var conn = new McpImportedConnection("claude", "global", "conflict-server", null, + string.Empty, McpImportCandidateStatus.SkippedConflict, + "conflict — different configuration already exists"); + var sourceResult = new McpImportSourceResult("claude", "global", [conn]); + _importService.ImportAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Ok(new McpImportReport([sourceResult], 0, 0, 1, 1))); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "import"]); + + Assert.Equal(0, exit); + var output = capture.Stdout.ToString(); + Assert.Contains("~", output); + Assert.Contains("conflict-server", output); + } + + [Fact] + public async Task ImportCommand_WithDuplicate_PrintsEqualSymbol() + { + var conn = new McpImportedConnection("claude", "global", "my-server", null, + string.Empty, McpImportCandidateStatus.SkippedDuplicate, "already present"); + var sourceResult = new McpImportSourceResult("claude", "global", [conn]); + _importService.ImportAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Ok(new McpImportReport([sourceResult], 0, 1, 1, 0))); + + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "import"]); + + Assert.Equal(0, exit); + var output = capture.Stdout.ToString(); + Assert.Contains("=", output); + Assert.Contains("my-server", output); + } + + [Fact] + public async Task ImportCommand_ScopeProject_PassesScopeToService() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "import", "--scope", "project", + "--project-root", "/tmp/myproject"]); + + Assert.Equal(0, exit); + await _importService.Received(1).ImportAsync( + Arg.Is(r => + r.Scope == McpImportScope.Project && + r.ProjectRoot == "/tmp/myproject"), + Arg.Any()); + } + + [Fact] + public async Task ImportCommand_ScopeProject_NoProjectRoot_ReturnsExitCode1() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "import", "--scope", "project"]); + + Assert.Equal(1, exit); + Assert.Contains("--project-root", capture.Stderr.ToString()); + await _importService.DidNotReceive().ImportAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ImportCommand_ScopeAll_NoProjectRoot_ReturnsExitCode1() + { + var root = BuildRoot(); + using var capture = new ConsoleCapture(); + + var exit = await root.InvokeAsync(["mcp", "import", "--scope", "all"]); + + Assert.Equal(1, exit); + Assert.Contains("--project-root", capture.Stderr.ToString()); + await _importService.DidNotReceive().ImportAsync( + Arg.Any(), Arg.Any()); + } + + private sealed class ConsoleCapture : IDisposable + { + private readonly TextWriter _origOut; + private readonly TextWriter _origErr; + public StringBuilder Stdout { get; } = new(); + public StringBuilder Stderr { get; } = new(); + + public ConsoleCapture() + { + _origOut = Console.Out; + _origErr = Console.Error; + Console.SetOut(new StringWriter(Stdout)); + Console.SetError(new StringWriter(Stderr)); + } + + public void Dispose() + { + Console.SetOut(_origOut); + Console.SetError(_origErr); + } + } +} diff --git a/tests/Hypa.UnitTests/Cli/MdCommandTests.cs b/tests/Hypa.UnitTests/Cli/MdCommandTests.cs new file mode 100644 index 0000000..0f8c0f7 --- /dev/null +++ b/tests/Hypa.UnitTests/Cli/MdCommandTests.cs @@ -0,0 +1,178 @@ +using System.CommandLine; +using System.Text.Json; +using Hypa.Cli.Commands; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Sdk.CodeIntelligence; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Cli; + +public sealed class MdCommandTests +{ + [Fact] + public async Task MdCommand_WhenTocFlag_CallsQueryTocAndPrintsHeadings() + { + var repository = Substitute.For(); + repository.QueryMarkdownSectionsAsync("notes.md", Arg.Any()) + .Returns(Task.FromResult>([ + MakeSection("notes.md", "Intro", 1, 0), + MakeSection("notes.md", "Details", 2, 10), + ])); + var root = BuildRoot(repository); + + var (exitCode, output) = await InvokeAsync(root, ["md", "notes.md", "--toc"]); + + Assert.Equal(0, exitCode); + Assert.Contains("Intro", output); + Assert.Contains("Details", output); + await repository.Received(1).QueryMarkdownSectionsAsync("notes.md", Arg.Any()); + } + + [Fact] + public async Task MdCommand_WhenFrontmatterFlag_CallsQueryFrontmatterAndPrintsKeys() + { + var repository = Substitute.For(); + repository.QueryMarkdownAsync("notes.md", Arg.Any()) + .Returns(Task.FromResult(new CodeStructureDocument + { + File = MakeFile("notes.md"), + Provenance = MakeProvenance(), + FrontmatterYaml = "title\nauthor", + })); + var root = BuildRoot(repository); + + var (exitCode, output) = await InvokeAsync(root, ["md", "notes.md", "--frontmatter"]); + + Assert.Equal(0, exitCode); + Assert.Contains("title", output); + Assert.Contains("author", output); + await repository.Received(1).QueryMarkdownAsync("notes.md", Arg.Any()); + } + + [Fact] + public async Task MdCommand_WhenNoFlags_DefaultsToToc() + { + var repository = Substitute.For(); + repository.QueryMarkdownSectionsAsync("notes.md", Arg.Any()) + .Returns(Task.FromResult>([ + MakeSection("notes.md", "Intro", 1, 0), + ])); + var root = BuildRoot(repository); + + var (exitCode, output) = await InvokeAsync(root, ["md", "notes.md"]); + + Assert.Equal(0, exitCode); + Assert.Contains("Intro", output); + await repository.Received(1).QueryMarkdownSectionsAsync("notes.md", Arg.Any()); + } + + [Fact] + public async Task MdCommand_WhenCombinedJson_EmitsSingleJsonObject() + { + var repository = Substitute.For(); + repository.QueryMarkdownAsync("notes.md", Arg.Any()) + .Returns(Task.FromResult(new CodeStructureDocument + { + File = MakeFile("notes.md"), + Provenance = MakeProvenance(), + FrontmatterYaml = "title: Notes", + })); + repository.QueryMarkdownSectionsAsync("notes.md", Arg.Any()) + .Returns(Task.FromResult>([ + MakeSection("notes.md", "Intro", 1, 0), + MakeSection("notes.md", "Details", 2, 10), + ])); + var root = BuildRoot(repository); + + var (exitCode, output) = await InvokeAsync(root, ["md", "notes.md", "--frontmatter", "--toc", "--section", "Intro", "--json"]); + + Assert.Equal(0, exitCode); + Assert.Single(output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)); + using var document = JsonDocument.Parse(output); + Assert.Equal("notes.md", document.RootElement.GetProperty("filePath").GetString()); + Assert.Equal("title: Notes", document.RootElement.GetProperty("frontmatter").GetString()); + Assert.Equal(2, document.RootElement.GetProperty("toc").GetArrayLength()); + Assert.True(document.RootElement.GetProperty("sectionMatched").GetBoolean()); + Assert.Equal(1, document.RootElement.GetProperty("sections").GetArrayLength()); + } + + [Fact] + public async Task MdCommand_WhenSectionMiss_TextPrintsClearMessage() + { + var repository = Substitute.For(); + repository.QueryMarkdownSectionsAsync("notes.md", Arg.Any()) + .Returns(Task.FromResult>([ + MakeSection("notes.md", "Intro", 1, 0), + ])); + var root = BuildRoot(repository); + + var (exitCode, output) = await InvokeAsync(root, ["md", "notes.md", "--section", "Missing"]); + + Assert.Equal(0, exitCode); + Assert.Contains("No Markdown section matched 'Missing'.", output); + } + + private static RootCommand BuildRoot(ICodeIndexRepository repository) + { + var registry = new CodeStructureProviderRegistry([]); + var queryService = new CodeQueryService(repository); + var indexService = new CodeIndexService(Substitute.For(), registry, repository, Substitute.For()); + var diagnosticsService = new CodeDiagnosticsService(repository, registry); + var root = new RootCommand(); + root.AddCommand(new CodeCommand(indexService, queryService, diagnosticsService).BuildMd()); + return root; + } + + private static async Task<(int ExitCode, string Output)> InvokeAsync(Command command, string[] args) + { + var originalOut = Console.Out; + using var writer = new StringWriter(); + try + { + Console.SetOut(writer); + var exitCode = await command.InvokeAsync(args); + return (exitCode, writer.ToString()); + } + finally + { + Console.SetOut(originalOut); + } + } + + private static MarkdownSection MakeSection(string filePath, string headingText, int headingLevel, int startByte) => new() + { + Id = $"section_{startByte}", + FilePath = filePath, + HeadingText = headingText, + HeadingLevel = headingLevel, + HeadingPath = headingText, + HeadingAnchor = headingText.ToLowerInvariant(), + StartLine = startByte + 1, + EndLine = startByte + 2, + StartByte = startByte, + EndByte = startByte + 5, + PlainText = $"{headingText} content", + Provenance = MakeProvenance(), + }; + + private static CodeFileIdentity MakeFile(string relativePath) => new() + { + ProjectRoot = "/project", + Path = $"/project/{relativePath}", + RelativePath = relativePath, + Language = "markdown", + ContentHash = "hash", + SizeBytes = 0, + }; + + private static ProviderProvenance MakeProvenance() => new() + { + ProviderId = "markdown", + ProviderVersion = "1", + QueryVersion = "1", + FactKind = "syntactic", + Confidence = 1, + }; +} diff --git a/tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/CodeStructureProviderRegistryTests.cs b/tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/CodeStructureProviderRegistryTests.cs new file mode 100644 index 0000000..bde47e1 --- /dev/null +++ b/tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/CodeStructureProviderRegistryTests.cs @@ -0,0 +1,102 @@ +using Hypa.Infrastructure.CodeIntelligence; +using Hypa.Runtime.Application.Services; +using Xunit; + +namespace Hypa.UnitTests.Infrastructure.CodeIntelligence; + +/// +/// Verifies that selects the correct provider +/// for each language, and that the providers' CanHandle contracts are mutually exclusive +/// for languages that have a dedicated provider. +/// +public sealed class CodeStructureProviderRegistryTests +{ + private static readonly CodeStructureProviderRegistry Registry = new( + [ + new TreeSitterCodeStructureProvider(), + new MarkdownStructureProvider(), + new RegexFallbackCodeStructureProvider(), + ]); + + // ── Provider selection ──────────────────────────────────────────────────── + + [Fact] + public void Select_ForMarkdown_ReturnsMarkdownProvider() + { + var provider = Registry.Select("markdown"); + + Assert.Equal("markdown", provider.Id); + } + + [Theory] + [InlineData("c-sharp")] + [InlineData("typescript")] + [InlineData("python")] + [InlineData("rust")] + [InlineData("go")] + public void Select_ForCodeLanguage_ReturnsTreeSitterProvider(string language) + { + var provider = Registry.Select(language); + + Assert.Equal("tree-sitter", provider.Id); + } + + [Fact] + public void Select_ForUnknownLanguage_ReturnsFallbackProvider() + { + var provider = Registry.Select("cobol"); + + Assert.Equal("regex-fallback", provider.Id); + } + + // ── CanHandle exclusivity ───────────────────────────────────────────────── + + [Fact] + public void TreeSitterProvider_CanHandle_ReturnsFalseForMarkdown() + { + // Regression: TreeSitterCodeStructureProvider used to claim markdown + // once libtree-sitter-markdown.so became loadable, causing it to win + // over MarkdownStructureProvider in the registry's FirstOrDefault scan. + var provider = new TreeSitterCodeStructureProvider(); + + Assert.False(provider.CanHandle("markdown")); + } + + [Fact] + public void MarkdownProvider_CanHandle_ReturnsFalseForCodeLanguages() + { + var provider = new MarkdownStructureProvider(); + + Assert.False(provider.CanHandle("c-sharp")); + Assert.False(provider.CanHandle("typescript")); + Assert.False(provider.CanHandle("python")); + } + + [Fact] + public void RegexFallback_CanHandle_ReturnsTrueForAnyLanguage() + { + var provider = new RegexFallbackCodeStructureProvider(); + + Assert.True(provider.CanHandle("markdown")); + Assert.True(provider.CanHandle("c-sharp")); + Assert.True(provider.CanHandle("cobol")); + } + + // ── No two non-fallback providers claim the same language ───────────────── + + [Theory] + [InlineData("markdown")] + [InlineData("c-sharp")] + [InlineData("typescript")] + [InlineData("python")] + [InlineData("rust")] + public void NonFallbackProviders_DoNotBothClaimSameLanguage(string language) + { + var nonFallback = Registry.Providers + .Where(p => p.Id != "regex-fallback" && p.CanHandle(language)) + .ToList(); + + Assert.True(nonFallback.Count <= 1, + $"Multiple non-fallback providers claim '{language}': {string.Join(", ", nonFallback.Select(p => p.Id))}"); + } +} diff --git a/tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/GitFileStateProviderTests.cs b/tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/GitFileStateProviderTests.cs new file mode 100644 index 0000000..b0866d0 --- /dev/null +++ b/tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/GitFileStateProviderTests.cs @@ -0,0 +1,132 @@ +using Hypa.Infrastructure.CodeIntelligence; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Runner; +using Xunit; + +namespace Hypa.UnitTests.Infrastructure.CodeIntelligence; + +public sealed class GitFileStateProviderTests +{ + private const string ProjectRoot = "/repo"; + + [Fact] + public async Task GetCleanBlobOidsAsync_ParsesLsFilesOutput_ReturnsOidMap() + { + var runner = new FakeCommandRunner() + .Add(["ls-files", "-s"], + "100644 oid-one 0\tsrc/One.cs\n100644 oid-two 0\tdocs/Two.md\n") + .Add(["ls-files", "--modified"], ""); + var provider = new GitFileStateProvider(runner); + + var result = await provider.GetCleanBlobOidsAsync(ProjectRoot, CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal("oid-one", result["src/One.cs"]); + Assert.Equal("oid-two", result["docs/Two.md"]); + } + + [Fact] + public async Task GetCleanBlobOidsAsync_ExcludesDirtyFiles() + { + var runner = new FakeCommandRunner() + .Add(["ls-files", "-s"], + "100644 clean-oid 0\tsrc/Clean.cs\n100644 dirty-oid 0\tsrc/Dirty.cs\n") + .Add(["ls-files", "--modified"], "src/Dirty.cs\n"); + var provider = new GitFileStateProvider(runner); + + var result = await provider.GetCleanBlobOidsAsync(ProjectRoot, CancellationToken.None); + + Assert.NotNull(result); + var clean = Assert.Single(result); + Assert.Equal("src/Clean.cs", clean.Key); + Assert.Equal("clean-oid", clean.Value); + } + + [Fact] + public async Task GetCleanBlobOidsAsync_WhenGitUnavailable_ReturnsNull() + { + var runner = new FakeCommandRunner() + .Add(["ls-files", "-s"], "", exitCode: 128); + var provider = new GitFileStateProvider(runner); + + var result = await provider.GetCleanBlobOidsAsync(ProjectRoot, CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task GetCleanBlobOidsAsync_WhenEmptyRepo_ReturnsEmptyDictionary() + { + var runner = new FakeCommandRunner() + .Add(["ls-files", "-s"], "") + .Add(["ls-files", "--modified"], ""); + var provider = new GitFileStateProvider(runner); + + var result = await provider.GetCleanBlobOidsAsync(ProjectRoot, CancellationToken.None); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetCleanBlobOidAsync_TrackedCleanFile_ReturnsOid() + { + var runner = new FakeCommandRunner() + .Add(["ls-files", "-s", "--", "src/File.cs"], "100644 clean-oid 0\tsrc/File.cs\n") + .Add(["ls-files", "--modified", "--", "src/File.cs"], ""); + var provider = new GitFileStateProvider(runner); + + var result = await provider.GetCleanBlobOidAsync("/repo/src/File.cs", ProjectRoot, CancellationToken.None); + + Assert.Equal("clean-oid", result); + } + + [Fact] + public async Task GetCleanBlobOidAsync_DirtyFile_ReturnsNull() + { + var runner = new FakeCommandRunner() + .Add(["ls-files", "-s", "--", "src/File.cs"], "100644 dirty-oid 0\tsrc/File.cs\n") + .Add(["ls-files", "--modified", "--", "src/File.cs"], "src/File.cs\n"); + var provider = new GitFileStateProvider(runner); + + var result = await provider.GetCleanBlobOidAsync("/repo/src/File.cs", ProjectRoot, CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task GetCleanBlobOidAsync_UntrackedFile_ReturnsNull() + { + var runner = new FakeCommandRunner() + .Add(["ls-files", "-s", "--", "src/File.cs"], "") + .Add(["ls-files", "--modified", "--", "src/File.cs"], ""); + var provider = new GitFileStateProvider(runner); + + var result = await provider.GetCleanBlobOidAsync("/repo/src/File.cs", ProjectRoot, CancellationToken.None); + + Assert.Null(result); + } + + private sealed class FakeCommandRunner : ICommandRunner + { + private readonly Dictionary _outputs = new(StringComparer.Ordinal); + + public FakeCommandRunner Add(IReadOnlyList arguments, string stdout, int exitCode = 0) + { + _outputs[Key(arguments)] = CommandOutput.Captured(stdout, "", exitCode, TimeSpan.Zero); + return this; + } + + public Task> RunAsync(CommandInvocation invocation, CancellationToken ct) + { + if (_outputs.TryGetValue(Key(invocation.Arguments), out var output)) + return Task.FromResult(Result.Ok(output)); + + return Task.FromResult(Result.Fail( + new Error("UNEXPECTED_COMMAND", string.Join(" ", invocation.Arguments)))); + } + + private static string Key(IReadOnlyList arguments) => string.Join('\u001f', arguments); + } +} diff --git a/tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/MarkdownStructureProviderIntegrationTests.cs b/tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/MarkdownStructureProviderIntegrationTests.cs new file mode 100644 index 0000000..8f7949b --- /dev/null +++ b/tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/MarkdownStructureProviderIntegrationTests.cs @@ -0,0 +1,111 @@ +using Hypa.Infrastructure.CodeIntelligence; +using Hypa.Sdk.CodeIntelligence; +using Xunit; + +namespace Hypa.UnitTests.Infrastructure.CodeIntelligence; + +/// +/// Integration tests that exercise with the +/// real tree-sitter native library. Tests gracefully skip when the native grammar is +/// not available (e.g. developer machines that haven't run build-tree-sitter-markdown.sh). +/// On CI the grammar is always built before the .NET build, so tests always run there. +/// +public sealed class MarkdownStructureProviderIntegrationTests +{ + private static readonly MarkdownStructureProvider Provider = new(); + + [Fact] + public void CheckHealth_WhenGrammarAvailable_ReturnsOk() + { + if (!Provider.CanHandle("markdown")) + return; // native library not present — acceptable on dev machines + + var health = Provider.CheckHealth(); + + Assert.Equal("markdown", health.ProviderId); + Assert.Equal("ok", health.Status); + } + + [Fact] + public async Task ParseAsync_WhenGrammarAvailable_ExtractsSections() + { + if (!Provider.CanHandle("markdown")) + return; + + var content = """ + # Introduction + + Some body text. + + ## Installation + + Run the installer. + + ### Prerequisites + + You need .NET 10. + """; + + var doc = await Provider.ParseAsync(MakeFile("guide.md"), content, CancellationToken.None); + + Assert.True(doc.Sections.Count >= 3, $"Expected at least 3 sections, got {doc.Sections.Count}"); + Assert.Contains(doc.Sections, s => s.HeadingText == "Introduction" && s.HeadingLevel == 1); + Assert.Contains(doc.Sections, s => s.HeadingText == "Installation" && s.HeadingLevel == 2); + Assert.Contains(doc.Sections, s => s.HeadingText == "Prerequisites" && s.HeadingLevel == 3); + } + + [Fact] + public async Task ParseAsync_WhenGrammarAvailable_SectionProviderIdIsMarkdown() + { + if (!Provider.CanHandle("markdown")) + return; + + var doc = await Provider.ParseAsync(MakeFile("notes.md"), "# Heading\n\nBody.\n", CancellationToken.None); + + var section = Assert.Single(doc.Sections); + Assert.Equal("markdown", section.Provenance.ProviderId); + } + + [Fact] + public async Task ParseAsync_WhenGrammarAvailable_ExtractsFrontmatter() + { + if (!Provider.CanHandle("markdown")) + return; + + var content = """ + --- + title: My Doc + version: 1.0 + --- + + # Heading + + Body. + """; + + var doc = await Provider.ParseAsync(MakeFile("meta.md"), content, CancellationToken.None); + + Assert.NotNull(doc.FrontmatterYaml); + Assert.Contains("title", doc.FrontmatterYaml, StringComparison.Ordinal); + Assert.Contains("My Doc", doc.FrontmatterYaml, StringComparison.Ordinal); + } + + [Fact] + public void CanHandle_ReturnsFalseForCodeLanguages() + { + Assert.False(Provider.CanHandle("c-sharp")); + Assert.False(Provider.CanHandle("typescript")); + Assert.False(Provider.CanHandle("python")); + Assert.False(Provider.CanHandle("rust")); + } + + private static CodeFileIdentity MakeFile(string name) => new() + { + ProjectRoot = "/project", + Path = $"/project/{name}", + RelativePath = name, + Language = "markdown", + ContentHash = "hash", + SizeBytes = 0, + }; +} diff --git a/tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/MarkdownStructureProviderTests.cs b/tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/MarkdownStructureProviderTests.cs new file mode 100644 index 0000000..fd89b6d --- /dev/null +++ b/tests/Hypa.UnitTests/Infrastructure/CodeIntelligence/MarkdownStructureProviderTests.cs @@ -0,0 +1,126 @@ +using Hypa.Infrastructure.CodeIntelligence; +using Hypa.Sdk.CodeIntelligence; +using Xunit; + +namespace Hypa.UnitTests.Infrastructure.CodeIntelligence; + +public sealed class MarkdownStructureProviderTests +{ + [Fact] + public void ExtractMarkdown_SingleHeading_PopulatesSectionMetadata() + { + var document = CodePatternExtractor.ExtractMarkdown(MakeFile("single.md"), "# Heading\n\nBody\n", MakeProvenance()); + + var section = Assert.Single(document.Sections); + Assert.Equal(1, section.HeadingLevel); + Assert.Equal("Heading", section.HeadingText); + Assert.Equal("Heading", section.HeadingPath); + Assert.Equal("heading", section.HeadingAnchor); + } + + [Fact] + public void ExtractMarkdown_NestedHeadings_PopulatesSectionPaths() + { + var content = "# H1\n\n## H2\n\n### H3\n\ntext\n"; + var document = CodePatternExtractor.ExtractMarkdown(MakeFile("nested.md"), content, MakeProvenance()); + + Assert.Equal(3, document.Sections.Count); + Assert.Equal("H1", document.Sections[0].HeadingPath); + Assert.Equal("H1/H2", document.Sections[1].HeadingPath); + Assert.Equal("H1/H2/H3", document.Sections[2].HeadingPath); + } + + [Fact] + public void ExtractMarkdown_Frontmatter_PopulatesFrontmatterYaml() + { + var content = "---\ntitle: Test\n---\n\n# Heading\n"; + var document = CodePatternExtractor.ExtractMarkdown(MakeFile("frontmatter.md"), content, MakeProvenance()); + + Assert.NotNull(document.FrontmatterYaml); + Assert.Contains("title: Test", document.FrontmatterYaml); + } + + [Fact] + public void ExtractMarkdown_NoHeadings_ReturnsEmptySections() + { + var document = CodePatternExtractor.ExtractMarkdown(MakeFile("plain.md"), "Just text\n", MakeProvenance()); + + Assert.NotNull(document); + Assert.Empty(document.Sections); + } + + [Fact] + public void ExtractMarkdown_SecondHeading_SectionTextIsBoundedToSecondSection() + { + var content = "# First\n\nfirst content\n\n## Second\n\nsecond content\n"; + var document = CodePatternExtractor.ExtractMarkdown(MakeFile("bounded.md"), content, MakeProvenance()); + + var second = Assert.Single(document.Sections, s => s.HeadingText == "Second"); + Assert.NotNull(second.Text); + Assert.Contains("second content", second.Text); + Assert.DoesNotContain("first content", second.Text); + } + + [Fact] + public void ExtractMarkdown_OutOfOrderHeadingLevels_UsesActualAncestorChain() + { + var content = "# H1\n\n### H3\n\ntext\n"; + var document = CodePatternExtractor.ExtractMarkdown(MakeFile("out-of-order.md"), content, MakeProvenance()); + + Assert.Equal(2, document.Sections.Count); + Assert.Equal("H1", document.Sections[0].HeadingPath); + Assert.Equal("H1/H3", document.Sections[1].HeadingPath); + } + + [Fact] + public void ExtractMarkdown_SpecialCharactersInHeading_NormalizesAnchor() + { + var document = CodePatternExtractor.ExtractMarkdown(MakeFile("anchor.md"), "# API / REST Basics\n\nBody\n", MakeProvenance()); + + var section = Assert.Single(document.Sections); + Assert.Equal("API / REST Basics", section.HeadingText); + Assert.Equal("api--rest-basics", section.HeadingAnchor); + } + + [Fact] + public void ExtractMarkdown_SetextHeading_IsNotExtracted() + { + var content = "Introduction\n============\n\nBody\n"; + var document = CodePatternExtractor.ExtractMarkdown(MakeFile("setext.md"), content, MakeProvenance()); + + Assert.Empty(document.Sections); + } + + [Fact] + public void ExtractMarkdown_PlainText_StripsMarkdownSyntax() + { + var content = "# Heading\n\nBody with **bold**, `inline code`, and a [link](https://example.com).\n"; + var document = CodePatternExtractor.ExtractMarkdown(MakeFile("plain-text.md"), content, MakeProvenance()); + + var section = Assert.Single(document.Sections); + Assert.NotNull(section.PlainText); + Assert.Contains("Body with bold, inline code, and a link.", section.PlainText); + Assert.DoesNotContain("**", section.PlainText); + Assert.DoesNotContain("`", section.PlainText); + Assert.DoesNotContain("[link](https://example.com)", section.PlainText); + } + + private static CodeFileIdentity MakeFile(string relativePath) => new() + { + ProjectRoot = "/project", + Path = $"/project/{relativePath}", + RelativePath = relativePath, + Language = "markdown", + ContentHash = "hash", + SizeBytes = 0, + }; + + private static ProviderProvenance MakeProvenance() => new() + { + ProviderId = "markdown", + ProviderVersion = "1", + QueryVersion = "1", + FactKind = "syntactic", + Confidence = 1, + }; +} diff --git a/tests/Hypa.UnitTests/Infrastructure/Doctor/McpOAuthTokenFilePermissionsCheckTests.cs b/tests/Hypa.UnitTests/Infrastructure/Doctor/McpOAuthTokenFilePermissionsCheckTests.cs new file mode 100644 index 0000000..d6a5cf1 --- /dev/null +++ b/tests/Hypa.UnitTests/Infrastructure/Doctor/McpOAuthTokenFilePermissionsCheckTests.cs @@ -0,0 +1,85 @@ +using Hypa.Infrastructure.Doctor; +using Hypa.Runtime.Application.Ports; +using Xunit; + +namespace Hypa.UnitTests.Infrastructure.Doctor; + +public sealed class McpOAuthTokenFilePermissionsCheckTests : IDisposable +{ + private readonly string _dataDir; + + public McpOAuthTokenFilePermissionsCheckTests() + { + _dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_dataDir); + } + + public void Dispose() + { + try { Directory.Delete(_dataDir, recursive: true); } catch { } + } + + private McpOAuthTokenFilePermissionsCheck CreateSut() => + new(_dataDir); + + private string TokenFilePath => Path.Combine(_dataDir, "mcp-oauth-tokens.json"); + + [Fact] + public void Run_MissingFile_ReturnsOk() + { + var result = CreateSut().Run(); + + Assert.Equal(DoctorStatus.Ok, result.Status); + } + + [Fact] + public void Category_IsMcp() + { + Assert.Equal("MCP", CreateSut().Category); + } + + [Fact] + public void Run_SecurePermissions_ReturnsOk() + { + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + return; + + File.WriteAllText(TokenFilePath, "{}"); + File.SetUnixFileMode(TokenFilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + + var result = CreateSut().Run(); + + Assert.Equal(DoctorStatus.Ok, result.Status); + } + + [Fact] + public void Run_GroupReadable_ReturnsWarn() + { + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + return; + + File.WriteAllText(TokenFilePath, "{}"); + File.SetUnixFileMode(TokenFilePath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead); + + var result = CreateSut().Run(); + + Assert.Equal(DoctorStatus.Warn, result.Status); + Assert.Contains("chmod 600", result.Detail, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Run_WorldReadable_ReturnsWarn() + { + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + return; + + File.WriteAllText(TokenFilePath, "{}"); + File.SetUnixFileMode(TokenFilePath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.OtherRead); + + var result = CreateSut().Run(); + + Assert.Equal(DoctorStatus.Warn, result.Status); + } +} diff --git a/tests/Hypa.UnitTests/Infrastructure/Hooks/ReadRedirectorTests.cs b/tests/Hypa.UnitTests/Infrastructure/Hooks/ReadRedirectorTests.cs new file mode 100644 index 0000000..264aae5 --- /dev/null +++ b/tests/Hypa.UnitTests/Infrastructure/Hooks/ReadRedirectorTests.cs @@ -0,0 +1,188 @@ +using System.Text; +using Hypa.Infrastructure.Hooks; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Sdk.CodeIntelligence; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Infrastructure.Hooks; + +public sealed class ReadRedirectorTests : IDisposable +{ + private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"hypa-read-redirector-{Guid.NewGuid():N}"); + + [Fact] + public async Task RedirectAsync_WhenMarkdownFileIsLarge_ParsesAsMarkdown() + { + Directory.CreateDirectory(_tempDir); + var path = Path.Combine(_tempDir, "notes.md"); + File.WriteAllText(path, "# Notes\n"); + var content = "# Notes\n\n" + string.Join('\n', Enumerable.Repeat("Long markdown body.", 600)); + var bytes = Encoding.UTF8.GetBytes(content); + var fileSystem = Substitute.For(); + var projectRootDetector = Substitute.For(); + var provider = Substitute.For(); + provider.Id.Returns("markdown"); + provider.CanHandle("markdown").Returns(true); + provider.ParseAsync(Arg.Is(f => f.Language == "markdown"), content, Arg.Any()) + .Returns(ci => Task.FromResult(new CodeStructureDocument + { + File = ci.ArgAt(0), + Provenance = MakeProvenance(), + Sections = [MakeSection("Notes", 1, 1)], + })); + fileSystem.ReadAllBytes(path).Returns(bytes); + projectRootDetector.Detect(Arg.Any()).Returns(_tempDir); + var redirector = new ReadRedirector(fileSystem, projectRootDetector, new CodeStructureProviderRegistry([provider])); + + var redirectedPath = await redirector.RedirectAsync(path, CancellationToken.None); + + Assert.NotNull(redirectedPath); + Assert.True(File.Exists(redirectedPath)); + var outline = await File.ReadAllTextAsync(redirectedPath); + Assert.Contains("# Notes", outline); + await provider.Received(1).ParseAsync( + Arg.Is(f => f.Language == "markdown" && f.RelativePath == "notes.md"), + content, + Arg.Any()); + } + + [Fact] + public async Task RedirectAsync_SmallMarkdownFile_ReturnsNull() + { + Directory.CreateDirectory(_tempDir); + var path = Path.Combine(_tempDir, "small.md"); + var content = "# Hello\n"; + var bytes = Encoding.UTF8.GetBytes(content); + var fileSystem = Substitute.For(); + var projectRootDetector = Substitute.For(); + var provider = Substitute.For(); + provider.Id.Returns("markdown"); + provider.CanHandle("markdown").Returns(true); + fileSystem.ReadAllBytes(path).Returns(bytes); + File.WriteAllText(path, content); + var redirector = new ReadRedirector(fileSystem, projectRootDetector, new CodeStructureProviderRegistry([provider])); + + var result = await redirector.RedirectAsync(path, CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task RedirectAsync_ClaudeMdFile_ReturnsNull() + { + Directory.CreateDirectory(_tempDir); + var path = Path.Combine(_tempDir, "CLAUDE.md"); + var content = "# CLAUDE\n\n" + string.Join('\n', Enumerable.Repeat("Some instruction.", 600)); + var bytes = Encoding.UTF8.GetBytes(content); + var fileSystem = Substitute.For(); + var projectRootDetector = Substitute.For(); + var provider = Substitute.For(); + provider.Id.Returns("markdown"); + provider.CanHandle("markdown").Returns(true); + fileSystem.ReadAllBytes(path).Returns(bytes); + File.WriteAllText(path, content); + var redirector = new ReadRedirector(fileSystem, projectRootDetector, new CodeStructureProviderRegistry([provider])); + + var result = await redirector.RedirectAsync(path, CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task RedirectAsync_LargeMarkdownNoHeadings_ReturnsNull() + { + Directory.CreateDirectory(_tempDir); + var path = Path.Combine(_tempDir, "noheadings.md"); + var content = string.Join('\n', Enumerable.Repeat("Just plain text with no headings.", 400)); + var bytes = Encoding.UTF8.GetBytes(content); + var fileSystem = Substitute.For(); + var projectRootDetector = Substitute.For(); + var provider = Substitute.For(); + provider.Id.Returns("markdown"); + provider.CanHandle("markdown").Returns(true); + provider.ParseAsync(Arg.Is(f => f.Language == "markdown"), content, Arg.Any()) + .Returns(ci => Task.FromResult(new CodeStructureDocument + { + File = ci.ArgAt(0), + Provenance = MakeProvenance(), + })); + fileSystem.ReadAllBytes(path).Returns(bytes); + File.WriteAllText(path, content); + projectRootDetector.Detect(Arg.Any()).Returns(_tempDir); + var redirector = new ReadRedirector(fileSystem, projectRootDetector, new CodeStructureProviderRegistry([provider])); + + var result = await redirector.RedirectAsync(path, CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task RedirectAsync_LargeMarkdownWithHeadings_OutlineContainsHeadingHash() + { + Directory.CreateDirectory(_tempDir); + var path = Path.Combine(_tempDir, "multi.md"); + var content = "# Title\n\n## Section\n\n### Sub\n\n" + string.Join('\n', Enumerable.Repeat("Body text here.", 600)); + var bytes = Encoding.UTF8.GetBytes(content); + var fileSystem = Substitute.For(); + var projectRootDetector = Substitute.For(); + var provider = Substitute.For(); + provider.Id.Returns("markdown"); + provider.CanHandle("markdown").Returns(true); + provider.ParseAsync(Arg.Is(f => f.Language == "markdown"), content, Arg.Any()) + .Returns(ci => Task.FromResult(new CodeStructureDocument + { + File = ci.ArgAt(0), + Provenance = MakeProvenance(), + Sections = + [ + MakeSection("Title", 1, 1), + MakeSection("Section", 2, 3), + MakeSection("Sub", 3, 5), + ], + })); + fileSystem.ReadAllBytes(path).Returns(bytes); + File.WriteAllText(path, content); + projectRootDetector.Detect(Arg.Any()).Returns(_tempDir); + var redirector = new ReadRedirector(fileSystem, projectRootDetector, new CodeStructureProviderRegistry([provider])); + + var redirectedPath = await redirector.RedirectAsync(path, CancellationToken.None); + + Assert.NotNull(redirectedPath); + var outline = await File.ReadAllTextAsync(redirectedPath); + Assert.Contains("# Title", outline); + Assert.Contains("## Section", outline); + Assert.Contains("### Sub", outline); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + private static MarkdownSection MakeSection(string heading, int level, int line) => new() + { + Id = $"sec-{heading.ToLowerInvariant()}", + FilePath = "notes.md", + HeadingText = heading, + HeadingLevel = level, + HeadingPath = heading, + HeadingAnchor = heading.ToLowerInvariant(), + StartLine = line, + EndLine = line + 5, + StartByte = 0, + EndByte = 100, + Provenance = MakeProvenance(), + }; + + private static ProviderProvenance MakeProvenance() => new() + { + ProviderId = "markdown", + ProviderVersion = "1", + QueryVersion = "1", + FactKind = "syntactic", + Confidence = 1, + }; +} diff --git a/tests/Hypa.UnitTests/Infrastructure/Mcp/ClaudeMcpConnectionImportSourceTests.cs b/tests/Hypa.UnitTests/Infrastructure/Mcp/ClaudeMcpConnectionImportSourceTests.cs new file mode 100644 index 0000000..89f65ea --- /dev/null +++ b/tests/Hypa.UnitTests/Infrastructure/Mcp/ClaudeMcpConnectionImportSourceTests.cs @@ -0,0 +1,260 @@ +using System.Text.Json; +using Hypa.Infrastructure.Mcp.Import; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Mcp; +using Xunit; + +namespace Hypa.UnitTests.Infrastructure.Mcp; + +[Trait("Category", "McpImport")] +public sealed class ClaudeMcpConnectionImportSourceTests : IDisposable +{ + private readonly string _tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + public ClaudeMcpConnectionImportSourceTests() + { + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + private ClaudeMcpConnectionImportSource Sut(string? globalHome = null) + { + var home = globalHome ?? Path.Combine(_tempDir, "claude_home"); + Directory.CreateDirectory(home); + return new ClaudeMcpConnectionImportSource(home); + } + + private void WriteSettings(string claudeDir, object mcpServers) + { + Directory.CreateDirectory(claudeDir); + var json = JsonSerializer.Serialize(new { mcpServers }); + File.WriteAllText(Path.Combine(claudeDir, "settings.json"), json); + } + + private void WriteLocalSettings(string projectRoot, object mcpServers) + { + var claudeDir = Path.Combine(projectRoot, ".claude"); + Directory.CreateDirectory(claudeDir); + var json = JsonSerializer.Serialize(new { mcpServers }); + File.WriteAllText(Path.Combine(claudeDir, "settings.local.json"), json); + } + + [Fact] + public async Task DiscoverAsync_MissingSettingsFile_ReturnsEmpty() + { + var home = Path.Combine(_tempDir, "no_claude"); + var sut = new ClaudeMcpConnectionImportSource(home); + + var result = await sut.DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Empty(result); + } + + [Fact] + public async Task DiscoverAsync_StdioEntry_ParsesCommand() + { + var home = Path.Combine(_tempDir, "claude_stdio"); + WriteSettings(home, new + { + github = new { type = "stdio", command = "gh-mcp" }, + }); + + var sut = new ClaudeMcpConnectionImportSource(home); + var result = await sut.DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + var conn = result[0]; + Assert.Equal("github", conn.SourceName); + Assert.Equal(McpImportCandidateStatus.Importable, conn.Status); + Assert.NotNull(conn.Server); + Assert.Equal(McpTransportKind.Stdio, conn.Server.Transport.Kind); + Assert.Equal("gh-mcp", conn.Server.Transport.Endpoint); + } + + [Fact] + public async Task DiscoverAsync_StdioEntry_WithArgs_JoinsCommandAndArgs() + { + var home = Path.Combine(_tempDir, "claude_args"); + WriteSettings(home, new + { + github = new { type = "stdio", command = "gh-mcp", args = new[] { "--stdio", "--port", "8080" } }, + }); + + var sut = new ClaudeMcpConnectionImportSource(home); + var result = await sut.DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + Assert.Equal("gh-mcp --stdio --port 8080", result[0].Server?.Transport.Endpoint); + } + + [Fact] + public async Task DiscoverAsync_HttpEntry_WithUrl_ParsesRemote() + { + var home = Path.Combine(_tempDir, "claude_http"); + WriteSettings(home, new + { + remote = new { type = "streamableHttp", url = "https://tools.example.com/mcp" }, + }); + + var sut = new ClaudeMcpConnectionImportSource(home); + var result = await sut.DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + var conn = result[0]; + Assert.Equal(McpImportCandidateStatus.Importable, conn.Status); + Assert.Equal(McpTransportKind.Http, conn.Server?.Transport.Kind); + Assert.Equal("https://tools.example.com/mcp", conn.Server?.Transport.Endpoint); + } + + [Fact] + public async Task DiscoverAsync_SseEntry_WithEndpoint_ParsesRemote() + { + var home = Path.Combine(_tempDir, "claude_sse"); + WriteSettings(home, new + { + events = new { type = "sse", endpoint = "https://events.example.com/mcp" }, + }); + + var sut = new ClaudeMcpConnectionImportSource(home); + var result = await sut.DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + Assert.Equal(McpTransportKind.Sse, result[0].Server?.Transport.Kind); + Assert.Equal("https://events.example.com/mcp", result[0].Server?.Transport.Endpoint); + } + + [Fact] + public async Task DiscoverAsync_HypaEntry_SkippedSelf() + { + var home = Path.Combine(_tempDir, "claude_self"); + WriteSettings(home, new + { + hypa = new { type = "stdio", command = "hypa serve" }, + }); + + var sut = new ClaudeMcpConnectionImportSource(home); + var result = await sut.DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + Assert.Equal(McpImportCandidateStatus.SkippedSelf, result[0].Status); + } + + [Fact] + public async Task DiscoverAsync_CommandIsHypaServe_SkippedSelf() + { + var home = Path.Combine(_tempDir, "claude_selfcmd"); + WriteSettings(home, new + { + mymcp = new { type = "stdio", command = "hypa serve" }, + }); + + var sut = new ClaudeMcpConnectionImportSource(home); + var result = await sut.DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + Assert.Equal(McpImportCandidateStatus.SkippedSelf, result[0].Status); + } + + [Fact] + public async Task DiscoverAsync_EntryWithRawEnvValue_SkippedUnsafeSecret() + { + var home = Path.Combine(_tempDir, "claude_rawenv"); + WriteSettings(home, new + { + secured = new + { + type = "stdio", + command = "secure-mcp", + env = new { API_TOKEN = "raw-secret-value" }, + }, + }); + + var sut = new ClaudeMcpConnectionImportSource(home); + var result = await sut.DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + Assert.Equal(McpImportCandidateStatus.SkippedUnsafeSecret, result[0].Status); + } + + [Fact] + public async Task DiscoverAsync_EntryWithNoEnv_AuthNone_Importable() + { + var home = Path.Combine(_tempDir, "claude_noenv"); + WriteSettings(home, new + { + plain = new { type = "stdio", command = "plain-mcp" }, + }); + + var sut = new ClaudeMcpConnectionImportSource(home); + var result = await sut.DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + Assert.Equal(McpImportCandidateStatus.Importable, result[0].Status); + Assert.IsType(result[0].Server?.Auth); + } + + [Fact] + public async Task DiscoverAsync_ProjectScope_ReadsLocalSettings() + { + var home = Path.Combine(_tempDir, "claude_proj_home"); + var projectRoot = Path.Combine(_tempDir, "myproject"); + WriteLocalSettings(projectRoot, new + { + local_tool = new { type = "stdio", command = "local-mcp" }, + }); + + var sut = new ClaudeMcpConnectionImportSource(home); + var result = await sut.DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Project, projectRoot), default); + + Assert.Single(result); + Assert.Equal("local_tool", result[0].SourceName); + Assert.Equal("project", result[0].SourceScope); + } + + [Fact] + public async Task DiscoverAsync_MissingCommand_StdioEntry_SkippedIncomplete() + { + var home = Path.Combine(_tempDir, "claude_nocommand"); + WriteSettings(home, new + { + broken = new { type = "stdio" }, + }); + + var sut = new ClaudeMcpConnectionImportSource(home); + var result = await sut.DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + Assert.Equal(McpImportCandidateStatus.SkippedIncomplete, result[0].Status); + } + + [Fact] + public async Task DiscoverAsync_MalformedJson_ReturnsParseError() + { + var home = Path.Combine(_tempDir, "claude_bad"); + Directory.CreateDirectory(home); + File.WriteAllText(Path.Combine(home, "settings.json"), "{ not valid json {{"); + + var sut = new ClaudeMcpConnectionImportSource(home); + var result = await sut.DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + Assert.Equal(McpImportCandidateStatus.ParseError, result[0].Status); + } +} diff --git a/tests/Hypa.UnitTests/Infrastructure/Mcp/CodexMcpConnectionImportSourceTests.cs b/tests/Hypa.UnitTests/Infrastructure/Mcp/CodexMcpConnectionImportSourceTests.cs new file mode 100644 index 0000000..249fd0f --- /dev/null +++ b/tests/Hypa.UnitTests/Infrastructure/Mcp/CodexMcpConnectionImportSourceTests.cs @@ -0,0 +1,254 @@ +using Hypa.Infrastructure.Mcp.Import; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Mcp; +using Xunit; + +namespace Hypa.UnitTests.Infrastructure.Mcp; + +[Trait("Category", "McpImport")] +public sealed class CodexMcpConnectionImportSourceTests : IDisposable +{ + private readonly string _tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + public CodexMcpConnectionImportSourceTests() + { + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + private CodexMcpConnectionImportSource Sut(string? globalConfigPath = null) + { + var path = globalConfigPath ?? Path.Combine(_tempDir, "codex_home", "config.toml"); + return new CodexMcpConnectionImportSource(path); + } + + private string WriteGlobalConfig(string toml) + { + var dir = Path.Combine(_tempDir, "codex_home"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "config.toml"); + File.WriteAllText(path, toml); + return path; + } + + private void WriteProjectConfig(string projectRoot, string toml) + { + var dir = Path.Combine(projectRoot, ".codex"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "config.toml"), toml); + } + + [Fact] + public async Task DiscoverAsync_MissingConfigToml_ReturnsEmpty() + { + var sut = Sut(Path.Combine(_tempDir, "nonexistent", "config.toml")); + var result = await sut.DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + Assert.Empty(result); + } + + [Fact] + public async Task DiscoverAsync_StdioEntry_ParsesCommand() + { + var path = WriteGlobalConfig(""" + [mcp_servers.github] + command = "gh-mcp" + """); + + var result = await Sut(path).DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + var conn = result[0]; + Assert.Equal("github", conn.SourceName); + Assert.Equal(McpImportCandidateStatus.Importable, conn.Status); + Assert.Equal(McpTransportKind.Stdio, conn.Server?.Transport.Kind); + Assert.Equal("gh-mcp", conn.Server?.Transport.Endpoint); + } + + [Fact] + public async Task DiscoverAsync_StdioEntry_WithArgs_ParsesInlineArray() + { + var path = WriteGlobalConfig(""" + [mcp_servers.github] + command = "gh-mcp" + args = ["--stdio", "--verbose"] + """); + + var result = await Sut(path).DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + Assert.Equal("gh-mcp --stdio --verbose", result[0].Server?.Transport.Endpoint); + } + + [Fact] + public async Task DiscoverAsync_RemoteEntry_ParsesUrl() + { + var path = WriteGlobalConfig(""" + [mcp_servers.remote] + url = "https://tools.example.com/mcp" + """); + + var result = await Sut(path).DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + var conn = result[0]; + Assert.Equal(McpImportCandidateStatus.Importable, conn.Status); + Assert.Equal(McpTransportKind.HttpAutoDetect, conn.Server?.Transport.Kind); + Assert.Equal("https://tools.example.com/mcp", conn.Server?.Transport.Endpoint); + } + + [Fact] + public async Task DiscoverAsync_HypaSection_SkippedSelf() + { + var path = WriteGlobalConfig(""" + [mcp_servers.hypa] + command = "hypa" + args = ["serve"] + """); + + var result = await Sut(path).DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + Assert.Equal(McpImportCandidateStatus.SkippedSelf, result[0].Status); + } + + [Fact] + public async Task DiscoverAsync_CommandIsHypaServe_SkippedSelf() + { + var path = WriteGlobalConfig(""" + [mcp_servers.mymcp] + command = "hypa serve" + """); + + var result = await Sut(path).DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + Assert.Equal(McpImportCandidateStatus.SkippedSelf, result[0].Status); + } + + [Fact] + public async Task DiscoverAsync_MultipleServers_ParsesAll() + { + var path = WriteGlobalConfig(""" + [mcp_servers.github] + command = "gh-mcp" + + [mcp_servers.linear] + command = "linear-mcp" + """); + + var result = await Sut(path).DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Equal(2, result.Count); + Assert.Contains(result, c => c.SourceName == "github"); + Assert.Contains(result, c => c.SourceName == "linear"); + } + + [Fact] + public async Task DiscoverAsync_ProjectScope_ReadsProjectConfigToml() + { + var globalPath = Path.Combine(_tempDir, "codex_no_global", "config.toml"); + var projectRoot = Path.Combine(_tempDir, "myproject"); + WriteProjectConfig(projectRoot, """ + [mcp_servers.local_tool] + command = "local-mcp" + """); + + var result = await new CodexMcpConnectionImportSource(globalPath).DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Project, projectRoot), default); + + Assert.Single(result); + Assert.Equal("local_tool", result[0].SourceName); + Assert.Equal("project", result[0].SourceScope); + } + + [Fact] + public async Task DiscoverAsync_MalformedToml_MultilineArray_ReturnsParseError() + { + var path = WriteGlobalConfig(""" + [mcp_servers.broken] + command = "my-mcp" + args = [ + "--arg1", + "--arg2" + ] + """); + + var result = await Sut(path).DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + Assert.Equal(McpImportCandidateStatus.ParseError, result[0].Status); + } + + [Fact] + public async Task DiscoverAsync_RawSecretBearerToken_SkippedUnsupported() + { + var path = WriteGlobalConfig(""" + [mcp_servers.unsafe-server] + command = "my-mcp" + bearer_token = "sk-1234567890abcdefghij" + """); + + var result = await Sut(path).DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + var conn = result[0]; + Assert.Equal(McpImportCandidateStatus.SkippedUnsupported, conn.Status); + Assert.Contains("raw secret", conn.Detail ?? "", StringComparison.OrdinalIgnoreCase); + Assert.Contains("env:", conn.Detail ?? ""); + Assert.Null(conn.Server); + } + + [Fact] + public async Task DiscoverAsync_EnvBearerToken_Importable() + { + var path = WriteGlobalConfig(""" + [mcp_servers.safe-server] + command = "my-mcp" + bearer_token = "env:MCP_TOKEN" + """); + + var result = await Sut(path).DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + var conn = result[0]; + Assert.Equal(McpImportCandidateStatus.Importable, conn.Status); + Assert.NotNull(conn.Server); + Assert.IsType(conn.Server.Auth); + Assert.Equal("env:MCP_TOKEN", ((BearerAuthConfig)conn.Server.Auth).TokenRef); + } + + [Fact] + public async Task DiscoverAsync_FileBearerToken_Importable() + { + var path = WriteGlobalConfig(""" + [mcp_servers.safe-server] + url = "https://api.example.com" + bearer_token = "file:/path/to/token" + """); + + var result = await Sut(path).DiscoverAsync( + new McpImportDiscoveryRequest(McpImportScope.Global, null), default); + + Assert.Single(result); + var conn = result[0]; + Assert.Equal(McpImportCandidateStatus.Importable, conn.Status); + Assert.NotNull(conn.Server); + Assert.IsType(conn.Server.Auth); + Assert.Equal("file:/path/to/token", ((BearerAuthConfig)conn.Server.Auth).TokenRef); + } +} diff --git a/tests/Hypa.UnitTests/Infrastructure/McpServerConfigWriterTests.cs b/tests/Hypa.UnitTests/Infrastructure/McpServerConfigWriterTests.cs new file mode 100644 index 0000000..02d7446 --- /dev/null +++ b/tests/Hypa.UnitTests/Infrastructure/McpServerConfigWriterTests.cs @@ -0,0 +1,292 @@ +using System.Text.Json; +using Hypa.Infrastructure.Mcp.Config; +using Hypa.Runtime.Domain.Mcp; +using Xunit; + +namespace Hypa.UnitTests.Infrastructure; + +[Trait("Category", "McpServerConfig")] +public sealed class McpServerConfigWriterTests : IDisposable +{ + private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"hypa-writer-test-{Guid.NewGuid():N}"); + + public McpServerConfigWriterTests() => Directory.CreateDirectory(_tempDir); + + public void Dispose() => Directory.Delete(_tempDir, recursive: true); + + private McpServerConfigWriter Sut => new(_tempDir); + + private static McpServerDefinition StdioNone(string name = "local") => + new(name, new McpTransportConfig(McpTransportKind.Stdio, "hypa serve"), new NoneAuthConfig()); + + private static McpServerDefinition HttpBearer(string name = "remote") => + new(name, + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new BearerAuthConfig("env:TOKEN")); + + // Read tests + + [Fact] + public async Task ReadEditableAsync_MissingFile_ReturnsEmptyList() + { + var result = await Sut.ReadEditableAsync(default); + + Assert.True(result.IsOk); + Assert.Empty(result.Value); + } + + [Fact] + public async Task ReadEditableAsync_ExistingFile_ReturnsServers() + { + await Sut.WriteAsync([StdioNone()], default); + + var result = await Sut.ReadEditableAsync(default); + + Assert.True(result.IsOk); + var server = Assert.Single(result.Value); + Assert.Equal("local", server.Name); + } + + // Write tests + + [Fact] + public async Task WriteAsync_MissingDirectory_CreatesDirectoryAndFile() + { + var subDir = Path.Combine(_tempDir, "nested", "config"); + var writer = new McpServerConfigWriter(subDir); + + var result = await writer.WriteAsync([StdioNone()], default); + + Assert.True(result.IsOk); + Assert.True(File.Exists(Path.Combine(subDir, "mcp-servers.json"))); + } + + [Fact] + public async Task WriteAsync_CreatesValidJsonFile() + { + await Sut.WriteAsync([StdioNone()], default); + + var json = await File.ReadAllTextAsync(Path.Combine(_tempDir, "mcp-servers.json")); + using var doc = JsonDocument.Parse(json); + Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind); + Assert.True(doc.RootElement.TryGetProperty("servers", out _)); + } + + [Fact] + public async Task WriteAsync_PreservesExistingEntries() + { + await Sut.WriteAsync([StdioNone("a")], default); + var readResult = await Sut.ReadEditableAsync(default); + var updated = readResult.Value.Append(StdioNone("b")).ToList(); + + await Sut.WriteAsync(updated, default); + + var result = await Sut.ReadEditableAsync(default); + Assert.Equal(2, result.Value.Count); + Assert.Contains(result.Value, s => s.Name == "a"); + Assert.Contains(result.Value, s => s.Name == "b"); + } + + [Fact] + public async Task WriteAsync_LeavesNoTempFileOnSuccess() + { + await Sut.WriteAsync([StdioNone()], default); + + var tmpFiles = Directory.GetFiles(_tempDir, "*.tmp"); + Assert.Empty(tmpFiles); + } + + // Round-trip tests per auth mode + + [Fact] + public async Task RoundTrip_NoneAuth_Stdio() + { + await Sut.WriteAsync([StdioNone()], default); + var result = await Sut.ReadEditableAsync(default); + + var server = Assert.Single(result.Value); + Assert.Equal(McpTransportKind.Stdio, server.Transport.Kind); + Assert.Equal("hypa serve", server.Transport.Endpoint); + Assert.IsType(server.Auth); + } + + [Fact] + public async Task RoundTrip_BearerAuth() + { + await Sut.WriteAsync([HttpBearer()], default); + var result = await Sut.ReadEditableAsync(default); + + var server = Assert.Single(result.Value); + Assert.Equal(McpTransportKind.Http, server.Transport.Kind); + var bearer = Assert.IsType(server.Auth); + Assert.Equal("env:TOKEN", bearer.TokenRef); + } + + [Fact] + public async Task RoundTrip_ApiKeyAuth() + { + var def = new McpServerDefinition( + "api", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new ApiKeyAuthConfig("X-Api-Key", "env:KEY", false)); + + await Sut.WriteAsync([def], default); + var result = await Sut.ReadEditableAsync(default); + + var server = Assert.Single(result.Value); + var ak = Assert.IsType(server.Auth); + Assert.Equal("X-Api-Key", ak.HeaderName); + Assert.Equal("env:KEY", ak.ValueRef); + } + + [Fact] + public async Task RoundTrip_BasicAuth() + { + var def = new McpServerDefinition( + "basic", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new BasicAuthConfig("env:USER", "env:PASS")); + + await Sut.WriteAsync([def], default); + var result = await Sut.ReadEditableAsync(default); + + var server = Assert.Single(result.Value); + var ba = Assert.IsType(server.Auth); + Assert.Equal("env:USER", ba.UsernameRef); + Assert.Equal("env:PASS", ba.PasswordRef); + } + + [Fact] + public async Task RoundTrip_OAuth2ClientCredentials() + { + var def = new McpServerDefinition( + "cc", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new OAuth2ClientCredentialsConfig( + "https://auth/token", + "env:CLIENT_ID", + "env:CLIENT_SECRET", + ["repo.read", "user.read"])); + + await Sut.WriteAsync([def], default); + var result = await Sut.ReadEditableAsync(default); + + var server = Assert.Single(result.Value); + var cc = Assert.IsType(server.Auth); + Assert.Equal("https://auth/token", cc.TokenUrl); + Assert.Equal("env:CLIENT_ID", cc.ClientIdRef); + Assert.Equal(2, cc.Scopes?.Length); + } + + [Fact] + public async Task RoundTrip_OAuth2DeviceCode() + { + var def = new McpServerDefinition( + "dc", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new OAuth2DeviceCodeConfig( + "https://auth/device", + "https://auth/token", + "my-client-id")); + + await Sut.WriteAsync([def], default); + var result = await Sut.ReadEditableAsync(default); + + var server = Assert.Single(result.Value); + var dc = Assert.IsType(server.Auth); + Assert.Equal("https://auth/device", dc.AuthUrl); + Assert.Equal("my-client-id", dc.ClientId); + } + + [Fact] + public async Task RoundTrip_MtlsAuth() + { + var def = new McpServerDefinition( + "mtls", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new MtlsConfig("env:CERT", "env:KEY")); + + await Sut.WriteAsync([def], default); + var result = await Sut.ReadEditableAsync(default); + + var server = Assert.Single(result.Value); + var m = Assert.IsType(server.Auth); + Assert.Equal("env:CERT", m.ClientCertRef); + Assert.Equal("env:KEY", m.ClientKeyRef); + } + + [Fact] + public async Task RoundTrip_TransportCanonicalCasing_StreamableHttp() + { + var def = new McpServerDefinition( + "http", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new NoneAuthConfig()); + + await Sut.WriteAsync([def], default); + var json = await File.ReadAllTextAsync(Path.Combine(_tempDir, "mcp-servers.json")); + + Assert.Contains("streamableHttp", json); + } + + [Fact] + public async Task RoundTrip_HttpAutoDetect_CanonicalCasing() + { + var def = new McpServerDefinition( + "auto", + new McpTransportConfig(McpTransportKind.HttpAutoDetect, "https://example.com"), + new NoneAuthConfig()); + + await Sut.WriteAsync([def], default); + var json = await File.ReadAllTextAsync(Path.Combine(_tempDir, "mcp-servers.json")); + + Assert.Contains("httpAutoDetect", json); + } + + [Fact] + public async Task RoundTrip_Tls_PreservesFields() + { + var def = new McpServerDefinition( + "tls", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new NoneAuthConfig(), + Tls: new McpTlsConfig("/ca.pem", "/cert.pem", "/key.pem")); + + await Sut.WriteAsync([def], default); + var result = await Sut.ReadEditableAsync(default); + + var server = Assert.Single(result.Value); + Assert.NotNull(server.Tls); + Assert.Equal("/ca.pem", server.Tls.CaCertPath); + Assert.Equal("/cert.pem", server.Tls.ClientCertPath); + Assert.Equal("/key.pem", server.Tls.ClientKeyPath); + } + + [Fact] + public async Task RoundTrip_Timeouts_Preserved() + { + var def = new McpServerDefinition( + "t", + new McpTransportConfig(McpTransportKind.Stdio, "cmd"), + new NoneAuthConfig(), + ConnectTimeout: TimeSpan.FromSeconds(10), + RequestTimeout: TimeSpan.FromSeconds(30)); + + await Sut.WriteAsync([def], default); + var result = await Sut.ReadEditableAsync(default); + + var server = Assert.Single(result.Value); + Assert.Equal(TimeSpan.FromSeconds(10), server.ConnectTimeout); + Assert.Equal(TimeSpan.FromSeconds(30), server.RequestTimeout); + } + + [Fact] + public async Task WriteAsync_EmptyList_WritesValidJson() + { + await Sut.WriteAsync([], default); + + var result = await Sut.ReadEditableAsync(default); + Assert.True(result.IsOk); + Assert.Empty(result.Value); + } +} diff --git a/tests/Hypa.UnitTests/Infrastructure/Storage/CodeQueryServiceSqliteTests.cs b/tests/Hypa.UnitTests/Infrastructure/Storage/CodeQueryServiceSqliteTests.cs new file mode 100644 index 0000000..b88c3e6 --- /dev/null +++ b/tests/Hypa.UnitTests/Infrastructure/Storage/CodeQueryServiceSqliteTests.cs @@ -0,0 +1,281 @@ +using Hypa.Infrastructure.Storage; +using Hypa.Runtime.Application.Services; +using Hypa.Sdk.CodeIntelligence; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Hypa.UnitTests.Infrastructure.Storage; + +public sealed class CodeQueryServiceSqliteTests +{ + [Fact] + public async Task QueryMarkdownSectionsAsync_WhenSectionsExist_ReturnsSectionsForFile() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + try + { + var (repository, service) = CreateStore(dataDir); + var provenance = MakeProvenance(); + var file = MakeFile("notes.md"); + await repository.SaveDocumentsAsync( + [ + new CodeStructureDocument + { + File = file, + Provenance = provenance, + Sections = + [ + MakeSection(file.RelativePath, "sec_2", "Second", 1, 20, provenance), + MakeSection(file.RelativePath, "sec_1", "First", 1, 0, provenance), + ], + }, + ], + CancellationToken.None); + + var sections = await service.QueryMarkdownSectionsAsync(file.RelativePath, CancellationToken.None); + + Assert.Equal(2, sections.Count); + Assert.Equal(["First", "Second"], sections.Select(s => s.HeadingText).ToArray()); + } + finally + { + await DeleteDataDirectoryAsync(dataDir); + } + } + + [Fact] + public async Task QueryTocAsync_WhenMaxDepthTwo_ExcludesDeepHeadings() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + try + { + var (repository, service) = CreateStore(dataDir); + var provenance = MakeProvenance(); + var file = MakeFile("toc.md"); + await repository.SaveDocumentsAsync( + [ + new CodeStructureDocument + { + File = file, + Provenance = provenance, + Sections = + [ + MakeSection(file.RelativePath, "sec_1", "One", 1, 0, provenance), + MakeSection(file.RelativePath, "sec_2", "Two", 2, 10, provenance), + MakeSection(file.RelativePath, "sec_3", "Three", 3, 20, provenance), + ], + }, + ], + CancellationToken.None); + + var sections = await service.QueryTocAsync(file.RelativePath, maxDepth: 2, CancellationToken.None); + + Assert.Equal(2, sections.Count); + Assert.Equal([1, 2], sections.Select(s => s.HeadingLevel).ToArray()); + Assert.Equal(["One", "Two"], sections.Select(s => s.HeadingText).ToArray()); + } + finally + { + await DeleteDataDirectoryAsync(dataDir); + } + } + + [Fact] + public async Task QueryFrontmatterAsync_WhenFrontmatterYamlExists_ReturnsRawYaml() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + try + { + var (repository, service) = CreateStore(dataDir); + var provenance = MakeProvenance(); + var file = MakeFile("frontmatter.md"); + const string yaml = "title: Test\nauthor: Ada\ntags:\n - docs"; + await repository.SaveDocumentsAsync( + [ + new CodeStructureDocument + { + File = file, + Provenance = provenance, + FrontmatterYaml = yaml, + PlainText = "Heading\nBody", + References = + [ + MakeReference(file.RelativePath, "ref_1", "title", 0, provenance), + MakeReference(file.RelativePath, "ref_2", "author", 10, provenance), + ], + }, + ], + CancellationToken.None); + + var frontmatter = await service.QueryFrontmatterAsync(file.RelativePath, CancellationToken.None); + var document = await service.QueryMarkdownAsync(file.RelativePath, CancellationToken.None); + + Assert.Equal(yaml, frontmatter); + Assert.NotNull(document); + Assert.Equal(yaml, document.FrontmatterYaml); + Assert.Equal("Heading\nBody", document.PlainText); + Assert.Equal(["title", "author"], document.References.Select(r => r.Target).ToArray()); + } + finally + { + await DeleteDataDirectoryAsync(dataDir); + } + } + + [Fact] + public async Task QueryMarkdownSectionsAsync_WhenNoSections_ReturnsEmpty() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + try + { + var (repository, service) = CreateStore(dataDir); + var provenance = MakeProvenance(); + var file = MakeFile("empty.md"); + await repository.SaveDocumentsAsync( + [ + new CodeStructureDocument + { + File = file, + Provenance = provenance, + Sections = [], + }, + ], + CancellationToken.None); + + var sections = await service.QueryMarkdownSectionsAsync(file.RelativePath, CancellationToken.None); + + Assert.NotNull(sections); + Assert.Empty(sections); + } + finally + { + await DeleteDataDirectoryAsync(dataDir); + } + } + + [Fact] + public async Task QueryMarkdownSectionsAsync_WhenDuplicateHeadingPathsExist_PreservesAllSections() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + try + { + var (repository, service) = CreateStore(dataDir); + var provenance = MakeProvenance(); + var file = MakeFile("duplicates.md"); + await repository.SaveDocumentsAsync( + [ + new CodeStructureDocument + { + File = file, + Provenance = provenance, + Sections = + [ + MakeSection(file.RelativePath, "sec_1", "Overview", 2, 0, provenance), + MakeSection(file.RelativePath, "sec_2", "Overview", 2, 40, provenance), + ], + }, + ], + CancellationToken.None); + + var sections = await service.QueryMarkdownSectionsAsync(file.RelativePath, CancellationToken.None); + + Assert.Equal(2, sections.Count); + Assert.Equal([0, 40], sections.Select(s => s.StartByte).ToArray()); + } + finally + { + await DeleteDataDirectoryAsync(dataDir); + } + } + + private static (SqliteCodeIndexRepository Repository, CodeQueryService Service) CreateStore(string dataDir) + { + var options = new HypaDataOptions { DataDirectory = dataDir }; + var schema = new SqliteSchemaInitializer(options); + var repository = new SqliteCodeIndexRepository(options, schema); + return (repository, new CodeQueryService(repository)); + } + + private static MarkdownSection MakeSection( + string filePath, + string id, + string headingText, + int headingLevel, + int startByte, + ProviderProvenance provenance) => new() + { + Id = id, + FilePath = filePath, + HeadingText = headingText, + HeadingLevel = headingLevel, + HeadingPath = headingText, + HeadingAnchor = headingText.ToLowerInvariant(), + StartLine = startByte + 1, + EndLine = startByte + 2, + StartByte = startByte, + EndByte = startByte + 5, + Text = headingText, + PlainText = headingText, + Provenance = provenance, + }; + + private static CodeReference MakeReference( + string filePath, + string id, + string target, + int startByte, + ProviderProvenance provenance) => new() + { + Id = id, + FilePath = filePath, + Kind = "frontmatter", + Target = target, + Span = new SourceSpan + { + StartLine = 1, + StartColumn = startByte + 1, + EndLine = 1, + EndColumn = startByte + target.Length + 1, + StartByte = startByte, + EndByte = startByte + target.Length, + }, + Provenance = provenance, + }; + + private static CodeFileIdentity MakeFile(string relativePath) => new() + { + ProjectRoot = "/project", + Path = $"/project/{relativePath}", + RelativePath = relativePath, + Language = "markdown", + ContentHash = "hash", + SizeBytes = 0, + }; + + private static ProviderProvenance MakeProvenance() => new() + { + ProviderId = "markdown", + ProviderVersion = "1", + QueryVersion = "1", + FactKind = "syntactic", + Confidence = 1, + }; + + private static async Task DeleteDataDirectoryAsync(string dataDir) + { + if (Directory.Exists(dataDir)) + foreach (var f in Directory.EnumerateFiles(dataDir, "*", SearchOption.AllDirectories)) + File.SetAttributes(f, FileAttributes.Normal); + + SqliteConnection.ClearAllPools(); + + if (!Directory.Exists(dataDir)) return; + + for (var attempt = 1; attempt <= 5; attempt++) + { + try { Directory.Delete(dataDir, recursive: true); return; } + catch (IOException) when (attempt < 5) { await Task.Delay(50 * attempt); } + catch (UnauthorizedAccessException) when (attempt < 5) { await Task.Delay(50 * attempt); } + } + } +} diff --git a/tests/Hypa.UnitTests/Infrastructure/Storage/SqliteCodeIndexRepositoryFreshnessTests.cs b/tests/Hypa.UnitTests/Infrastructure/Storage/SqliteCodeIndexRepositoryFreshnessTests.cs new file mode 100644 index 0000000..c5503a7 --- /dev/null +++ b/tests/Hypa.UnitTests/Infrastructure/Storage/SqliteCodeIndexRepositoryFreshnessTests.cs @@ -0,0 +1,218 @@ +using Hypa.Infrastructure.Storage; +using Hypa.Sdk.CodeIntelligence; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Hypa.UnitTests.Infrastructure.Storage; + +public sealed class SqliteCodeIndexRepositoryFreshnessTests +{ + [Fact] + public async Task QueryFileStateAsync_WhenFileNotIndexed_ReturnsNull() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + try + { + var (repo, _) = CreateStore(dataDir); + + var state = await repo.QueryFileStateAsync("/project/missing.md", CancellationToken.None); + + Assert.Null(state); + } + finally + { + await DeleteDataDirectoryAsync(dataDir); + } + } + + [Fact] + public async Task QueryFileStateAsync_WhenFileIndexed_ReturnsMtimeAndSize() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + try + { + var (repo, _) = CreateStore(dataDir); + await repo.SaveDocumentsAsync([MakeDocument("notes.md", mtimeMs: 12345L, sizeBytes: 999L)], CancellationToken.None); + + var state = await repo.QueryFileStateAsync("/project/notes.md", CancellationToken.None); + + Assert.NotNull(state); + Assert.Equal("/project/notes.md", state.AbsolutePath); + Assert.Equal(12345L, state.MTimeMs); + Assert.Equal(999L, state.SizeBytes); + } + finally + { + await DeleteDataDirectoryAsync(dataDir); + } + } + + [Fact] + public async Task QueryFileStateAsync_WhenFileIndexedWithGitOid_ReturnsOid() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + try + { + var (repo, _) = CreateStore(dataDir); + await repo.SaveDocumentsAsync( + [MakeDocument("notes.md", gitBlobOid: "abc123def456", mtimeMs: 0L, sizeBytes: 100L)], + CancellationToken.None); + + var state = await repo.QueryFileStateAsync("/project/notes.md", CancellationToken.None); + + Assert.NotNull(state); + Assert.Equal("abc123def456", state.GitBlobOid); + } + finally + { + await DeleteDataDirectoryAsync(dataDir); + } + } + + [Fact] + public async Task QueryFileStatesAsync_ReturnsAllFilesUnderRoot() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + try + { + var (repo, _) = CreateStore(dataDir); + await repo.SaveDocumentsAsync( + [ + MakeDocument("a.md", projectRoot: "/project", mtimeMs: 1L, sizeBytes: 10L), + MakeDocument("b.md", projectRoot: "/project", mtimeMs: 2L, sizeBytes: 20L), + MakeDocument("c.md", projectRoot: "/other", mtimeMs: 3L, sizeBytes: 30L), + ], + CancellationToken.None); + + var states = await repo.QueryFileStatesAsync("/project", CancellationToken.None); + + Assert.Equal(2, states.Count); + Assert.True(states.ContainsKey("/project/a.md")); + Assert.True(states.ContainsKey("/project/b.md")); + Assert.False(states.ContainsKey("/other/c.md")); + } + finally + { + await DeleteDataDirectoryAsync(dataDir); + } + } + + [Fact] + public async Task DeleteFileAsync_RemovesFileAndDerivedFacts() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + try + { + var (repo, _) = CreateStore(dataDir); + var provenance = MakeProvenance(); + var file = MakeFileIdentity("notes.md"); + var section = new MarkdownSection + { + Id = "sec_1", + FilePath = file.RelativePath, + HeadingText = "Intro", + HeadingLevel = 1, + HeadingPath = "Intro", + HeadingAnchor = "intro", + StartLine = 1, + EndLine = 5, + StartByte = 0, + EndByte = 50, + Provenance = provenance, + }; + await repo.SaveDocumentsAsync( + [new CodeStructureDocument { File = file, Provenance = provenance, Sections = [section] }], + CancellationToken.None); + + await repo.DeleteFileAsync(file.Path, CancellationToken.None); + + await using var conn = new SqliteConnection( + $"Data Source={Path.Combine(dataDir, "hypa.db")}"); + await conn.OpenAsync(); + + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT COUNT(*) FROM code_files WHERE absolute_path = @p"; + cmd.Parameters.AddWithValue("@p", file.Path); + Assert.Equal(0L, (long)(await cmd.ExecuteScalarAsync())!); + } + + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT COUNT(*) FROM markdown_sections WHERE file_path = @p"; + cmd.Parameters.AddWithValue("@p", file.RelativePath); + Assert.Equal(0L, (long)(await cmd.ExecuteScalarAsync())!); + } + } + finally + { + await DeleteDataDirectoryAsync(dataDir); + } + } + + private static (SqliteCodeIndexRepository repo, object _) CreateStore(string dataDir) + { + var options = new HypaDataOptions { DataDirectory = dataDir }; + var schema = new SqliteSchemaInitializer(options); + return (new SqliteCodeIndexRepository(options, schema), new object()); + } + + private static CodeStructureDocument MakeDocument( + string relativePath, + string projectRoot = "/project", + string? gitBlobOid = null, + long mtimeMs = 0L, + long sizeBytes = 0L) => + new() + { + File = new CodeFileIdentity + { + ProjectRoot = projectRoot, + Path = $"{projectRoot}/{relativePath}", + RelativePath = relativePath, + Language = "markdown", + ContentHash = "hash", + SizeBytes = sizeBytes, + GitBlobOid = gitBlobOid, + MTimeMs = mtimeMs, + }, + Provenance = MakeProvenance(), + }; + + private static CodeFileIdentity MakeFileIdentity(string relativePath, string projectRoot = "/project") => new() + { + ProjectRoot = projectRoot, + Path = $"{projectRoot}/{relativePath}", + RelativePath = relativePath, + Language = "markdown", + ContentHash = "hash", + SizeBytes = 100L, + }; + + private static ProviderProvenance MakeProvenance() => new() + { + ProviderId = "markdown", + ProviderVersion = "1", + QueryVersion = "1", + FactKind = "syntactic", + Confidence = 1.0, + }; + + private static async Task DeleteDataDirectoryAsync(string dataDir) + { + if (Directory.Exists(dataDir)) + foreach (var f in Directory.EnumerateFiles(dataDir, "*", SearchOption.AllDirectories)) + File.SetAttributes(f, FileAttributes.Normal); + + SqliteConnection.ClearAllPools(); + + if (!Directory.Exists(dataDir)) return; + + for (var attempt = 1; attempt <= 5; attempt++) + { + try { Directory.Delete(dataDir, recursive: true); return; } + catch (IOException) when (attempt < 5) { await Task.Delay(50 * attempt); } + catch (UnauthorizedAccessException) when (attempt < 5) { await Task.Delay(50 * attempt); } + } + } +} diff --git a/tests/Hypa.UnitTests/Infrastructure/Storage/SqliteSchemaInitializerTests.cs b/tests/Hypa.UnitTests/Infrastructure/Storage/SqliteSchemaInitializerTests.cs index 5396dbb..5ea722e 100644 --- a/tests/Hypa.UnitTests/Infrastructure/Storage/SqliteSchemaInitializerTests.cs +++ b/tests/Hypa.UnitTests/Infrastructure/Storage/SqliteSchemaInitializerTests.cs @@ -1,4 +1,5 @@ using Hypa.Infrastructure.Storage; +using Hypa.Sdk.CodeIntelligence; using Microsoft.Data.Sqlite; using Xunit; @@ -89,7 +90,7 @@ public async Task SchemaInitializer_WhenFreshWritableDb_RunsMigrationsAndSetsVer await using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT value FROM schema_metadata WHERE key = 'schema_version'"; var value = (string?)await cmd.ExecuteScalarAsync(); - Assert.Equal("1", value); + Assert.Equal("3", value); } finally { @@ -210,7 +211,104 @@ public async Task SchemaInitializer_WhenCompatibleWritableDb_StampsSchemaVersion await verify.OpenAsync(); await using var cmd = verify.CreateCommand(); cmd.CommandText = "SELECT value FROM schema_metadata WHERE key = 'schema_version'"; - Assert.Equal("1", (string?)await cmd.ExecuteScalarAsync()); + Assert.Equal("3", (string?)await cmd.ExecuteScalarAsync()); + } + finally + { + await DeleteDataDirectoryAsync(dataDir); + } + } + + [Fact] + public async Task SchemaInitializer_WhenFreshDb_CreatesMandatoryMarkdownTables() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + try + { + var options = new HypaDataOptions { DataDirectory = dataDir }; + var schema = new SqliteSchemaInitializer(options); + + var result = await schema.InitAsync(CancellationToken.None); + + Assert.True(result.IsOk); + + await using var conn = new SqliteConnection($"Data Source={options.DatabasePath}"); + await conn.OpenAsync(); + + await using (var tableCmd = conn.CreateCommand()) + { + tableCmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='markdown_sections'"; + Assert.Equal(1L, (long)(await tableCmd.ExecuteScalarAsync())!); + } + + await using (var pragma = conn.CreateCommand()) + { + pragma.CommandText = "PRAGMA table_info(markdown_sections)"; + await using var reader = await pragma.ExecuteReaderAsync(); + var hasHeadingPath = false; + while (await reader.ReadAsync()) + { + if (string.Equals(reader.GetString(1), "heading_path", StringComparison.OrdinalIgnoreCase)) + { + hasHeadingPath = true; + break; + } + } + + Assert.True(hasHeadingPath); + } + } + finally + { + await DeleteDataDirectoryAsync(dataDir); + } + } + + [Fact] + public async Task SaveDocumentsAsync_WhenMarkdownDocument_PersistsSection() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + try + { + var options = new HypaDataOptions { DataDirectory = dataDir }; + var schema = new SqliteSchemaInitializer(options); + var repository = new SqliteCodeIndexRepository(options, schema); + + var provenance = MakeProvenance(); + var file = MakeFile("notes.md"); + var section = new MarkdownSection + { + Id = "sec_1", + FilePath = file.RelativePath, + HeadingText = "Heading", + HeadingLevel = 1, + HeadingPath = "Heading", + HeadingAnchor = "heading", + StartLine = 1, + EndLine = 3, + StartByte = 0, + EndByte = 20, + Text = "# Heading\n\nBody", + PlainText = "Heading\n\nBody", + Provenance = provenance, + }; + + await repository.SaveDocumentsAsync( + [ + new CodeStructureDocument + { + File = file, + Provenance = provenance, + Sections = [section], + } + ], + CancellationToken.None); + + await using var conn = new SqliteConnection($"Data Source={options.DatabasePath}"); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM markdown_sections"; + Assert.Equal(1L, (long)(await cmd.ExecuteScalarAsync())!); } finally { @@ -244,6 +342,104 @@ public async Task SchemaInitializer_WhenInitFails_CachesDegradedResult() } } + [Fact] + public async Task SchemaInitializer_WhenFreshDb_CodeFilesHasGitBlobOidColumn() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + try + { + var options = new HypaDataOptions { DataDirectory = dataDir }; + var schema = new SqliteSchemaInitializer(options); + var result = await schema.InitAsync(CancellationToken.None); + Assert.True(result.IsOk); + + await using var conn = new SqliteConnection($"Data Source={options.DatabasePath}"); + await conn.OpenAsync(); + await using var pragma = conn.CreateCommand(); + pragma.CommandText = "PRAGMA table_info(code_files)"; + await using var reader = await pragma.ExecuteReaderAsync(); + var hasColumn = false; + while (await reader.ReadAsync()) + if (string.Equals(reader.GetString(1), "git_blob_oid", StringComparison.OrdinalIgnoreCase)) + hasColumn = true; + Assert.True(hasColumn); + } + finally + { + await DeleteDataDirectoryAsync(dataDir); + } + } + + [Fact] + public async Task SchemaInitializer_WhenFreshDb_CodeFilesHasMtimeMsColumn() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + try + { + var options = new HypaDataOptions { DataDirectory = dataDir }; + var schema = new SqliteSchemaInitializer(options); + var result = await schema.InitAsync(CancellationToken.None); + Assert.True(result.IsOk); + + await using var conn = new SqliteConnection($"Data Source={options.DatabasePath}"); + await conn.OpenAsync(); + await using var pragma = conn.CreateCommand(); + pragma.CommandText = "PRAGMA table_info(code_files)"; + await using var reader = await pragma.ExecuteReaderAsync(); + var hasColumn = false; + while (await reader.ReadAsync()) + if (string.Equals(reader.GetString(1), "mtime_ms", StringComparison.OrdinalIgnoreCase)) + hasColumn = true; + Assert.True(hasColumn); + } + finally + { + await DeleteDataDirectoryAsync(dataDir); + } + } + + [Fact] + public async Task SchemaInitializer_ReportsCorrectSchemaVersion() + { + var dataDir = Path.Combine(Path.GetTempPath(), $"hypa-test-{Guid.NewGuid():N}"); + try + { + var options = new HypaDataOptions { DataDirectory = dataDir }; + var schema = new SqliteSchemaInitializer(options); + var result = await schema.InitAsync(CancellationToken.None); + Assert.True(result.IsOk); + + await using var conn = new SqliteConnection($"Data Source={options.DatabasePath}"); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT value FROM schema_metadata WHERE key = 'schema_version'"; + Assert.Equal("3", (string?)await cmd.ExecuteScalarAsync()); + } + finally + { + await DeleteDataDirectoryAsync(dataDir); + } + } + + private static CodeFileIdentity MakeFile(string relativePath) => new() + { + ProjectRoot = "/project", + Path = $"/project/{relativePath}", + RelativePath = relativePath, + Language = "markdown", + ContentHash = "hash", + SizeBytes = 0, + }; + + private static ProviderProvenance MakeProvenance() => new() + { + ProviderId = "markdown", + ProviderVersion = "1", + QueryVersion = "1", + FactKind = "syntactic", + Confidence = 1, + }; + private static async Task DeleteDataDirectoryAsync(string dataDir) { if (Directory.Exists(dataDir)) diff --git a/tests/Hypa.UnitTests/Mcp/Auth/BrowserLauncherAdapterTests.cs b/tests/Hypa.UnitTests/Mcp/Auth/BrowserLauncherAdapterTests.cs new file mode 100644 index 0000000..c0ce1d6 --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/Auth/BrowserLauncherAdapterTests.cs @@ -0,0 +1,64 @@ +using Hypa.Infrastructure.Mcp.Auth; +using Xunit; + +namespace Hypa.UnitTests.Mcp.Auth; + +public sealed class BrowserLauncherAdapterTests +{ + [Fact] + public void GetCommand_Linux_ReturnsXdgOpen() + { + if (!OperatingSystem.IsLinux()) + return; + + var command = BrowserLauncherAdapter.GetBrowserCommand(isWsl: false); + + Assert.Equal("xdg-open", command); + } + + [Fact] + public void GetCommand_Wsl_ReturnsWslview() + { + if (!OperatingSystem.IsLinux()) + return; + + var command = BrowserLauncherAdapter.GetBrowserCommand(isWsl: true); + + Assert.Equal("wslview", command); + } + + [Fact] + public void GetCommand_MacOs_ReturnsOpen() + { + if (!OperatingSystem.IsMacOS()) + return; + + var command = BrowserLauncherAdapter.GetBrowserCommand(isWsl: false); + + Assert.Equal("open", command); + } + + [Fact] + public void TryOpen_CommandNotFound_ReturnsFalse_NoThrow() + { + // Use a non-existent command to test the false-return behavior + var launcher = new BrowserLauncherAdapter(overrideCommand: "hypa-nonexistent-browser-9999"); + + var result = launcher.TryOpen("https://example.com"); + + Assert.False(result); + } + + [Fact] + public void TryOpen_ValidCommand_ReturnsTrue() + { + // Use 'true' (Unix) or 'cmd /c exit 0' (Windows) as a no-op process + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + return; + + var launcher = new BrowserLauncherAdapter(overrideCommand: "true"); + var result = launcher.TryOpen("https://example.com"); + + Assert.True(result); + } +} diff --git a/tests/Hypa.UnitTests/Mcp/Auth/HypaBrowserOAuthDelegateTests.cs b/tests/Hypa.UnitTests/Mcp/Auth/HypaBrowserOAuthDelegateTests.cs new file mode 100644 index 0000000..340da0d --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/Auth/HypaBrowserOAuthDelegateTests.cs @@ -0,0 +1,59 @@ +using Hypa.Infrastructure.Mcp.Auth; +using Hypa.Runtime.Application.Ports; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Mcp.Auth; + +[Trait("Category", "HypaBrowserOAuthDelegate")] +public sealed class HypaBrowserOAuthDelegateTests +{ + private static readonly Uri AuthUri = new("https://auth.example.com/authorize?response_type=code"); + private static readonly Uri RedirectUri = new("http://localhost:9876/callback"); + + private static (IBrowserLauncher Browser, IOAuthCallbackListener Listener) MakeMocks(bool browserSucceeds = true) + { + var browser = Substitute.For(); + browser.TryOpen(Arg.Any()).Returns(browserSucceeds); + + var listener = Substitute.For(); + listener.StartAsync(Arg.Any()).Returns(Task.CompletedTask); + listener.StopAsync().Returns(Task.CompletedTask); + listener.GetRedirectUri().Returns(RedirectUri); + + return (browser, listener); + } + + [Fact] + public async Task HandleAsync_NoBrowserNonInteractive_ThrowsBeforeWaiting() + { + var (browser, listener) = MakeMocks(); + var sut = new HypaBrowserOAuthDelegate( + browser, listener, + progress: null, + noBrowser: true, + interactive: false); + + await Assert.ThrowsAsync( + () => sut.HandleAsync(AuthUri, RedirectUri, CancellationToken.None)); + + await listener.DidNotReceive().WaitForCallbackAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_BrowserFailNonInteractive_ThrowsBeforeWaiting() + { + // Browser fails to open, noBrowser becomes true internally; non-interactive → should throw + var (browser, listener) = MakeMocks(browserSucceeds: false); + var sut = new HypaBrowserOAuthDelegate( + browser, listener, + progress: null, + noBrowser: false, + interactive: false); + + await Assert.ThrowsAsync( + () => sut.HandleAsync(AuthUri, RedirectUri, CancellationToken.None)); + + await listener.DidNotReceive().WaitForCallbackAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/tests/Hypa.UnitTests/Mcp/Auth/McpAuthProviderServiceTests.cs b/tests/Hypa.UnitTests/Mcp/Auth/McpAuthProviderServiceTests.cs new file mode 100644 index 0000000..ca7edab --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/Auth/McpAuthProviderServiceTests.cs @@ -0,0 +1,205 @@ +using Hypa.Infrastructure.Mcp.Auth; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Mcp; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Mcp.Auth; + +public sealed class McpAuthProviderServiceTests +{ + private readonly ISecretResolver _secrets = Substitute.For(); + private readonly SecretRedactionRegistry _redaction = new(); + private readonly FakeOAuthTokenService _fakeOAuth = new(); + private readonly McpAuthProviderService _sut; + + public McpAuthProviderServiceTests() + { + _sut = new McpAuthProviderService( + _secrets, + _fakeOAuth, + _redaction, + NullLogger.Instance); + } + + private static McpServerDefinition Server(McpAuthConfig auth) => + new("test", new McpTransportConfig(McpTransportKind.Http, "https://example.com"), auth); + + [Fact] + public async Task None_ReturnsEmptyHeaders() + { + var ctx = await _sut.GetAuthContextAsync(Server(new NoneAuthConfig()), default); + Assert.Empty(ctx.Headers); + Assert.Null(ctx.BearerToken); + Assert.Null(ctx.QueryParameters); + } + + [Fact] + public async Task Bearer_ReturnsAuthorizationHeader() + { + _secrets.ResolveAsync("env:TOKEN", default).Returns(new ValueTask("my-secret")); + + var ctx = await _sut.GetAuthContextAsync(Server(new BearerAuthConfig("env:TOKEN")), default); + + Assert.Equal("Bearer my-secret", ctx.Headers["Authorization"]); + } + + [Fact] + public async Task ApiKey_HeaderMode_SetsNamedHeader() + { + _secrets.ResolveAsync("env:APIKEY", default).Returns(new ValueTask("key123")); + + var ctx = await _sut.GetAuthContextAsync( + Server(new ApiKeyAuthConfig("X-Api-Key", "env:APIKEY")), default); + + Assert.Equal("key123", ctx.Headers["X-Api-Key"]); + Assert.Null(ctx.QueryParameters); + } + + [Fact] + public async Task ApiKey_QueryStringMode_PopulatesQueryParameters() + { + _secrets.ResolveAsync("env:APIKEY", default).Returns(new ValueTask("key123")); + + var ctx = await _sut.GetAuthContextAsync( + Server(new ApiKeyAuthConfig("api_key", "env:APIKEY", InQueryString: true)), default); + + Assert.Empty(ctx.Headers); + Assert.NotNull(ctx.QueryParameters); + Assert.Equal("key123", ctx.QueryParameters!["api_key"]); + } + + [Fact] + public async Task Basic_SetsBase64EncodedAuthorizationHeader() + { + _secrets.ResolveAsync("env:USER", default).Returns(new ValueTask("alice")); + _secrets.ResolveAsync("env:PASS", default).Returns(new ValueTask("p@ssw0rd")); + + var ctx = await _sut.GetAuthContextAsync( + Server(new BasicAuthConfig("env:USER", "env:PASS")), default); + + var expected = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("alice:p@ssw0rd")); + Assert.Equal($"Basic {expected}", ctx.Headers["Authorization"]); + } + + [Fact] + public async Task OAuth2ClientCredentials_DelegatesToOAuthTokenService() + { + _fakeOAuth.ClientCredentialsToken = "oauth-access-token"; + + var config = new OAuth2ClientCredentialsConfig( + "https://auth.example.com/token", "env:CID", "env:CSECRET"); + + var ctx = await _sut.GetAuthContextAsync(Server(config), default); + + Assert.Equal("Bearer oauth-access-token", ctx.Headers["Authorization"]); + } + + [Fact] + public async Task OAuth2DeviceCode_WithCachedToken_SetsBearerHeader() + { + _fakeOAuth.DeviceCodeToken = "device-token-xyz"; + + var config = new OAuth2DeviceCodeConfig( + "https://auth.example.com/device", "https://auth.example.com/token", "client1"); + + var ctx = await _sut.GetAuthContextAsync(Server(config), default); + + Assert.Equal("Bearer device-token-xyz", ctx.Headers["Authorization"]); + Assert.Equal("device-token-xyz", ctx.BearerToken); + } + + [Fact] + public async Task OAuth2DeviceCode_WithoutCachedToken_ReturnsEmptyContext() + { + _fakeOAuth.DeviceCodeToken = null; + + var config = new OAuth2DeviceCodeConfig( + "https://auth.example.com/device", "https://auth.example.com/token", "client1"); + + var ctx = await _sut.GetAuthContextAsync(Server(config), default); + + Assert.Empty(ctx.Headers); + Assert.Null(ctx.BearerToken); + } + + [Fact] + public async Task Mtls_PopulatesCertAndKeyPaths() + { + _secrets.ResolveAsync("file:/certs/client.pem", default).Returns(new ValueTask("/certs/client.pem")); + _secrets.ResolveAsync("file:/certs/client.key", default).Returns(new ValueTask("/certs/client.key")); + + var ctx = await _sut.GetAuthContextAsync( + Server(new MtlsConfig("file:/certs/client.pem", "file:/certs/client.key")), default); + + Assert.Equal("/certs/client.pem", ctx.ClientCertificatePath); + Assert.Equal("/certs/client.key", ctx.ClientKeyPath); + } + + [Fact] + public async Task Bearer_NullSecret_ThrowsCredentialResolutionException() + { + _secrets.ResolveAsync("env:TOKEN", default).Returns(new ValueTask(null as string)); + + await Assert.ThrowsAsync( + () => _sut.GetAuthContextAsync(Server(new BearerAuthConfig("env:TOKEN")), default).AsTask()); + } + + [Fact] + public async Task Basic_NullUsername_ThrowsCredentialResolutionException() + { + _secrets.ResolveAsync("env:USER", default).Returns(new ValueTask(null as string)); + _secrets.ResolveAsync("env:PASS", default).Returns(new ValueTask("p@ss")); + + await Assert.ThrowsAsync( + () => _sut.GetAuthContextAsync(Server(new BasicAuthConfig("env:USER", "env:PASS")), default).AsTask()); + } + + [Fact] + public async Task Basic_NullPassword_ThrowsCredentialResolutionException() + { + _secrets.ResolveAsync("env:USER", default).Returns(new ValueTask("alice")); + _secrets.ResolveAsync("env:PASS", default).Returns(new ValueTask(null as string)); + + await Assert.ThrowsAsync( + () => _sut.GetAuthContextAsync(Server(new BasicAuthConfig("env:USER", "env:PASS")), default).AsTask()); + } + + [Fact] + public async Task Bearer_RegistersTokenWithRedactionRegistry() + { + _secrets.ResolveAsync("env:SECRET", default).Returns(new ValueTask("super-secret-token")); + + await _sut.GetAuthContextAsync(Server(new BearerAuthConfig("env:SECRET")), default); + + Assert.Equal("[REDACTED]", _redaction.Redact("super-secret-token")); + } + + [Fact] + public async Task OAuth2ClientCredentials_RegistersTokenWithRedactionRegistry() + { + _fakeOAuth.ClientCredentialsToken = "oauth-secret-xyz"; + + var config = new OAuth2ClientCredentialsConfig( + "https://auth.example.com/token", "env:CID", "env:CSECRET"); + + await _sut.GetAuthContextAsync(Server(config), default); + + Assert.Equal("[REDACTED]", _redaction.Redact("oauth-secret-xyz")); + } + + private sealed class FakeOAuthTokenService : IOAuthTokenService + { + public string? ClientCredentialsToken { get; set; } + public string? DeviceCodeToken { get; set; } + + public Task GetClientCredentialsTokenAsync( + OAuth2ClientCredentialsConfig config, CancellationToken ct) => + Task.FromResult(ClientCredentialsToken ?? string.Empty); + + public Task GetDeviceCodeTokenAsync( + OAuth2DeviceCodeConfig config, CancellationToken ct) => + Task.FromResult(DeviceCodeToken); + } +} diff --git a/tests/Hypa.UnitTests/Mcp/Auth/McpOAuthTokenStoreTests.cs b/tests/Hypa.UnitTests/Mcp/Auth/McpOAuthTokenStoreTests.cs new file mode 100644 index 0000000..6e53a9b --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/Auth/McpOAuthTokenStoreTests.cs @@ -0,0 +1,225 @@ +using Hypa.Infrastructure.Mcp.Auth; +using ModelContextProtocol.Authentication; +using Xunit; + +namespace Hypa.UnitTests.Mcp.Auth; + +public sealed class McpOAuthTokenStoreTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"hypa-token-test-{Guid.NewGuid():N}"); + + public McpOAuthTokenStoreTests() => Directory.CreateDirectory(_dir); + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + } + + private McpOAuthTokenStore Store(string serverName = "test-server") => + new(serverName, _dir); + + private static TokenContainer Token(int expiresIn = 3600) => new() + { + TokenType = "Bearer", + AccessToken = "access-abc", + RefreshToken = "refresh-xyz", + ExpiresIn = expiresIn, + ObtainedAt = DateTimeOffset.UtcNow, + Scope = "read", + }; + + [Fact] + public async Task RoundTrip_StoreAndRetrieve_ReturnsSameToken() + { + var store = Store(); + var token = Token(); + + await store.StoreTokensAsync(token, CancellationToken.None); + var retrieved = await store.GetTokensAsync(CancellationToken.None); + + Assert.NotNull(retrieved); + Assert.Equal("access-abc", retrieved.AccessToken); + Assert.Equal("refresh-xyz", retrieved.RefreshToken); + Assert.Equal("read", retrieved.Scope); + } + + [Fact] + public async Task GetTokens_MissingFile_ReturnsNull() + { + var store = Store("no-file-server"); + + var result = await store.GetTokensAsync(CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task GetTokens_ExpiredToken_ReturnsNull() + { + var store = Store("expired-server"); + var expired = new TokenContainer + { + TokenType = "Bearer", + AccessToken = "old-token", + ExpiresIn = 3600, + ObtainedAt = DateTimeOffset.UtcNow.AddHours(-2), + }; + + await store.StoreTokensAsync(expired, CancellationToken.None); + var result = await store.GetTokensAsync(CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task GetTokens_NoExpiresIn_ReturnsToken() + { + var store = Store("no-expiry-server"); + var noExpiry = MinToken("forever-token"); + + await store.StoreTokensAsync(noExpiry, CancellationToken.None); + var result = await store.GetTokensAsync(CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal("forever-token", result.AccessToken); + } + + [Fact] + public async Task MultipleServers_IsolatedByServerName() + { + var storeA = new McpOAuthTokenStore("server-a", _dir); + var storeB = new McpOAuthTokenStore("server-b", _dir); + + await storeA.StoreTokensAsync(MinToken("token-a"), CancellationToken.None); + await storeB.StoreTokensAsync(MinToken("token-b"), CancellationToken.None); + + var a = await storeA.GetTokensAsync(CancellationToken.None); + var b = await storeB.GetTokensAsync(CancellationToken.None); + + Assert.Equal("token-a", a?.AccessToken); + Assert.Equal("token-b", b?.AccessToken); + } + + private static TokenContainer MinToken(string accessToken) => new() + { + TokenType = "Bearer", + AccessToken = accessToken, + ObtainedAt = DateTimeOffset.UtcNow, + }; + + [Fact] + public async Task FilePermissions_AreUserOnly_OnUnix() + { + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + return; + + var store = Store("perm-server"); + await store.StoreTokensAsync(Token(), CancellationToken.None); + + var filePath = Path.Combine(_dir, "mcp-oauth-tokens.json"); + Assert.True(File.Exists(filePath)); + + var mode = File.GetUnixFileMode(filePath); + Assert.True(mode.HasFlag(UnixFileMode.UserRead)); + Assert.True(mode.HasFlag(UnixFileMode.UserWrite)); + Assert.False(mode.HasFlag(UnixFileMode.GroupRead)); + Assert.False(mode.HasFlag(UnixFileMode.OtherRead)); + } + + [Fact] + public async Task VersionMismatch_ReturnsNull() + { + var filePath = Path.Combine(_dir, "mcp-oauth-tokens.json"); + await File.WriteAllTextAsync(filePath, """{"version":99,"tokens":{}}"""); + + var store = Store("any-server"); + var result = await store.GetTokensAsync(CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task DcrCredentials_StoreAndRetrieve_ReturnsSameValues() + { + var store = Store("dcr-store-server"); + await store.StoreTokensAsync(MinToken("access-token"), CancellationToken.None); + await store.StoreDcrCredentialsAsync("my-client-id", "my-client-secret", CancellationToken.None); + + var (clientId, secret) = await store.GetDcrCredentialsAsync(CancellationToken.None); + Assert.Equal("my-client-id", clientId); + Assert.Equal("my-client-secret", secret); + } + + [Fact] + public async Task DcrCredentials_MissingFile_ReturnsNullTuple() + { + var store = Store("no-dcr-file-server"); + var (clientId, secret) = await store.GetDcrCredentialsAsync(CancellationToken.None); + Assert.Null(clientId); + Assert.Null(secret); + } + + [Fact] + public async Task DcrCredentials_NoTokenEntry_ReturnsNullTuple() + { + // No tokens stored for this server. + var store = Store("no-dcr-entry-server"); + var (clientId, secret) = await store.GetDcrCredentialsAsync(CancellationToken.None); + Assert.Null(clientId); + Assert.Null(secret); + } + + [Fact] + public async Task DcrCredentials_StoreWhenNoTokenEntry_NothingPersisted() + { + // Store DCR credentials without a token entry. + var store = Store("dcr-no-token-server"); + await store.StoreDcrCredentialsAsync("some-id", "some-secret", CancellationToken.None); + + // The file should not exist because StoreDcrCredentials returns early. + var filePath = Path.Combine(_dir, "mcp-oauth-tokens.json"); + Assert.False(File.Exists(filePath)); + } + + [Fact] + public async Task DcrCredentials_PersistsAlongsideTokens() + { + var storeA = new McpOAuthTokenStore("dcr-multi-a", _dir); + var storeB = new McpOAuthTokenStore("dcr-multi-b", _dir); + + await storeA.StoreTokensAsync(MinToken("token-a"), CancellationToken.None); + await storeA.StoreDcrCredentialsAsync("id-a", "secret-a", CancellationToken.None); + await storeB.StoreTokensAsync(MinToken("token-b"), CancellationToken.None); + + // Verify A has DCR credentials. + var (aId, aSecret) = await storeA.GetDcrCredentialsAsync(CancellationToken.None); + Assert.Equal("id-a", aId); + Assert.Equal("secret-a", aSecret); + + // Verify B does not have DCR credentials. + var (bId, bSecret) = await storeB.GetDcrCredentialsAsync(CancellationToken.None); + Assert.Null(bId); + Assert.Null(bSecret); + + // Verify tokens are still accessible for both. + var aToken = await storeA.GetTokensAsync(CancellationToken.None); + var bToken = await storeB.GetTokensAsync(CancellationToken.None); + Assert.Equal("token-a", aToken?.AccessToken); + Assert.Equal("token-b", bToken?.AccessToken); + } + + [Fact] + public async Task DcrCredentials_VersionUpgrade_ReadsCorrectly() + { + // Write a version 1 file (old schema without DCR fields). + var filePath = Path.Combine(_dir, "mcp-oauth-tokens.json"); + await File.WriteAllTextAsync(filePath, """ + {"version":1,"tokens":{"legacy-server":{"tokenType":"Bearer","accessToken":"old-token","refreshToken":null,"expiresIn":3600,"obtainedAt":"2025-01-01T00:00:00+00:00","scope":"read"}}} + """); + + var store = Store("legacy-server"); + var (clientId, secret) = await store.GetDcrCredentialsAsync(CancellationToken.None); + Assert.Null(clientId); + Assert.Null(secret); + } +} diff --git a/tests/Hypa.UnitTests/Mcp/Auth/OAuthCallbackListenerTests.cs b/tests/Hypa.UnitTests/Mcp/Auth/OAuthCallbackListenerTests.cs new file mode 100644 index 0000000..f492fbb --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/Auth/OAuthCallbackListenerTests.cs @@ -0,0 +1,133 @@ +using Hypa.Infrastructure.Mcp.Auth; +using Xunit; + +namespace Hypa.UnitTests.Mcp.Auth; + +public sealed class OAuthCallbackListenerTests +{ + [Fact] + public void GetRedirectUri_ThrowsBeforeStartAsync() + { + var listener = new OAuthCallbackListener(); + + var ex = Assert.Throws(() => listener.GetRedirectUri()); + Assert.Contains("StartAsync", ex.Message); + } + + [Fact] + public async Task Start_BindsTo127_0_0_1() + { + var listener = new OAuthCallbackListener(); + await listener.StartAsync(CancellationToken.None); + try + { + var uri = listener.GetRedirectUri(); + Assert.Equal("127.0.0.1", uri.Host); + Assert.Equal("/callback", uri.AbsolutePath); + } + finally + { + await listener.StopAsync(); + } + } + + [Fact] + public async Task GetRedirectUri_ReturnsPortFromListener() + { + var listener = new OAuthCallbackListener(); + await listener.StartAsync(CancellationToken.None); + try + { + var uri = listener.GetRedirectUri(); + Assert.True(uri.Port > 0); + } + finally + { + await listener.StopAsync(); + } + } + + [Fact] + public async Task WaitForCallback_ReturnsCode_OnQueryString() + { + var listener = new OAuthCallbackListener(); + await listener.StartAsync(CancellationToken.None); + try + { + var callbackUri = listener.GetRedirectUri(); + + using var http = new HttpClient(); + var callbackTask = listener.WaitForCallbackAsync(TimeSpan.FromSeconds(5), CancellationToken.None); + + _ = Task.Run(async () => + { + await Task.Delay(50); + await http.GetAsync($"{callbackUri}?code=test-code-123&state=abc"); + }); + + var result = await callbackTask; + + Assert.Equal("test-code-123", result.Code); + Assert.Null(result.Error); + } + finally + { + await listener.StopAsync(); + } + } + + [Fact] + public async Task WaitForCallback_ReturnsError_OnErrorQueryString() + { + var listener = new OAuthCallbackListener(); + await listener.StartAsync(CancellationToken.None); + try + { + var callbackUri = listener.GetRedirectUri(); + + using var http = new HttpClient(); + var callbackTask = listener.WaitForCallbackAsync(TimeSpan.FromSeconds(5), CancellationToken.None); + + _ = Task.Run(async () => + { + await Task.Delay(50); + await http.GetAsync($"{callbackUri}?error=access_denied"); + }); + + var result = await callbackTask; + + Assert.Null(result.Code); + Assert.Equal("access_denied", result.Error); + } + finally + { + await listener.StopAsync(); + } + } + + [Fact] + public async Task WaitForCallback_ReturnsNull_OnTimeout() + { + var listener = new OAuthCallbackListener(); + await listener.StartAsync(CancellationToken.None); + try + { + var result = await listener.WaitForCallbackAsync(TimeSpan.FromMilliseconds(100), CancellationToken.None); + Assert.Null(result.Code); + Assert.Null(result.Error); + } + finally + { + await listener.StopAsync(); + } + } + + [Fact] + public async Task Stop_DisposesCleanly() + { + var listener = new OAuthCallbackListener(); + await listener.StartAsync(CancellationToken.None); + await listener.StopAsync(); + // No exception = pass + } +} diff --git a/tests/Hypa.UnitTests/Mcp/Auth/OAuthTokenCacheTests.cs b/tests/Hypa.UnitTests/Mcp/Auth/OAuthTokenCacheTests.cs new file mode 100644 index 0000000..8da0991 --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/Auth/OAuthTokenCacheTests.cs @@ -0,0 +1,86 @@ +using Hypa.Infrastructure.Mcp.Auth; +using Xunit; + +namespace Hypa.UnitTests.Mcp.Auth; + +public sealed class OAuthTokenCacheTests +{ + private readonly OAuthTokenCache _sut = new(); + + [Fact] + public void TryGet_FreshToken_ReturnsToken() + { + _sut.Set("key", "my-token", expiresIn: 3600); + Assert.Equal("my-token", _sut.TryGet("key")); + } + + [Fact] + public void TryGet_UnknownKey_ReturnsNull() + { + Assert.Null(_sut.TryGet("unknown-key")); + } + + [Fact] + public void TryGet_NullExpiresIn_DefaultsToOneHour_ReturnsToken() + { + _sut.Set("key", "my-token", expiresIn: null); + Assert.Equal("my-token", _sut.TryGet("key")); + } + + [Fact] + public void TryGet_TokenWithinDefaultSkew_ReturnsNull() + { + // Token expires in 30 seconds, default skew is 60 seconds — treated as expired. + _sut.Set("key", "my-token", expiresIn: 30); + Assert.Null(_sut.TryGet("key")); + } + + [Fact] + public void TryGet_TokenJustOutsideSkew_ReturnsToken() + { + // Token expires in 120 seconds, default skew is 60 — still valid. + _sut.Set("key", "my-token", expiresIn: 120); + Assert.Equal("my-token", _sut.TryGet("key")); + } + + [Fact] + public void TryGet_WithCustomSkew_RespectsCustomSkew() + { + // Expires in 30s — invalid under default 60s skew, valid under custom 10s skew. + _sut.Set("key", "my-token", expiresIn: 30); + Assert.Equal("my-token", _sut.TryGet("key", skewSeconds: 10)); + } + + [Fact] + public void TryGet_AfterRemove_ReturnsNull() + { + _sut.Set("key", "my-token", expiresIn: 3600); + _sut.Remove("key"); + Assert.Null(_sut.TryGet("key")); + } + + [Fact] + public void Set_OverwritesExistingToken() + { + _sut.Set("key", "first-token", expiresIn: 3600); + _sut.Set("key", "second-token", expiresIn: 3600); + Assert.Equal("second-token", _sut.TryGet("key")); + } + + [Fact] + public void Remove_NonExistentKey_DoesNotThrow() + { + // Should not throw even when the key was never set. + _sut.Remove("ghost-key"); + } + + [Fact] + public void TryGet_MultipleKeys_AreIndependent() + { + _sut.Set("key-a", "token-a", expiresIn: 3600); + _sut.Set("key-b", "token-b", expiresIn: 3600); + + Assert.Equal("token-a", _sut.TryGet("key-a")); + Assert.Equal("token-b", _sut.TryGet("key-b")); + } +} diff --git a/tests/Hypa.UnitTests/Mcp/Auth/OAuthTokenServiceTests.cs b/tests/Hypa.UnitTests/Mcp/Auth/OAuthTokenServiceTests.cs new file mode 100644 index 0000000..cd7dfae --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/Auth/OAuthTokenServiceTests.cs @@ -0,0 +1,169 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Hypa.Infrastructure.Mcp.Auth; +using Hypa.Infrastructure.Mcp.Secrets; +using Hypa.Infrastructure.Storage; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Mcp; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Mcp.Auth; + +public sealed class OAuthTokenServiceTests : IDisposable +{ + private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"hypa-oauth-test-{Guid.NewGuid():N}"); + private readonly ISecretResolver _secrets = Substitute.For(); + + public OAuthTokenServiceTests() => Directory.CreateDirectory(_tempDir); + public void Dispose() => Directory.Delete(_tempDir, recursive: true); + + private OAuthTokenService BuildSut(HttpMessageHandler handler, OAuthTokenCache? cache = null) => + new( + new FakeHttpClientFactory(handler), + _secrets, + cache ?? new OAuthTokenCache(), + new HypaDataOptions { DataDirectory = _tempDir }, + NullLogger.Instance); + + private static HttpMessageHandler RespondWith(object body, HttpStatusCode status = HttpStatusCode.OK) + { + var json = JsonSerializer.Serialize(body, new JsonSerializerOptions + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower, + }); + return new StaticResponseHandler(json, status); + } + + [Fact] + public async Task GetClientCredentialsTokenAsync_FirstCall_MakesHttpRequest() + { + var handler = RespondWith(new { access_token = "tok1", token_type = "bearer", expires_in = 3600 }); + _secrets.ResolveAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask("resolved")); + + var sut = BuildSut(handler); + var config = new OAuth2ClientCredentialsConfig( + "https://auth.example.com/token", "env:CID", "env:CSECRET"); + + var token = await sut.GetClientCredentialsTokenAsync(config, default); + + Assert.Equal("tok1", token); + } + + [Fact] + public async Task GetClientCredentialsTokenAsync_SecondCall_ReturnsCachedToken() + { + var callCount = 0; + var handler = new CountingHandler( + () => JsonSerializer.Serialize(new { access_token = $"tok{++callCount}", token_type = "bearer", expires_in = 3600 }, + new JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower })); + + _secrets.ResolveAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask("resolved")); + + var cache = new OAuthTokenCache(); + var sut = BuildSut(handler, cache); + var config = new OAuth2ClientCredentialsConfig( + "https://auth.example.com/token", "env:CID", "env:CSECRET"); + + var first = await sut.GetClientCredentialsTokenAsync(config, default); + var second = await sut.GetClientCredentialsTokenAsync(config, default); + + Assert.Equal("tok1", first); + Assert.Equal("tok1", second); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task GetClientCredentialsTokenAsync_ExpiredToken_TriggersRefresh() + { + _secrets.ResolveAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask("resolved")); + + var cache = new OAuthTokenCache(); + var config = new OAuth2ClientCredentialsConfig( + "https://auth.example.com/token", "env:CID", "env:CSECRET"); + var cacheKey = $"{config.ClientIdRef}@{config.TokenUrl}"; + + cache.Set(cacheKey, "old-token", expiresIn: -10); + + var handler = RespondWith(new { access_token = "new-token", token_type = "bearer", expires_in = 3600 }); + var sut = BuildSut(handler, cache); + + var token = await sut.GetClientCredentialsTokenAsync(config, default); + Assert.Equal("new-token", token); + } + + [Fact] + public async Task GetClientCredentialsTokenAsync_TokenExpiringWithin60s_TriggersRefresh() + { + _secrets.ResolveAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask("resolved")); + + var cache = new OAuthTokenCache(); + var config = new OAuth2ClientCredentialsConfig( + "https://auth.example.com/token", "env:CID", "env:CSECRET"); + var cacheKey = $"{config.ClientIdRef}@{config.TokenUrl}"; + + cache.Set(cacheKey, "old-token", expiresIn: 30); + + var handler = RespondWith(new { access_token = "refreshed-token", token_type = "bearer", expires_in = 3600 }); + var sut = BuildSut(handler, cache); + + var token = await sut.GetClientCredentialsTokenAsync(config, default); + Assert.Equal("refreshed-token", token); + } + + [Fact] + public async Task GetDeviceCodeTokenAsync_EmptyCache_ReturnsNull() + { + var sut = BuildSut(new StaticResponseHandler("{}", HttpStatusCode.OK)); + var config = new OAuth2DeviceCodeConfig( + "https://auth.example.com/device", "https://auth.example.com/token", "client1"); + + var token = await sut.GetDeviceCodeTokenAsync(config, default); + Assert.Null(token); + } + + private sealed class FakeHttpClientFactory : IHttpClientFactory + { + private readonly HttpMessageHandler _handler; + public FakeHttpClientFactory(HttpMessageHandler handler) => _handler = handler; + public HttpClient CreateClient(string name) => new(_handler); + } + + private sealed class StaticResponseHandler : HttpMessageHandler + { + private readonly string _json; + private readonly HttpStatusCode _status; + + public StaticResponseHandler(string json, HttpStatusCode status) + { + _json = json; + _status = status; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(_status) + { + Content = new StringContent(_json, Encoding.UTF8, "application/json"), + }); + } + + private sealed class CountingHandler : HttpMessageHandler + { + private readonly Func _jsonFactory; + public CountingHandler(Func jsonFactory) => _jsonFactory = jsonFactory; + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(_jsonFactory(), Encoding.UTF8, "application/json"), + }); + } +} diff --git a/tests/Hypa.UnitTests/Mcp/Auth/SecretRedactionRegistryTests.cs b/tests/Hypa.UnitTests/Mcp/Auth/SecretRedactionRegistryTests.cs new file mode 100644 index 0000000..fd11e30 --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/Auth/SecretRedactionRegistryTests.cs @@ -0,0 +1,50 @@ +using Hypa.Infrastructure.Mcp.Auth; +using Xunit; + +namespace Hypa.UnitTests.Mcp.Auth; + +public sealed class SecretRedactionRegistryTests +{ + private readonly SecretRedactionRegistry _sut = new(); + + [Fact] + public void Redact_RegisteredSecret_IsReplaced() + { + _sut.Register("my-token-abc"); + var result = _sut.Redact("Authorization: Bearer my-token-abc"); + Assert.Equal("Authorization: Bearer [REDACTED]", result); + } + + [Fact] + public void Redact_MultipleSecrets_AllReplaced() + { + _sut.Register("secret1"); + _sut.Register("password99"); + var result = _sut.Redact("user=secret1 pass=password99"); + Assert.Equal("user=[REDACTED] pass=[REDACTED]", result); + } + + [Fact] + public void Redact_NoRegisteredSecrets_TextUnchanged() + { + var text = "nothing to see here"; + var result = _sut.Redact(text); + Assert.Equal(text, result); + } + + [Fact] + public void Redact_UnregisteredValue_NotReplaced() + { + _sut.Register("known-secret"); + var result = _sut.Redact("some other value"); + Assert.Equal("some other value", result); + } + + [Fact] + public void Register_EmptyString_DoesNotRedactAnything() + { + _sut.Register(string.Empty); + var result = _sut.Redact("something"); + Assert.Equal("something", result); + } +} diff --git a/tests/Hypa.UnitTests/Mcp/Connection/DirectMcpDispatcherTests.cs b/tests/Hypa.UnitTests/Mcp/Connection/DirectMcpDispatcherTests.cs new file mode 100644 index 0000000..3a3927b --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/Connection/DirectMcpDispatcherTests.cs @@ -0,0 +1,468 @@ +using Hypa.Infrastructure.Mcp.Connection; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Mcp.Connection; + +public sealed class DirectMcpDispatcherTests +{ + private readonly IMcpServerDefinitionRepository _repo = Substitute.For(); + private readonly IMcpClientConnectionFactory _factory = Substitute.For(); + private readonly IClock _clock = Substitute.For(); + private readonly McpConfigValidationService _validator = new(); + private readonly DirectMcpDispatcher _sut; + + public DirectMcpDispatcherTests() + { + _clock.UtcNow.Returns(DateTimeOffset.UtcNow); + _sut = new DirectMcpDispatcher( + _repo, + _factory, + _validator, + _clock, + NullLogger.Instance); + } + + private static McpServerDefinition Server(string name = "svc") => + new(name, + new McpTransportConfig(McpTransportKind.Http, "https://example.com/mcp"), + new NoneAuthConfig()); + + private static McpProxyRequest Request(string server = "svc", string tool = "echo") => + new(server, tool, new JsonPayload("{}")); + + private IMcpClientFacade FakeClient( + IList? tools = null, + CallToolResult? result = null) + { + var client = Substitute.For(); + client.ListToolsAsync(Arg.Any()) + .Returns(new ValueTask>(tools ?? [])); + client.CallToolAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(new ValueTask(result ?? new CallToolResult())); + return client; + } + + [Fact] + public async Task GetSchemaAsync_MapsToolsFromAllServers() + { + var servers = new[] { Server("a"), Server("b") }; + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok(servers))); + + var toolA = CreateMcpClientTool("tool_a", "Does A"); + var toolB = CreateMcpClientTool("tool_b", "Does B"); + + var clientA = FakeClient([toolA]); + var clientB = FakeClient([toolB]); + _factory.GetOrCreateAsync(servers[0], Arg.Any()) + .Returns(Task.FromResult(Result.Ok(clientA))); + _factory.GetOrCreateAsync(servers[1], Arg.Any()) + .Returns(Task.FromResult(Result.Ok(clientB))); + + var manifest = await _sut.GetSchemaAsync(default); + + Assert.Equal(2, manifest.Servers.Count); + Assert.Equal("a", manifest.Servers[0].ServerName); + Assert.Single(manifest.Servers[0].Tools); + Assert.Equal("tool_a", manifest.Servers[0].Tools[0].Name); + Assert.Equal("b", manifest.Servers[1].ServerName); + Assert.Equal("tool_b", manifest.Servers[1].Tools[0].Name); + } + + [Fact] + public async Task InvokeAsync_ReturnsResult_WithLatency() + { + var server = Server(); + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)[server]))); + + var sdkResult = new CallToolResult + { + Content = [new TextContentBlock { Text = "pong" }] + }; + var client = FakeClient(result: sdkResult); + _factory.GetOrCreateAsync(server, Arg.Any()) + .Returns(Task.FromResult(Result.Ok(client))); + + var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + var end = start.AddMilliseconds(42); + _clock.UtcNow.Returns(start, end); + + var result = await _sut.InvokeAsync(Request(), default); + + Assert.False(result.IsError); + Assert.Equal("svc", result.ServerName); + Assert.Equal("echo", result.ToolName); + Assert.Equal("pong", result.CompressedResponse); + Assert.Equal(start, result.Latency.StartedAt); + Assert.Equal(TimeSpan.FromMilliseconds(42), result.Latency.Elapsed); + } + + [Fact] + public async Task InvokeAsync_OnSdkException_InvalidatesAndReturnsError() + { + var server = Server(); + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)[server]))); + + var client = Substitute.For(); + client.CallToolAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns>(_ => throw new InvalidOperationException("upstream error")); + + _factory.GetOrCreateAsync(server, Arg.Any()) + .Returns(Task.FromResult(Result.Ok(client))); + + var result = await _sut.InvokeAsync(Request(), default); + + Assert.True(result.IsError); + Assert.Equal(McpErrorCodes.ToolInvocationFailed, result.Error!.Code); + await _factory.Received(1).InvalidateAsync("svc"); + } + + [Fact] + public async Task InvokeBatchAsync_FansOutToAllRequests() + { + var server = Server(); + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)[server]))); + + var sdkResult = new CallToolResult { Content = [new TextContentBlock { Text = "ok" }] }; + var client = FakeClient(result: sdkResult); + _factory.GetOrCreateAsync(server, Arg.Any()) + .Returns(Task.FromResult(Result.Ok(client))); + + var requests = new[] + { + Request("svc", "tool1"), + Request("svc", "tool2"), + Request("svc", "tool3"), + }; + + var results = await _sut.InvokeBatchAsync(requests, default); + + Assert.Equal(3, results.Count); + Assert.Equal("tool1", results[0].ToolName); + Assert.Equal("tool2", results[1].ToolName); + Assert.Equal("tool3", results[2].ToolName); + } + + [Fact] + public async Task SearchToolsAsync_FiltersOnNameAndDescription() + { + var server = Server(); + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)[server]))); + + IList tools = + [ + CreateMcpClientTool("list_files", "Lists files in a directory"), + CreateMcpClientTool("run_command", "Executes a shell command"), + CreateMcpClientTool("read_file", "Reads file content"), + ]; + var clientWithTools = FakeClient(tools); + _factory.GetOrCreateAsync(server, Arg.Any()) + .Returns(Task.FromResult(Result.Ok(clientWithTools))); + + var results = await _sut.SearchToolsAsync("file", default); + + Assert.Equal(2, results.Count); + var names = results.Select(r => r.ToolName).ToHashSet(); + Assert.Contains("list_files", names); + Assert.Contains("read_file", names); + } + + [Fact] + public async Task InvokeAsync_UnknownServer_ReturnsError() + { + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)[Server("other")]))); + + var result = await _sut.InvokeAsync(Request("missing"), default); + + Assert.True(result.IsError); + Assert.Equal(McpErrorCodes.UnknownServer, result.Error!.Code); + } + + [Fact] + public async Task InvokeAsync_ConnectionFactoryFailure_ReturnsStructuredError() + { + var server = Server(); + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)[server]))); + _factory.GetOrCreateAsync(server, Arg.Any()) + .Returns(Task.FromResult(Result.Fail( + new McpProxyError(McpErrorCodes.ConnectionFailed, "refused", server.Name)))); + + var result = await _sut.InvokeAsync(Request(), default); + + Assert.True(result.IsError); + Assert.Equal(McpErrorCodes.ConnectionFailed, result.Error!.Code); + } + + [Fact] + public async Task InvokeAsync_Timeout_ReturnsTimeoutErrorAndInvalidatesClient() + { + var server = Server(); + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)[server]))); + + var client = Substitute.For(); + client.CallToolAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns>(_ => throw new OperationCanceledException(CancellationToken.None)); + _factory.GetOrCreateAsync(server, Arg.Any()) + .Returns(Task.FromResult(Result.Ok(client))); + + var result = await _sut.InvokeAsync(Request(), CancellationToken.None); + + Assert.True(result.IsError); + Assert.Equal(McpErrorCodes.Timeout, result.Error!.Code); + await _factory.Received(1).InvalidateAsync("svc"); + } + + [Fact] + public async Task GetSchemaAsync_OneServerUnavailable_IncludesOtherServers() + { + var servers = new[] { Server("ok"), Server("bad") }; + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)servers))); + + var goodClient = FakeClient([CreateMcpClientTool("my_tool", "desc")]); + _factory.GetOrCreateAsync(servers[0], Arg.Any()) + .Returns(Task.FromResult(Result.Ok(goodClient))); + _factory.GetOrCreateAsync(servers[1], Arg.Any()) + .Returns(Task.FromResult(Result.Fail( + new McpProxyError(McpErrorCodes.ConnectionFailed, "refused", "bad")))); + + var manifest = await _sut.GetSchemaAsync(default); + + Assert.Single(manifest.Servers); + Assert.Equal("ok", manifest.Servers[0].ServerName); + } + + [Fact] + public async Task InvokeBatchAsync_PartialFailure_PreservesInputOrder() + { + var server = Server(); + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)[server]))); + + var successResult = new CallToolResult { Content = [new TextContentBlock { Text = "ok" }] }; + var failClient = Substitute.For(); + failClient.CallToolAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns>(_ => throw new InvalidOperationException("boom")); + var successClient = FakeClient(result: successResult); + + _factory.GetOrCreateAsync(server, Arg.Any()) + .Returns( + Task.FromResult(Result.Ok(successClient)), + Task.FromResult(Result.Ok(failClient)), + Task.FromResult(Result.Ok(successClient))); + + var requests = new[] + { + Request("svc", "tool1"), + Request("svc", "tool2"), + Request("svc", "tool3"), + }; + + var results = await _sut.InvokeBatchAsync(requests, default); + + Assert.Equal(3, results.Count); + Assert.Equal("tool1", results[0].ToolName); + Assert.False(results[0].IsError); + Assert.Equal("tool2", results[1].ToolName); + Assert.True(results[1].IsError); + Assert.Equal("tool3", results[2].ToolName); + Assert.False(results[2].IsError); + } + + [Fact] + public async Task InvokeAsync_InvalidServerConfig_ReturnsInvalidRequestBeforeFactory() + { + var invalidServer = new McpServerDefinition( + "svc", + new McpTransportConfig(McpTransportKind.Stdio, null), + new NoneAuthConfig()); + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)[invalidServer]))); + + var result = await _sut.InvokeAsync(Request(), default); + + Assert.True(result.IsError); + Assert.Equal(McpErrorCodes.InvalidRequest, result.Error!.Code); + await _factory.DidNotReceiveWithAnyArgs().GetOrCreateAsync(default!, default); + } + + [Fact] + public async Task GetSchemaAsync_InvalidServerConfig_SkipsServerWithoutContactingFactory() + { + var validServer = Server("good"); + var invalidServer = new McpServerDefinition( + "bad", + new McpTransportConfig(McpTransportKind.Stdio, null), + new NoneAuthConfig()); + + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)[validServer, invalidServer]))); + + var goodClient = FakeClient([CreateMcpClientTool("my_tool", "desc")]); + _factory.GetOrCreateAsync(validServer, Arg.Any()) + .Returns(Task.FromResult(Result.Ok(goodClient))); + + var manifest = await _sut.GetSchemaAsync(default); + + Assert.Single(manifest.Servers); + Assert.Equal("good", manifest.Servers[0].ServerName); + await _factory.DidNotReceive().GetOrCreateAsync(invalidServer, Arg.Any()); + } + + [Fact] + public async Task InvokeAsync_RemoteToolError_SetsIsErrorTrue() + { + var server = Server(); + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)[server]))); + + var sdkResult = new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "something failed" }], + }; + var fakeClient = FakeClient(result: sdkResult); + _factory.GetOrCreateAsync(server, Arg.Any()) + .Returns(Task.FromResult(Result.Ok(fakeClient))); + + var result = await _sut.InvokeAsync(Request(), default); + + Assert.True(result.IsError); + Assert.Equal(McpErrorCodes.RemoteToolError, result.Error!.Code); + } + + [Fact] + public async Task GetSchemaAsync_ConnectionFailure_PopulatesManifestErrors() + { + var server = Server("bad"); + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)[server]))); + _factory.GetOrCreateAsync(server, Arg.Any()) + .Returns(Task.FromResult(Result.Fail( + new McpProxyError(McpErrorCodes.ConnectionFailed, "refused", "bad")))); + + var manifest = await _sut.GetSchemaAsync(default); + + Assert.Empty(manifest.Servers); + Assert.NotNull(manifest.Errors); + Assert.Single(manifest.Errors!); + Assert.Equal("bad", manifest.Errors![0].ServerName); + Assert.Equal(McpErrorCodes.ConnectionFailed, manifest.Errors![0].Code); + } + + [Fact] + public async Task GetSchemaAsync_ListToolsThrows_PopulatesSchemaUnavailableError() + { + var server = Server("svc"); + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)[server]))); + + var client = Substitute.For(); + client.ListToolsAsync(Arg.Any()) + .Returns>>(_ => throw new InvalidOperationException("upstream unavailable")); + _factory.GetOrCreateAsync(server, Arg.Any()) + .Returns(Task.FromResult(Result.Ok(client))); + + var manifest = await _sut.GetSchemaAsync(default); + + Assert.Empty(manifest.Servers); + Assert.NotNull(manifest.Errors); + Assert.Single(manifest.Errors!); + Assert.Equal("svc", manifest.Errors![0].ServerName); + Assert.Equal(McpErrorCodes.SchemaUnavailable, manifest.Errors![0].Code); + await _factory.Received(1).InvalidateAsync("svc"); + } + + [Fact] + public async Task GetSchemaAsync_AllServersSucceed_ErrorsIsNull() + { + var server = Server("svc"); + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)[server]))); + var client = FakeClient([CreateMcpClientTool("t", "d")]); + _factory.GetOrCreateAsync(server, Arg.Any()) + .Returns(Task.FromResult(Result.Ok(client))); + + var manifest = await _sut.GetSchemaAsync(default); + + Assert.Single(manifest.Servers); + Assert.Null(manifest.Errors); + } + + [Fact] + public async Task InvokeAsync_AuthenticatedServer_PassesServerDefinitionWithAuthToFactory() + { + var authConfig = new BearerAuthConfig("env:TOKEN"); + var server = new McpServerDefinition( + "svc", + new McpTransportConfig(McpTransportKind.Http, "https://example.com/mcp"), + authConfig); + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Ok( + (IReadOnlyList)[server]))); + + // Pre-create the fake client before configuring the factory mock to avoid + // NSubstitute's prohibition on substitute creation inside Returns callbacks. + var fakeClient = FakeClient(); + McpServerDefinition? capturedServer = null; + _factory.GetOrCreateAsync( + Arg.Do(s => capturedServer = s), + Arg.Any()) + .Returns(Task.FromResult(Result.Ok(fakeClient))); + + await _sut.InvokeAsync(Request(), default); + + Assert.NotNull(capturedServer); + Assert.IsType(capturedServer.Auth); + } + + [Fact] + public async Task InvokeAsync_RepoLoadFails_ReturnsError() + { + _repo.LoadAsync(Arg.Any()) + .Returns(Task.FromResult(Result, Error>.Fail( + new Error("IoError", "disk failure")))); + + var result = await _sut.InvokeAsync(Request(), default); + + Assert.True(result.IsError); + await _factory.DidNotReceiveWithAnyArgs().GetOrCreateAsync(default!, default); + } + + private static McpClientTool CreateMcpClientTool(string name, string description) + { + var fakeClient = Substitute.For(); + var protocolTool = new Tool { Name = name, Description = description }; + return new McpClientTool(fakeClient, protocolTool, null!); + } +} diff --git a/tests/Hypa.UnitTests/Mcp/Connection/McpClientConnectionFactoryTests.cs b/tests/Hypa.UnitTests/Mcp/Connection/McpClientConnectionFactoryTests.cs new file mode 100644 index 0000000..f146fe3 --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/Connection/McpClientConnectionFactoryTests.cs @@ -0,0 +1,273 @@ +using Hypa.Infrastructure.Mcp.Auth; +using Hypa.Infrastructure.Mcp.Connection; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Mcp; +using Hypa.Runtime.Domain.Rewrite; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Client; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Hypa.UnitTests.Mcp.Connection; + +public sealed class McpClientConnectionFactoryTests +{ + private readonly IMcpAuthProvider _authProvider = Substitute.For(); + private readonly IMcpSdkBridge _sdk = Substitute.For(); + private readonly IShellLexer _shellLexer = Substitute.For(); + private readonly ILoggerFactory _loggerFactory = NullLoggerFactory.Instance; + private readonly ILogger _logger = + NullLogger.Instance; + + private static readonly IReadOnlyDictionary EmptyHeaders = + new Dictionary(); + + public McpClientConnectionFactoryTests() + { + _shellLexer.Lex(Arg.Any()).Returns(call => + { + var cmd = call.Arg(); + return (IReadOnlyList)cmd + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select((p, i) => new ShellToken(TokenKind.Arg, p, i)) + .ToList(); + }); + } + + private McpTransportBuilder BuildTransport() + { + var redaction = new SecretRedactionRegistry(); + var tokenFactory = new McpOAuthTokenStoreFactory( + Path.GetTempPath(), redaction, NullLogger.Instance); + var browserLauncher = Substitute.For(); + var secretResolver = Substitute.For(); + return new McpTransportBuilder(_authProvider, _sdk, _shellLexer, browserLauncher, tokenFactory, secretResolver); + } + + private McpClientConnectionFactory CreateSut() => + new(BuildTransport(), _sdk, _loggerFactory, _logger); + + private static McpServerDefinition StdioServer(string name = "test", string endpoint = "echo hello") => + new(name, + new McpTransportConfig(McpTransportKind.Stdio, endpoint), + new NoneAuthConfig()); + + private static McpServerDefinition HttpServer(string name = "test", string endpoint = "https://example.com/mcp") => + new(name, + new McpTransportConfig(McpTransportKind.Http, endpoint), + new NoneAuthConfig()); + + [Fact] + public async Task GetOrCreateAsync_ReturnsCachedFacade_OnSecondCall() + { + var transport = Substitute.For(); + var client = Substitute.For(); + _sdk.CreateStdioTransport(Arg.Any()).Returns(transport); + _sdk.CreateClientAsync(transport, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(client)); + + await using var sut = CreateSut(); + + var first = await sut.GetOrCreateAsync(StdioServer(), default); + var second = await sut.GetOrCreateAsync(StdioServer(), default); + + Assert.True(first.IsOk); + Assert.True(second.IsOk); + await _sdk.Received(1).CreateClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task InvalidateAsync_ForcesRecreation() + { + var transport = Substitute.For(); + var client1 = Substitute.For(); + var client2 = Substitute.For(); + _sdk.CreateStdioTransport(Arg.Any()).Returns(transport); + _sdk.CreateClientAsync(transport, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(client1), Task.FromResult(client2)); + + await using var sut = CreateSut(); + + var first = await sut.GetOrCreateAsync(StdioServer(), default); + await sut.InvalidateAsync("test"); + var second = await sut.GetOrCreateAsync(StdioServer(), default); + + Assert.True(first.IsOk); + Assert.True(second.IsOk); + await _sdk.Received(2).CreateClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Stdio_ParsesCommandAndArgs_ViaShellLexer() + { + // Override default lexer to return tokens for a quoted-path command + _shellLexer.Lex("node \"/path/with spaces/server.js\" --root \"/tmp/my dir\"") + .Returns([ + new ShellToken(TokenKind.Arg, "node", 0), + new ShellToken(TokenKind.QuotedArg, "/path/with spaces/server.js", 5), + new ShellToken(TokenKind.Arg, "--root", 32), + new ShellToken(TokenKind.QuotedArg, "/tmp/my dir", 39), + ]); + + StdioClientTransportOptions? captured = null; + var transport = Substitute.For(); + var client = Substitute.For(); + _sdk.CreateStdioTransport(Arg.Do(o => captured = o)) + .Returns(transport); + _sdk.CreateClientAsync(transport, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(client)); + + await using var sut = CreateSut(); + await sut.GetOrCreateAsync( + StdioServer("s", "node \"/path/with spaces/server.js\" --root \"/tmp/my dir\""), + default); + + Assert.NotNull(captured); + Assert.Equal("node", captured!.Command); + Assert.Equal(["/path/with spaces/server.js", "--root", "/tmp/my dir"], captured.Arguments); + } + + [Fact] + public async Task Http_AppliesAuthHeaders() + { + var headers = new Dictionary { ["Authorization"] = "Bearer token123" }; + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask(new McpAuthContext(headers))); + + HttpClientTransportOptions? captured = null; + var transport = Substitute.For(); + var client = Substitute.For(); + _sdk.CreateHttpTransport(Arg.Do(o => captured = o), Arg.Any()) + .Returns(transport); + _sdk.CreateClientAsync(transport, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(client)); + + await using var sut = CreateSut(); + await sut.GetOrCreateAsync(HttpServer(), default); + + Assert.NotNull(captured); + Assert.NotNull(captured!.AdditionalHeaders); + Assert.Equal("Bearer token123", captured.AdditionalHeaders!["Authorization"]); + } + + [Fact] + public async Task Http_AppliesQueryParamAuth() + { + var queryParams = new Dictionary { ["api_key"] = "secret" }; + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask(new McpAuthContext(EmptyHeaders, QueryParameters: queryParams))); + + HttpClientTransportOptions? captured = null; + var transport = Substitute.For(); + var client = Substitute.For(); + _sdk.CreateHttpTransport(Arg.Do(o => captured = o), Arg.Any()) + .Returns(transport); + _sdk.CreateClientAsync(transport, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(client)); + + await using var sut = CreateSut(); + await sut.GetOrCreateAsync(HttpServer("test", "https://example.com/mcp"), default); + + Assert.NotNull(captured); + Assert.Contains("api_key=secret", captured!.Endpoint.Query); + } + + [Fact] + public async Task Http_AppliesConnectTimeout() + { + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask(new McpAuthContext(EmptyHeaders))); + + HttpClientTransportOptions? captured = null; + var transport = Substitute.For(); + var client = Substitute.For(); + _sdk.CreateHttpTransport(Arg.Do(o => captured = o), Arg.Any()) + .Returns(transport); + _sdk.CreateClientAsync(transport, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(client)); + + var server = new McpServerDefinition( + "test", + new McpTransportConfig(McpTransportKind.Http, "https://example.com/mcp"), + new NoneAuthConfig(), + ConnectTimeout: TimeSpan.FromSeconds(15)); + + await using var sut = CreateSut(); + await sut.GetOrCreateAsync(server, default); + + Assert.NotNull(captured); + Assert.Equal(TimeSpan.FromSeconds(15), captured!.ConnectionTimeout); + } + + [Fact] + public async Task Http_BuildsMtlsHandler_WhenCertPathsProvided() + { + // Auth context contains cert paths; cert files won't exist on disk in tests, + // so X509Certificate2.CreateFromPemFile will throw and factory wraps it as ConnectionFailed. + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask(new McpAuthContext( + EmptyHeaders, + ClientCertificatePath: "/nonexistent/client.pem", + ClientKeyPath: "/nonexistent/client.key"))); + + var transport = Substitute.For(); + _sdk.CreateHttpTransport(Arg.Any(), Arg.Any()) + .Returns(transport); + + await using var sut = CreateSut(); + var result = await sut.GetOrCreateAsync(HttpServer(), default); + + // Cert files don't exist → exception wrapped in ConnectionFailed + Assert.False(result.IsOk); + Assert.Equal(McpErrorCodes.ConnectionFailed, result.Error.Code); + } + + [Fact] + public async Task ConnectionFailure_ReturnsError_DoesNotThrow() + { + var transport = Substitute.For(); + _sdk.CreateStdioTransport(Arg.Any()).Returns(transport); + _sdk.CreateClientAsync(transport, Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Connection refused")); + + await using var sut = CreateSut(); + var result = await sut.GetOrCreateAsync(StdioServer(), default); + + Assert.False(result.IsOk); + Assert.Equal(McpErrorCodes.ConnectionFailed, result.Error.Code); + Assert.Contains("Failed to connect to server 'test'.", result.Error.Message); + } + + [Fact] + public async Task McpOAuthConfig_WithNoCachedToken_ReturnsAuthRequired_NotHangs() + { + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask(new McpAuthContext(EmptyHeaders))); + + await using var sut = CreateSut(); + var server = new McpServerDefinition( + "oauth-srv", + new McpTransportConfig(McpTransportKind.Http, "https://example.com/mcp"), + new McpOAuthConfig(ClientId: "test-client")); + + var result = await sut.GetOrCreateAsync(server, default); + + Assert.False(result.IsOk); + Assert.Equal(McpErrorCodes.AuthRequired, result.Error.Code); + await _sdk.DidNotReceive().CreateClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } +} diff --git a/tests/Hypa.UnitTests/Mcp/Connection/McpServerProbeAdapterTests.cs b/tests/Hypa.UnitTests/Mcp/Connection/McpServerProbeAdapterTests.cs new file mode 100644 index 0000000..4c646b8 --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/Connection/McpServerProbeAdapterTests.cs @@ -0,0 +1,683 @@ +using System.Net; +using Hypa.Infrastructure.Mcp.Auth; +using Hypa.Infrastructure.Mcp.Connection; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Mcp; +using Hypa.Runtime.Domain.Rewrite; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Client; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Hypa.UnitTests.Mcp.Connection; + +public sealed class McpServerProbeAdapterTests +{ + private readonly IMcpAuthProvider _authProvider = Substitute.For(); + private readonly IShellLexer _shellLexer = Substitute.For(); + private readonly IMcpSdkBridge _sdk = Substitute.For(); + private readonly McpConfigValidationService _validator = new(); + private readonly McpTransportBuilder _transportBuilder; + private readonly McpServerProbeAdapter _sut; + + private static readonly IReadOnlyDictionary EmptyHeaders = + new Dictionary(); + + public McpServerProbeAdapterTests() + { + _shellLexer.Lex(Arg.Any()).Returns(call => + { + var cmd = call.Arg(); + return (IReadOnlyList)cmd + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select((p, i) => new ShellToken(TokenKind.Arg, p, i)) + .ToList(); + }); + + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask(new McpAuthContext(EmptyHeaders))); + + _sdk.CreateHttpTransport(Arg.Any(), Arg.Any()) + .Returns(Substitute.For()); + + _transportBuilder = McpTransportBuilderFactory.Create(_authProvider, _sdk, _shellLexer); + _sut = new McpServerProbeAdapter( + _transportBuilder, + _sdk, + _validator, + NullLoggerFactory.Instance, + NullLogger.Instance); + } + + private static McpServerDefinition RemoteServer( + string name = "svc", + string endpoint = "https://example.com/mcp", + McpAuthConfig? auth = null, + TimeSpan? connectTimeout = null) => + new(name, + new McpTransportConfig(McpTransportKind.HttpAutoDetect, endpoint), + auth ?? new NoneAuthConfig(), + ConnectTimeout: connectTimeout); + + private IProbeClientFacade FakeClient( + IList? tools = null, + Exception? listToolsException = null) + { + var client = Substitute.For(); + if (listToolsException is not null) + client.ListToolsAsync(Arg.Any()) + .Returns(new ValueTask>( + Task.FromException>(listToolsException))); + else + client.ListToolsAsync(Arg.Any()) + .Returns(new ValueTask>(tools ?? [])); + client.DisposeAsync().Returns(ValueTask.CompletedTask); + return client; + } + + private static HttpRequestException HttpException(int statusCode) => + new($"HTTP {statusCode}", null, (HttpStatusCode)statusCode); + + private void SetupProbeClientAsync(IProbeClientFacade? client = null, Exception? throws = null) + { + if (throws is not null) + { + _sdk.CreateProbeClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(throws); + } + else + { + var c = client ?? FakeClient(); + _sdk.CreateProbeClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(c)); + } + } + + [Fact] + public async Task ProbeAsync_Reachable_WhenListToolsSucceeds() + { + SetupProbeClientAsync(FakeClient()); + + var result = await _sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.Reachable, result.Status); + Assert.Contains("tools/list", result.Message); + } + + [Fact] + public async Task ProbeAsync_InvalidConfig_BeforeAnyNetwork() + { + var server = new McpServerDefinition( + "svc", + new McpTransportConfig(McpTransportKind.Http, string.Empty), + new NoneAuthConfig()); + + var result = await _sut.ProbeAsync(server, CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.InvalidConfig, result.Status); + await _sdk.DidNotReceive().CreateProbeClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ProbeAsync_AuthRequired_OnCredentialResolutionException() + { + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask( + Task.FromException(new McpCredentialResolutionException("env:X missing")))); + + var result = await _sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.AuthRequired, result.Status); + Assert.Contains("env:X missing", result.Message); + Assert.NotNull(result.AuthGuidance); + } + + [Fact] + public async Task ProbeAsync_AuthRequired_On401HttpRequestException() + { + SetupProbeClientAsync(throws: HttpException(401)); + + var result = await _sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.AuthRequired, result.Status); + Assert.NotNull(result.AuthGuidance); + } + + [Fact] + public async Task ProbeAsync_AuthRequired_On403HttpRequestException() + { + SetupProbeClientAsync(throws: HttpException(403)); + + var result = await _sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.AuthRequired, result.Status); + Assert.NotNull(result.AuthGuidance); + } + + [Fact] + public async Task ProbeAsync_AuthRequired_OnSdkExceptionWith401InMessage() + { + SetupProbeClientAsync(throws: new InvalidOperationException("HTTP 401 Unauthorized")); + + var result = await _sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.AuthRequired, result.Status); + Assert.NotNull(result.AuthGuidance); + } + + [Fact] + public async Task ProbeAsync_Timeout_WhenConnectTimeoutFires() + { + var tcs = new TaskCompletionSource(); + _sdk.CreateProbeClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(call => + { + var ct = call.Arg(); + ct.Register(() => tcs.TrySetCanceled(ct)); + return tcs.Task; + }); + + var server = RemoteServer(connectTimeout: TimeSpan.FromMilliseconds(50)); + var result = await _sut.ProbeAsync(server, CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.Timeout, result.Status); + } + + [Fact] + public async Task ProbeAsync_RespectsOuterCancellation() + { + using var cts = new CancellationTokenSource(); + var tcs = new TaskCompletionSource(); + _sdk.CreateProbeClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(call => + { + var ct = call.Arg(); + ct.Register(() => tcs.TrySetCanceled(ct)); + return tcs.Task; + }); + + cts.Cancel(); + await Assert.ThrowsAnyAsync( + () => _sut.ProbeAsync(RemoteServer(), cts.Token)); + } + + [Fact] + public async Task ProbeAsync_ConnectionFailed_OnHttpRequestException() + { + SetupProbeClientAsync(throws: HttpException(500)); + + var result = await _sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.ConnectionFailed, result.Status); + } + + [Fact] + public async Task ProbeAsync_Unknown_OnArbitraryException() + { + SetupProbeClientAsync(throws: new InvalidOperationException("something unexpected")); + + var result = await _sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.Unknown, result.Status); + } + + [Fact] + public async Task ProbeAsync_DoesNotLeakSecretsInMessage() + { + // Exception message contains a literal that looks like a resolved secret value. + // The adapter must not include it verbatim (uses type name only for Unknown). + SetupProbeClientAsync(throws: new InvalidOperationException("resolved: SECRET_VALUE actual-token")); + + var result = await _sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.Unknown, result.Status); + Assert.DoesNotContain("SECRET_VALUE", result.Message); + Assert.DoesNotContain("actual-token", result.Message); + } + + [Fact] + public async Task ProbeAsync_DisposesClientOnSuccess() + { + var client = FakeClient(); + SetupProbeClientAsync(client); + + await _sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + await client.Received(1).DisposeAsync(); + } + + [Fact] + public async Task ProbeAsync_DisposesClientOnFailure() + { + var client = FakeClient(listToolsException: new InvalidOperationException("boom")); + SetupProbeClientAsync(client); + + await _sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + await client.Received(1).DisposeAsync(); + } + + [Fact] + public async Task BuildGuidance_None_SuggestsBearerAndDeviceCodeExamples() + { + SetupProbeClientAsync(throws: HttpException(401)); + var server = RemoteServer(name: "my-server", endpoint: "https://example.com/mcp", auth: new NoneAuthConfig()); + + var result = await _sut.ProbeAsync(server, CancellationToken.None); + + Assert.NotNull(result.AuthGuidance); + Assert.NotNull(result.AuthGuidance!.NextCommands); + Assert.Equal(2, result.AuthGuidance.NextCommands!.Count); + Assert.All(result.AuthGuidance.NextCommands, cmd => Assert.StartsWith("hypa mcp add", cmd)); + Assert.Contains(result.AuthGuidance.NextCommands, cmd => cmd.Contains("--auth bearer")); + Assert.Contains(result.AuthGuidance.NextCommands, cmd => cmd.Contains("--auth oauth2DeviceCode")); + Assert.All(result.AuthGuidance.NextCommands, cmd => Assert.Contains("--transport http", cmd)); + } + + [Fact] + public async Task ProbeAsync_ConnectionFailed_OnHttpRequestExceptionWithAuthKeywordInMessage() + { + // 500 with "unauthorized" in the message must not be routed through the semantic auth check; + // only exceptions without a status code fall through to IsAuthSemantic. + var ex = new HttpRequestException("Internal Server Error: unauthorized operation", null, HttpStatusCode.InternalServerError); + SetupProbeClientAsync(throws: ex); + + var result = await _sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.ConnectionFailed, result.Status); + Assert.Null(result.AuthGuidance); + } + + [Fact] + public async Task ProbeAsync_AuthRequired_OnHttpRequestExceptionWithNullStatusAndAuthKeyword() + { + // SDK wraps a 401 in HttpRequestException without setting StatusCode — exercises catch #2. + var ex = new HttpRequestException("HTTP 401 Unauthorized", null, null); + SetupProbeClientAsync(throws: ex); + + var result = await _sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.AuthRequired, result.Status); + Assert.NotNull(result.AuthGuidance); + } + + [Fact] + public async Task ProbeAsync_ConnectionFailed_OnDnsFailure() + { + // DNS failure: HttpRequestException with null StatusCode, no auth keyword — exercises + // the null-status non-auth path through catch #2 (condition false) → catch #4. + var ex = new HttpRequestException("Name resolution failure", null, null); + SetupProbeClientAsync(throws: ex); + + var result = await _sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.ConnectionFailed, result.Status); + } + + [Fact] + public async Task BuildGuidance_Sse_SuggestsCorrectTransport() + { + SetupProbeClientAsync(throws: HttpException(401)); + var server = new McpServerDefinition( + "my-server", + new McpTransportConfig(McpTransportKind.Sse, "https://example.com/mcp"), + new NoneAuthConfig()); + + var result = await _sut.ProbeAsync(server, CancellationToken.None); + + Assert.NotNull(result.AuthGuidance?.NextCommands); + Assert.All(result.AuthGuidance!.NextCommands!, cmd => Assert.Contains("--transport sse", cmd)); + } + + [Fact] + public async Task BuildGuidance_StreamableHttp_SuggestsCorrectTransport() + { + SetupProbeClientAsync(throws: HttpException(401)); + var server = new McpServerDefinition( + "my-server", + new McpTransportConfig(McpTransportKind.Http, "https://example.com/mcp"), + new NoneAuthConfig()); + + var result = await _sut.ProbeAsync(server, CancellationToken.None); + + Assert.NotNull(result.AuthGuidance?.NextCommands); + Assert.All(result.AuthGuidance!.NextCommands!, cmd => Assert.Contains("--transport streamableHttp", cmd)); + } + + [Fact] + public async Task BuildGuidance_DeviceCode_EchoesNonSecretMetadata() + { + SetupProbeClientAsync(throws: HttpException(401)); + var auth = new OAuth2DeviceCodeConfig( + AuthUrl: "https://auth.example.com/authorize", + TokenUrl: "https://auth.example.com/token", + ClientId: "my-client-id", + Scopes: ["read", "write"]); + var server = RemoteServer(auth: auth); + + var result = await _sut.ProbeAsync(server, CancellationToken.None); + + Assert.NotNull(result.AuthGuidance); + var guidance = result.AuthGuidance!; + Assert.Equal("oauth2DeviceCode", guidance.SuggestedAuthMode); + Assert.Equal("https://auth.example.com/authorize", guidance.AuthorizationUrl); + Assert.Equal("https://auth.example.com/token", guidance.TokenUrl); + Assert.Equal("my-client-id", guidance.ClientId); + Assert.NotNull(guidance.Scopes); + Assert.Contains("read", guidance.Scopes!); + Assert.Contains("write", guidance.Scopes!); + } +} + +[Collection("SequentialEnvTests")] +public sealed class McpServerProbeAdapterEnvTests +{ + private readonly IMcpAuthProvider _authProvider = Substitute.For(); + private readonly IShellLexer _shellLexer = Substitute.For(); + private readonly IMcpSdkBridge _sdk = Substitute.For(); + private readonly McpServerProbeAdapter _sut; + + private static readonly IReadOnlyDictionary EmptyHeaders = + new Dictionary(); + + public McpServerProbeAdapterEnvTests() + { + _shellLexer.Lex(Arg.Any()).Returns(call => + { + var cmd = call.Arg(); + return (IReadOnlyList)cmd + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select((p, i) => new ShellToken(TokenKind.Arg, p, i)) + .ToList(); + }); + + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask(new McpAuthContext(EmptyHeaders))); + + _sdk.CreateHttpTransport(Arg.Any(), Arg.Any()) + .Returns(Substitute.For()); + + _sdk.CreateProbeClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new System.Net.Http.HttpRequestException("Unauthorized", null, HttpStatusCode.Unauthorized)); + + var transportBuilder = McpTransportBuilderFactory.Create(_authProvider, _sdk, _shellLexer); + _sut = new McpServerProbeAdapter( + transportBuilder, + _sdk, + new McpConfigValidationService(), + NullLoggerFactory.Instance, + NullLogger.Instance); + } + + [Fact] + public async Task BuildGuidance_NeverIncludesSecretRefValues() + { + Environment.SetEnvironmentVariable("LEAK", "actual-secret"); + try + { + var server = new McpServerDefinition( + "svc", + new McpTransportConfig(McpTransportKind.HttpAutoDetect, "https://example.com/mcp"), + new BearerAuthConfig("env:LEAK")); + + var result = await _sut.ProbeAsync(server, CancellationToken.None); + + Assert.NotNull(result.AuthGuidance); + var guidanceText = result.AuthGuidance!.NextCommands is not null + ? string.Join(" ", result.AuthGuidance.NextCommands) + : string.Empty; + + Assert.DoesNotContain("actual-secret", result.Message); + Assert.DoesNotContain("actual-secret", guidanceText); + Assert.DoesNotContain("actual-secret", + result.AuthGuidance.AuthorizationUrl ?? string.Empty); + Assert.DoesNotContain("actual-secret", + result.AuthGuidance.TokenUrl ?? string.Empty); + } + finally + { + Environment.SetEnvironmentVariable("LEAK", null); + } + } +} + +// Phase E — MCP OAuth probe detection tests (separate class for clarity) +public sealed class McpServerProbeAdapterOAuthDetectionTests +{ + private readonly IMcpAuthProvider _authProvider = Substitute.For(); + private readonly IShellLexer _shellLexer = Substitute.For(); + private readonly IMcpSdkBridge _sdk = Substitute.For(); + + private static readonly IReadOnlyDictionary EmptyHeaders = + new Dictionary(); + + public McpServerProbeAdapterOAuthDetectionTests() + { + _shellLexer.Lex(Arg.Any()).Returns(call => + { + var cmd = call.Arg(); + return (IReadOnlyList)cmd + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select((p, i) => new ShellToken(TokenKind.Arg, p, i)) + .ToList(); + }); + + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask(new McpAuthContext(EmptyHeaders))); + + _sdk.CreateHttpTransport(Arg.Any(), Arg.Any()) + .Returns(Substitute.For()); + } + + // bearerChallengeOverride simulates WwwAuthenticateCapture having intercepted a 401+Bearer + // from the actual SDK probe request, bypassing the need for a real HTTP client in tests. + private McpServerProbeAdapter CreateSut(bool? bearerChallengeOverride = null) + { + var transportBuilder = McpTransportBuilderFactory.Create(_authProvider, _sdk, _shellLexer); + var sut = new McpServerProbeAdapter( + transportBuilder, + _sdk, + new McpConfigValidationService(), + NullLoggerFactory.Instance, + NullLogger.Instance); + if (bearerChallengeOverride.HasValue) + sut.BearerChallengeOverride = bearerChallengeOverride.Value; + return sut; + } + + private static McpServerDefinition RemoteServer(string endpoint = "https://example.com/mcp") => + new("svc", + new McpTransportConfig(McpTransportKind.HttpAutoDetect, endpoint), + new NoneAuthConfig()); + + private void SetupProbe401() + { + _sdk.CreateProbeClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new HttpRequestException("401", null, HttpStatusCode.Unauthorized)); + } + + [Fact] + public async Task ProbeAsync_McpOAuth_Detected_On401_WithBearerChallenge() + { + SetupProbe401(); + + // bearerChallengeOverride: true simulates WwwAuthenticateCapture detecting 401+Bearer. + // No discovery client needed — Bearer challenge alone is the mcpOAuth signal. + var sut = CreateSut(bearerChallengeOverride: true); + var result = await sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.AuthRequired, result.Status); + Assert.NotNull(result.AuthGuidance); + Assert.Equal("mcpOAuth", result.AuthGuidance!.SuggestedAuthMode); + Assert.NotNull(result.AuthGuidance.NextCommands); + } + + [Fact] + public async Task ProbeAsync_GenericGuidance_When401_And_NoBearerChallenge() + { + // Probe got 401 but WwwAuthenticateCapture did not see WWW-Authenticate: Bearer. + SetupProbe401(); + + var sut = CreateSut(bearerChallengeOverride: false); + var result = await sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.AuthRequired, result.Status); + Assert.NotEqual("mcpOAuth", result.AuthGuidance?.SuggestedAuthMode); + } + + [Fact] + public async Task ProbeAsync_McpOAuth_StillReturned_WhenDiscoveryEndpointWouldReturn404() + { + // Bearer challenge → mcpOAuth even when a discovery endpoint would 404. + // The probe no longer makes the discovery call; SDK handles it during the OAuth flow. + SetupProbe401(); + + var sut = CreateSut(bearerChallengeOverride: true); + var result = await sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.AuthRequired, result.Status); + Assert.Equal("mcpOAuth", result.AuthGuidance?.SuggestedAuthMode); + } + + [Fact] + public async Task ProbeAsync_McpOAuth_StillReturned_WhenDiscoveryEndpointWouldTimeout() + { + // Bearer challenge → mcpOAuth even when a discovery endpoint would time out. + SetupProbe401(); + + var sut = CreateSut(bearerChallengeOverride: true); + var result = await sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.AuthRequired, result.Status); + Assert.Equal("mcpOAuth", result.AuthGuidance?.SuggestedAuthMode); + } + + [Fact] + public async Task ProbeAsync_McpOAuth_StillReturned_WhenDiscoveryEndpointWouldReturnNoAuthServers() + { + // Bearer challenge → mcpOAuth regardless of what a discovery response would contain. + SetupProbe401(); + + var sut = CreateSut(bearerChallengeOverride: true); + var result = await sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.AuthRequired, result.Status); + Assert.Equal("mcpOAuth", result.AuthGuidance?.SuggestedAuthMode); + } + + [Fact] + public async Task ProbeAsync_McpOAuth_StillReturned_WhenDiscoveryWouldReturnEmptyAuthServers() + { + // Bearer challenge is sufficient; empty authorization_servers in a discovery response + // would have been a false negative under the old design — no longer possible. + SetupProbe401(); + + var sut = CreateSut(bearerChallengeOverride: true); + var result = await sut.ProbeAsync(RemoteServer(), CancellationToken.None); + + Assert.Equal(McpServerProbeStatus.AuthRequired, result.Status); + Assert.Equal("mcpOAuth", result.AuthGuidance?.SuggestedAuthMode); + } +} + +// Helper shared by probe adapter and connection factory tests +file static class McpTransportBuilderFactory +{ + public static McpTransportBuilder Create( + IMcpAuthProvider authProvider, + IMcpSdkBridge sdk, + IShellLexer shellLexer) + { + var redaction = new SecretRedactionRegistry(); + var tokenFactory = new McpOAuthTokenStoreFactory( + Path.GetTempPath(), redaction, NullLogger.Instance); + var browserLauncher = Substitute.For(); + var secretResolver = Substitute.For(); + return new McpTransportBuilder(authProvider, sdk, shellLexer, browserLauncher, tokenFactory, secretResolver); + } +} + +public sealed class WwwAuthenticateCaptureTests +{ + [Fact] + public async Task Sets_HasBearerChallenge_On_401_With_Bearer_Header() + { + var inner = new StubInnerHandler(HttpStatusCode.Unauthorized, bearerChallenge: true); + var capture = new WwwAuthenticateCapture { InnerHandler = inner }; + using var client = new HttpClient(capture); + + using var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://example.com/")); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.True(capture.HasBearerChallenge); + } + + [Fact] + public async Task Does_Not_Set_HasBearerChallenge_On_401_Without_Bearer_Header() + { + var inner = new StubInnerHandler(HttpStatusCode.Unauthorized, bearerChallenge: false); + var capture = new WwwAuthenticateCapture { InnerHandler = inner }; + using var client = new HttpClient(capture); + + using var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://example.com/")); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.False(capture.HasBearerChallenge); + } + + [Fact] + public async Task Does_Not_Set_HasBearerChallenge_On_200() + { + var inner = new StubInnerHandler(HttpStatusCode.OK, bearerChallenge: false); + var capture = new WwwAuthenticateCapture { InnerHandler = inner }; + using var client = new HttpClient(capture); + + using var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://example.com/")); + + Assert.False(capture.HasBearerChallenge); + } + + private sealed class StubInnerHandler(HttpStatusCode status, bool bearerChallenge) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var resp = new HttpResponseMessage(status); + if (bearerChallenge) + resp.Headers.WwwAuthenticate.Add( + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer")); + return Task.FromResult(resp); + } + } +} diff --git a/tests/Hypa.UnitTests/Mcp/Connection/McpTransportBuilderTests.cs b/tests/Hypa.UnitTests/Mcp/Connection/McpTransportBuilderTests.cs new file mode 100644 index 0000000..273a2dd --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/Connection/McpTransportBuilderTests.cs @@ -0,0 +1,364 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Hypa.Infrastructure.Mcp.Auth; +using Hypa.Infrastructure.Mcp.Connection; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Domain.Mcp; +using Hypa.Runtime.Domain.Rewrite; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Mcp.Connection; + +public sealed class McpTransportBuilderTests +{ + private readonly IMcpAuthProvider _authProvider = Substitute.For(); + private readonly IMcpSdkBridge _sdk = Substitute.For(); + private readonly IShellLexer _shellLexer = Substitute.For(); + + private static readonly IReadOnlyDictionary EmptyHeaders = + new Dictionary(); + + public McpTransportBuilderTests() + { + _shellLexer.Lex(Arg.Any()).Returns(call => + { + var cmd = call.Arg(); + return (IReadOnlyList)cmd + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select((p, i) => new ShellToken(TokenKind.Arg, p, i)) + .ToList(); + }); + + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask(new McpAuthContext(EmptyHeaders))); + + _sdk.CreateHttpTransport(Arg.Any(), Arg.Any()) + .Returns(Substitute.For()); + + _sdk.CreateStdioTransport(Arg.Any()) + .Returns(Substitute.For()); + } + + private readonly IBrowserLauncher _browserLauncher = Substitute.For(); + + private McpTransportBuilder CreateSut() + { + var dir = Path.GetTempPath(); + var redaction = new SecretRedactionRegistry(); + var tokenFactory = new McpOAuthTokenStoreFactory(dir, redaction, NullLogger.Instance); + return new McpTransportBuilder(_authProvider, _sdk, _shellLexer, _browserLauncher, tokenFactory, Substitute.For()); + } + + private static McpServerDefinition StdioServer(string endpoint = "echo hello") => + new("test", new McpTransportConfig(McpTransportKind.Stdio, endpoint), new NoneAuthConfig()); + + private static McpServerDefinition HttpServer( + McpTransportKind kind = McpTransportKind.Http, + string endpoint = "https://example.com/mcp", + McpTlsConfig? tls = null) => + new("test", new McpTransportConfig(kind, endpoint), new NoneAuthConfig(), Tls: tls); + + private static McpServerDefinition OAuthServer( + string endpoint = "https://example.com/mcp", + string? clientId = null) => + new("oauth-server", new McpTransportConfig(McpTransportKind.Http, endpoint), + new McpOAuthConfig(ClientId: clientId)); + + [Fact] + public async Task Stdio_LexesEndpointIntoCommandAndArgs() + { + _shellLexer.Lex("node /path/server.js --port 3000") + .Returns([ + new ShellToken(TokenKind.Arg, "node", 0), + new ShellToken(TokenKind.QuotedArg, "/path/server.js", 5), + new ShellToken(TokenKind.Arg, "--port", 20), + new ShellToken(TokenKind.Arg, "3000", 27), + ]); + + StdioClientTransportOptions? captured = null; + _sdk.CreateStdioTransport(Arg.Do(o => captured = o)) + .Returns(Substitute.For()); + + await CreateSut().BuildAsync(StdioServer("node /path/server.js --port 3000"), default); + + Assert.NotNull(captured); + Assert.Equal("node", captured!.Command); + Assert.Equal(["/path/server.js", "--port", "3000"], captured.Arguments); + } + + [Fact] + public async Task Http_MapsToStreamableHttp_ForHttpKind() + { + HttpClientTransportOptions? captured = null; + _sdk.CreateHttpTransport(Arg.Do(o => captured = o), Arg.Any()) + .Returns(Substitute.For()); + + await CreateSut().BuildAsync(HttpServer(McpTransportKind.Http), default); + + Assert.Equal(HttpTransportMode.StreamableHttp, captured!.TransportMode); + } + + [Fact] + public async Task Http_MapsToSse_ForSseKind() + { + HttpClientTransportOptions? captured = null; + _sdk.CreateHttpTransport(Arg.Do(o => captured = o), Arg.Any()) + .Returns(Substitute.For()); + + await CreateSut().BuildAsync(HttpServer(McpTransportKind.Sse), default); + + Assert.Equal(HttpTransportMode.Sse, captured!.TransportMode); + } + + [Fact] + public async Task Http_MapsToAutoDetect_ForHttpAutoDetectKind() + { + HttpClientTransportOptions? captured = null; + _sdk.CreateHttpTransport(Arg.Do(o => captured = o), Arg.Any()) + .Returns(Substitute.For()); + + await CreateSut().BuildAsync(HttpServer(McpTransportKind.HttpAutoDetect), default); + + Assert.Equal(HttpTransportMode.AutoDetect, captured!.TransportMode); + } + + [Fact] + public async Task Http_AppendsQueryParams_FromAuthContext() + { + var qp = new Dictionary { ["token"] = "val" }; + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask(new McpAuthContext(EmptyHeaders, QueryParameters: qp))); + + HttpClientTransportOptions? captured = null; + _sdk.CreateHttpTransport(Arg.Do(o => captured = o), Arg.Any()) + .Returns(Substitute.For()); + + await CreateSut().BuildAsync(HttpServer(), default); + + Assert.Contains("token=val", captured!.Endpoint.Query); + } + + [Fact] + public async Task Http_PropagatesAuthHeaders_ToAdditionalHeaders() + { + var headers = new Dictionary { ["Authorization"] = "Bearer tok" }; + _authProvider.GetAuthContextAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask(new McpAuthContext(headers))); + + HttpClientTransportOptions? captured = null; + _sdk.CreateHttpTransport(Arg.Do(o => captured = o), Arg.Any()) + .Returns(Substitute.For()); + + await CreateSut().BuildAsync(HttpServer(), default); + + Assert.NotNull(captured!.AdditionalHeaders); + Assert.Equal("Bearer tok", captured.AdditionalHeaders!["Authorization"]); + } + + [Fact] + public async Task Http_PassesNonNullHttpClient_WhenCaCertPathProvided() + { + var caCertPath = CreateTempCaCertDerFile(); + try + { + HttpClient? capturedClient = null; + _sdk.CreateHttpTransport( + Arg.Any(), + Arg.Do(c => capturedClient = c)) + .Returns(Substitute.For()); + + var server = HttpServer(tls: new McpTlsConfig( + CaCertPath: caCertPath, + ClientCertPath: null, + ClientKeyPath: null)); + + await CreateSut().BuildAsync(server, default); + + Assert.NotNull(capturedClient); + } + finally + { + if (File.Exists(caCertPath)) + File.Delete(caCertPath); + } + } + + [Fact] + public async Task Http_PassesNonNullHttpClient_WhenClientCertAndKeyPathProvided() + { + var (certPath, keyPath) = CreateTempClientCertPemFiles(); + try + { + HttpClient? capturedClient = null; + _sdk.CreateHttpTransport( + Arg.Any(), + Arg.Do(c => capturedClient = c)) + .Returns(Substitute.For()); + + var server = HttpServer(tls: new McpTlsConfig( + CaCertPath: null, + ClientCertPath: certPath, + ClientKeyPath: keyPath)); + + await CreateSut().BuildAsync(server, default); + + Assert.NotNull(capturedClient); + } + finally + { + if (File.Exists(certPath)) File.Delete(certPath); + if (File.Exists(keyPath)) File.Delete(keyPath); + } + } + + [Fact] + public async Task Http_PassesNullHttpClient_WhenNoTlsMaterial() + { + HttpClient? capturedClient = new(); // seed non-null to detect no-override + _sdk.CreateHttpTransport( + Arg.Any(), + Arg.Do(c => capturedClient = c)) + .Returns(Substitute.For()); + + await CreateSut().BuildAsync(HttpServer(tls: null), default); + + Assert.Null(capturedClient); + } + + private static (string certPath, string keyPath) CreateTempClientCertPemFiles() + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest( + "CN=test-client", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + using var cert = req.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + var certPath = Path.GetTempFileName(); + var keyPath = Path.GetTempFileName(); + File.WriteAllText(certPath, cert.ExportCertificatePem()); + File.WriteAllText(keyPath, rsa.ExportPkcs8PrivateKeyPem()); + return (certPath, keyPath); + } + + private static string CreateTempCaCertDerFile() + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest( + "CN=test-ca", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + using var cert = req.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + var path = Path.GetTempFileName(); + File.WriteAllBytes(path, cert.Export(X509ContentType.Cert)); + return path; + } + + // Phase D — OAuth wiring tests + // + // Non-interactive operations (schema, tools, invoke, etc.) must NEVER start an + // interactive OAuth flow. BuildAsync uses cached-token-only: inject as bearer header + // when present, throw McpCredentialResolutionException when absent/expired. + // The interactive flow lives exclusively in McpBrowserOAuthFlowProvider (auth login). + + [Fact] + public async Task McpOAuthConfig_WithNoCachedToken_ThrowsCredentialResolutionException() + { + // Fresh temp dir — no token file exists. + await Assert.ThrowsAsync(() => + CreateSut().BuildAsync(OAuthServer(), CancellationToken.None)); + } + + [Fact] + public async Task McpOAuthConfig_WithValidCachedToken_InjectsBearerHeader() + { + var dir = await WriteCachedTokenAsync("oauth-server", "cached-token-abc"); + try + { + var sut = CreateSutWithTokenDir(dir); + + HttpClientTransportOptions? captured = null; + _sdk.CreateHttpTransport( + Arg.Do(o => captured = o), + Arg.Any()) + .Returns(Substitute.For()); + + await sut.BuildAsync(OAuthServer(), CancellationToken.None); + + Assert.Equal("Bearer cached-token-abc", captured?.AdditionalHeaders?["Authorization"]); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task McpOAuthConfig_WithValidCachedToken_DoesNotSetOAuthOnOptions() + { + var dir = await WriteCachedTokenAsync("oauth-server", "some-token"); + try + { + var sut = CreateSutWithTokenDir(dir); + + HttpClientTransportOptions? captured = null; + _sdk.CreateHttpTransport( + Arg.Do(o => captured = o), + Arg.Any()) + .Returns(Substitute.For()); + + await sut.BuildAsync(OAuthServer(), CancellationToken.None); + + Assert.Null(captured?.OAuth); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task NoneAuthConfig_LeavesOAuthNull() + { + HttpClientTransportOptions? captured = null; + _sdk.CreateHttpTransport( + Arg.Do(o => captured = o), + Arg.Any()) + .Returns(Substitute.For()); + + await CreateSut().BuildAsync(HttpServer(), CancellationToken.None); + + Assert.Null(captured?.OAuth); + } + + private McpTransportBuilder CreateSutWithTokenDir(string dir) + { + var redaction = new SecretRedactionRegistry(); + var tokenFactory = new McpOAuthTokenStoreFactory(dir, redaction, NullLogger.Instance); + return new McpTransportBuilder(_authProvider, _sdk, _shellLexer, _browserLauncher, tokenFactory, Substitute.For()); + } + + private static async Task WriteCachedTokenAsync(string serverName, string accessToken) + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var store = new McpOAuthTokenStore( + serverName, dir, new SecretRedactionRegistry(), NullLogger.Instance); + await store.StoreTokensAsync(new TokenContainer + { + TokenType = "Bearer", + AccessToken = accessToken, + ObtainedAt = DateTimeOffset.UtcNow, + }, CancellationToken.None); + return dir; + } +} diff --git a/tests/Hypa.UnitTests/Mcp/McpConfigValidationServiceTests.cs b/tests/Hypa.UnitTests/Mcp/McpConfigValidationServiceTests.cs new file mode 100644 index 0000000..971ee91 --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/McpConfigValidationServiceTests.cs @@ -0,0 +1,472 @@ +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Mcp; +using Xunit; + +namespace Hypa.UnitTests.Mcp; + +[Trait("Category", "McpConfig")] +public sealed class McpConfigValidationServiceTests +{ + private static readonly McpConfigValidationService _sut = new(); + + private static McpServerDefinition MakeServer( + string name, + McpTransportConfig? transport = null, + McpAuthConfig? auth = null) => + new( + name, + transport ?? new McpTransportConfig(McpTransportKind.Stdio, "my-mcp-server"), + auth ?? new NoneAuthConfig()); + + // Valid configs — one per auth mode + + [Fact] + public void Validate_NoneAuth_Stdio_IsValid() + { + var result = _sut.Validate([MakeServer("s")]); + Assert.True(result.IsOk); + } + + [Fact] + public void Validate_BearerAuth_IsValid() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new BearerAuthConfig("TOKEN_REF")); + + Assert.True(_sut.Validate([server]).IsOk); + } + + [Fact] + public void Validate_ApiKeyAuth_IsValid() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new ApiKeyAuthConfig("X-Key", "KEY_REF")); + + Assert.True(_sut.Validate([server]).IsOk); + } + + [Fact] + public void Validate_BasicAuth_IsValid() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new BasicAuthConfig("USER_REF", "PASS_REF")); + + Assert.True(_sut.Validate([server]).IsOk); + } + + [Fact] + public void Validate_OAuth2ClientCredentials_IsValid() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new OAuth2ClientCredentialsConfig("https://auth/token", "CID_REF", "CSECRET_REF")); + + Assert.True(_sut.Validate([server]).IsOk); + } + + [Fact] + public void Validate_OAuth2DeviceCode_IsValid() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new OAuth2DeviceCodeConfig("https://auth/device", "https://auth/token", "my-client")); + + Assert.True(_sut.Validate([server]).IsOk); + } + + [Fact] + public void Validate_MtlsAuth_BothRefs_IsValid() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new MtlsConfig("CERT_REF", "KEY_REF")); + + Assert.True(_sut.Validate([server]).IsOk); + } + + [Fact] + public void Validate_MtlsAuth_NeitherRef_ProducesErrors() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new MtlsConfig(null, null)); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Auth.ClientCertRef"); + Assert.Contains(result.Error, e => e.Field == "Auth.ClientKeyRef"); + } + + // Invalid configs — missing required fields per auth mode + + [Fact] + public void Validate_BearerAuth_MissingTokenRef_ProducesError() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new BearerAuthConfig(string.Empty)); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Auth.TokenRef" && e.ServerName == "s"); + } + + [Fact] + public void Validate_ApiKeyAuth_MissingHeaderName_ProducesError() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new ApiKeyAuthConfig(string.Empty, "VALUE_REF")); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Auth.HeaderName"); + } + + [Fact] + public void Validate_ApiKeyAuth_MissingValueRef_ProducesError() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new ApiKeyAuthConfig("X-Key", string.Empty)); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Auth.ValueRef"); + } + + [Fact] + public void Validate_BasicAuth_MissingUsernameRef_ProducesError() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new BasicAuthConfig(string.Empty, "PASS_REF")); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Auth.UsernameRef"); + } + + [Fact] + public void Validate_BasicAuth_MissingPasswordRef_ProducesError() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new BasicAuthConfig("USER_REF", string.Empty)); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Auth.PasswordRef"); + } + + [Fact] + public void Validate_OAuth2ClientCredentials_MissingTokenUrl_ProducesError() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new OAuth2ClientCredentialsConfig(string.Empty, "CID", "CSECRET")); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Auth.TokenUrl"); + } + + [Fact] + public void Validate_OAuth2ClientCredentials_MissingClientIdRef_ProducesError() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new OAuth2ClientCredentialsConfig("https://auth/token", string.Empty, "CSECRET")); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Auth.ClientIdRef"); + } + + [Fact] + public void Validate_OAuth2ClientCredentials_MissingClientSecretRef_ProducesError() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new OAuth2ClientCredentialsConfig("https://auth/token", "CID", string.Empty)); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Auth.ClientSecretRef"); + } + + [Fact] + public void Validate_OAuth2DeviceCode_MissingAuthUrl_ProducesError() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new OAuth2DeviceCodeConfig(string.Empty, "https://auth/token", "client-id")); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Auth.AuthUrl"); + } + + [Fact] + public void Validate_OAuth2DeviceCode_MissingClientId_ProducesError() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new OAuth2DeviceCodeConfig("https://auth/device", "https://auth/token", string.Empty)); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Auth.ClientId"); + } + + [Fact] + public void Validate_MtlsAuth_OnlyCertRef_ProducesKeyRefError() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new MtlsConfig("CERT_REF", null)); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Auth.ClientKeyRef"); + Assert.DoesNotContain(result.Error, e => e.Field == "Auth.ClientCertRef"); + } + + [Fact] + public void Validate_MtlsAuth_OnlyKeyRef_ProducesCertRefError() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new MtlsConfig(null, "KEY_REF")); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Auth.ClientCertRef"); + Assert.DoesNotContain(result.Error, e => e.Field == "Auth.ClientKeyRef"); + } + + // Transport rules + + [Fact] + public void Validate_Stdio_WithoutEndpoint_ProducesError() + { + var server = MakeServer("s", new McpTransportConfig(McpTransportKind.Stdio, null)); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Transport.Endpoint"); + } + + [Fact] + public void Validate_Stdio_WithEndpoint_IsValid() + { + var server = MakeServer("s", new McpTransportConfig(McpTransportKind.Stdio, "my-mcp-server --port 8080")); + + Assert.True(_sut.Validate([server]).IsOk); + } + + [Fact] + public void Validate_Http_WithoutEndpoint_ProducesError() + { + var server = MakeServer("s", new McpTransportConfig(McpTransportKind.Http, null)); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Transport.Endpoint"); + } + + [Fact] + public void Validate_Sse_WithoutEndpoint_ProducesError() + { + var server = MakeServer("s", new McpTransportConfig(McpTransportKind.Sse, null)); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Transport.Endpoint"); + } + + [Fact] + public void Validate_Http_WithInvalidUri_ProducesError() + { + var server = MakeServer("s", new McpTransportConfig(McpTransportKind.Http, "not-a-uri")); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Transport.Endpoint"); + } + + // Unknown transport + + [Fact] + public void Validate_UnknownTransport_ProducesError() + { + var server = MakeServer("s", new McpTransportConfig(McpTransportKind.Unknown, null)); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Transport.Kind" && e.ServerName == "s"); + } + + // Unknown auth type + + [Fact] + public void Validate_UnknownAuthType_ProducesError() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new UnknownAuthConfig("bearr")); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + var error = Assert.Single(result.Error, e => e.Field == "Auth.Type"); + Assert.Contains("bearr", error.Message); + } + + [Fact] + public void Validate_AuthBlockPresentWithNoType_ProducesRequiredError() + { + var server = MakeServer("s", + new McpTransportConfig(McpTransportKind.Stdio, null), + new UnknownAuthConfig(string.Empty)); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + var error = Assert.Single(result.Error, e => e.Field == "Auth.Type"); + Assert.Contains("required", error.Message); + } + + // Blank server names + + [Fact] + public void Validate_EmptyServerName_ProducesError() + { + var server = MakeServer(string.Empty); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Name"); + } + + [Fact] + public void Validate_WhitespaceServerName_ProducesError() + { + var server = MakeServer(" "); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Name"); + } + + // Duplicate names + + [Fact] + public void Validate_DuplicateServerNames_ProducesError() + { + var servers = new[] + { + MakeServer("duplicate"), + MakeServer("duplicate"), + }; + + var result = _sut.Validate(servers); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.ServerName == "duplicate" && e.Field == "Name"); + } + + [Fact] + public void Validate_DuplicateServerNames_CaseInsensitive_ProducesError() + { + var servers = new[] + { + MakeServer("MyServer"), + MakeServer("myserver"), + }; + + var result = _sut.Validate(servers); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Name"); + } + + // TLS rules + + [Fact] + public void Validate_Tls_OnRemoteTransport_BothCertAndKey_IsValid() + { + var server = new McpServerDefinition( + "s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new NoneAuthConfig(), + new McpTlsConfig(null, "/cert.pem", "/key.pem")); + + Assert.True(_sut.Validate([server]).IsOk); + } + + [Fact] + public void Validate_Tls_OnRemoteTransport_OnlyCaCert_IsValid() + { + var server = new McpServerDefinition( + "s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new NoneAuthConfig(), + new McpTlsConfig("/ca.pem", null, null)); + + Assert.True(_sut.Validate([server]).IsOk); + } + + [Fact] + public void Validate_Tls_OnStdioTransport_ProducesError() + { + var server = new McpServerDefinition( + "s", + new McpTransportConfig(McpTransportKind.Stdio, "cmd"), + new NoneAuthConfig(), + new McpTlsConfig("/ca.pem", null, null)); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Tls"); + } + + [Fact] + public void Validate_Tls_CertWithoutKey_ProducesError() + { + var server = new McpServerDefinition( + "s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new NoneAuthConfig(), + new McpTlsConfig(null, "/cert.pem", null)); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Tls.ClientCert"); + } + + [Fact] + public void Validate_Tls_KeyWithoutCert_ProducesError() + { + var server = new McpServerDefinition( + "s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new NoneAuthConfig(), + new McpTlsConfig(null, null, "/key.pem")); + + var result = _sut.Validate([server]); + Assert.False(result.IsOk); + Assert.Contains(result.Error, e => e.Field == "Tls.ClientCert"); + } + + [Fact] + public void Validate_NullTls_IsValid() + { + var server = new McpServerDefinition( + "s", + new McpTransportConfig(McpTransportKind.Http, "https://example.com"), + new NoneAuthConfig(), + Tls: null); + + Assert.True(_sut.Validate([server]).IsOk); + } +} diff --git a/tests/Hypa.UnitTests/Mcp/McpProxyServiceTests.cs b/tests/Hypa.UnitTests/Mcp/McpProxyServiceTests.cs new file mode 100644 index 0000000..cb028bb --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/McpProxyServiceTests.cs @@ -0,0 +1,139 @@ +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Mcp; +using NSubstitute; +using Xunit; + +namespace Hypa.UnitTests.Mcp; + +public sealed class McpProxyServiceTests +{ + private readonly IMcpDispatcher _dispatcher = Substitute.For(); + private readonly McpResponseCompressionService _compression = new(); + private readonly McpToolSearchIndex _searchIndex = new(); + private readonly IClock _clock = Substitute.For(); + private readonly McpProxyService _sut; + + public McpProxyServiceTests() + { + _clock.UtcNow.Returns(DateTimeOffset.UtcNow); + _sut = new McpProxyService(_dispatcher, _compression, _searchIndex, _clock); + } + + private static McpResult SuccessResult(string server, string tool) => + new(server, tool, + new JsonPayload("[{\"type\":\"text\",\"text\":\"ok\"}]"), + "ok", + new McpLatencyMetadata(DateTimeOffset.UtcNow, TimeSpan.Zero), + IsError: false, Error: null); + + [Fact] + public async Task InvokeAsync_empty_server_name_returns_error_without_dispatching() + { + var request = new McpProxyRequest("", "tool", new JsonPayload("{}")); + var result = await _sut.InvokeAsync(request, CancellationToken.None); + + Assert.True(result.IsError); + Assert.Equal(McpErrorCodes.InvalidRequest, result.Error!.Code); + await _dispatcher.DidNotReceive().InvokeAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task InvokeAsync_empty_tool_name_returns_error_without_dispatching() + { + var request = new McpProxyRequest("srv", "", new JsonPayload("{}")); + var result = await _sut.InvokeAsync(request, CancellationToken.None); + + Assert.True(result.IsError); + Assert.Equal(McpErrorCodes.InvalidRequest, result.Error!.Code); + await _dispatcher.DidNotReceive().InvokeAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task InvokeAsync_success_applies_compression() + { + var request = new McpProxyRequest("srv", "echo", new JsonPayload("{}"), CompressionHint.Summary); + var raw = SuccessResult("srv", "echo"); + _dispatcher.InvokeAsync(request, Arg.Any()).Returns(raw); + + var result = await _sut.InvokeAsync(request, CancellationToken.None); + + Assert.False(result.IsError); + Assert.Equal("ok", result.CompressedResponse); + } + + [Fact] + public async Task InvokeAsync_raw_hint_skips_compression() + { + var request = new McpProxyRequest("srv", "echo", new JsonPayload("{}"), CompressionHint.Raw); + var raw = SuccessResult("srv", "echo") with { CompressedResponse = "original" }; + _dispatcher.InvokeAsync(request, Arg.Any()).Returns(raw); + + var result = await _sut.InvokeAsync(request, CancellationToken.None); + + Assert.Equal("original", result.CompressedResponse); + } + + [Fact] + public async Task InvokeBatchAsync_preserves_input_order() + { + var requests = new[] + { + new McpProxyRequest("srv", "first", new JsonPayload("{}")), + new McpProxyRequest("srv", "second", new JsonPayload("{}")), + new McpProxyRequest("srv", "third", new JsonPayload("{}")), + }; + + foreach (var r in requests) + _dispatcher.InvokeAsync(r, Arg.Any()).Returns(SuccessResult(r.ServerName, r.ToolName)); + + var results = await _sut.InvokeBatchAsync(requests, CancellationToken.None); + + Assert.Equal(3, results.Count); + Assert.Equal("first", results[0].ToolName); + Assert.Equal("second", results[1].ToolName); + Assert.Equal("third", results[2].ToolName); + } + + [Fact] + public async Task InvokeBatchAsync_one_failure_does_not_affect_others() + { + var requests = new[] + { + new McpProxyRequest("srv", "ok1", new JsonPayload("{}")), + new McpProxyRequest("srv", "fail", new JsonPayload("{}")), + new McpProxyRequest("srv", "ok2", new JsonPayload("{}")), + }; + + _dispatcher.InvokeAsync(requests[0], Arg.Any()).Returns(SuccessResult("srv", "ok1")); + _dispatcher.InvokeAsync(requests[1], Arg.Any()).Returns( + new McpResult("srv", "fail", new JsonPayload("{}"), string.Empty, + new McpLatencyMetadata(DateTimeOffset.UtcNow, TimeSpan.Zero), + IsError: true, + new McpProxyError(McpErrorCodes.RemoteToolError, "boom", "srv", "fail"))); + _dispatcher.InvokeAsync(requests[2], Arg.Any()).Returns(SuccessResult("srv", "ok2")); + + var results = await _sut.InvokeBatchAsync(requests, CancellationToken.None); + + Assert.False(results[0].IsError); + Assert.True(results[1].IsError); + Assert.False(results[2].IsError); + } + + [Fact] + public async Task SearchToolsAsync_returns_ranked_results_from_schema() + { + var manifest = new McpSchemaManifest([ + new McpServerSchema("srv", [ + new McpToolSchema("read_file", "reads a file", new JsonPayload("{}")), + new McpToolSchema("write_file", "writes a file", new JsonPayload("{}")), + ]) + ]); + _dispatcher.GetSchemaAsync(Arg.Any()).Returns(manifest); + + var results = await _sut.SearchToolsAsync("read file", CancellationToken.None); + + Assert.NotEmpty(results); + Assert.Equal("read_file", results[0].ToolName); + } +} diff --git a/tests/Hypa.UnitTests/Mcp/McpResponseCompressionServiceTests.cs b/tests/Hypa.UnitTests/Mcp/McpResponseCompressionServiceTests.cs new file mode 100644 index 0000000..67fb9d0 --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/McpResponseCompressionServiceTests.cs @@ -0,0 +1,109 @@ +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Mcp; +using Xunit; + +namespace Hypa.UnitTests.Mcp; + +public sealed class McpResponseCompressionServiceTests +{ + private readonly McpResponseCompressionService _sut = new(); + + private static McpResult OkResult(string raw, string compressed = "") => + new("srv", "tool", + new JsonPayload(raw), compressed, + new McpLatencyMetadata(DateTimeOffset.UtcNow, TimeSpan.Zero), + IsError: false, Error: null); + + private static McpResult ErrorResult(string raw) => + new("srv", "tool", + new JsonPayload(raw), string.Empty, + new McpLatencyMetadata(DateTimeOffset.UtcNow, TimeSpan.Zero), + IsError: true, + new McpProxyError(McpErrorCodes.RemoteToolError, "err", "srv", "tool")); + + [Fact] + public void Raw_hint_returns_result_unchanged() + { + var result = OkResult("[{\"type\":\"text\",\"text\":\"hello\"}]"); + var out_ = _sut.Compress(result, CompressionHint.Raw); + Assert.Same(result, out_); + } + + [Fact] + public void Error_result_is_never_compressed() + { + var result = ErrorResult("[{\"type\":\"text\",\"text\":\"oops\"}]"); + var out_ = _sut.Compress(result, CompressionHint.Summary); + Assert.Same(result, out_); + } + + [Fact] + public void Summary_extracts_text_blocks() + { + var raw = "[{\"type\":\"text\",\"text\":\"hello\"},{\"type\":\"image\"},{\"type\":\"text\",\"text\":\"world\"}]"; + var out_ = _sut.Compress(OkResult(raw), CompressionHint.Summary); + Assert.Equal("hello\nworld", out_.CompressedResponse); + } + + [Fact] + public void Summary_collapses_blank_lines() + { + var raw = "[{\"type\":\"text\",\"text\":\"line1\\n\\n\\nline2\"}]"; + var out_ = _sut.Compress(OkResult(raw), CompressionHint.Summary); + Assert.DoesNotContain("\n\n", out_.CompressedResponse); + } + + [Fact] + public void Null_hint_behaves_as_summary() + { + var raw = "[{\"type\":\"text\",\"text\":\"hello\"}]"; + var withNull = _sut.Compress(OkResult(raw), hint: null); + var withSummary = _sut.Compress(OkResult(raw), CompressionHint.Summary); + Assert.Equal(withSummary.CompressedResponse, withNull.CompressedResponse); + } + + [Fact] + public void Structured_compacts_json() + { + var pretty = "{ \"a\" : 1,\n \"b\" : 2 }"; + var out_ = _sut.Compress(OkResult(pretty), CompressionHint.Structured); + Assert.Equal("{\"a\":1,\"b\":2}", out_.CompressedResponse); + } + + [Fact] + public void Structured_falls_back_to_summary_on_invalid_json() + { + var raw = "[{\"type\":\"text\",\"text\":\"fallback\"}]"; + var out_ = _sut.Compress(OkResult(raw), CompressionHint.Structured); + // Content block array is valid JSON but not a plain object — compacts as array, + // so we just verify the result is non-empty and the raw response is preserved. + Assert.False(string.IsNullOrWhiteSpace(out_.CompressedResponse)); + Assert.Equal(raw, out_.RawResponse.RawJson); + } + + [Fact] + public void Summary_EmptyRawContent_ReturnsEmptyCompressedResponse() + { + var out_ = _sut.Compress(OkResult(""), CompressionHint.Summary); + Assert.Equal(string.Empty, out_.CompressedResponse); + } + + [Fact] + public void Summary_AllWhitespaceTextBlocks_FallsBackToRawJson() + { + // When all text blocks are whitespace-only the joined text is empty, + // so the service falls back to returning the original raw JSON string. + var raw = "[{\"type\":\"text\",\"text\":\" \\n \"}]"; + var out_ = _sut.Compress(OkResult(raw), CompressionHint.Summary); + Assert.False(string.IsNullOrWhiteSpace(out_.CompressedResponse)); + Assert.Equal(raw, out_.RawResponse.RawJson); + } + + [Fact] + public void Summary_falls_back_to_raw_json_when_no_text_blocks() + { + var raw = "[{\"type\":\"image\"}]"; + var out_ = _sut.Compress(OkResult(raw), CompressionHint.Summary); + Assert.False(string.IsNullOrWhiteSpace(out_.CompressedResponse)); + } +} diff --git a/tests/Hypa.UnitTests/Mcp/McpServerConfigLoaderTests.cs b/tests/Hypa.UnitTests/Mcp/McpServerConfigLoaderTests.cs new file mode 100644 index 0000000..4bfe5d5 --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/McpServerConfigLoaderTests.cs @@ -0,0 +1,375 @@ +using System.Text.Json; +using Hypa.Infrastructure.Mcp.Config; +using Hypa.Runtime.Domain.Mcp; +using Xunit; + +namespace Hypa.UnitTests.Mcp; + +[Trait("Category", "McpConfig")] +public sealed class McpServerConfigLoaderTests : IDisposable +{ + private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"hypa-mcp-test-{Guid.NewGuid():N}"); + + public McpServerConfigLoaderTests() => Directory.CreateDirectory(_tempDir); + + public void Dispose() => Directory.Delete(_tempDir, recursive: true); + + [Fact] + public async Task LoadAsync_MissingFile_ReturnsEmptyList() + { + var loader = new McpServerConfigLoader(_tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + Assert.Empty(result.Value); + } + + [Fact] + public async Task LoadAsync_EmptyServers_ReturnsEmptyList() + { + WriteJson(new { servers = Array.Empty() }); + + var loader = new McpServerConfigLoader(_tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + Assert.Empty(result.Value); + } + + [Fact] + public async Task LoadAsync_StdioServer_NoneAuth_ParsesCorrectly() + { + WriteJson(new + { + servers = new[] + { + new { name = "local", transport = "stdio" } + } + }); + + var loader = new McpServerConfigLoader(_tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + var server = Assert.Single(result.Value); + Assert.Equal("local", server.Name); + Assert.Equal(McpTransportKind.Stdio, server.Transport.Kind); + Assert.Null(server.Transport.Endpoint); + Assert.IsType(server.Auth); + } + + [Fact] + public async Task LoadAsync_BearerAuth_ParsesCorrectly() + { + WriteJson(new + { + servers = new[] + { + new + { + name = "bearer-server", + transport = "http", + endpoint = "https://example.com/mcp", + auth = new { type = "bearer", tokenRef = "MY_TOKEN" } + } + } + }); + + var loader = new McpServerConfigLoader(_tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + var server = Assert.Single(result.Value); + Assert.Equal(McpTransportKind.HttpAutoDetect, server.Transport.Kind); + var auth = Assert.IsType(server.Auth); + Assert.Equal("MY_TOKEN", auth.TokenRef); + } + + [Fact] + public async Task LoadAsync_ApiKeyAuth_ParsesCorrectly() + { + WriteJson(new + { + servers = new[] + { + new + { + name = "apikey-server", + transport = "http", + endpoint = "https://example.com/mcp", + auth = new { type = "apikey", headerName = "X-Api-Key", valueRef = "API_KEY_REF", inQueryString = false } + } + } + }); + + var loader = new McpServerConfigLoader(_tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + var server = Assert.Single(result.Value); + var auth = Assert.IsType(server.Auth); + Assert.Equal("X-Api-Key", auth.HeaderName); + Assert.Equal("API_KEY_REF", auth.ValueRef); + Assert.False(auth.InQueryString); + } + + [Fact] + public async Task LoadAsync_BasicAuth_ParsesCorrectly() + { + WriteJson(new + { + servers = new[] + { + new + { + name = "basic-server", + transport = "http", + endpoint = "https://example.com/mcp", + auth = new { type = "basic", usernameRef = "USER_REF", passwordRef = "PASS_REF" } + } + } + }); + + var loader = new McpServerConfigLoader(_tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + var auth = Assert.IsType(Assert.Single(result.Value).Auth); + Assert.Equal("USER_REF", auth.UsernameRef); + Assert.Equal("PASS_REF", auth.PasswordRef); + } + + [Fact] + public async Task LoadAsync_OAuth2ClientCredentials_ParsesCorrectly() + { + WriteJson(new + { + servers = new[] + { + new + { + name = "oauth2cc-server", + transport = "http", + endpoint = "https://example.com/mcp", + auth = new + { + type = "oauth2clientcredentials", + tokenUrl = "https://auth.example.com/token", + clientIdRef = "CLIENT_ID", + clientSecretRef = "CLIENT_SECRET", + scopes = new[] { "read", "write" } + } + } + } + }); + + var loader = new McpServerConfigLoader(_tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + var auth = Assert.IsType(Assert.Single(result.Value).Auth); + Assert.Equal("https://auth.example.com/token", auth.TokenUrl); + Assert.Equal("CLIENT_ID", auth.ClientIdRef); + Assert.Equal("CLIENT_SECRET", auth.ClientSecretRef); + Assert.Equal(["read", "write"], auth.Scopes!); + } + + [Fact] + public async Task LoadAsync_OAuth2DeviceCode_ParsesCorrectly() + { + WriteJson(new + { + servers = new[] + { + new + { + name = "oauth2dc-server", + transport = "sse", + endpoint = "https://example.com/sse", + auth = new + { + type = "oauth2devicecode", + authUrl = "https://auth.example.com/device", + tokenUrl = "https://auth.example.com/token", + clientId = "my-client" + } + } + } + }); + + var loader = new McpServerConfigLoader(_tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + var auth = Assert.IsType(Assert.Single(result.Value).Auth); + Assert.Equal("https://auth.example.com/device", auth.AuthUrl); + Assert.Equal("https://auth.example.com/token", auth.TokenUrl); + Assert.Equal("my-client", auth.ClientId); + } + + [Fact] + public async Task LoadAsync_MtlsAuth_ParsesCorrectly() + { + WriteJson(new + { + servers = new[] + { + new + { + name = "mtls-server", + transport = "http", + endpoint = "https://example.com/mcp", + auth = new { type = "mtls", clientCertRef = "CERT_REF", clientKeyRef = "KEY_REF" } + } + } + }); + + var loader = new McpServerConfigLoader(_tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + var auth = Assert.IsType(Assert.Single(result.Value).Auth); + Assert.Equal("CERT_REF", auth.ClientCertRef); + Assert.Equal("KEY_REF", auth.ClientKeyRef); + } + + [Fact] + public async Task LoadAsync_Timeouts_ParsedCorrectly() + { + WriteJson(new + { + servers = new[] + { + new + { + name = "timed-server", + transport = "http", + endpoint = "https://example.com/mcp", + connectTimeoutSeconds = 5, + requestTimeoutSeconds = 30 + } + } + }); + + var loader = new McpServerConfigLoader(_tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + var server = Assert.Single(result.Value); + Assert.Equal(TimeSpan.FromSeconds(5), server.ConnectTimeout); + Assert.Equal(TimeSpan.FromSeconds(30), server.RequestTimeout); + } + + [Fact] + public async Task LoadAsync_TlsConfig_ParsedCorrectly() + { + WriteJson(new + { + servers = new[] + { + new + { + name = "tls-server", + transport = "http", + endpoint = "https://example.com/mcp", + tls = new { caCertPath = "/certs/ca.pem", clientCertPath = "/certs/client.pem", clientKeyPath = "/certs/client.key" } + } + } + }); + + var loader = new McpServerConfigLoader(_tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + var server = Assert.Single(result.Value); + Assert.NotNull(server.Tls); + Assert.Equal("/certs/ca.pem", server.Tls.CaCertPath); + Assert.Equal("/certs/client.pem", server.Tls.ClientCertPath); + Assert.Equal("/certs/client.key", server.Tls.ClientKeyPath); + } + + [Fact] + public async Task LoadAsync_UnknownTransport_MapsToUnknownKind() + { + WriteJson(new + { + servers = new[] + { + new { name = "bad", transport = "grpc" } + } + }); + + var loader = new McpServerConfigLoader(_tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + Assert.Equal(McpTransportKind.Unknown, result.Value[0].Transport.Kind); + } + + [Fact] + public async Task LoadAsync_UnknownAuthType_MapsToUnknownAuthConfig() + { + WriteJson(new + { + servers = new[] + { + new + { + name = "bad", + transport = "http", + endpoint = "https://example.com", + auth = new { type = "bearr" } + } + } + }); + + var loader = new McpServerConfigLoader(_tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + var auth = Assert.IsType(result.Value[0].Auth); + Assert.Equal("bearr", auth.Type); + } + + [Fact] + public async Task LoadAsync_AuthBlockPresentWithNoType_MapsToUnknownAuthConfig() + { + WriteJson(new + { + servers = new[] + { + new { name = "s", transport = "stdio", auth = new { tokenRef = "X" } } + } + }); + + var loader = new McpServerConfigLoader(_tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + var auth = Assert.IsType(result.Value[0].Auth); + Assert.Equal(string.Empty, auth.Type); + } + + [Fact] + public async Task LoadAsync_NoAuthBlock_MapsToNoneAuthConfig() + { + WriteJson(new + { + servers = new[] + { + new { name = "s", transport = "stdio" } + } + }); + + var loader = new McpServerConfigLoader(_tempDir); + var result = await loader.LoadAsync(CancellationToken.None); + + Assert.True(result.IsOk); + Assert.IsType(result.Value[0].Auth); + } + + private void WriteJson(object obj) => + File.WriteAllText( + Path.Combine(_tempDir, "mcp-servers.json"), + JsonSerializer.Serialize(obj)); +} diff --git a/tests/Hypa.UnitTests/Mcp/McpToolSearchIndexTests.cs b/tests/Hypa.UnitTests/Mcp/McpToolSearchIndexTests.cs new file mode 100644 index 0000000..b4ecaaf --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/McpToolSearchIndexTests.cs @@ -0,0 +1,101 @@ +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Mcp; +using Xunit; + +namespace Hypa.UnitTests.Mcp; + +public sealed class McpToolSearchIndexTests +{ + private readonly McpToolSearchIndex _sut = new(); + + private static McpSchemaManifest Manifest(params (string server, string tool, string desc, string schema)[] entries) + { + var servers = entries + .GroupBy(e => e.server) + .Select(g => new McpServerSchema( + g.Key, + g.Select(e => new McpToolSchema(e.tool, e.desc, new JsonPayload(e.schema))).ToList())) + .ToList(); + return new McpSchemaManifest(servers); + } + + [Fact] + public void Empty_query_returns_no_results() + { + var manifest = Manifest(("srv", "echo", "echoes text", "{}")); + Assert.Empty(_sut.Search(manifest, "")); + } + + [Fact] + public void Whitespace_query_returns_no_results() + { + var manifest = Manifest(("srv", "echo", "echoes text", "{}")); + Assert.Empty(_sut.Search(manifest, " ")); + } + + [Fact] + public void Exact_tool_name_match_returns_result_with_positive_score() + { + var manifest = Manifest(("srv", "read_file", "reads a file", "{}")); + var results = _sut.Search(manifest, "read_file"); + Assert.Single(results); + Assert.True(results[0].Score > 0); + } + + [Fact] + public void Irrelevant_query_returns_empty() + { + var manifest = Manifest(("srv", "read_file", "reads a file", "{}")); + Assert.Empty(_sut.Search(manifest, "xyzzy_totally_unrelated")); + } + + [Fact] + public void Higher_scoring_result_is_ranked_first() + { + var manifest = Manifest( + ("srv", "search_text", "searches text content", "{}"), + ("srv", "read_file", "reads a file from disk", "{}")); + + var results = _sut.Search(manifest, "search text"); + Assert.Equal("search_text", results[0].ToolName); + } + + [Fact] + public void Server_name_is_included_in_search() + { + var manifest = Manifest(("filesystem", "list_dir", "lists directory", "{}")); + var results = _sut.Search(manifest, "filesystem"); + Assert.Single(results); + Assert.Equal("list_dir", results[0].ToolName); + } + + [Fact] + public void Schema_text_is_included_in_search() + { + var schema = "{\"properties\":{\"targetPath\":{\"type\":\"string\"}}}"; + var manifest = Manifest(("srv", "move_file", "moves a file", schema)); + var results = _sut.Search(manifest, "targetPath"); + Assert.Single(results); + } + + [Fact] + public void Search_is_case_insensitive() + { + var manifest = Manifest(("srv", "ReadFile", "Reads A File", "{}")); + var results = _sut.Search(manifest, "readfile"); + Assert.Single(results); + } + + [Fact] + public void Results_are_deterministic_for_same_input() + { + var manifest = Manifest( + ("a", "alpha", "first tool", "{}"), + ("b", "beta", "second tool", "{}")); + + var r1 = _sut.Search(manifest, "tool"); + var r2 = _sut.Search(manifest, "tool"); + + Assert.Equal(r1.Select(x => x.ToolName), r2.Select(x => x.ToolName)); + } +} diff --git a/tests/Hypa.UnitTests/Mcp/Secrets/EnvironmentSecretResolverTests.cs b/tests/Hypa.UnitTests/Mcp/Secrets/EnvironmentSecretResolverTests.cs new file mode 100644 index 0000000..c7a8892 --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/Secrets/EnvironmentSecretResolverTests.cs @@ -0,0 +1,110 @@ +using Hypa.Infrastructure.Mcp.Auth; +using Hypa.Infrastructure.Mcp.Secrets; +using ModelContextProtocol.Authentication; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Hypa.UnitTests.Mcp.Secrets; + +public sealed class EnvironmentSecretResolverTests : IDisposable +{ + private const string TestEnvVar = "HYPA_TEST_SECRET_VAR_12345"; + private readonly string _tempFile = Path.Combine(Path.GetTempPath(), $"hypa-secret-{Guid.NewGuid():N}.txt"); + private readonly string _tokenDir = Path.Combine(Path.GetTempPath(), $"hypa-token-resolver-{Guid.NewGuid():N}"); + private readonly McpOAuthTokenStoreFactory _factory; + private readonly EnvironmentSecretResolver _sut; + + public EnvironmentSecretResolverTests() + { + Directory.CreateDirectory(_tokenDir); + _factory = new McpOAuthTokenStoreFactory( + _tokenDir, + new SecretRedactionRegistry(), + NullLogger.Instance); + _sut = new( + _factory, + NullLogger.Instance); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(TestEnvVar, null); + if (File.Exists(_tempFile)) + File.Delete(_tempFile); + try { Directory.Delete(_tokenDir, recursive: true); } catch { } + } + + [Fact] + public async Task ResolveAsync_EnvPrefix_ReturnsVariableValue() + { + Environment.SetEnvironmentVariable(TestEnvVar, "supersecret"); + var result = await _sut.ResolveAsync($"env:{TestEnvVar}", CancellationToken.None); + Assert.Equal("supersecret", result); + } + + [Fact] + public async Task ResolveAsync_EnvPrefix_UnsetVariable_ReturnsNull() + { + Environment.SetEnvironmentVariable(TestEnvVar, null); + var result = await _sut.ResolveAsync($"env:{TestEnvVar}", CancellationToken.None); + Assert.Null(result); + } + + [Fact] + public async Task ResolveAsync_FilePrefix_ReturnsTrimmedContent() + { + await File.WriteAllTextAsync(_tempFile, " token-value \n"); + var result = await _sut.ResolveAsync($"file:{_tempFile}", CancellationToken.None); + Assert.Equal("token-value", result); + } + + [Fact] + public async Task ResolveAsync_FilePrefix_MissingFile_ReturnsNull() + { + var result = await _sut.ResolveAsync("file:/nonexistent/path/secret.txt", CancellationToken.None); + Assert.Null(result); + } + + [Fact] + public async Task ResolveAsync_NoPrefix_ReturnsLiteralValue() + { + var result = await _sut.ResolveAsync("literal-token", CancellationToken.None); + Assert.Equal("literal-token", result); + } + + [Fact] + public async Task ResolveAsync_DcrPrefix_WithStoredCredentials_ReturnsSecret() + { + var serverName = "dcr-test-server"; + var store = new McpOAuthTokenStore(serverName, _tokenDir); + await store.StoreTokensAsync(new TokenContainer + { + TokenType = "Bearer", + AccessToken = "access-token", + ObtainedAt = DateTimeOffset.UtcNow, + }, CancellationToken.None); + await store.StoreDcrCredentialsAsync("dcr-client-id", "dcr-secret-value", CancellationToken.None); + + var result = await _sut.ResolveAsync($"hypa:dcr:{serverName}", CancellationToken.None); + Assert.Equal("dcr-secret-value", result); + } + + [Fact] + public async Task ResolveAsync_DcrPrefix_NoStoredCredentials_ReturnsNull() + { + var result = await _sut.ResolveAsync("hypa:dcr:nonexistent-server", CancellationToken.None); + Assert.Null(result); + } + + [Fact] + public async Task ResolveAsync_DcrPrefix_MissingTokenEntry_ReturnsNull() + { + // Store DCR credentials for a server that has no token entry. + var store = new McpOAuthTokenStore("no-token-server", _tokenDir); + await store.StoreDcrCredentialsAsync("some-id", "some-secret", CancellationToken.None); + + var result = await _sut.ResolveAsync("hypa:dcr:no-token-server", CancellationToken.None); + // StoreDcrCredentials returns early if no token entry exists, so nothing was persisted. + Assert.Null(result); + } +} diff --git a/tests/Hypa.UnitTests/Mcp/Tools/HypaMcpToolTests.cs b/tests/Hypa.UnitTests/Mcp/Tools/HypaMcpToolTests.cs new file mode 100644 index 0000000..663eac5 --- /dev/null +++ b/tests/Hypa.UnitTests/Mcp/Tools/HypaMcpToolTests.cs @@ -0,0 +1,426 @@ +using Hypa.Infrastructure.Mcp; +using Hypa.Infrastructure.Mcp.Auth; +using Hypa.Infrastructure.Mcp.Tools; +using Hypa.Runtime.Application.Ports; +using Hypa.Runtime.Application.Services; +using Hypa.Runtime.Domain.Common; +using Hypa.Runtime.Domain.Mcp; +using Hypa.Runtime.Domain.Sessions; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Protocol; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Hypa.UnitTests.Mcp.Tools; + +public sealed class HypaMcpToolTests +{ + private readonly IMcpDispatcher _dispatcher = Substitute.For(); + private readonly IMcpServerDefinitionRepository _repo = Substitute.For(); + private readonly IMcpAuthProvider _authProvider = Substitute.For(); + private readonly IEvidenceLedger _ledger = Substitute.For(); + private readonly ISessionResolver _sessionResolver = Substitute.For(); + private readonly IClock _clock = Substitute.For(); + private readonly SecretRedactionRegistry _redactionRegistry = new(); + private readonly McpProxyService _proxyService; + + public HypaMcpToolTests() + { + _clock.UtcNow.Returns(DateTimeOffset.UtcNow); + _proxyService = new McpProxyService( + _dispatcher, + new McpResponseCompressionService(), + new McpToolSearchIndex(), + _clock); + + _sessionResolver + .ResolveAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Fail(new Error("NoSession", "no session"))); + } + + private Task Execute( + string action, + string? server = null, + string? tool = null, + string? arguments = null, + string? hint = null, + string? requests = null, + string? query = null) => + HypaMcpTool.ExecuteAsync( + _proxyService, _repo, _authProvider, _ledger, _sessionResolver, + _redactionRegistry, + NullLogger.Instance, + CancellationToken.None, + action, server, tool, arguments, hint, requests, query); + + private static string TextOf(CallToolResult r) => McpToolResult.TextOf(r); + + [Fact] + public async Task Unknown_action_returns_InvalidRequest() + { + var result = await Execute("bogus"); + + Assert.True(result.IsError); + Assert.Contains(McpErrorCodes.InvalidRequest, TextOf(result)); + } + + [Theory] + [InlineData(null, "echo")] + [InlineData("", "echo")] + [InlineData("srv", null)] + [InlineData("srv", "")] + public async Task Invoke_missing_required_params_returns_InvalidRequest(string? server, string? tool) + { + var result = await Execute("invoke", server: server, tool: tool); + + Assert.True(result.IsError); + Assert.Contains(McpErrorCodes.InvalidRequest, TextOf(result)); + } + + [Fact] + public async Task Batch_missing_requests_returns_InvalidRequest() + { + var result = await Execute("batch", requests: null); + + Assert.True(result.IsError); + Assert.Contains(McpErrorCodes.InvalidRequest, TextOf(result)); + } + + [Fact] + public async Task Batch_malformed_json_returns_InvalidRequest_without_exception_detail() + { + var result = await Execute("batch", requests: "not-json"); + + Assert.True(result.IsError); + var text = TextOf(result); + Assert.Contains(McpErrorCodes.InvalidRequest, text); + Assert.DoesNotContain("Exception", text); + Assert.DoesNotContain("JsonReaderException", text); + } + + [Fact] + public async Task Batch_empty_array_returns_InvalidRequest() + { + var result = await Execute("batch", requests: "[]"); + + Assert.True(result.IsError); + Assert.Contains(McpErrorCodes.InvalidRequest, TextOf(result)); + } + + [Fact] + public async Task Search_missing_query_returns_InvalidRequest() + { + var result = await Execute("search", query: null); + + Assert.True(result.IsError); + Assert.Contains(McpErrorCodes.InvalidRequest, TextOf(result)); + } + + [Fact] + public async Task AuthCheck_missing_server_returns_InvalidRequest() + { + var result = await Execute("auth_check", server: null); + + Assert.True(result.IsError); + Assert.Contains(McpErrorCodes.InvalidRequest, TextOf(result)); + } + + [Fact] + public async Task AuthCheck_config_load_failure_returns_SchemaUnavailable() + { + _repo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Fail(new Error("IoError", "disk failure"))); + + var result = await Execute("auth_check", server: "svc"); + + Assert.True(result.IsError); + var text = TextOf(result); + Assert.Contains(McpErrorCodes.SchemaUnavailable, text); + Assert.DoesNotContain("disk failure", text); + } + + [Fact] + public async Task AuthCheck_unknown_server_returns_UnknownServer() + { + _repo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([])); + + var result = await Execute("auth_check", server: "missing"); + + Assert.True(result.IsError); + Assert.Contains(McpErrorCodes.UnknownServer, TextOf(result)); + } + + [Fact] + public async Task AuthCheck_provider_exception_returns_AuthRequired_without_exception_detail() + { + var definition = new McpServerDefinition( + "svc", + new McpTransportConfig(McpTransportKind.Http, "https://example.com/mcp"), + new NoneAuthConfig()); + + _repo.LoadAsync(Arg.Any()) + .Returns(Result, Error>.Ok([definition])); + + _authProvider + .GetAuthContextAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("token endpoint unreachable — internal detail")); + + var result = await Execute("auth_check", server: "svc"); + + Assert.True(result.IsError); + var text = TextOf(result); + Assert.Contains(McpErrorCodes.AuthRequired, text); + Assert.DoesNotContain("token endpoint unreachable", text); + Assert.DoesNotContain("internal detail", text); + } + + [Fact] + public async Task Invoke_success_returns_formatted_output() + { + var mcpResult = new McpResult( + "svc", "echo", + new JsonPayload("[{\"type\":\"text\",\"text\":\"pong\"}]"), + "pong", + new McpLatencyMetadata(DateTimeOffset.UtcNow, TimeSpan.FromMilliseconds(42)), + IsError: false, Error: null); + + _dispatcher.InvokeAsync(Arg.Any(), Arg.Any()) + .Returns(mcpResult); + + var result = await Execute("invoke", server: "svc", tool: "echo"); + + Assert.True(result.IsError is not true); + var text = TextOf(result); + Assert.Contains("SUMMARY", text); + Assert.Contains("DETAILS", text); + Assert.Contains("STATS", text); + Assert.Contains("pong", text); + } + + [Fact] + public async Task Invoke_success_records_to_evidence_ledger() + { + var mcpResult = new McpResult( + "svc", "echo", + new JsonPayload("{}"), "ok", + new McpLatencyMetadata(DateTimeOffset.UtcNow, TimeSpan.Zero), + IsError: false, Error: null); + + _dispatcher.InvokeAsync(Arg.Any(), Arg.Any()) + .Returns(mcpResult); + + await Execute("invoke", server: "svc", tool: "echo"); + + await _ledger.Received(1).RecordToolCallAsync( + Arg.Is(r => + r.ToolName == "hypa_mcp" && + !string.IsNullOrEmpty(r.ArgsHash) && + !string.IsNullOrEmpty(r.OutputHash)), + Arg.Any()); + } + + [Fact] + public async Task Invoke_success_args_json_does_not_contain_raw_tool_arguments() + { + // Only action/server/tool/hint/query are captured in argsJson — not the raw `arguments` + // value, which may contain secrets. Verify the ledger record's Args field omits `arguments`. + var mcpResult = new McpResult( + "svc", "echo", + new JsonPayload("{}"), "ok", + new McpLatencyMetadata(DateTimeOffset.UtcNow, TimeSpan.Zero), + IsError: false, Error: null); + + _dispatcher.InvokeAsync(Arg.Any(), Arg.Any()) + .Returns(mcpResult); + + await Execute("invoke", server: "svc", tool: "echo", arguments: """{"password":"secret-value"}"""); + + await _ledger.Received(1).RecordToolCallAsync( + Arg.Is(r => !r.Args.Contains("secret-value")), + Arg.Any()); + } + + [Fact] + public async Task Invoke_success_registered_secrets_are_redacted_in_evidence_result() + { + // Secrets registered with SecretRedactionRegistry must not appear in evidence Result text. + const string secretToken = "super-secret-output-token"; + _redactionRegistry.Register(secretToken); + + var mcpResult = new McpResult( + "svc", "echo", + new JsonPayload("{}"), $"response contains {secretToken} value", + new McpLatencyMetadata(DateTimeOffset.UtcNow, TimeSpan.Zero), + IsError: false, Error: null); + + _dispatcher.InvokeAsync(Arg.Any(), Arg.Any()) + .Returns(mcpResult); + + await Execute("invoke", server: "svc", tool: "echo"); + + await _ledger.Received(1).RecordToolCallAsync( + Arg.Is(r => !r.Result.Contains(secretToken)), + Arg.Any()); + } + + [Fact] + public async Task Batch_success_and_failure_returns_ordered_summary_table() + { + var ok = new McpResult( + "svc", "tool1", + new JsonPayload("{}"), "done", + new McpLatencyMetadata(DateTimeOffset.UtcNow, TimeSpan.FromMilliseconds(10)), + IsError: false, Error: null); + + var err = new McpResult( + "svc", "tool2", + new JsonPayload("{}"), string.Empty, + new McpLatencyMetadata(DateTimeOffset.UtcNow, TimeSpan.FromMilliseconds(5)), + IsError: true, + new McpProxyError(McpErrorCodes.ConnectionFailed, "refused", "svc", "tool2")); + + _dispatcher.InvokeAsync( + Arg.Is(r => r.ToolName == "tool1"), + Arg.Any()) + .Returns(ok); + + _dispatcher.InvokeAsync( + Arg.Is(r => r.ToolName == "tool2"), + Arg.Any()) + .Returns(err); + + var result = await Execute( + "batch", + requests: """[{"server":"svc","tool":"tool1"},{"server":"svc","tool":"tool2"}]"""); + + Assert.True(result.IsError is not true); + var text = TextOf(result); + Assert.Contains("SUMMARY", text); + Assert.Contains("RESULTS", text); + Assert.Contains("tool1", text); + Assert.Contains("OK", text); + Assert.Contains("tool2", text); + Assert.Contains("ERROR", text); + // [0] before [1] in the output (order preserved) + Assert.True(text.IndexOf("[0]", StringComparison.Ordinal) < text.IndexOf("[1]", StringComparison.Ordinal)); + } + + [Fact] + public async Task Batch_string_arguments_are_forwarded_as_object_json() + { + var mcpResult = new McpResult( + "svc", "tool1", + new JsonPayload("{}"), "done", + new McpLatencyMetadata(DateTimeOffset.UtcNow, TimeSpan.FromMilliseconds(10)), + IsError: false, Error: null); + + _dispatcher.InvokeAsync( + Arg.Is(r => + r.ToolName == "tool1" && + r.Arguments.RawJson == "{\"command\":\"echo ok\"}"), + Arg.Any()) + .Returns(mcpResult); + + var result = await Execute( + "batch", + requests: """[{"server":"svc","tool":"tool1","arguments":"{\"command\":\"echo ok\"}"}]"""); + + Assert.True(result.IsError is not true); + await _dispatcher.Received(1).InvokeAsync( + Arg.Is(r => r.Arguments.RawJson == "{\"command\":\"echo ok\"}"), + Arg.Any()); + } + + [Fact] + public async Task Search_returns_matching_results() + { + var manifest = new McpSchemaManifest( + [ + new McpServerSchema("svc", + [ + new McpToolSchema("read_file", "Read a file from disk", new JsonPayload("{}")), + new McpToolSchema("run_command", "Execute a shell command", new JsonPayload("{}")), + ]), + ]); + + _dispatcher.GetSchemaAsync(Arg.Any()) + .Returns(Task.FromResult(manifest)); + + var result = await Execute("search", query: "read file"); + + Assert.True(result.IsError is not true); + var text = TextOf(result); + Assert.Contains("SUMMARY", text); + Assert.Contains("RESULTS", text); + Assert.Contains("read_file", text); + } + + [Fact] + public async Task Schema_returns_schema_output_with_tool_names() + { + var manifest = new McpSchemaManifest( + [ + new McpServerSchema("svc", + [ + new McpToolSchema("list_files", "List files", new JsonPayload("{}")), + ]), + ]); + + _dispatcher.GetSchemaAsync(Arg.Any()) + .Returns(Task.FromResult(manifest)); + + var result = await Execute("schema"); + + Assert.True(result.IsError is not true); + var text = TextOf(result); + Assert.Contains("SUMMARY", text); + Assert.Contains("SCHEMA", text); + Assert.Contains("svc", text); + Assert.Contains("list_files", text); + } + + [Fact] + public async Task Invoke_CancelledToken_ThrowsOperationCancelled() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + _dispatcher.InvokeAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new OperationCanceledException()); + + await Assert.ThrowsAsync(() => + HypaMcpTool.ExecuteAsync( + _proxyService, _repo, _authProvider, _ledger, _sessionResolver, + _redactionRegistry, + NullLogger.Instance, + cts.Token, + "invoke", "svc", "echo")); + } + + [Fact] + public async Task Invoke_error_result_carries_stable_code() + { + var definition = new McpServerDefinition( + "svc", + new McpTransportConfig(McpTransportKind.Http, "https://example.com/mcp"), + new NoneAuthConfig()); + + var error = new McpResult( + "svc", "echo", + new JsonPayload("{}"), + string.Empty, + new McpLatencyMetadata(DateTimeOffset.UtcNow, TimeSpan.Zero), + IsError: true, + new McpProxyError(McpErrorCodes.ConnectionFailed, "Failed to connect to server 'svc'.", "svc", "echo")); + + _dispatcher.InvokeAsync(Arg.Any(), Arg.Any()) + .Returns(error); + + var result = await Execute("invoke", server: "svc", tool: "echo"); + + Assert.True(result.IsError); + Assert.Contains(McpErrorCodes.ConnectionFailed, TextOf(result)); + } +} From 2504c397f10ec4a3d0e483ed5e35a1854a106774 Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Sun, 31 May 2026 19:24:59 +1000 Subject: [PATCH 05/10] chore: native grammar bundling for v0.1.1 release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added pip and homebrew release workflows * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Matthew Gribben * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Matthew Gribben * added gating to the release workflow * Add CI gate, concurrency control, and REPO_TOKEN for homebrew tap - Gate job verifies all CI checks passed on the tagged commit before any build or publish job runs; skipped for workflow_dispatch - Concurrency group prevents parallel release runs for the same ref - publish-homebrew now uses secrets.REPO_TOKEN for cross-repo push to homebrew-tap Co-Authored-By: Claude Sonnet 4.6 * Sync public source from PR #29 This pull request adds significant architectural changes as well as a GitHub Actions workflow for syncing source and test files to a public repository. **Architectural Decisions:** * Establishes a package boundary between provider-neutral structural search contracts (`Hypa.Sdk`) and the Tree-sitter-based engine implementation (`Hypa.CodePatterns`). This separation enables downstream consumers to use structural search results without taking a dependency on native parsing libraries, and allows for future NuGet publication of the engine. * Details the sequencing and rationale for three major 2026 features: MCP Proxy Layer, code grep (structural search/scan/rewrite), and Code Mode Scripting Engine. We plan phased delivery, risk mitigation, and integration points to maximize compound value and minimize technical risk. **Timeouts** * Some commands would hit the hypa internal timeout limit, mainly package manager, so we've introduced pipeline specific limits and an override command to allow the agent to specify a timeout plus more intelligent feedback when there is an error. **Automation and Workflow:** * Introduced a GitHub Actions workflow (`.github/workflows/sync-public.yml`) that automatically syncs the `src` and `tests` directories to the public `Hypabolic/Hypa` repository on pushes to `main`. The workflow includes PR metadata extraction, payload creation, public repo checkout, file replacement, and commit/push logic with descriptive commit messages. Source repo: matt-gribben/Hypa Source SHA: 25f836cb7f71a4f63dc3423d5a971bf57c713083 PR URL: https://github.com/matt-gribben/Hypa/pull/29 * feat: simplify commit message generation for public repo sync workflow action update * Enhance MCP server configuration, validation, and connection handling This pull request introduces several documentation updates and new documents to clarify workflows, address architectural issues, and plan for future features. The most significant changes include the addition of an ADR for GitHub Copilot Extension integration, detailed documentation for local preview installs, a bug report on `hypa_shell` error handling, and an architecture review highlighting key technical debt and recommendations. There are also minor roadmap clarifications and new usage/setup instructions for Hypa CLI tools. **Key changes:** **1. GitHub Copilot Extension Integration** - Added ADR 0011 describing the design and implementation plan for a thin Node.js ESM adapter to integrate Hypa tools and context with GitHub Copilot CLI sessions, including command rewriting and session lifecycle hooks. The ADR details the rationale, consequences, implementation strategy, and alternatives considered. **2. Local Preview Installs Documentation** - Added a new document and updated the `README.md` to describe the workflow for installing local preview builds of Hypa. This includes commands for installing, restoring, managing, and cleaning preview builds, as well as the effect on update behavior. [[1]](diffhunk://#diff-51a886bc8914e40ea5afcc9459caae38f5fa33b3e6bd25b5fc2e5cbe05f9af5aR1-R38) [[2]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R115-R153) **3. Bug Report: `hypa_shell` Error Handling** - Added a detailed bug report documenting how `hypa_shell` misreports missing executables as working directory errors, including root cause analysis, impact, suggested fixes, and regression test cases. **4. Architecture Review and Technical Debt** - Added an architecture review document identifying several high and medium priority issues, such as dependency rule violations, improper use of application ports, unimplemented ADRs, CLI orchestration bloat, adapter boundary drift, and improper unit test practices. Each issue includes recommendations for resolution. **5. Roadmap and Usage Documentation Updates** - Updated the strategic roadmap to remove week estimates and clarify the sequencing and rationale for technical spikes and MCP proxy layer work. [[1]](diffhunk://#diff-5e4d86614a4a31c4b4fef6106f6de445905aac468f94d0cbd512f57d825d1690L47-R55) [[2]](diffhunk://#diff-5e4d86614a4a31c4b4fef6106f6de445905aac468f94d0cbd512f57d825d1690L77-R77) [[3]](diffhunk://#diff-5e4d86614a4a31c4b4fef6106f6de445905aac468f94d0cbd512f57d825d1690L108-R112) [[4]](diffhunk://#diff-5e4d86614a4a31c4b4fef6106f6de445905aac468f94d0cbd512f57d825d1690R232-R237) - Added a "Hypa Rules" section to `CLAUDE.md` with best practices for using Hypa CLI wrappers and initial setup instructions. * Release: MCP Proxy, Browser OAuth, and Markdown Mode ## Summary Three features developed across this release cycle, plus post-integration fixes and hardening discovered during live testing. ### MCP Proxy Layer A full MCP proxy implementation that lets Hypa act as a man-in-the-middle between agents and external MCP servers — forwarding tool calls, compressing responses, and exposing a unified tool search index. - `McpProxyService` with `DirectMcpDispatcher` for request routing and response compression - `McpTransportBuilder` for transport creation; `McpClientConnectionFactory` refactored to use it - MCP import with bearer token authentication and error handling - `McpServerProbeAdapter` — probes remote servers on `mcp add` to validate connectivity and detect auth requirements; `--no-probe` flag to skip - Tool search index for fast tool lookup across registered servers - Credential resolution with secret redaction in test output ### Browser OAuth Onboarding Automated OAuth flow for MCP servers that require browser-based authentication. - `OAuth2BrowserConfig` and interactive/non-interactive auth modes in `McpTransportBuilder` - Browser launch + local callback listener for authorization code exchange - DCR secret storage, TLS options, state validation, and manual paste fallback for restricted environments - Auth defaults to `none` on `mcp add`; guided setup triggered on probe auth failure ### Markdown Mode Structured Markdown indexing and querying via tree-sitter, with a freshness-aware query gate and read hook compression. - `MarkdownStructureProvider` and `CodePatternExtractor` — extracts sections (heading text, level, anchor, byte spans, plain text) and frontmatter YAML via tree-sitter - `hypa md ` subcommand with `--toc`, `--section`, `--frontmatter`, and `--json` flags - Git-aware incremental indexer (`IndexIncrementalAsync`): clean tracked files compared by blob OID (no file I/O), dirty/untracked files fall back to mtime+size — only stale files are re-parsed - `EnsureFreshAsync` gate on `hypa md` — auto-indexes on first use, re-indexes on change, no-op when fresh - `ReadRedirector` extended to compress large `.md` files to a heading outline; `CLAUDE.md` / `SKILL.md` pass through unchanged - `hypa code index --full` flag for forced full rebuild ### Post-integration fixes (found during live testing) - **`IndexFullAsync` OID regression**: full rebuilds stored `git_blob_oid = NULL`, causing the next incremental run to re-index every file. Fixed by calling `GetCleanBlobOidsAsync` in `IndexFullAsync` and persisting the OID alongside mtime. - **`libtree-sitter-markdown.so` not bundled**: `TreeSitter.DotNet` 1.3.0 doesn't ship the markdown grammar. Fixed by building from source and bundling via `Directory.Build.targets` (RID-aware, copies to output/publish root on all platforms). - **Provider conflict**: once the native library loaded, both `TreeSitterCodeStructureProvider` and `MarkdownStructureProvider` claimed `CanHandle("markdown")`, causing the generic provider to win and extract C# symbols from markdown files. Fixed with an explicit exclusion in `TreeSitterCodeStructureProvider.CanHandle`. - **CI hardening**: grammar build script runs on all matrix runners before the .NET build; AOT publish job verifies the symbol export and asserts `markdown: ok` in provider health after indexing. - **Missing test coverage**: added `MarkdownStructureProviderIntegrationTests` (exercises real tree-sitter native library, asserts sections and provenance) and `CodeStructureProviderRegistryTests` (asserts correct provider selection per language; regression guard ensures no two non-fallback providers claim the same language). ## Test plan - [ ] `dotnet test tests/Hypa.UnitTests` — 1325 tests, all passing - [ ] `dotnet test tests/Hypa.GoldenTests` — all passing - [ ] `hypa mcp add ` probes the server and reports auth requirements - [ ] OAuth browser flow launches, completes exchange, and stores credentials - [ ] `hypa md README.md --toc` on a fresh clone auto-indexes and returns ToC - [ ] Second `hypa md README.md --toc` with no file changes is a no-op (no re-parse) - [ ] `hypa code index` is incremental by default; `--full` re-indexes then subsequent incremental is a no-op - [ ] `hypa code index --json` shows `markdown: ok` in provider health - [ ] Large `.md` files intercepted by the read hook produce a heading outline; `CLAUDE.md` passes through unchanged * feat(native): bundle libtree-sitter-markdown grammar and wire into CI and release pipeline - Add native/runtimes/linux-x64/native/libtree-sitter-markdown.so (pre-built) - Add scripts/build-tree-sitter-markdown.sh/.ps1 to build from source on any platform - Add Directory.Build.targets to copy the RID-appropriate grammar to every project output and publish root, matching how TreeSitter.DotNet flattens its native assets - ci.yml: build grammar before dotnet build on all matrix runners; aot-publish job rebuilds with FORCE_BUILD=1 and verifies symbol before and after publish - release.yml: build grammar (FORCE_BUILD=1) in every platform build job before dotnet publish; verify grammar file is present in publish output before packaging --------- Signed-off-by: Matthew Gribben Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> From 985cd33237ea2b0d8faf3a4c3a6a27fa8e8e4be7 Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Mon, 1 Jun 2026 09:19:45 +1000 Subject: [PATCH 06/10] feat(instructions): update agent instructions for markdown and incremental indexing --- .../Hooks/Adapters/ClaudeCodeAdapter.cs | 1 + .../Skills/Resources/HYPA.md | 27 +++++++++ .../Skills/Resources/SKILL.md | 55 +++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/src/Hypa.Infrastructure/Hooks/Adapters/ClaudeCodeAdapter.cs b/src/Hypa.Infrastructure/Hooks/Adapters/ClaudeCodeAdapter.cs index a8998c8..2160fce 100644 --- a/src/Hypa.Infrastructure/Hooks/Adapters/ClaudeCodeAdapter.cs +++ b/src/Hypa.Infrastructure/Hooks/Adapters/ClaudeCodeAdapter.cs @@ -184,6 +184,7 @@ private static AgentHookOutput FormatBlock(string reason) Hypa is active as a PreToolUse hook. - **Bash**: Commands are rewritten for compressed, token-efficient output. - **Read**: Large code files are redirected through Hypa's smart reader (outline mode). Use `hypa_read` MCP tool for explicit control over read mode (full | outline | signatures | pruned | smart). + - **Read (Markdown)**: Large `.md` files are compressed to a heading outline. Use `hypa md --section ""` to read a specific section, or `hypa md --toc` for the full structure. Run `hypa doctor` to verify setup. diff --git a/src/Hypa.Infrastructure/Skills/Resources/HYPA.md b/src/Hypa.Infrastructure/Skills/Resources/HYPA.md index 64025a2..a4998bb 100644 --- a/src/Hypa.Infrastructure/Skills/Resources/HYPA.md +++ b/src/Hypa.Infrastructure/Skills/Resources/HYPA.md @@ -11,6 +11,33 @@ When calling CLI tools directly, use the Hypa wrappers: When MCP is not available, wrap any other shell command with `hypa -c ""`. +## Code intelligence + +Use `hypa_code` (MCP) or `hypa code` (CLI) to index and query code structure. Indexing +is incremental by default — only changed files are re-parsed. + +```bash +hypa code index # incremental index (default) +hypa code index --full # force complete re-index +hypa code symbols --query Foo # find symbols +hypa code graph --callers # dependency graph +``` + +## Markdown queries + +Large Markdown files read via the `Read` tool are automatically compressed to a heading +outline. To read a specific section, use `hypa md` instead of reading the raw file: + +```bash +hypa md README.md --toc # table of contents +hypa md README.md --toc --depth 2 # limit heading depth +hypa md docs/guide.md --section "Install" # section body by heading path or text +hypa md docs/guide.md --frontmatter # frontmatter YAML +hypa md README.md --json # all output as JSON +``` + +`hypa md` auto-indexes the file if it has changed — no manual `hypa code index` needed. + ## Setup Run `hypa init --global` once to wire hooks and MCP into your agent harness. Run `hypa doctor` to verify installation health. diff --git a/src/Hypa.Infrastructure/Skills/Resources/SKILL.md b/src/Hypa.Infrastructure/Skills/Resources/SKILL.md index 6636a77..9b232db 100644 --- a/src/Hypa.Infrastructure/Skills/Resources/SKILL.md +++ b/src/Hypa.Infrastructure/Skills/Resources/SKILL.md @@ -50,6 +50,61 @@ Exit codes: ``` +## Code Intelligence + +Index and query source code structure. Indexing is incremental by default — only files +that have changed since the last run are re-parsed. + +**Via MCP (`hypa_code` tool):** +| Action | Description | +|--------|-------------| +| `index` | Index the current project (incremental). Mutating — blocked in read-only mode. | +| `symbols` | Query indexed symbols by name, kind, or path. | +| `references` | Syntactic reference candidates for a name. | +| `graph` | Dependency edges: callers, callees, inheritance. | +| `diagnostics` | Parse errors and indexing diagnostics. | + +**Via CLI:** +```bash +hypa code index # incremental index (default) +hypa code index --full # force complete re-index +hypa code index --path src/ # index a specific path +hypa code symbols --query Foo # symbols whose name contains "Foo" +hypa code symbols --kind class # all classes +hypa code graph --callers # what calls this symbol +hypa code diagnostics # list diagnostics +``` + +Supported languages: C#, TypeScript, TSX, JavaScript, JSX, Python, Go, Rust, Java, C, C++, Bash, JSON, YAML, TOML, Markdown. + + +## Markdown Queries + +Large Markdown files read via the `Read` tool are automatically compressed to a heading +outline — the agent sees the structure, not the full content. To read a specific section, +use `hypa md` rather than reading the raw file. + +```bash +hypa md README.md # table of contents (default) +hypa md README.md --toc --depth 2 # limit heading depth +hypa md docs/guide.md --section "Install" # section body by heading path or text +hypa md docs/guide.md --section "Getting Started/Prerequisites" +hypa md docs/guide.md --frontmatter # frontmatter YAML +hypa md README.md --json # all output as JSON +``` + +`hypa md` auto-indexes the file on first use and re-indexes if the file has changed — +no manual `hypa code index` needed before querying markdown. + +| Flag | Default | Description | +|------|---------|-------------| +| `--toc` | implied | Table of contents | +| `--depth ` | 3 | Max heading level for `--toc` | +| `--section ` | — | Section body by heading path or text | +| `--frontmatter` | — | Frontmatter YAML | +| `--json` | — | JSON output | + + ## Advanced / Analytics **Savings:** `hypa filters savings` — estimates token savings for current session From 1533b6795faa56ea1ca1c9c32784854f48ef0be6 Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Mon, 1 Jun 2026 09:23:11 +1000 Subject: [PATCH 07/10] feat(instructions): add hypa_mcp, hypa_read, hypa_search, and MCP server management to agent instructions --- .../Skills/Resources/HYPA.md | 41 ++++++ .../Skills/Resources/SKILL.md | 118 +++++++++++++++--- 2 files changed, 140 insertions(+), 19 deletions(-) diff --git a/src/Hypa.Infrastructure/Skills/Resources/HYPA.md b/src/Hypa.Infrastructure/Skills/Resources/HYPA.md index a4998bb..df06e24 100644 --- a/src/Hypa.Infrastructure/Skills/Resources/HYPA.md +++ b/src/Hypa.Infrastructure/Skills/Resources/HYPA.md @@ -11,6 +11,28 @@ When calling CLI tools directly, use the Hypa wrappers: When MCP is not available, wrap any other shell command with `hypa -c ""`. +## Reading files + +Prefer `hypa_read` over the native Read tool for code files — it returns symbol-aware +outlines that fit more structure in fewer tokens. + +``` +hypa_read(path, mode?) +mode: smart (default) | full | outline | signatures | pruned +``` + +Large Markdown files read via the native Read tool are automatically compressed to a +heading outline. Use `hypa_read` or `hypa md` to get specific section content. + +## Searching + +Use `hypa_search` to find files, symbols, or indexed context: + +``` +hypa_search(query, kind?) +kind: text (default) | regex | symbol +``` + ## Code intelligence Use `hypa_code` (MCP) or `hypa code` (CLI) to index and query code structure. Indexing @@ -38,6 +60,25 @@ hypa md README.md --json # all output as JSON `hypa md` auto-indexes the file if it has changed — no manual `hypa code index` needed. +## MCP proxy + +Use `hypa_mcp` to call tools on any configured upstream MCP server: + +``` +hypa_mcp(action, server?, tool?, arguments?, hint?, requests?, query?) +``` + +| Action | When to use | +|--------|------------| +| `search` | Find which server and tool to use — search by name or description | +| `schema` | Inspect a server's tool schemas before invoking | +| `invoke` | Call a single tool: `server`, `tool`, `arguments` (JSON string) | +| `batch` | Call multiple tools in parallel: `requests` JSON array | +| `auth_check` | Verify a server's credentials before invoking | + +`hypa_mcp(action="search", query="...")` is the recommended starting point when you +don't know which upstream server has the tool you need. + ## Setup Run `hypa init --global` once to wire hooks and MCP into your agent harness. Run `hypa doctor` to verify installation health. diff --git a/src/Hypa.Infrastructure/Skills/Resources/SKILL.md b/src/Hypa.Infrastructure/Skills/Resources/SKILL.md index 9b232db..81b0e8f 100644 --- a/src/Hypa.Infrastructure/Skills/Resources/SKILL.md +++ b/src/Hypa.Infrastructure/Skills/Resources/SKILL.md @@ -35,18 +35,73 @@ Exit codes: - Ask: user confirmation required -## Session + Trust + Filters +## MCP Tools Reference -**Sessions:** `hypa session list`, `hypa session show ` -**Trust:** `hypa trust status`, `hypa trust add ` -**Filters:** `hypa filters list`, `hypa filters add ` +Hypa exposes MCP tools to agents when running as an MCP server (`hypa serve`). -**Custom filter DSL example:** -```json -{ - "name": "my-filter", - "stages": [{"kind": "grep", "pattern": "error|warning"}] -} +### hypa_shell +Run shell commands with compression, rewrite rules, and evidence recording. +``` +hypa_shell(command, cwd?, mode?, timeoutMs?) +mode: omit for compressed output | "raw" for uncompressed +``` + +### hypa_read +Read files in context-aware modes. Prefer over the native Read tool for code files — returns symbol-aware outlines that fit more structure in fewer tokens. +``` +hypa_read(path, mode?, maxTokens?) +mode: smart (default) | full | outline | signatures | pruned +``` +| Mode | Returns | +|------|---------| +| `smart` | Auto-selects best mode for the file type and size | +| `full` | Complete file content (cached) | +| `outline` | Top-level symbols with children | +| `signatures` | Function/method signatures only | +| `pruned` | File with low-signal sections removed | + +### hypa_search +Search files, symbols, and indexed context. +``` +hypa_search(query, scope?, kind?, maxResults?) +scope: project | session | code | docs +kind: text (default) | regex | symbol +``` + +### hypa_code +Code intelligence: index, symbol queries, reference graph, diagnostics. +``` +hypa_code(action, path?, symbol?) +action: index | symbols | references | graph | diagnostics +``` +`index` is mutating — blocked in read-only mode. + +### hypa_mcp +MCP proxy — invoke tools on configured upstream servers, search across server tool schemas, and check auth status. +``` +hypa_mcp(action, server?, tool?, arguments?, hint?, requests?, query?) +action: invoke | batch | schema | search | auth_check +``` +| Action | Description | +|--------|-------------| +| `invoke` | Call a tool on an upstream server. `server` and `tool` required. `arguments` is a JSON object string. `hint`: raw \| summary \| structured | +| `batch` | Call multiple tools in parallel. `requests` is a JSON array of `{server, tool, arguments?, hint?}` objects | +| `schema` | Show tool schemas. Filter by `server` or leave blank for all | +| `search` | Search for tools by name or description. `query` required | +| `auth_check` | Check authentication status for a server | + +### hypa_compress +Compress explicit text. Useful for large tool outputs or logs before storing. +``` +hypa_compress(input, kind?) +kind: shell-output | log | code | generic +``` + +### hypa_session +Inspect and mutate local session state. +``` +hypa_session(action, sessionId?, text?, category?) +action: status | init | attach | checkpoint ``` @@ -55,15 +110,6 @@ Exit codes: Index and query source code structure. Indexing is incremental by default — only files that have changed since the last run are re-parsed. -**Via MCP (`hypa_code` tool):** -| Action | Description | -|--------|-------------| -| `index` | Index the current project (incremental). Mutating — blocked in read-only mode. | -| `symbols` | Query indexed symbols by name, kind, or path. | -| `references` | Syntactic reference candidates for a name. | -| `graph` | Dependency edges: callers, callees, inheritance. | -| `diagnostics` | Parse errors and indexing diagnostics. | - **Via CLI:** ```bash hypa code index # incremental index (default) @@ -105,6 +151,40 @@ no manual `hypa code index` needed before querying markdown. | `--json` | — | JSON output | +## MCP Server Management + +```bash +hypa serve # start MCP stdio server +hypa serve --read-only # disable mutating tools (index, shell writes) +hypa serve --tool hypa_shell # expose only specific tools + +hypa mcp list # list configured upstream servers +hypa mcp add # add an upstream server +hypa mcp import # import servers from Claude/Codex config +hypa mcp tools # list all tools across all servers +hypa mcp schema --server # show tool schemas for a server +hypa mcp search # find tools by name or description +hypa mcp invoke [args] # call a tool directly +hypa mcp auth check --server # check authentication status +hypa mcp auth login --server # initiate OAuth2 login +``` + + +## Session + Trust + Filters + +**Sessions:** `hypa session list`, `hypa session show ` +**Trust:** `hypa trust status`, `hypa trust add ` +**Filters:** `hypa filters list`, `hypa filters add ` + +**Custom filter DSL example:** +```json +{ + "name": "my-filter", + "stages": [{"kind": "grep", "pattern": "error|warning"}] +} +``` + + ## Advanced / Analytics **Savings:** `hypa filters savings` — estimates token savings for current session From 545fbe1ebe42aec5e5ba06614da78c737b88c5b9 Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Mon, 1 Jun 2026 09:47:19 +1000 Subject: [PATCH 08/10] fix(packaging): correct license to FSL-1.1-ALv2 and add READMEs for npm and PyPI - npm/hypa and npm/hypa-platform: MIT -> FSL-1.1-ALv2; add README.md - python/pyproject.toml: MIT -> FSL-1.1-ALv2; point readme at README.md file instead of inline text placeholder - python/README.md: new file with install, quick start, and docs link - release.yml: copy README.md into npm staging dirs before publish so it appears on the npmjs.com package pages --- .github/workflows/release.yml | 2 ++ npm/hypa-platform/README.md | 13 ++++++++++++ npm/hypa-platform/package.json | 2 +- npm/hypa/README.md | 36 ++++++++++++++++++++++++++++++++++ npm/hypa/package.json | 2 +- python/README.md | 36 ++++++++++++++++++++++++++++++++++ python/pyproject.toml | 4 ++-- 7 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 npm/hypa-platform/README.md create mode 100644 npm/hypa/README.md create mode 100644 python/README.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 431048a..bdf20b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -329,6 +329,7 @@ jobs: npm/hypa-platform/package.json > "${STAGE}/package.json" cp npm/hypa-platform/postinstall.js "${STAGE}/postinstall.js" + cp npm/hypa-platform/README.md "${STAGE}/README.md" npx --yes npm@^11 publish "./${STAGE}" --access public done @@ -349,6 +350,7 @@ jobs: npm/hypa/package.json > "${STAGE}/package.json" cp npm/hypa/bin.js "${STAGE}/bin.js" + cp npm/hypa/README.md "${STAGE}/README.md" npx --yes npm@^11 publish "./${STAGE}" --access public diff --git a/npm/hypa-platform/README.md b/npm/hypa-platform/README.md new file mode 100644 index 0000000..6355b93 --- /dev/null +++ b/npm/hypa-platform/README.md @@ -0,0 +1,13 @@ +# @hypabolic/hypa-[platform] + +Platform-specific native binary for [Hypa](https://www.npmjs.com/package/@hypabolic/hypa). + +This package is installed automatically as an optional dependency of `@hypabolic/hypa`. Install the main package instead: + +```bash +npm install -g @hypabolic/hypa +``` + +## License + +[Functional Source License 1.1, ALv2 Future License](https://github.com/Hypabolic/Hypa/blob/main/license.md) diff --git a/npm/hypa-platform/package.json b/npm/hypa-platform/package.json index 4cd6b6e..c739f0a 100644 --- a/npm/hypa-platform/package.json +++ b/npm/hypa-platform/package.json @@ -7,7 +7,7 @@ "url": "git+https://github.com/Hypabolic/Hypa.git", "directory": "npm/hypa-platform" }, - "license": "MIT", + "license": "FSL-1.1-ALv2", "os": [ "PLACEHOLDER_OS" ], diff --git a/npm/hypa/README.md b/npm/hypa/README.md new file mode 100644 index 0000000..91c0318 --- /dev/null +++ b/npm/hypa/README.md @@ -0,0 +1,36 @@ +# Hypa + +Local context runtime for agentic development — compresses shell output, indexes code and Markdown, and proxies upstream MCP servers. + +## Installation + +```bash +npm install -g @hypabolic/hypa +``` + +## Quick start + +```bash +# Compress command output +hypa git status +hypa dotnet build +hypa -c "kubectl get pods -A" + +# Index your codebase +hypa code index + +# Query Markdown structure +hypa md README.md --toc +hypa md docs/guide.md --section "Installation" + +# Wire into your agent harness +hypa init --global +``` + +## Documentation + +[hypabolic.dev/products/hypa/docs](https://hypabolic.dev/products/hypa/docs) + +## License + +[Functional Source License 1.1, ALv2 Future License](https://github.com/Hypabolic/Hypa/blob/main/license.md) diff --git a/npm/hypa/package.json b/npm/hypa/package.json index 792f704..b1b9e60 100644 --- a/npm/hypa/package.json +++ b/npm/hypa/package.json @@ -14,7 +14,7 @@ "url": "git+https://github.com/Hypabolic/Hypa.git", "directory": "npm/hypa" }, - "license": "MIT", + "license": "FSL-1.1-ALv2", "bin": { "hypa": "bin.js" }, diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..c984c6b --- /dev/null +++ b/python/README.md @@ -0,0 +1,36 @@ +# Hypa + +Local context runtime for agentic development — compresses shell output, indexes code and Markdown, and proxies upstream MCP servers. + +## Installation + +```bash +pip install hypa +``` + +## Quick start + +```bash +# Compress command output +hypa git status +hypa dotnet build +hypa -c "kubectl get pods -A" + +# Index your codebase +hypa code index + +# Query Markdown structure +hypa md README.md --toc +hypa md docs/guide.md --section "Installation" + +# Wire into your agent harness +hypa init --global +``` + +## Documentation + +[hypabolic.dev/products/hypa/docs](https://hypabolic.dev/products/hypa/docs) + +## License + +[Functional Source License 1.1, ALv2 Future License](https://github.com/Hypabolic/Hypa/blob/main/license.md) diff --git a/python/pyproject.toml b/python/pyproject.toml index 86cbcfc..bf02a52 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -6,8 +6,8 @@ build-backend = "hatchling.build" name = "hypa" dynamic = ["version"] description = "Local context runtime for agentic development" -readme = { text = "# Hypa\n\nLocal context runtime for agentic development.", content-type = "text/markdown" } -license = { text = "MIT" } +readme = "README.md" +license = { text = "FSL-1.1-ALv2" } requires-python = ">=3.9" [project.urls] From 305b08dff60c8a70ec9da936efa3e0a18f21bc68 Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Mon, 1 Jun 2026 10:18:10 +1000 Subject: [PATCH 09/10] Develop (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added pip and homebrew release workflows * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Matthew Gribben * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Matthew Gribben * added gating to the release workflow * Add CI gate, concurrency control, and REPO_TOKEN for homebrew tap - Gate job verifies all CI checks passed on the tagged commit before any build or publish job runs; skipped for workflow_dispatch - Concurrency group prevents parallel release runs for the same ref - publish-homebrew now uses secrets.REPO_TOKEN for cross-repo push to homebrew-tap Co-Authored-By: Claude Sonnet 4.6 * Sync public source from PR #29 This pull request adds significant architectural changes as well as a GitHub Actions workflow for syncing source and test files to a public repository. **Architectural Decisions:** * Establishes a package boundary between provider-neutral structural search contracts (`Hypa.Sdk`) and the Tree-sitter-based engine implementation (`Hypa.CodePatterns`). This separation enables downstream consumers to use structural search results without taking a dependency on native parsing libraries, and allows for future NuGet publication of the engine. * Details the sequencing and rationale for three major 2026 features: MCP Proxy Layer, code grep (structural search/scan/rewrite), and Code Mode Scripting Engine. We plan phased delivery, risk mitigation, and integration points to maximize compound value and minimize technical risk. **Timeouts** * Some commands would hit the hypa internal timeout limit, mainly package manager, so we've introduced pipeline specific limits and an override command to allow the agent to specify a timeout plus more intelligent feedback when there is an error. **Automation and Workflow:** * Introduced a GitHub Actions workflow (`.github/workflows/sync-public.yml`) that automatically syncs the `src` and `tests` directories to the public `Hypabolic/Hypa` repository on pushes to `main`. The workflow includes PR metadata extraction, payload creation, public repo checkout, file replacement, and commit/push logic with descriptive commit messages. Source repo: matt-gribben/Hypa Source SHA: 25f836cb7f71a4f63dc3423d5a971bf57c713083 PR URL: https://github.com/matt-gribben/Hypa/pull/29 * feat: simplify commit message generation for public repo sync workflow action update * Enhance MCP server configuration, validation, and connection handling This pull request introduces several documentation updates and new documents to clarify workflows, address architectural issues, and plan for future features. The most significant changes include the addition of an ADR for GitHub Copilot Extension integration, detailed documentation for local preview installs, a bug report on `hypa_shell` error handling, and an architecture review highlighting key technical debt and recommendations. There are also minor roadmap clarifications and new usage/setup instructions for Hypa CLI tools. **Key changes:** **1. GitHub Copilot Extension Integration** - Added ADR 0011 describing the design and implementation plan for a thin Node.js ESM adapter to integrate Hypa tools and context with GitHub Copilot CLI sessions, including command rewriting and session lifecycle hooks. The ADR details the rationale, consequences, implementation strategy, and alternatives considered. **2. Local Preview Installs Documentation** - Added a new document and updated the `README.md` to describe the workflow for installing local preview builds of Hypa. This includes commands for installing, restoring, managing, and cleaning preview builds, as well as the effect on update behavior. [[1]](diffhunk://#diff-51a886bc8914e40ea5afcc9459caae38f5fa33b3e6bd25b5fc2e5cbe05f9af5aR1-R38) [[2]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R115-R153) **3. Bug Report: `hypa_shell` Error Handling** - Added a detailed bug report documenting how `hypa_shell` misreports missing executables as working directory errors, including root cause analysis, impact, suggested fixes, and regression test cases. **4. Architecture Review and Technical Debt** - Added an architecture review document identifying several high and medium priority issues, such as dependency rule violations, improper use of application ports, unimplemented ADRs, CLI orchestration bloat, adapter boundary drift, and improper unit test practices. Each issue includes recommendations for resolution. **5. Roadmap and Usage Documentation Updates** - Updated the strategic roadmap to remove week estimates and clarify the sequencing and rationale for technical spikes and MCP proxy layer work. [[1]](diffhunk://#diff-5e4d86614a4a31c4b4fef6106f6de445905aac468f94d0cbd512f57d825d1690L47-R55) [[2]](diffhunk://#diff-5e4d86614a4a31c4b4fef6106f6de445905aac468f94d0cbd512f57d825d1690L77-R77) [[3]](diffhunk://#diff-5e4d86614a4a31c4b4fef6106f6de445905aac468f94d0cbd512f57d825d1690L108-R112) [[4]](diffhunk://#diff-5e4d86614a4a31c4b4fef6106f6de445905aac468f94d0cbd512f57d825d1690R232-R237) - Added a "Hypa Rules" section to `CLAUDE.md` with best practices for using Hypa CLI wrappers and initial setup instructions. * Release: MCP Proxy, Browser OAuth, and Markdown Mode ## Summary Three features developed across this release cycle, plus post-integration fixes and hardening discovered during live testing. ### MCP Proxy Layer A full MCP proxy implementation that lets Hypa act as a man-in-the-middle between agents and external MCP servers — forwarding tool calls, compressing responses, and exposing a unified tool search index. - `McpProxyService` with `DirectMcpDispatcher` for request routing and response compression - `McpTransportBuilder` for transport creation; `McpClientConnectionFactory` refactored to use it - MCP import with bearer token authentication and error handling - `McpServerProbeAdapter` — probes remote servers on `mcp add` to validate connectivity and detect auth requirements; `--no-probe` flag to skip - Tool search index for fast tool lookup across registered servers - Credential resolution with secret redaction in test output ### Browser OAuth Onboarding Automated OAuth flow for MCP servers that require browser-based authentication. - `OAuth2BrowserConfig` and interactive/non-interactive auth modes in `McpTransportBuilder` - Browser launch + local callback listener for authorization code exchange - DCR secret storage, TLS options, state validation, and manual paste fallback for restricted environments - Auth defaults to `none` on `mcp add`; guided setup triggered on probe auth failure ### Markdown Mode Structured Markdown indexing and querying via tree-sitter, with a freshness-aware query gate and read hook compression. - `MarkdownStructureProvider` and `CodePatternExtractor` — extracts sections (heading text, level, anchor, byte spans, plain text) and frontmatter YAML via tree-sitter - `hypa md ` subcommand with `--toc`, `--section`, `--frontmatter`, and `--json` flags - Git-aware incremental indexer (`IndexIncrementalAsync`): clean tracked files compared by blob OID (no file I/O), dirty/untracked files fall back to mtime+size — only stale files are re-parsed - `EnsureFreshAsync` gate on `hypa md` — auto-indexes on first use, re-indexes on change, no-op when fresh - `ReadRedirector` extended to compress large `.md` files to a heading outline; `CLAUDE.md` / `SKILL.md` pass through unchanged - `hypa code index --full` flag for forced full rebuild ### Post-integration fixes (found during live testing) - **`IndexFullAsync` OID regression**: full rebuilds stored `git_blob_oid = NULL`, causing the next incremental run to re-index every file. Fixed by calling `GetCleanBlobOidsAsync` in `IndexFullAsync` and persisting the OID alongside mtime. - **`libtree-sitter-markdown.so` not bundled**: `TreeSitter.DotNet` 1.3.0 doesn't ship the markdown grammar. Fixed by building from source and bundling via `Directory.Build.targets` (RID-aware, copies to output/publish root on all platforms). - **Provider conflict**: once the native library loaded, both `TreeSitterCodeStructureProvider` and `MarkdownStructureProvider` claimed `CanHandle("markdown")`, causing the generic provider to win and extract C# symbols from markdown files. Fixed with an explicit exclusion in `TreeSitterCodeStructureProvider.CanHandle`. - **CI hardening**: grammar build script runs on all matrix runners before the .NET build; AOT publish job verifies the symbol export and asserts `markdown: ok` in provider health after indexing. - **Missing test coverage**: added `MarkdownStructureProviderIntegrationTests` (exercises real tree-sitter native library, asserts sections and provenance) and `CodeStructureProviderRegistryTests` (asserts correct provider selection per language; regression guard ensures no two non-fallback providers claim the same language). ## Test plan - [ ] `dotnet test tests/Hypa.UnitTests` — 1325 tests, all passing - [ ] `dotnet test tests/Hypa.GoldenTests` — all passing - [ ] `hypa mcp add ` probes the server and reports auth requirements - [ ] OAuth browser flow launches, completes exchange, and stores credentials - [ ] `hypa md README.md --toc` on a fresh clone auto-indexes and returns ToC - [ ] Second `hypa md README.md --toc` with no file changes is a no-op (no re-parse) - [ ] `hypa code index` is incremental by default; `--full` re-indexes then subsequent incremental is a no-op - [ ] `hypa code index --json` shows `markdown: ok` in provider health - [ ] Large `.md` files intercepted by the read hook produce a heading outline; `CLAUDE.md` passes through unchanged * feat(native): bundle libtree-sitter-markdown grammar and wire into CI and release pipeline - Add native/runtimes/linux-x64/native/libtree-sitter-markdown.so (pre-built) - Add scripts/build-tree-sitter-markdown.sh/.ps1 to build from source on any platform - Add Directory.Build.targets to copy the RID-appropriate grammar to every project output and publish root, matching how TreeSitter.DotNet flattens its native assets - ci.yml: build grammar before dotnet build on all matrix runners; aot-publish job rebuilds with FORCE_BUILD=1 and verifies symbol before and after publish - release.yml: build grammar (FORCE_BUILD=1) in every platform build job before dotnet publish; verify grammar file is present in publish output before packaging * Enhance agent instructions for Markdown and MCP server management Update src and tests. --------- Signed-off-by: Matthew Gribben Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> From b0a2a0cd313e82026eb765a0d7ffadf082835e22 Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Mon, 1 Jun 2026 12:40:42 +1000 Subject: [PATCH 10/10] Create pi-package-release.yml Signed-off-by: Matthew Gribben --- .github/workflows/pi-package-release.yml | 64 ++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/pi-package-release.yml diff --git a/.github/workflows/pi-package-release.yml b/.github/workflows/pi-package-release.yml new file mode 100644 index 0000000..7dda50f --- /dev/null +++ b/.github/workflows/pi-package-release.yml @@ -0,0 +1,64 @@ +name: Publish Pi Package + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Release tag, for example v0.1.0' + required: true + type: string + +permissions: + contents: read + id-token: write + +concurrency: + group: pi-package-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish-pi-package: + name: Publish @hypabolic/pi-hypa + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Resolve version + id: version + shell: pwsh + run: | + $tag = "${{ github.event.inputs.tag }}" + if ([string]::IsNullOrWhiteSpace($tag)) { $tag = $env:GITHUB_REF_NAME } + if ($tag -notmatch '^v\d+\.\d+\.\d+$') { throw "Tag must be vX.Y.Z. Got '$tag'." } + $version = $tag.Substring(1) + "version=$version" >> $env:GITHUB_OUTPUT + + - uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + + - name: Stamp package version + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + tmp=$(mktemp) + jq --arg version "$VERSION" '.version = $version' packages/pi-hypa/package.json > "$tmp" + mv "$tmp" packages/pi-hypa/package.json + + - name: Validate package + working-directory: packages/pi-hypa + run: | + npm ci + npm run build + npm test + npm pack --dry-run + + - name: Publish package + working-directory: packages/pi-hypa + run: npm publish --access public