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/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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20b4421..bdf20b2 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 @@ -49,7 +85,7 @@ jobs: executable: hypa.exe steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Resolve version id: version @@ -67,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' @@ -75,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 @@ -90,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: | @@ -143,9 +212,10 @@ jobs: publish-sdk: name: Pack & Publish Hypa.Sdk runs-on: ubuntu-latest + needs: [gate] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Resolve version id: version @@ -163,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' @@ -202,7 +272,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Resolve version id: version @@ -215,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' @@ -259,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 @@ -279,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 @@ -288,7 +360,7 @@ jobs: needs: [checksums, publish-sdk, publish-npm] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Resolve tag id: version @@ -328,7 +400,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Resolve version id: version @@ -374,7 +446,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 \ @@ -396,7 +468,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 0000000..0fa05bd Binary files /dev/null and b/native/runtimes/linux-x64/native/libtree-sitter-markdown.so differ 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] 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/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.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/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..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(); @@ -148,12 +158,58 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); 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/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/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/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/Skills/Resources/HYPA.md b/src/Hypa.Infrastructure/Skills/Resources/HYPA.md index 64025a2..df06e24 100644 --- a/src/Hypa.Infrastructure/Skills/Resources/HYPA.md +++ b/src/Hypa.Infrastructure/Skills/Resources/HYPA.md @@ -11,6 +11,74 @@ 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 +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. + +## 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 6636a77..81b0e8f 100644 --- a/src/Hypa.Infrastructure/Skills/Resources/SKILL.md +++ b/src/Hypa.Infrastructure/Skills/Resources/SKILL.md @@ -35,6 +35,141 @@ Exit codes: - Ask: user confirmation required +## MCP Tools Reference + +Hypa exposes MCP tools to agents when running as an MCP server (`hypa serve`). + +### 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 +``` + + +## 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 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 | + + +## 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 ` @@ -49,7 +184,7 @@ Exit codes: } ``` - + ## Advanced / Analytics **Savings:** `hypa filters savings` — estimates token savings for current session 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.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/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.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/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/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/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/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/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.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/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/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.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/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.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/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/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/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/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/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/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/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/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/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/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/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] 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/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); 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)); + } +}