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