From e596996d34fa873661d36e1a0494b948d12f05c2 Mon Sep 17 00:00:00 2001 From: yumakakuya Date: Sat, 13 Jun 2026 11:34:50 +0900 Subject: [PATCH] [PM][P1-pre] sync sanitized acquisition readiness tree --- .dockerignore | 16 + .github/workflows/mcphub-ci.yml | 56 +- .github/workflows/release.yml | 91 +++ .gitignore | 4 +- .gitleaks.toml | 8 + .gitleaksignore | 5 + Dockerfile | 61 ++ Makefile | 162 ++++ README.md | 253 +++--- adapters/edit/index.ts | 762 +++++++++++++++++- adapters/nexus/index.ts | 269 +++++++ adapters/package-lock.json | 15 + adapters/package.json | 10 +- adapters/project/index.ts | 372 ++++++++- adapters/session/index.ts | 5 + adapters/tsconfig.json | 2 +- adapters/web/index.ts | 45 +- cmd/mcphub/main.go | 16 + cmd/mcphub/main_test.go | 40 + docs/guide/api-reference.md | 583 ++++++++++++++ docs/guide/benchmark-report.md | 78 ++ docs/guide/context-reduction-report.md | 95 +++ docs/guide/getting-started.md | 142 ++++ docs/guide/policy-guide.md | 239 ++++++ docs/guide/relay-setup.md | 263 ++++++ install-remote.sh | 234 ++++++ install.sh | 69 +- integration/vt_test.go | 752 ++++++++++++++++- .../sorted/mcphub/CapabilityGapExplainer.java | 156 ++++ .../dev/sorted/mcphub/CapabilityRegistry.java | 15 +- .../dev/sorted/mcphub/ControlHandler.java | 200 +++-- .../dev/sorted/mcphub/DashboardServer.java | 523 ++++++++++++ .../dev/sorted/mcphub/DatabaseManager.java | 24 +- .../java/dev/sorted/mcphub/DryRunService.java | 91 +++ .../src/main/java/dev/sorted/mcphub/Main.java | 391 ++++++++- .../java/dev/sorted/mcphub/McpHandler.java | 545 ++++++++----- .../java/dev/sorted/mcphub/PolicyEngine.java | 35 +- .../sorted/mcphub/ProviderHealthTracker.java | 15 +- .../dev/sorted/mcphub/ProviderManager.java | 93 ++- .../java/dev/sorted/mcphub/ResultCache.java | 127 +++ .../java/dev/sorted/mcphub/StateMachine.java | 5 +- .../java/dev/sorted/mcphub/StdioBridge.java | 26 +- .../dev/sorted/mcphub/TaskContextFilter.java | 159 ++++ java/src/main/resources/capabilities.yaml | 279 ++++++- java/src/main/resources/dashboard.html | 286 +++++++ java/src/main/resources/relays-example.yaml | 66 +- .../sorted/mcphub/CapabilityRegistryTest.java | 11 +- .../dev/sorted/mcphub/ControlHandlerTest.java | 239 ++---- .../sorted/mcphub/DatabaseManagerTest.java | 6 +- .../dev/sorted/mcphub/McpHandlerTest.java | 446 ++++------ .../dev/sorted/mcphub/PolicyEngineTest.java | 20 - .../mcphub/ProviderHealthTrackerTest.java | 8 + .../sorted/mcphub/ProviderManagerTest.java | 18 +- .../dev/sorted/mcphub/SecretScannerTest.java | 16 +- .../dev/sorted/mcphub/StateMachineTest.java | 3 +- .../dev/sorted/mcphub/StdioBridgeTest.java | 22 + mcphub-bridge.sh | 3 + packaging/launchd/dev.sorted.mcphub.plist | 12 +- packaging/systemd/mcphub.service | 7 +- scripts/release-build.sh | 104 +++ 60 files changed, 7499 insertions(+), 1099 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/release.yml create mode 100644 .gitleaks.toml create mode 100644 .gitleaksignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 adapters/nexus/index.ts create mode 100644 cmd/mcphub/main_test.go create mode 100644 docs/guide/api-reference.md create mode 100644 docs/guide/benchmark-report.md create mode 100644 docs/guide/context-reduction-report.md create mode 100644 docs/guide/getting-started.md create mode 100644 docs/guide/policy-guide.md create mode 100644 docs/guide/relay-setup.md create mode 100644 install-remote.sh create mode 100644 java/src/main/java/dev/sorted/mcphub/CapabilityGapExplainer.java create mode 100644 java/src/main/java/dev/sorted/mcphub/DashboardServer.java create mode 100644 java/src/main/java/dev/sorted/mcphub/DryRunService.java create mode 100644 java/src/main/java/dev/sorted/mcphub/ResultCache.java create mode 100644 java/src/main/java/dev/sorted/mcphub/TaskContextFilter.java create mode 100644 java/src/main/resources/dashboard.html create mode 100755 mcphub-bridge.sh create mode 100755 scripts/release-build.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b417cc4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +git-backup-* +docs +briefs +*.md +!README.md +java/build +java/.gradle +adapters/node_modules +adapters/dist +dist +*.db +*.sqlite +*.log +.env* diff --git a/.github/workflows/mcphub-ci.yml b/.github/workflows/mcphub-ci.yml index e711114..9e2b29f 100644 --- a/.github/workflows/mcphub-ci.yml +++ b/.github/workflows/mcphub-ci.yml @@ -1,4 +1,4 @@ -name: MCPHUB CI +name: MCPHUB Internal CI on: pull_request: @@ -10,53 +10,15 @@ permissions: contents: read jobs: - java-test: - name: Java Tests (Gradle) + validate: + name: Validate Docs runs-on: ubuntu-latest - defaults: - run: - working-directory: java steps: - uses: actions/checkout@v4 - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - - - name: Cache Gradle packages - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('java/**/*.gradle*', 'java/gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Compile Java - run: ./gradlew compileJava - - - name: Run Java tests - run: ./gradlew test - - go-build: - name: Go Build & Vet - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.25' - - - name: Go build - run: go build ./... - - - name: Go vet - run: go vet ./... + - name: Check required docs exist + run: | + for f in CLAUDE.md docs/ lessons.md; do + [ -e "$f" ] || { echo "MISSING: $f"; exit 1; } + done + echo "All required docs present." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d5b8096 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Release tag to build (for example: v0.2.1-relpipe-test)' + required: true + type: string + product_name: + description: 'Product/archive binary name' + required: false + default: mcphub + type: string + prerelease: + description: 'Mark the GitHub Release as a prerelease' + required: false + default: true + type: boolean + +permissions: + contents: write + +jobs: + release: + name: Build and publish release assets + runs-on: ubuntu-latest + env: + PRODUCT_NAME: ${{ inputs.product_name || 'mcphub' }} + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + steps: + - name: Checkout release source + uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_TAG }} + fetch-depth: 0 + + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: adapters/package-lock.json + + - name: Derive release version + id: release_meta + run: echo "version=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT" + + - name: Build release archives + run: make release VERSION="${{ steps.release_meta.outputs.version }}" PRODUCT_NAME="$PRODUCT_NAME" + + - name: Verify release artifact manifest + run: | + set -euo pipefail + cd dist/release + expected=( + "${PRODUCT_NAME}-${{ steps.release_meta.outputs.version }}-linux-amd64.tar.gz" + "${PRODUCT_NAME}-${{ steps.release_meta.outputs.version }}-darwin-amd64.tar.gz" + "${PRODUCT_NAME}-${{ steps.release_meta.outputs.version }}-darwin-arm64.tar.gz" + "sha256sums.txt" + ) + for file in "${expected[@]}"; do + test -f "$file" || { echo "missing release asset: $file"; exit 1; } + done + actual_tar_count=$(ls "${PRODUCT_NAME}-${{ steps.release_meta.outputs.version }}"-*.tar.gz | wc -l | tr -d ' ') + test "$actual_tar_count" = "3" || { echo "expected 3 tar.gz assets, got $actual_tar_count"; exit 1; } + sha256sum -c sha256sums.txt + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.RELEASE_TAG }} + name: ${{ env.RELEASE_TAG }} + prerelease: ${{ inputs.prerelease || contains(env.RELEASE_TAG, '-') }} + files: | + dist/release/${{ env.PRODUCT_NAME }}-${{ steps.release_meta.outputs.version }}-*.tar.gz + dist/release/sha256sums.txt diff --git a/.gitignore b/.gitignore index 2293bb4..1f8043e 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,6 @@ credentials* *.sqlite3 # Logs -*.log \ No newline at end of file +*.log +# Git backup directories +git-backup-*/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..d1462b8 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,8 @@ +title = "MCPHUB curated export gitleaks allowlist" + +[allowlist] +description = "Synthetic test fixtures documented as non-real credentials." +paths = [ + '''java/src/test/java/dev/sorted/mcphub/SecretScannerTest\.java''', + '''java/src/test/java/dev/sorted/mcphub/McpHandlerTest\.java''', +] diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..c8f860a --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,5 @@ +# P1-pre curated export fixture allowlist notes. +# SecretScannerTest.java and McpHandlerTest.java contain synthetic test fixtures +# documented as non-real credentials. Current gitleaks directory-scan +# fingerprints include absolute output paths, so deterministic fixture +# allowlisting is enforced in .gitleaks.toml below. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..25b14fc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +# MCPHUB Docker Image — BL-07 +# Multi-stage build: build everything, then create minimal runtime image. +# +# Build: docker build -t mcphub . +# Run: docker run --rm -it -v /tmp/mcphub:/data mcphub +# Bridge: docker run --rm -i mcphub bridge + +# ────────────────────────────────────────────────── +# Stage 1: Build +# ────────────────────────────────────────────────── +FROM eclipse-temurin:21-jdk AS java-build +WORKDIR /build/java +COPY java/ . +RUN chmod +x gradlew && ./gradlew jar --no-daemon + +FROM golang:1.25-bookworm AS go-build +WORKDIR /build +COPY go.mod ./ +COPY cmd/ cmd/ +RUN go build -o mcphub ./cmd/mcphub + +FROM node:20-slim AS ts-build +WORKDIR /build/adapters +COPY adapters/package.json adapters/package-lock.json* ./ +RUN npm install --silent +COPY adapters/ . +RUN npx tsc --build + +# ────────────────────────────────────────────────── +# Stage 2: Runtime +# ────────────────────────────────────────────────── +FROM eclipse-temurin:21-jre-noble + +RUN apt-get update && apt-get install -y --no-install-recommends \ + nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd -m -s /bin/bash mcphub + +# Distribution layout: bin/ lib/ adapters/ +WORKDIR /opt/mcphub + +COPY --from=go-build /build/mcphub bin/mcphub +COPY --from=java-build /build/java/build/libs/mcphub-core-*.jar lib/mcphub-core.jar +COPY --from=ts-build /build/adapters/dist/ adapters/ +COPY README.md LICENSE ./ + +RUN chmod +x bin/mcphub && chown -R mcphub:mcphub /opt/mcphub + +USER mcphub + +# Data directory +RUN mkdir -p /home/mcphub/.local/share/mcphub + +ENV PATH="/opt/mcphub/bin:${PATH}" +ENV MCPHUB_DATA_DIR="/home/mcphub/.local/share/mcphub" +ENV MCPHUB_ADAPTER_DIR="/opt/mcphub/adapters" + +ENTRYPOINT ["mcphub"] +CMD ["_daemon"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a87ec8a --- /dev/null +++ b/Makefile @@ -0,0 +1,162 @@ +# MCPHUB Makefile — BL-05: One-command build for Java + Go + TS +# Usage: +# make — build all (jar + binary + adapters) +# make test — run all tests +# make install — build + install to ~/.local +# make clean — remove all build artifacts +# make dev — build + start daemon in foreground +# make status — check daemon health + +# ────────────────────────────────────────────────── +# Configuration +# ────────────────────────────────────────────────── + +REPO_ROOT := $(shell pwd) +GO_BIN := $(REPO_ROOT)/mcphub +JAR_DIR := $(REPO_ROOT)/java/build/libs +JAR_NAME := mcphub-core.jar +GRADLEW := $(REPO_ROOT)/java/gradlew +ADAPTER_DIR := $(REPO_ROOT)/adapters +PRODUCT_NAME ?= mcphub +VERSION ?= + +# Install paths (override with PREFIX=...) +PREFIX ?= $(HOME)/.local/share/mcphub +BIN_PREFIX ?= $(HOME)/.local/bin + +.PHONY: all jar go-build adapters test test-java test-go clean install dev status stop release curated-export help + +# ────────────────────────────────────────────────── +# Default target +# ────────────────────────────────────────────────── + +all: jar go-build adapters + @echo "" + @echo "Build complete." + @echo " JAR: java/build/libs/$(JAR_NAME)" + @echo " Binary: ./mcphub" + @echo " Adapters: adapters/dist/" + @echo "" + @echo "Run 'make install' to install, or './mcphub start' to run." + +# ────────────────────────────────────────────────── +# Java (Gradle fat-JAR) +# ────────────────────────────────────────────────── + +jar: + @echo "==> Building Java fat-JAR..." + cd java && ./gradlew jar --quiet + @# Rename versioned JAR to stable name for Go launcher + @JAR=$$(find $(JAR_DIR) -name "mcphub-core-*.jar" ! -name "*plain*" | head -1); \ + if [ -n "$$JAR" ] && [ "$$(basename $$JAR)" != "$(JAR_NAME)" ]; then \ + cp "$$JAR" "$(JAR_DIR)/$(JAR_NAME)"; \ + fi + @echo " $(JAR_DIR)/$(JAR_NAME)" + +# ────────────────────────────────────────────────── +# Go (JRE launcher binary) +# ────────────────────────────────────────────────── + +go-build: + @echo "==> Building Go launcher..." + go build -o $(GO_BIN) ./cmd/mcphub + @echo " $(GO_BIN)" + +# ────────────────────────────────────────────────── +# TypeScript adapters +# ────────────────────────────────────────────────── + +adapters: $(ADAPTER_DIR)/node_modules + @echo "==> Building TypeScript adapters..." + cd $(ADAPTER_DIR) && npx tsc --build + @echo " $(ADAPTER_DIR)/dist/" + +$(ADAPTER_DIR)/node_modules: + @echo "==> Installing adapter dependencies..." + cd $(ADAPTER_DIR) && npm install --silent + +# ────────────────────────────────────────────────── +# Tests +# ────────────────────────────────────────────────── + +test: test-java test-go + @echo "" + @echo "All tests passed." + +test-java: + @echo "==> Running Java tests..." + cd java && ./gradlew test --quiet + +test-go: + @echo "==> Running Go tests..." + go test ./... + +# ────────────────────────────────────────────────── +# Install +# ────────────────────────────────────────────────── + +install: all + @echo "==> Installing MCPHUB..." + ./install.sh --prefix $(PREFIX) + +# ────────────────────────────────────────────────── +# Development +# ────────────────────────────────────────────────── + +dev: all + @echo "==> Starting MCPHUB (foreground)..." + $(GO_BIN) start --no-daemon + +status: + @$(GO_BIN) health 2>/dev/null || echo "mcphub: not running" + +stop: + @$(GO_BIN) stop 2>/dev/null || echo "mcphub: not running" + +# ────────────────────────────────────────────────── +# Release +# ────────────────────────────────────────────────── + +release: + @echo "==> Building release archives..." + PRODUCT_NAME=$(PRODUCT_NAME) ./scripts/release-build.sh $(VERSION) + +curated-export: + @echo "==> Building curated export..." + ./scripts/curated-export.sh + +# ────────────────────────────────────────────────── +# Clean +# ────────────────────────────────────────────────── + +clean: + @echo "==> Cleaning build artifacts..." + rm -f $(GO_BIN) + cd java && ./gradlew clean --quiet 2>/dev/null || true + rm -rf $(ADAPTER_DIR)/dist + rm -rf dist/release + @echo " Done." + +# ────────────────────────────────────────────────── +# Help +# ────────────────────────────────────────────────── + +help: + @echo "MCPHUB Build System" + @echo "" + @echo "Targets:" + @echo " make Build all (jar + binary + adapters)" + @echo " make test Run all tests (Java + Go)" + @echo " make jar Build Java fat-JAR only" + @echo " make go-build Build Go launcher only" + @echo " make adapters Build TypeScript adapters only" + @echo " make install Build + install to ~/.local" + @echo " make dev Build + run daemon (foreground)" + @echo " make status Check daemon health" + @echo " make stop Stop daemon" + @echo " make release Build release archives (all platforms)" + @echo " make curated-export Build and verify curated export tree" + @echo " make clean Remove all build artifacts" + @echo " make help Show this message" + @echo "" + @echo "Prerequisites: Java 21+, Go 1.25+, Node 20+" diff --git a/README.md b/README.md index c81249b..57932af 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Helm. +# MCPHUB A tool hub and governance layer for AI coding agents. One MCP endpoint, many providers, less context waste. @@ -11,18 +11,18 @@ AI coding agents load ALL tool schemas into every API request. As MCP servers gr - **Deferred loading latency**: ToolSearch workarounds add 3-8 seconds per tool use - **Cost waste**: Tool schemas burn tokens every turn, regardless of whether tools are used -This is a known problem in the OpenCode ecosystem: [#9350](https://github.com/anomalyco/opencode/issues/9350), [#8625](https://github.com/anomalyco/opencode/issues/8625), [#16206](https://github.com/anomalyco/opencode/issues/16206), [#20489](https://github.com/anomalyco/opencode/issues/20489). +This is a known problem in the OpenCode ecosystem: [#9350](https://github.com/anomalyco/opencode/issues/9350) (85% token reduction request), [#8625](https://github.com/anomalyco/opencode/issues/8625), [#16206](https://github.com/anomalyco/opencode/issues/16206), [#20489](https://github.com/anomalyco/opencode/issues/20489). -## What Helm Does +## What MCPHUB Does -Helm sits between your AI agent and your tool providers. Instead of loading every tool schema into every API call, the agent connects to one MCP endpoint and Helm routes requests to the right provider. +MCPHUB sits between your AI agent and your tool providers. Instead of loading every tool schema into every API call, the agent connects to one MCP endpoint and MCPHUB routes requests to the right provider. ``` AI Agent (OpenCode / Claude Code / Cursor) | | stdio (single MCP connection) v -+-- Helm. -----------------------------------------+ ++-- MCPHUB ----------------------------------------+ | | | Go Launcher Java Daemon | | (JRE discovery, (state machine, policy, | @@ -34,11 +34,11 @@ AI Agent (OpenCode / Claude Code / Cursor) | | | +--------|------------------------------------------+ | - +---------+---------+---------+---------+-----------+ - | web | edit | project | session | synthetic | <-- TS adapters - +---------+---------+---------+---------+-----------+ - | relay providers (your existing MCP servers) | - +---------------------------------------------------+ + +---------+---------+---------+---------+ + | web | edit | project | session | <-- TypeScript adapters + +---------+---------+---------+---------+ + | relay providers (Coffer, custom MCP) | + +--------------------------------------+ ``` Core capabilities: @@ -46,65 +46,133 @@ Core capabilities: - **Single MCP endpoint** aggregating multiple tool providers - **Classifier-invisible path** for tools (avoids Anthropic API body-size limits) - **Session lifecycle** (Closed / Armed / Open / CoolingDown) with auto-close -- **Allow/deny policy** per tool, with session-scoped rule injection -- **Structured failure responses** with error codes, recovery guidance, and fallback suggestions -- **Route logging** for observability, with intent annotation support -- **Contract-based disambiguation** (AI asks the hub which tool to use; hub resolves from capability contracts) +- **Allow/deny policy** per tool +- **Route logging** for observability +- **Disambiguation endpoint** (AI asks the hub which tool to use) - **Body-budget monitoring** to detect regressions early -- **Secret scrub** on intent annotations before persistence +- **Result caching** for read-only tools (session-scoped, eliminates duplicate calls) +- **Dry-run mode** (preview destructive tool effects before execution) +- **Task-context filtering** (show only relevant tools per task: coding/research/planning) +- **Capability-gap explanation** (structured feedback when tools are denied or missing) +- **LSP support** (hover, go-to-definition, references for TypeScript and Go) ## Quick Start -### Prerequisites +### Option 1: Prebuilt Binary (no build tools needed) -- Java 21+ -- Go 1.21+ -- Node.js 18+ -- Linux or macOS (Windows via WSL) +Download the latest release for your platform from the release page of the repository you are installing from: -### Build +```bash +# Linux (amd64) +curl -L https://github.com///releases/latest/download/mcphub-linux-amd64.tar.gz | tar xz +cd mcphub + +# macOS (Apple Silicon) +curl -L https://github.com///releases/latest/download/mcphub-darwin-arm64.tar.gz | tar xz +cd mcphub +``` + +Requires: Java 21+ runtime, Node.js 18+ + +### Option 2: Docker (zero dependencies) ```bash -git clone https://github.com/YumaKakuya/helm.git -cd helm +docker run --rm -it ghcr.io// +``` -# Build Go launcher -go build -o helm ./cmd/mcphub +### Option 3: Build from source -# Build Java daemon -cd java && ./gradlew jar && cd .. +Prerequisites: Java 21+, Go 1.25+, Node.js 20+, Linux or macOS (Windows via WSL) -# Build TypeScript adapters -cd adapters && npm install && npx tsc && cd .. +```bash +git clone https://github.com//.git +cd +make +``` + +This builds all three components (Java fat-JAR, Go launcher, TypeScript adapters) in one command. + +Other useful targets: + +```bash +make test # Run all tests (Java + Go) +make install # Build + install to ~/.local +make release # Build release archives (all platforms) +make clean # Remove all build artifacts +make dev # Build + run daemon (foreground) +make help # Show all targets ``` ### Configure your AI agent -Add Helm as an MCP server in your agent's config. Example for OpenCode (`opencode.jsonc`): +Add MCPHUB as an MCP server in your agent's config. The shell wrapper handles daemon startup, session opening, and bridge connection automatically. + +Replace `/path/to/MCPHUB` with the actual path to your MCPHUB directory. + +#### OpenCode / Hatch (`opencode.jsonc`) ```jsonc { "mcp": { - "helm": { + "mcphub": { "type": "local", - "command": ["sh", "-c", "cd /path/to/helm && (./helm health >/dev/null 2>&1 || (./helm _daemon /dev/null 2>/dev/null & sleep 3)) && ./helm open >/dev/null 2>&1 && exec ./helm bridge"], + "command": ["sh", "-c", "cd /path/to/MCPHUB && (./mcphub health >/dev/null 2>&1 || (./mcphub _daemon /dev/null 2>/dev/null & sleep 3)) && ./mcphub open >/dev/null 2>&1 && exec ./mcphub bridge"], "enabled": true } } } ``` +#### Claude Code (`~/.claude.json`) + +```json +{ + "mcpServers": { + "mcphub": { + "command": "sh", + "args": ["-c", "cd /path/to/MCPHUB && (./mcphub health >/dev/null 2>&1 || (./mcphub _daemon /dev/null 2>/dev/null & sleep 3)) && ./mcphub open >/dev/null 2>&1 && exec ./mcphub bridge"] + } + } +} +``` + +#### Cursor (MCP Settings) + +In Cursor Settings > MCP, add a new server: + +```json +{ + "mcpServers": { + "mcphub": { + "command": "sh", + "args": ["-c", "cd /path/to/MCPHUB && (./mcphub health >/dev/null 2>&1 || (./mcphub _daemon /dev/null 2>/dev/null & sleep 3)) && ./mcphub open >/dev/null 2>&1 && exec ./mcphub bridge"] + } + } +} +``` + ### Verify ```bash -./helm start -./helm health # {"status":"ok","state":"CLOSED"} -./helm open # {"state":"OPEN","session_id":"..."} -./helm status +# Start daemon +./mcphub start + +# Check health +./mcphub health +# {"status":"ok","state":"CLOSED"} + +# Open session +./mcphub open +# {"state":"OPEN","session_id":"..."} + +# Check status +./mcphub status ``` ### Web search setup (optional) +MCPHUB includes a websearch tool powered by Brave Search API: + ```bash # Get a free API key at https://api-dashboard.search.brave.com/ mkdir -p ~/.config/mcphub @@ -114,93 +182,92 @@ chmod 600 ~/.config/mcphub/brave-api-key ## What It Solves -| Problem | Status | -|---------|--------| -| Tool body-size API errors | Resolved | -| ToolSearch deferred loading (3-8s) | Eliminated | -| Tool exposure governance | Working | -| Route logging and observability | Working | -| Multi-MCP-server aggregation | Working | -| Disambiguation | Working (contract-based resolution) | -| Body-budget monitoring | Working | -| Intent annotation | Working (schema-documented, route-logged, secret-scrubbed) | -| Session-scoped policy rules | Working | -| Structured failure recovery | Working (error codes, fallback guidance, next actions) | +| Problem | Status | Details | +|---------|--------|---------| +| Tool body-size API errors | Resolved | Tools on classifier-invisible MCP path | +| ToolSearch deferred loading (3-8s) | Eliminated | Direct tool access, no roundtrip | +| Tool exposure governance | Working | Allow/deny policy, session lifecycle | +| Route logging | Working | Every tool call logged with routing decision | +| Multi-MCP-server aggregation | Working | Single endpoint for all providers | +| Disambiguation | Working | AI can ask which tool is appropriate | +| Body-budget monitoring | Working | Alerts before regressions become visible | ## What It Does Not Solve Yet -| Item | Status | -|------|--------| -| Task-context filtering | Planned | -| Result caching | Planned | -| Dry-run mode | Planned | -| Management UI | Not started | -| Full LSP support | Alpha stub | +| Item | Status | Notes | +|------|--------|-------| +| Management UI | Not started | CLI only for now | ## Hosted Tools -| Adapter | Tools | Type | -|---------|-------|------| -| web | `webfetch`, `websearch` | builtin | -| edit | `apply_patch` | builtin | -| project | `todowrite`, `list`, `codesearch`, `lsp` | builtin | -| session | `plan_enter`, `plan_exit`, `skill`, `batch` | builtin | - -## Relay Providers - -Relay providers are external MCP servers that Helm connects to via subprocess stdio. Any MCP server that speaks JSON-RPC over stdio can be added as a relay provider. - -To configure relay providers: +MCPHUB includes adapters for commonly needed tools, plus hub-level tools: -1. Copy the example config: - ```bash - mkdir -p ~/.config/mcphub - cp java/src/main/resources/relays-example.yaml ~/.config/mcphub/relays.yaml - ``` +| Adapter | Tools | +|---------|-------| +| web | `webfetch`, `websearch` | +| edit | `apply_patch` | +| project | `todowrite`, `list`, `codesearch`, `lsp`, `task_create`, `task_list`, `task_update`, `task_delete` | +| nexus | `nexus_issue_create`, `nexus_issue_list`, `nexus_issue_close`, `nexus_issue_update` | +| session | `plan_enter`, `plan_exit`, `skill`, `batch` | +| hub | `mcphub_disambiguate`, `mcphub_set_task_context`, `mcphub.session.open`, `mcphub_checkpoint` | -2. Edit `~/.config/mcphub/relays.yaml` and add your relay entries. Each relay defines a subprocess command and the tools it exposes. +When the session is CLOSED or ARMED, only `mcphub.session.open` is exposed in the tool list. If the client/model cannot call that MCP tool directly, run `mcphub open` from the CLI. -3. Alternatively, set a custom path via environment variable: - ```bash - export MCPHUB_RELAYS_PATH=/path/to/custom-relays.yaml - ``` +## Relay Providers -Relay tools are loaded dynamically at startup and appear alongside built-in tools in `tools/list`. +MCPHUB can relay requests to external MCP servers. Any MCP-compatible server can be added as a relay provider, aggregated behind the single MCPHUB endpoint. ## Performance +Measured overhead (excluding upstream provider execution time): + | Metric | Value | Target | |--------|-------|--------| | p50 latency | 1ms | <50ms | | p99 latency | 3ms | <100ms | -## CLI +## CLI Reference ``` -helm start Start the daemon -helm stop Stop the daemon -helm health Liveness check -helm status Current state and session info -helm open Open a session (idempotent) -helm close Close the current session -helm lock Emergency lock -helm capabilities List registered tools -helm bridge Run stdio bridge (used by AI agents) +mcphub start Start the daemon +mcphub stop Stop the daemon +mcphub health Liveness check +mcphub status Current state, session info, uptime +mcphub open Arm + open a session (idempotent) +mcphub close Close the current session +mcphub lock Emergency lock (rejects all tool calls) +mcphub unlock Clear emergency lock +mcphub capabilities List registered tools and their status +mcphub bridge Run stdio bridge (used by AI agents) +mcphub version Print version +mcphub query --sql Read-only SQL against mcphub.db ``` -The daemon also exposes a control surface for runtime policy management: +## Documentation + +- [Getting Started](docs/guide/getting-started.md) -- 5-minute onboarding +- [Relay Provider Setup](docs/guide/relay-setup.md) -- Connect external MCP servers +- [Policy Configuration](docs/guide/policy-guide.md) -- Allow/deny/hide rules +- [API Reference](docs/guide/api-reference.md) -- Full control surface and MCP endpoints +- [Benchmark Report](docs/guide/benchmark-report.md) -- Performance measurements +- [Context Reduction Report](docs/guide/context-reduction-report.md) -- Token savings analysis + +## Project Structure ``` -mcphub.control.add_session_rule Add a session-scoped allow/deny/hide rule +cmd/mcphub/ Go launcher (JRE discovery + process management) +java/ Java daemon (state machine, policy, routing, registry) +adapters/ TypeScript tool adapters (web, edit, project, session) +integration/ Integration tests +packaging/ systemd / launchd service templates +install.sh Install script ``` -Session rules are automatically purged when the session closes. - ## Project Status **Alpha (daily driver since 2026-04-19)** -Built by [Sorted.](https://github.com/YumaKakuya) for AI multi-agent orchestration. +Built by [Sorted.](https://github.com/OWNER) as part of the AXIOM product line for AI multi-agent orchestration. ## License diff --git a/adapters/edit/index.ts b/adapters/edit/index.ts index dcd692a..51cb11e 100644 --- a/adapters/edit/index.ts +++ b/adapters/edit/index.ts @@ -6,28 +6,655 @@ import * as child_process from 'child_process'; type NextAction = 'retry' | 'use_alternative' | 'disambiguate' | 'abort' | 'wait_session' | 'call_mcphub_session_open'; -/** Check whether a patch uses non-git-style headers that will fail with patch -p1. */ -function hasNonGitStyleHeaders(patchContent: string): boolean { +type PatchFormat = 'begin-patch' | 'git-unified' | 'plain-unified' | 'mixed-unified' | 'unknown'; + +function formatPatchMessage(message: string, nextAction: NextAction): string { + return `[apply_patch] ${message} | next_action: ${nextAction}`; +} + +/** + * Detect the patch format. + * - 'begin-patch': starts with or contains '*** Begin Patch' + * - 'git-unified': unified diff with git-style headers only (--- a/, +++ b/) + * - 'plain-unified': unified diff with plain headers only (--- path, +++ path) + * - 'mixed-unified': unified diff mixing git-style AND plain headers (ambiguous strip level) + * - 'unknown': none of the above + */ +function detectPatchFormat(patchContent: string): PatchFormat { + if (patchContent.includes('*** Begin Patch')) { + return 'begin-patch'; + } + // Look for diff headers + let hasGitStyle = false; + let hasPlainStyle = false; for (const line of patchContent.split('\n')) { if (line.startsWith('--- ')) { const p = line.slice(4).trim(); - if (p !== '/dev/null' && !p.startsWith('a/')) return true; + if (p === '/dev/null' || p.startsWith('a/')) { + hasGitStyle = true; + } else { + hasPlainStyle = true; + } } if (line.startsWith('+++ ')) { const p = line.slice(4).trim(); - if (p !== '/dev/null' && !p.startsWith('b/')) return true; + if (p === '/dev/null' || p.startsWith('b/')) { + hasGitStyle = true; + } else { + hasPlainStyle = true; + } } } - return false; + if (hasGitStyle && hasPlainStyle) return 'mixed-unified'; + if (hasGitStyle) return 'git-unified'; + if (hasPlainStyle) return 'plain-unified'; + return 'unknown'; } -function formatPatchMessage(message: string, nextAction: NextAction): string { - return `[apply_patch] ${message} | next_action: ${nextAction}`; +/** + * Check whether any path segment in filePath is exactly '..'. + * This rejects '../escape' and 'a/../b' while allowing 'version..txt', 'foo..bar'. + * Splits on both '/' and '\' to be cross-platform safe. + */ +function hasParentTraversalSegment(filePath: string): boolean { + return filePath.split(/[\\/]/).some(seg => seg === '..'); +} + +/** + * Validate that a path is safe: no absolute paths, no .. segments, resolves inside cwd. + * Returns an error string if unsafe, null if safe. + */ +function validateSafePath(filePath: string, cwd: string): string | null { + if (path.isAbsolute(filePath)) { + return `Path "${filePath}" is absolute. Only relative paths are allowed.`; + } + if (hasParentTraversalSegment(filePath)) { + return `Path "${filePath}" contains ".." traversal segment. This is not allowed.`; + } + const resolved = path.resolve(cwd, filePath); + const cwdResolved = path.resolve(cwd); + if (!resolved.startsWith(cwdResolved + path.sep) && resolved !== cwdResolved) { + return `Path "${filePath}" resolves outside the working directory.`; + } + return null; // safe +} + +/** + * Validate paths embedded in unified diff headers before invoking the patch binary. + * Scans '--- ' and '+++ ' header lines, skips /dev/null. + * For git-unified (-p1), strips the first path component (a/ or b/ prefix). + * For plain-unified (-p0), uses the path as-is. + * Returns an error string if any header path is unsafe, null if all are safe. + */ +function validateUnifiedDiffPaths(patchContent: string, format: PatchFormat, cwd: string): string | null { + for (const line of patchContent.split('\n')) { + let rawPath: string | null = null; + if (line.startsWith('--- ')) { + rawPath = line.slice(4).split('\t')[0].trim(); // strip optional tab+timestamp + } else if (line.startsWith('+++ ')) { + rawPath = line.slice(4).split('\t')[0].trim(); + } + if (rawPath === null) continue; + if (rawPath === '/dev/null' || rawPath === '') continue; + + let checkPath = rawPath; + if (format === 'git-unified') { + // Strip first component: 'a/foo/bar' → 'foo/bar', 'b/foo/bar' → 'foo/bar' + const slashIdx = rawPath.indexOf('/'); + if (slashIdx !== -1) { + checkPath = rawPath.slice(slashIdx + 1); + } else { + // No slash — after stripping there's nothing; skip (e.g. bare 'a') + continue; + } + } + + if (checkPath === '' || checkPath === '/dev/null') continue; + + const err = validateSafePath(checkPath, cwd); + if (err) { + return `Unsafe path in patch header: ${err}`; + } + } + return null; +} + +/** Begin Patch operation */ +interface BeginPatchOp { + type: 'add' | 'update' | 'delete'; + filePath: string; + hunks: BeginPatchHunk[]; +} + +interface BeginPatchHunk { + /** Lines in the hunk: prefix ' ' = context, '-' = remove, '+' = add */ + lines: Array<{ prefix: ' ' | '-' | '+'; content: string }>; +} + +interface FileSnapshot { + absPath: string; + existed: boolean; + content?: Buffer; +} + +interface RollbackResult { + ok: boolean; + restored: string[]; + removed: string[]; + errors: string[]; +} + +interface PlannedBeginPatchWrite { + filePath: string; + absPath: string; + action: 'write' | 'delete'; + content?: string; +} + +function uniquePaths(paths: string[]): string[] { + return Array.from(new Set(paths)); +} + +function normalizeUnifiedDiffPath(rawPath: string, format: PatchFormat): string | null { + if (rawPath === '/dev/null' || rawPath === '') return null; + if (format === 'git-unified') { + const slashIdx = rawPath.indexOf('/'); + if (slashIdx === -1) return null; + return rawPath.slice(slashIdx + 1); + } + return rawPath; +} + +function collectUnifiedDiffTargetPaths(patchContent: string, format: PatchFormat, cwd: string): string[] | string { + const paths: string[] = []; + for (const line of patchContent.split('\n')) { + if (!line.startsWith('--- ') && !line.startsWith('+++ ')) continue; + const rawPath = line.slice(4).split('\t')[0].trim(); + const normalized = normalizeUnifiedDiffPath(rawPath, format); + if (!normalized) continue; + const err = validateSafePath(normalized, cwd); + if (err) return `Unsafe path in patch header: ${err}`; + paths.push(path.resolve(cwd, normalized)); + } + return uniquePaths(paths); +} + +function patchArtifactPaths(targetPaths: string[]): string[] { + const artifacts: string[] = []; + for (const targetPath of targetPaths) { + artifacts.push(`${targetPath}.orig`, `${targetPath}.rej`); + } + return uniquePaths(artifacts); +} + +function validateNoSymlinkSegments(absPath: string, cwd: string): string | null { + const cwdResolved = path.resolve(cwd); + const relative = path.relative(cwdResolved, absPath); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + return `Path resolves outside the working directory: ${absPath}`; + } + let current = cwdResolved; + for (const segment of relative.split(path.sep)) { + if (!segment) continue; + current = path.join(current, segment); + if (!fs.existsSync(current)) break; + const stat = fs.lstatSync(current); + if (stat.isSymbolicLink()) { + return `Path contains symlink segment: ${current}. Symlink patch targets and artifacts are not allowed.`; + } + } + return null; +} + +function createFileSnapshots(absPaths: string[], cwd: string): FileSnapshot[] | string { + const snapshots: FileSnapshot[] = []; + for (const absPath of uniquePaths(absPaths)) { + try { + const symlinkErr = validateNoSymlinkSegments(absPath, cwd); + if (symlinkErr) return symlinkErr; + if (fs.existsSync(absPath)) { + const stat = fs.lstatSync(absPath); + if (!stat.isFile()) { + return `Cannot snapshot non-file path: ${absPath}`; + } + snapshots.push({ absPath, existed: true, content: fs.readFileSync(absPath) }); + } else { + snapshots.push({ absPath, existed: false }); + } + } catch (err) { + return `Cannot snapshot ${absPath}: ${err instanceof Error ? err.message : String(err)}`; + } + } + return snapshots; +} + +function restoreFileSnapshots(snapshots: FileSnapshot[], cwd: string): RollbackResult { + const result: RollbackResult = { ok: true, restored: [], removed: [], errors: [] }; + for (const snapshot of snapshots) { + try { + const symlinkErr = validateNoSymlinkSegments(snapshot.absPath, cwd); + if (symlinkErr) { + result.ok = false; + result.errors.push(`${snapshot.absPath}: ${symlinkErr}`); + continue; + } + if (snapshot.existed) { + fs.mkdirSync(path.dirname(snapshot.absPath), { recursive: true }); + fs.writeFileSync(snapshot.absPath, snapshot.content ?? Buffer.alloc(0)); + result.restored.push(snapshot.absPath); + } else if (fs.existsSync(snapshot.absPath)) { + fs.rmSync(snapshot.absPath, { force: true }); + result.removed.push(snapshot.absPath); + } + } catch (err) { + result.ok = false; + result.errors.push(`${snapshot.absPath}: ${err instanceof Error ? err.message : String(err)}`); + } + } + return result; +} + +function appendRollbackGuidance(message: string, rollback: RollbackResult): string { + const rollbackStatus = rollback.ok ? 'rollback: restored pre-apply state' : `rollback: partial failure (${rollback.errors.join('; ')})`; + const artifactStatus = rollback.removed.length > 0 + ? `artifacts_removed: ${rollback.removed.filter(p => p.endsWith('.orig') || p.endsWith('.rej')).length}` + : 'artifacts_removed: 0'; + return `${message} | ${rollbackStatus} | ${artifactStatus} | verify: run git status --short and inspect intended target files; confirm no .rej/.orig artifacts remain | issue_guidance: create or update a Nexus issue if rollback is partial or diagnostics are unclear`; +} + +function atomicNoWriteGuidance(message: string): string { + return `${message} | rollback: not_needed_no_files_modified | verify: run git status --short if uncertain | issue_guidance: create or update a Nexus issue if diagnostics are unclear`; +} + +/** + * Parse a Begin Patch format string into structured operations. + * Returns an error string on parse failure, or an array of ops on success. + * + * Rules: + * - Must have '*** Begin Patch' and '*** End Patch' markers (required). + * - Supports '*** Add File:', '*** Update File:', '*** Delete File:' directives. + * - For Add File: accepts @@ hunks OR bare +/space lines (no @@ header needed). + * - Top-level non-empty, non-directive, non-blank content → malformed error. + * - Blank lines at top level are harmless. + */ +function parseBeginPatch(patchContent: string): BeginPatchOp[] | string { + const lines = patchContent.split('\n'); + const ops: BeginPatchOp[] = []; + + // Find the Begin Patch marker + let i = 0; + while (i < lines.length && !lines[i].startsWith('*** Begin Patch')) { + i++; + } + if (i >= lines.length) { + return 'No "*** Begin Patch" marker found.'; + } + i++; // skip the Begin Patch line + + let sawEnd = false; + + while (i < lines.length) { + const line = lines[i]; + + if (line.startsWith('*** End Patch')) { + sawEnd = true; + break; + } + + // Blank lines at top level are harmless + if (line.trim() === '') { + i++; + continue; + } + + let opType: 'add' | 'update' | 'delete' | null = null; + let filePath = ''; + + if (line.startsWith('*** Add File: ')) { + opType = 'add'; + filePath = line.slice('*** Add File: '.length).trim(); + } else if (line.startsWith('*** Update File: ')) { + opType = 'update'; + filePath = line.slice('*** Update File: '.length).trim(); + } else if (line.startsWith('*** Delete File: ')) { + opType = 'delete'; + filePath = line.slice('*** Delete File: '.length).trim(); + } else { + // Non-empty, non-blank, non-directive top-level content → malformed + return `Unexpected content at top level (expected "*** Add/Update/Delete File:" or "*** End Patch"): "${line}"`; + } + + if (!filePath) { + return `Empty file path in directive: "${line}"`; + } + + i++; + const hunks: BeginPatchHunk[] = []; + + if (opType === 'add') { + // For Add File: accept @@ hunks OR bare content lines (+, space) with no @@ header. + // Both styles are collected and merged into a single content stream. + const bareLines: BeginPatchHunk['lines'] = []; + while (i < lines.length) { + const l = lines[i]; + if (l.startsWith('*** ')) break; + + if (l.startsWith('@@')) { + // @@ header — collect subsequent content lines into this hunk + i++; + const hunkLines: BeginPatchHunk['lines'] = []; + while (i < lines.length) { + const hl = lines[i]; + if (hl.startsWith('@@') || hl.startsWith('*** ')) break; + if (hl.startsWith('+')) { + hunkLines.push({ prefix: '+', content: hl.slice(1) }); + } else if (hl.startsWith('-')) { + hunkLines.push({ prefix: '-', content: hl.slice(1) }); + } else if (hl.startsWith(' ')) { + hunkLines.push({ prefix: ' ', content: hl.slice(1) }); + } else if (hl === '') { + hunkLines.push({ prefix: ' ', content: '' }); + } else { + hunkLines.push({ prefix: ' ', content: hl }); + } + i++; + } + if (hunkLines.length > 0) { + hunks.push({ lines: hunkLines }); + } + } else if (l.startsWith('+') || l.startsWith(' ') || l.startsWith('-')) { + // Bare content line (no @@ header) — collect into bareLines + if (l.startsWith('+')) { + bareLines.push({ prefix: '+', content: l.slice(1) }); + } else if (l.startsWith('-')) { + bareLines.push({ prefix: '-', content: l.slice(1) }); + } else { + bareLines.push({ prefix: ' ', content: l.slice(1) }); + } + i++; + } else if (l === '') { + // Blank line inside Add File body — treat as bare content (empty line) + bareLines.push({ prefix: '+', content: '' }); + i++; + } else { + // Non-content, non-directive line inside Add File body — unexpected + return `Unexpected line in Add File body: "${l}"`; + } + } + // If we have bare lines, wrap them as a single synthetic hunk + if (bareLines.length > 0) { + hunks.push({ lines: bareLines }); + } + } else { + // Update File / Delete File: parse @@ hunks only + while (i < lines.length) { + const l = lines[i]; + if (l.startsWith('*** ')) break; + + if (l.startsWith('@@')) { + i++; + const hunkLines: BeginPatchHunk['lines'] = []; + while (i < lines.length) { + const hl = lines[i]; + if (hl.startsWith('@@') || hl.startsWith('*** ')) break; + if (hl.startsWith('+')) { + hunkLines.push({ prefix: '+', content: hl.slice(1) }); + } else if (hl.startsWith('-')) { + hunkLines.push({ prefix: '-', content: hl.slice(1) }); + } else if (hl.startsWith(' ')) { + hunkLines.push({ prefix: ' ', content: hl.slice(1) }); + } else if (hl === '') { + hunkLines.push({ prefix: ' ', content: '' }); + } else { + hunkLines.push({ prefix: ' ', content: hl }); + } + i++; + } + if (hunkLines.length > 0) { + hunks.push({ lines: hunkLines }); + } + } else if (l.trim() === '') { + // Blank lines between hunks are harmless + i++; + } else { + // Non-blank, non-hunk line inside Update/Delete body + return `Unexpected line in ${opType === 'update' ? 'Update' : 'Delete'} File body (expected @@ hunk or blank): "${l}"`; + } + } + } + + ops.push({ type: opType, filePath, hunks }); + } + + // Finding 3: End Patch marker is required + if (!sawEnd) { + return 'Missing "*** End Patch" marker. Begin Patch block is incomplete.'; + } + + if (ops.length === 0) { + return 'No file operations found in Begin Patch block.'; + } + + return ops; +} + +/** + * Apply a single hunk to file content lines using context matching. + * Returns the new lines on success, or an error string. + * + * Pure insertion hunks (no context or removal lines) are rejected: they provide no + * anchor for placement and would silently append to EOF, which is almost never correct + * for Update File. Add context lines (space-prefixed) to pin the insertion location. + */ +function applyHunk(contentLines: string[], hunk: BeginPatchHunk): string[] | string { + // Build old-side pattern (context + removes) + const oldPattern = hunk.lines + .filter(l => l.prefix === ' ' || l.prefix === '-') + .map(l => l.content); + + if (oldPattern.length === 0) { + return 'Pure insertion hunk has no context or removal lines. ' + + 'Provide at least one surrounding context line (space-prefixed) to anchor the insertion. ' + + 'Re-generate the patch with context lines.'; + } + + // Search for the old pattern in contentLines + const matchAt = (startIdx: number): boolean => { + if (startIdx + oldPattern.length > contentLines.length) return false; + for (let j = 0; j < oldPattern.length; j++) { + if (contentLines[startIdx + j] !== oldPattern[j]) return false; + } + return true; + }; + + let matchIndex = -1; + let matchCount = 0; + for (let i = 0; i <= contentLines.length - oldPattern.length; i++) { + if (matchAt(i)) { + matchIndex = i; + matchCount++; + } + } + + if (matchCount === 0) { + return `Context not found in file. Expected lines:\n${oldPattern.map(l => ' ' + l).join('\n')}`; + } + if (matchCount > 1) { + return `Context is ambiguous — found ${matchCount} matches. Provide more surrounding context lines to identify the correct location.`; + } + + // Build replacement lines (context + adds, no removes) + const replacementLines = hunk.lines + .filter(l => l.prefix === ' ' || l.prefix === '+') + .map(l => l.content); + + return [ + ...contentLines.slice(0, matchIndex), + ...replacementLines, + ...contentLines.slice(matchIndex + oldPattern.length), + ]; +} + +function beginPatchContentLines(rawContent: string): string[] { + const trailingNewline = rawContent.endsWith('\n'); + let contentLines = rawContent.split('\n'); + if (trailingNewline && contentLines[contentLines.length - 1] === '') { + contentLines = contentLines.slice(0, -1); + } + return contentLines; +} + +function beginPatchLinesToContent(lines: string[]): string { + return lines.join('\n') + '\n'; +} + +function planBeginPatch(parsed: BeginPatchOp[], cwd: string): { writes: PlannedBeginPatchWrite[]; results: string[] } | string { + const states = new Map(); + const results: string[] = []; + + const stateFor = (filePath: string): { filePath: string; absPath: string; exists: boolean; lines: string[] } | string => { + const pathErr = validateSafePath(filePath, cwd); + if (pathErr) return `Path safety error: ${pathErr}`; + + const absPath = path.resolve(cwd, filePath); + const symlinkErr = validateNoSymlinkSegments(absPath, cwd); + if (symlinkErr) return symlinkErr; + const existing = states.get(absPath); + if (existing) return existing; + + const exists = fs.existsSync(absPath); + let lines: string[] = []; + if (exists) { + const stat = fs.lstatSync(absPath); + if (!stat.isFile()) return `Path is not a file: "${filePath}"`; + lines = beginPatchContentLines(fs.readFileSync(absPath, 'utf8')); + } + const state = { filePath, absPath, exists, lines }; + states.set(absPath, state); + return state; + }; + + for (const op of parsed) { + const state = stateFor(op.filePath); + if (typeof state === 'string') return state; + + if (op.type === 'add') { + if (state.exists) { + return `Add File failed: "${op.filePath}" already exists. Use "Update File" to modify it.`; + } + const newLines: string[] = []; + for (const hunk of op.hunks) { + for (const l of hunk.lines) { + if (l.prefix === '+') newLines.push(l.content); + } + } + state.exists = true; + state.lines = newLines; + results.push(`Added: ${op.filePath}`); + + } else if (op.type === 'delete') { + if (!state.exists) { + return `Delete File failed: "${op.filePath}" does not exist.`; + } + state.exists = false; + state.lines = []; + results.push(`Deleted: ${op.filePath}`); + + } else if (op.type === 'update') { + if (op.hunks.length === 0) { + return `Update File "${op.filePath}" has no hunks — no changes to apply. Add @@ hunk content or use Delete File to remove.`; + } + if (!state.exists) { + return `Update File failed: "${op.filePath}" does not exist. Use "Add File" to create it.`; + } + let contentLines = [...state.lines]; + for (const hunk of op.hunks) { + const applied = applyHunk(contentLines, hunk); + if (typeof applied === 'string') { + return `Hunk failed for "${op.filePath}": ${applied}`; + } + contentLines = applied; + } + state.lines = contentLines; + results.push(`Updated: ${op.filePath}`); + } + } + + const writes: PlannedBeginPatchWrite[] = []; + for (const state of states.values()) { + writes.push({ + filePath: state.filePath, + absPath: state.absPath, + action: state.exists ? 'write' : 'delete', + content: state.exists ? beginPatchLinesToContent(state.lines) : undefined, + }); + } + return { writes, results }; +} + +function commitBeginPatchPlan(plan: { writes: PlannedBeginPatchWrite[]; results: string[] }, cwd: string): { ok: true } | { ok: false; text: string } { + const snapshots = createFileSnapshots(plan.writes.map(w => w.absPath), cwd); + if (typeof snapshots === 'string') { + return { ok: false, text: formatPatchMessage(atomicNoWriteGuidance(`Begin Patch preflight snapshot error: ${snapshots}`), 'abort') }; + } + + try { + for (const write of plan.writes) { + const symlinkErr = validateNoSymlinkSegments(write.absPath, cwd); + if (symlinkErr) throw new Error(symlinkErr); + if (write.action === 'write') { + fs.mkdirSync(path.dirname(write.absPath), { recursive: true }); + fs.writeFileSync(write.absPath, write.content ?? '', 'utf8'); + } else { + fs.rmSync(write.absPath, { force: true }); + } + } + return { ok: true }; + } catch (err) { + const rollback = restoreFileSnapshots(snapshots, cwd); + return { + ok: false, + text: appendRollbackGuidance( + formatPatchMessage(`Begin Patch commit error: ${err instanceof Error ? err.message : String(err)}`, 'retry'), + rollback + ), + }; + } +} + +/** + * Apply Begin Patch format to the given cwd. + * Returns a result object with success text or error info. + */ +function applyBeginPatch(patchContent: string, cwd: string): { content: { type: string; text: string }[]; isError?: boolean } { + const parsed = parseBeginPatch(patchContent); + if (typeof parsed === 'string') { + return { + content: [{ type: 'text', text: formatPatchMessage(`Begin Patch parse error: ${parsed}`, 'abort') }], + isError: true, + }; + } + + const plan = planBeginPatch(parsed, cwd); + if (typeof plan === 'string') { + return { + content: [{ type: 'text', text: formatPatchMessage(atomicNoWriteGuidance(plan), plan.includes('Hunk failed') ? 'retry' : 'abort') }], + isError: true, + }; + } + + const commitResult = commitBeginPatchPlan(plan, cwd); + if (!commitResult.ok) { + return { content: [{ type: 'text', text: commitResult.text }], isError: true }; + } + + const summary = plan.results.join('\n'); + return { content: [{ type: 'text', text: `[apply_patch] Begin Patch applied successfully.\n${summary}` }] }; } /** Classify patch failure output into AI-actionable diagnostic text. */ function classifyPatchFailure( - patchContent: string, + format: PatchFormat, spawnErr: Error | null, stdout: string, stderr: string @@ -51,26 +678,18 @@ function classifyPatchFailure( return formatPatchMessage(`patch spawn error: ${msg}`, 'retry'); } - if (hasNonGitStyleHeaders(patchContent)) { - return formatPatchMessage( - "Patch uses plain diff headers (e.g. '--- file' instead of '--- a/file'). " + - "The adapter applies patches with 'patch -p1' from supplied cwd - git-style 'a/' and 'b/' path prefixes are required. " + - "Re-generate the patch with 'git diff', 'git format-patch', or manually prefix paths with 'a/' and 'b/'.", - 'abort' - ); - } - if (output.includes("can't find file to patch")) { + const strip = format === 'plain-unified' ? '-p0' : '-p1'; return formatPatchMessage( - "Cannot find file to patch. Verify the target file exists relative to the working directory (cwd), " + - "or adjust the 'cwd' parameter. Ensure git-style headers ('--- a/path', '+++ b/path') are used.", + `Cannot find file to patch (applied with ${strip}). Verify the target file exists relative to the working directory (cwd), ` + + "or adjust the 'cwd' parameter. Ensure path prefixes match the strip level.", 'disambiguate' ); } if (output.includes('File to patch:') || output.includes('Skip this patch?') || output.includes('Ignore this patch?')) { return formatPatchMessage( - 'Patch required interactive input. The file referenced in the patch may not exist, or the strip level (-p1) is wrong for the header format. Check paths relative to cwd and use git-style headers.', + 'Patch required interactive input. The file referenced in the patch may not exist, or the strip level is wrong for the header format. Check paths relative to cwd.', 'disambiguate' ); } @@ -110,12 +729,31 @@ function classifyPatchFailure( const TOOLS = [ { name: 'apply_patch', - description: 'Apply a unified diff patch to files in the workspace. Modifies local file state.', + description: [ + 'Apply a patch to files in the workspace. Modifies local file state.', + '', + 'Supported formats (auto-detected):', + '1. Git-style unified diff (preferred): headers "--- a/path" / "+++ b/path", applied with patch -p1.', + '2. Plain unified diff: headers "--- path" / "+++ path" (no a/ b/ prefix), applied with patch -p0.', + '3. OpenAI/GPT-style Begin Patch: starts with "*** Begin Patch", supports', + ' "*** Add File: path", "*** Update File: path", "*** Delete File: path" with @@ hunks.', + ' Add File also accepts bare + lines without an @@ header.', + ].join('\n'), inputSchema: { type: 'object', properties: { - patch: { type: 'string', description: 'Unified diff patch content' }, - cwd: { type: 'string', description: 'Working directory (optional, defaults to current)' } + patch: { + type: 'string', + description: [ + 'Patch content. Supported formats:', + '• Git-style unified diff (preferred): "--- a/path" / "+++ b/path" headers. Generate with git diff or git format-patch.', + '• Plain unified diff: "--- path" / "+++ path" headers (no a/ b/ prefix).', + '• OpenAI/GPT Begin Patch: starts with "*** Begin Patch", must end with "*** End Patch".', + ' Uses "*** Add/Update/Delete File: path" directives with @@ hunks.', + ' Add File also accepts bare + lines (no @@ header).', + ].join('\n'), + }, + cwd: { type: 'string', description: 'Working directory to apply patch in (optional, defaults to current)' } }, required: ['patch'] } @@ -127,15 +765,87 @@ async function dispatch(name: string, args: Record): Promise<{ const patch = args['patch'] as string; const cwd = (args['cwd'] as string) || process.cwd(); if (!patch) throw new Error('patch is required'); - // Write patch to temp file and apply. + + const format = detectPatchFormat(patch); + + // Route Begin Patch to our own parser/applicator + if (format === 'begin-patch') { + return applyBeginPatch(patch, cwd); + } + + // Mixed git-style and plain unified diff headers — ambiguous strip level, must reject + if (format === 'mixed-unified') { + return { + content: [{ + type: 'text', + text: formatPatchMessage( + 'Patch contains mixed unified diff header styles: some files use git-style headers ' + + '("--- a/path" / "+++ b/path") and others use plain headers ("--- path" / "+++ path"). ' + + 'These require different strip levels (-p1 vs -p0) and cannot be applied together. ' + + 'Re-generate the patch in one consistent format: use git diff or git format-patch for ' + + 'git-style headers, or diff -u for plain headers.', + 'abort' + ) + }], + isError: true, + }; + } + + // Unknown format — return a structured, AI-actionable error + if (format === 'unknown') { + return { + content: [{ + type: 'text', + text: formatPatchMessage( + 'Unrecognized patch format. Supported formats: ' + + '(1) git-style unified diff with "--- a/path" / "+++ b/path" headers (preferred), ' + + '(2) plain unified diff with "--- path" / "+++ path" headers, ' + + '(3) OpenAI/GPT "*** Begin Patch" format. ' + + 'Re-generate the patch in one of these formats.', + 'abort' + ) + }], + isError: true, + }; + } + + // Finding 5: Preflight path safety check for unified diffs before invoking patch binary + const pathSafetyErr = validateUnifiedDiffPaths(patch, format, cwd); + if (pathSafetyErr) { + return { + content: [{ type: 'text', text: formatPatchMessage(pathSafetyErr, 'abort') }], + isError: true, + }; + } + + // Unified diff (git-style or plain) — use the patch binary + const stripLevel = format === 'plain-unified' ? '-p0' : '-p1'; + const targetPaths = collectUnifiedDiffTargetPaths(patch, format, cwd); + if (typeof targetPaths === 'string') { + return { + content: [{ type: 'text', text: formatPatchMessage(atomicNoWriteGuidance(targetPaths), 'abort') }], + isError: true, + }; + } + const snapshots = createFileSnapshots([...targetPaths, ...patchArtifactPaths(targetPaths)], cwd); + if (typeof snapshots === 'string') { + return { + content: [{ type: 'text', text: formatPatchMessage(atomicNoWriteGuidance(`Patch preflight snapshot error: ${snapshots}`), 'abort') }], + isError: true, + }; + } const tmpFile = path.join(os.tmpdir(), `mcphub_patch_${Date.now()}.patch`); fs.writeFileSync(tmpFile, patch, 'utf8'); try { // -f forces non-interactive behavior so the adapter returns diagnostics instead of waiting for input. - const result = child_process.spawnSync('patch', ['-p1', '-f', '--input', tmpFile], { cwd, encoding: 'utf8', timeout: 30000 }); + const result = child_process.spawnSync('patch', [stripLevel, '-f', '--input', tmpFile], { cwd, encoding: 'utf8', timeout: 30000 }); fs.unlinkSync(tmpFile); if (result.error || result.status !== 0) { - const text = classifyPatchFailure(patch, result.error ?? null, result.stdout ?? '', result.stderr ?? ''); + const rollback = restoreFileSnapshots(snapshots, cwd); + const text = appendRollbackGuidance( + classifyPatchFailure(format, result.error ?? null, result.stdout ?? '', result.stderr ?? ''), + rollback + ); return { content: [{ type: 'text', text }], isError: true }; } return { content: [{ type: 'text', text: result.stdout || 'Patch applied successfully.' }] }; diff --git a/adapters/nexus/index.ts b/adapters/nexus/index.ts new file mode 100644 index 0000000..e364404 --- /dev/null +++ b/adapters/nexus/index.ts @@ -0,0 +1,269 @@ +import * as readline from 'readline'; +import * as fs from 'fs'; +import * as path from 'path'; + +const NEXUS_ISSUE_BASE = process.env['MCPHUB_NEXUS_BASE'] || path.join(process.env['HOME'] || '/tmp', 'nexus', 'issue'); + +const TOOLS = [ + { + name: 'nexus_issue_create', + description: 'Create a new issue in the Nexus issue tracker under a project. Returns the created file path. Modifies local state.', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string', description: 'Project name (e.g. mcphub, hatch, axis)' }, + title: { type: 'string', description: 'Issue title (used as filename slug)' }, + body: { type: 'string', description: 'Markdown body of the issue' }, + priority: { type: 'string', enum: ['HIGH', 'MEDIUM', 'LOW', 'INFO'], default: 'MEDIUM', description: 'Issue priority' } + }, + required: ['project', 'title', 'body'] + } + }, + { + name: 'nexus_issue_list', + description: 'List open issues for a project in the Nexus issue tracker. Read-only.', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string', description: 'Project name (e.g. mcphub, hatch, axis)' } + }, + required: ['project'] + } + }, + { + name: 'nexus_issue_close', + description: 'Close an issue by moving it to the closed/ subdirectory with an optional resolution note. Modifies local state.', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string', description: 'Project name' }, + file: { type: 'string', description: 'Issue filename (from nexus_issue_list)' }, + resolution: { type: 'string', description: 'Optional resolution note to append before closing' } + }, + required: ['project', 'file'] + } + }, + { + name: 'nexus_issue_update', + description: 'Append a status update or additional evidence to an existing issue. Modifies local state.', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string', description: 'Project name' }, + file: { type: 'string', description: 'Issue filename (from nexus_issue_list)' }, + addition: { type: 'string', description: 'Text to append to the issue' } + }, + required: ['project', 'file', 'addition'] + } + } +]; + +// --- Security: path traversal prevention (LESSON-002) --- + +function sanitizePath(base: string, ...segments: string[]): string { + const resolved = path.resolve(base, ...segments); + if (!resolved.startsWith(base + path.sep) && resolved !== base) { + throw new Error(`Path traversal detected: resolved path escapes base directory`); + } + return resolved; +} + +function slugify(title: string): string { + return title + .replace(/[^a-zA-Z0-9\-_]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') + .toUpperCase() + .slice(0, 80); +} + +function ensureDir(dir: string): void { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function projectDir(project: string): string { + return sanitizePath(NEXUS_ISSUE_BASE, project); +} + +function issuePath(project: string, file: string): string { + return sanitizePath(NEXUS_ISSUE_BASE, project, file); +} + +// --- JSON-RPC --- + +const out = process.stdout; +const rl = readline.createInterface({ input: process.stdin }); + +function respond(id: unknown, result: unknown): void { + out.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n'); +} + +function respondError(id: unknown, code: number, message: string): void { + out.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n'); +} + +// --- Tool dispatch --- + +type ToolResult = { content: { type: string; text: string }[]; isError?: boolean }; + +rl.on('line', async (line: string) => { + let req: { id: unknown; method: string; params?: unknown }; + try { + req = JSON.parse(line); + } catch { + return; // skip non-JSON + } + + const { id, method, params } = req; + if (method === 'tools/list') { + respond(id, { tools: TOOLS }); + return; + } + + if (method !== 'tools/call') return; + const p = params as { name?: string; arguments?: Record } | undefined; + try { + respond(id, await dispatch(p?.name ?? '', p?.arguments ?? {})); + } catch (err) { + respondError(id, -32603, err instanceof Error ? err.message : String(err)); + } +}); + +async function dispatch(name: string, args: Record): Promise { + switch (name) { + case 'nexus_issue_create': return handleCreate(args); + case 'nexus_issue_list': return handleList(args); + case 'nexus_issue_close': return handleClose(args); + case 'nexus_issue_update': return handleUpdate(args); + default: throw new Error(`Unknown tool: ${name}`); + } +} + +// --- Implementations --- + +async function handleCreate(args: Record): Promise { + const project = (args['project'] as string)?.trim(); + const title = (args['title'] as string)?.trim(); + const body = (args['body'] as string)?.trim(); + const priority = (args['priority'] as string) || 'MEDIUM'; + + if (!project || !title || !body) { + throw new Error('project, title, and body are required'); + } + + // Validate priority enum + if (!['HIGH', 'MEDIUM', 'LOW', 'INFO'].includes(priority)) { + throw new Error(`Invalid priority: ${priority}. Must be HIGH, MEDIUM, LOW, or INFO.`); + } + + const dir = projectDir(project); + ensureDir(dir); + + const today = new Date().toISOString().slice(0, 10); + const filename = `ISSUE_${slugify(title)}.md`; + const filePath = path.join(dir, filename); + + // Verify no path traversal in filename + sanitizePath(dir, filename); + + const content = `# ${title} +# Project: ${project} +# Priority: ${priority} +# Created: ${today} +# Status: OPEN + +--- + +${body} + +--- + +*Nexus Issue | ${project} | ${today}* +`; + + fs.writeFileSync(filePath, content); + return { content: [{ type: 'text', text: JSON.stringify({ created: filePath, project, filename }) }] }; +} + +async function handleList(args: Record): Promise { + const project = (args['project'] as string)?.trim(); + if (!project) throw new Error('project is required'); + + const dir = projectDir(project); + if (!fs.existsSync(dir)) { + return { content: [{ type: 'text', text: JSON.stringify({ project, issues: [], closed_count: 0 }) }] }; + } + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const openIssues: { file: string; size: number; mtime: string }[] = []; + let closedCount = 0; + + for (const entry of entries) { + if (entry.name === 'closed' && entry.isDirectory()) { + closedCount = fs.readdirSync(path.join(dir, 'closed')).filter(f => f.endsWith('.md')).length; + continue; + } + if (entry.isFile() && entry.name.endsWith('.md')) { + const stat = fs.statSync(path.join(dir, entry.name)); + openIssues.push({ file: entry.name, size: stat.size, mtime: stat.mtime.toISOString() }); + } + } + + return { content: [{ type: 'text', text: JSON.stringify({ project, issues: openIssues, closed_count: closedCount }) }] }; +} + +async function handleClose(args: Record): Promise { + const project = (args['project'] as string)?.trim(); + const file = (args['file'] as string)?.trim(); + const resolution = (args['resolution'] as string)?.trim(); + + if (!project || !file) throw new Error('project and file are required'); + + const srcPath = issuePath(project, file); + if (!fs.existsSync(srcPath)) { + throw new Error(`Issue not found: ${file}`); + } + + // Read existing content + let content = fs.readFileSync(srcPath, 'utf-8'); + + // Append resolution note if provided + if (resolution) { + content += `\n\n### Resolution (${new Date().toISOString().slice(0, 10)})\n\n${resolution}\n`; + } + + // Update status + const today = new Date().toISOString().slice(0, 10); + content = content.replace(/# Status: OPEN/, `# Status: CLOSED — ${today}`); + content += `\n\n*Closed: ${today}*\n`; + + // Move to closed/ + const closedDir = path.join(projectDir(project), 'closed'); + ensureDir(closedDir); + const destPath = path.join(closedDir, file); + fs.writeFileSync(destPath, content); + fs.unlinkSync(srcPath); + + return { content: [{ type: 'text', text: JSON.stringify({ closed: file, moved_to: destPath }) }] }; +} + +async function handleUpdate(args: Record): Promise { + const project = (args['project'] as string)?.trim(); + const file = (args['file'] as string)?.trim(); + const addition = (args['addition'] as string)?.trim(); + + if (!project || !file || !addition) throw new Error('project, file, and addition are required'); + + const filePath = issuePath(project, file); + if (!fs.existsSync(filePath)) { + throw new Error(`Issue not found: ${file}`); + } + + const now = new Date().toISOString().slice(0, 19).replace('T', ' '); + const append = `\n\n### Update ${now}\n\n${addition}\n`; + fs.appendFileSync(filePath, append); + + return { content: [{ type: 'text', text: JSON.stringify({ updated: file, timestamp: now }) }] }; +} \ No newline at end of file diff --git a/adapters/package-lock.json b/adapters/package-lock.json index ed9d149..2ff706a 100644 --- a/adapters/package-lock.json +++ b/adapters/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "mcphub-adapters", "version": "0.1.0", + "dependencies": { + "typescript-language-server": "^5.1.3" + }, "devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.4.0" @@ -36,6 +39,18 @@ "node": ">=14.17" } }, + "node_modules/typescript-language-server": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/typescript-language-server/-/typescript-language-server-5.1.3.tgz", + "integrity": "sha512-r+pAcYtWdN8tKlYZPwiiHNA2QPjXnI02NrW5Sf2cVM3TRtuQ3V9EKKwOxqwaQ0krsaEXk/CbN90I5erBuf84Vg==", + "license": "Apache-2.0", + "bin": { + "typescript-language-server": "lib/cli.mjs" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/adapters/package.json b/adapters/package.json index 709f16b..0ac7b0f 100644 --- a/adapters/package.json +++ b/adapters/package.json @@ -7,10 +7,14 @@ "web": "node dist/web/index.js", "edit": "node dist/edit/index.js", "project": "node dist/project/index.js", - "session": "node dist/session/index.js" + "session": "node dist/session/index.js", + "nexus": "node dist/nexus/index.js" }, "devDependencies": { - "typescript": "^5.4.0", - "@types/node": "^20.0.0" + "@types/node": "^20.0.0", + "typescript": "^5.4.0" + }, + "dependencies": { + "typescript-language-server": "^5.1.3" } } diff --git a/adapters/project/index.ts b/adapters/project/index.ts index f27e8d8..0b8ae90 100644 --- a/adapters/project/index.ts +++ b/adapters/project/index.ts @@ -36,7 +36,7 @@ const TOOLS = [ }, { name: 'task_update', - description: 'Update task status (e.g. mark as done for 消込) with optional note. Modifies local state.', + description: 'Update task status. done = 消込. Modifies local state.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Task ID from task_list' }, status: { type: 'string', enum: ['open', 'in_progress', 'done'], description: 'New status' }, note: { type: 'string', description: 'Optional note to record with this update' } }, required: ['id', 'status'] } }, { @@ -49,6 +49,10 @@ const TOOLS = [ const TODO_FILE = process.env['MCPHUB_TODO_FILE'] || path.join(process.env['HOME'] || '/tmp', '.mcphub_todos.json'); const TASKS_FILE = path.join(process.env['HOME'] || '/tmp', '.config', 'mcphub', 'tasks.json'); +// --------------------------------------------------------------------------- +// Persistent Task Store (LESSON-002: validate enums, detect corruption) +// --------------------------------------------------------------------------- + interface Task { id: string; title: string; @@ -63,9 +67,22 @@ interface Task { function loadTasks(): Task[] { try { if (fs.existsSync(TASKS_FILE)) { - return JSON.parse(fs.readFileSync(TASKS_FILE, 'utf-8')); + const data = fs.readFileSync(TASKS_FILE, 'utf-8'); + const parsed = JSON.parse(data); + if (!Array.isArray(parsed)) { + // LESSON-002: corruption detected — backup and throw + const backup = TASKS_FILE + '.corrupted.' + Date.now(); + fs.copyFileSync(TASKS_FILE, backup); + throw new Error('tasks.json is corrupted (not an array). Backup saved to ' + backup); + } + return parsed; + } + } catch (e) { + if (e instanceof SyntaxError || (e as Error).message?.includes('corrupted')) { + throw e; // Re-throw corruption errors } - } catch { /* corrupted file, start fresh */ } + // For other errors (e.g., file not found), start fresh + } return []; } @@ -88,6 +105,321 @@ function now(): string { return new Date().toISOString(); } +// --------------------------------------------------------------------------- +// LSP integration — Alpha implementation +// Supports: definition, references, hover +// Language servers: typescript-language-server (TS/JS), gopls (Go) +// --------------------------------------------------------------------------- + +interface LspMessage { + jsonrpc: string; + id?: number; + method?: string; + params?: unknown; + result?: unknown; + error?: { code: number; message: string }; +} + +function detectLanguage(filePath: string): 'typescript' | 'go' | 'unknown' { + const ext = path.extname(filePath).toLowerCase(); + if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext)) return 'typescript'; + if (ext === '.go') return 'go'; + return 'unknown'; +} + +function findTsServerPath(): string | null { + const candidates = [ + path.join(__dirname, '..', 'node_modules', 'typescript', 'lib'), + path.join(__dirname, '..', '..', 'node_modules', 'typescript', 'lib'), + path.join(__dirname, '..', '..', '..', 'node_modules', 'typescript', 'lib'), + ]; + for (const c of candidates) { + if (fs.existsSync(path.join(c, 'tsserver.js'))) return c; + } + try { + const result = child_process.execSync('node -e "console.log(require.resolve(\'typescript/lib/tsserver.js\'))"', + { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + if (result && fs.existsSync(result)) return path.dirname(result); + } catch {} + return null; +} + +function findTsLspBinary(): string | null { + const candidates = [ + path.join(__dirname, '..', 'node_modules', '.bin', 'typescript-language-server'), + path.join(__dirname, '..', '..', 'node_modules', '.bin', 'typescript-language-server'), + path.join(__dirname, '..', '..', '..', 'node_modules', '.bin', 'typescript-language-server'), + ]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + try { + const result = child_process.execSync('which typescript-language-server', + { encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + if (result) return result; + } catch {} + return null; +} + +function lspServerCommand(lang: 'typescript' | 'go'): { cmd: string; args: string[]; env?: Record } | null { + if (lang === 'typescript') { + const lspBinary = findTsLspBinary(); + if (!lspBinary) return null; + const tsserverPath = findTsServerPath(); + const env: Record = {}; + if (tsserverPath) { + const nodeModulesDir = path.resolve(tsserverPath, '..', '..'); + env['NODE_PATH'] = nodeModulesDir; + } + return { cmd: lspBinary, args: ['--stdio'], env }; + } + if (lang === 'go') { + try { + child_process.execSync('which gopls', { stdio: 'ignore' }); + return { cmd: 'gopls', args: ['serve'] }; + } catch { + return null; + } + } + return null; +} + +function fileUri(filePath: string): string { + const abs = path.resolve(filePath); + return `file://${abs}`; +} + +function findProjectRoot(filePath: string): string { + let dir = path.dirname(path.resolve(filePath)); + for (let i = 0; i < 20; i++) { + if (fs.existsSync(path.join(dir, 'tsconfig.json')) || + fs.existsSync(path.join(dir, 'package.json')) || + fs.existsSync(path.join(dir, 'go.mod')) || + fs.existsSync(path.join(dir, '.git'))) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return path.dirname(path.resolve(filePath)); +} + +async function runLspSession( + serverCmd: { cmd: string; args: string[]; env?: Record }, + filePath: string, + lang: 'typescript' | 'go', + command: string, + line: number, + character: number +): Promise { + return new Promise((resolve, reject) => { + const absFile = path.resolve(filePath); + const rootDir = findProjectRoot(absFile); + const uri = fileUri(absFile); + const rootUri = fileUri(rootDir); + + const proc = child_process.spawn(serverCmd.cmd, serverCmd.args, { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ...(serverCmd.env || {}) }, + }); + + let buffer = ''; + let reqId = 1; + const pendingReqs: Map void; reject: (e: Error) => void }> = new Map(); + const timeout = setTimeout(() => { + proc.kill(); + reject(new Error('LSP session timed out after 8s')); + }, 8000); + + function send(msg: LspMessage): void { + const body = JSON.stringify(msg); + proc.stdin!.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`); + } + + function sendRequest(method: string, params: unknown): Promise { + const id = reqId++; + return new Promise((res, rej) => { + pendingReqs.set(id, { resolve: res, reject: rej }); + send({ jsonrpc: '2.0', id, method, params }); + }); + } + + function sendNotification(method: string, params: unknown): void { + send({ jsonrpc: '2.0', method, params }); + } + + proc.stdout!.on('data', (data: Buffer) => { + buffer += data.toString(); + while (true) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd < 0) break; + const header = buffer.substring(0, headerEnd); + const match = header.match(/Content-Length:\s*(\d+)/i); + if (!match) { buffer = buffer.substring(headerEnd + 4); continue; } + const contentLen = parseInt(match[1], 10); + const bodyStart = headerEnd + 4; + if (buffer.length < bodyStart + contentLen) break; + const body = buffer.substring(bodyStart, bodyStart + contentLen); + buffer = buffer.substring(bodyStart + contentLen); + try { + const msg = JSON.parse(body) as LspMessage; + if (msg.id !== undefined && pendingReqs.has(msg.id as number)) { + const p = pendingReqs.get(msg.id as number)!; + pendingReqs.delete(msg.id as number); + if (msg.error) p.reject(new Error(msg.error.message)); + else p.resolve(msg.result); + } + } catch {} + } + }); + + proc.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + + proc.on('close', () => { + clearTimeout(timeout); + for (const p of pendingReqs.values()) { + p.reject(new Error('LSP server closed unexpectedly')); + } + }); + + (async () => { + try { + await sendRequest('initialize', { + processId: process.pid, + rootUri, + capabilities: { + textDocument: { + hover: { contentFormat: ['plaintext', 'markdown'] }, + definition: {}, + references: {}, + }, + }, + }); + sendNotification('initialized', {}); + + let fileContent = ''; + try { fileContent = fs.readFileSync(absFile, 'utf8'); } catch { /* file may not exist */ } + const langId = lang === 'typescript' ? (absFile.endsWith('.tsx') ? 'typescriptreact' : 'typescript') : 'go'; + sendNotification('textDocument/didOpen', { + textDocument: { uri, languageId: langId, version: 1, text: fileContent }, + }); + + await new Promise(r => setTimeout(r, 3000)); + + const pos = { line: Math.max(0, line - 1), character }; + let result: unknown; + if (command === 'hover') { + result = await sendRequest('textDocument/hover', { textDocument: { uri }, position: pos }); + } else if (command === 'definition') { + result = await sendRequest('textDocument/definition', { textDocument: { uri }, position: pos }); + } else if (command === 'references') { + result = await sendRequest('textDocument/references', { textDocument: { uri }, position: pos, context: { includeDeclaration: true } }); + } else { + throw new Error(`Unknown LSP command: ${command}. Supported: hover, definition, references`); + } + + const formatted = formatLspResult(command, result, absFile); + + try { + await sendRequest('shutdown', null); + sendNotification('exit', null); + } catch {} + + clearTimeout(timeout); + proc.kill(); + resolve(formatted); + } catch (err) { + clearTimeout(timeout); + proc.kill(); + reject(err); + } + })(); + }); +} + +function formatLspResult(command: string, result: unknown, filePath: string): string { + if (result === null || result === undefined) { + return `No ${command} information available at the specified position in ${path.basename(filePath)}.`; + } + if (command === 'hover') { + const hover = result as { contents?: { value?: string; kind?: string } | string | Array }; + if (!hover || !hover.contents) return 'No hover information available.'; + if (typeof hover.contents === 'string') return hover.contents; + if (typeof hover.contents === 'object' && 'value' in hover.contents) return hover.contents.value || 'No hover information available.'; + if (Array.isArray(hover.contents)) { + return hover.contents.map((c: unknown) => { + if (typeof c === 'string') return c; + if (typeof c === 'object' && c !== null && 'value' in c) return (c as { value: string }).value; + return JSON.stringify(c); + }).join('\n\n'); + } + return JSON.stringify(hover.contents); + } + if (command === 'definition') { + const defs = Array.isArray(result) ? result : [result]; + if (defs.length === 0) return 'No definition found.'; + return defs.map((d: unknown) => { + const loc = d as { uri?: string; range?: { start: { line: number; character: number } } }; + if (!loc.uri) return JSON.stringify(d); + const defFile = loc.uri.replace('file://', ''); + const line = loc.range ? loc.range.start.line + 1 : 0; + const char = loc.range ? loc.range.start.character : 0; + return `${defFile}:${line}:${char}`; + }).join('\n'); + } + if (command === 'references') { + const refs = Array.isArray(result) ? result : []; + if (refs.length === 0) return 'No references found.'; + return `Found ${refs.length} reference(s):\n` + refs.map((r: unknown) => { + const loc = r as { uri?: string; range?: { start: { line: number; character: number } } }; + if (!loc.uri) return JSON.stringify(r); + const refFile = loc.uri.replace('file://', ''); + const line = loc.range ? loc.range.start.line + 1 : 0; + return ` ${refFile}:${line}`; + }).join('\n'); + } + return JSON.stringify(result, null, 2); +} + +async function handleLsp( + command: string, + filePath: string, + line: number, + character: number +): Promise<{ content: { type: string; text: string }[]; isError?: boolean }> { + if (!fs.existsSync(filePath)) { + return { content: [{ type: 'text', text: `File not found: ${filePath}` }], isError: true }; + } + const lang = detectLanguage(filePath); + if (lang === 'unknown') { + return { + content: [{ type: 'text', text: `LSP not available for file type: ${path.extname(filePath)}. Supported: .ts, .tsx, .js, .jsx, .go` }], + isError: true + }; + } + const serverCmd = lspServerCommand(lang); + if (!serverCmd) { + const serverName = lang === 'typescript' ? 'typescript-language-server' : 'gopls'; + return { + content: [{ type: 'text', text: `Language server '${serverName}' not found. Install it to enable LSP for ${lang} files.` }], + isError: true + }; + } + try { + const result = await runLspSession(serverCmd, filePath, lang, command, line, character); + return { content: [{ type: 'text', text: result }] }; + } catch (err) { + return { + content: [{ type: 'text', text: `LSP error: ${err instanceof Error ? err.message : String(err)}` }], + isError: true + }; + } +} + async function dispatch(name: string, args: Record): Promise<{ content: { type: string; text: string }[]; isError?: boolean }> { if (name === 'todowrite') { const todos = args['todos']; @@ -108,7 +440,6 @@ async function dispatch(name: string, args: Record): Promise<{ if (!pattern) throw new Error('pattern is required'); const rgArgs = ['-n', '--no-heading', pattern, searchPath]; if (include) rgArgs.push('--glob', include); - // Try ripgrep, fall back to grep let cmd = 'rg'; let cmdArgs = rgArgs; try { child_process.execSync('which rg', { stdio: 'ignore' }); } catch { @@ -118,19 +449,29 @@ async function dispatch(name: string, args: Record): Promise<{ return { content: [{ type: 'text', text: result.stdout || '(no matches)' }] }; } if (name === 'lsp') { - // Alpha: stub — LSP integration requires a running language server const command = args['command'] as string; const file = args['file'] as string; - return { content: [{ type: 'text', text: `LSP ${command} for ${file}: LSP subprocess integration is alpha+ scope. Tool registered and callable; full language server protocol requires Session 4.` }] }; + const line = (args['line'] as number) || 1; + const character = (args['character'] as number) || 0; + if (!command || !file) throw new Error('command and file are required'); + return await handleLsp(command, file, line, character); } + + // --- Task management tools --- if (name === 'task_create') { const tasks = loadTasks(); + const title = (args['title'] as string)?.trim() || 'Untitled'; + const description = (args['description'] as string)?.trim() || ''; + const priority = (args['priority'] as string) || 'MEDIUM'; + if (!['HIGH', 'MEDIUM', 'LOW'].includes(priority)) { + throw new Error(`Invalid priority: ${priority}. Must be HIGH, MEDIUM, or LOW.`); + } const task: Task = { id: nextTaskId(tasks), - title: (args['title'] as string)?.trim() || 'Untitled', - description: (args['description'] as string)?.trim() || '', + title, + description, status: 'open', - priority: (args['priority'] as string) || 'MEDIUM', + priority, createdAt: now(), updatedAt: now(), notes: [] @@ -142,6 +483,9 @@ async function dispatch(name: string, args: Record): Promise<{ if (name === 'task_list') { const tasks = loadTasks(); const filter = (args['status'] as string) || 'all'; + if (filter !== 'all' && !['open', 'in_progress', 'done'].includes(filter)) { + throw new Error(`Invalid status filter: ${filter}. Must be open, in_progress, done, or all.`); + } const filtered = filter === 'all' ? tasks : tasks.filter(t => t.status === filter); const open = tasks.filter(t => t.status === 'open').length; const progress = tasks.filter(t => t.status === 'in_progress').length; @@ -153,6 +497,10 @@ async function dispatch(name: string, args: Record): Promise<{ const id = (args['id'] as string)?.trim(); const status = (args['status'] as string)?.trim(); const note = (args['note'] as string)?.trim(); + if (!id) throw new Error('id is required'); + if (!status || !['open', 'in_progress', 'done'].includes(status)) { + throw new Error(`Invalid status: ${status}. Must be open, in_progress, or done.`); + } const idx = tasks.findIndex(t => t.id === id); if (idx === -1) throw new Error(`Task not found: ${id}`); tasks[idx].status = status; @@ -164,12 +512,14 @@ async function dispatch(name: string, args: Record): Promise<{ if (name === 'task_delete') { const tasks = loadTasks(); const id = (args['id'] as string)?.trim(); + if (!id) throw new Error('id is required'); const before = tasks.length; const remaining = tasks.filter(t => t.id !== id); if (remaining.length === before) throw new Error(`Task not found: ${id}`); saveTasks(remaining); return { content: [{ type: 'text', text: JSON.stringify({ deleted: id, remaining: remaining.length }) }] }; } + throw new Error(`Unknown tool: ${name}`); } @@ -183,10 +533,10 @@ rl.on('line', async (line) => { try { req = JSON.parse(line); } catch { respondError(null, -32700, 'Parse error'); return; } const { id, method, params } = req as { id?: unknown; method?: string; params?: Record }; try { - if (method === 'initialize') respond(id, { protocolVersion: '2024-11-05', serverInfo: { name: 'mcphub-project', version: '0.1.0-alpha' }, capabilities: { tools: {} } }); + if (method === 'initialize') respond(id, { protocolVersion: '2024-11-05', serverInfo: { name: 'mcphub-project', version: '0.2.0-alpha' }, capabilities: { tools: {} } }); else if (method === 'notifications/initialized') { /* no-op */ } else if (method === 'tools/list') respond(id, { tools: TOOLS }); else if (method === 'tools/call') { const p = params as { name?: string; arguments?: Record }; respond(id, await dispatch(p?.name ?? '', p?.arguments ?? {})); } else respondError(id, -32601, `Unknown method: ${method}`); } catch (err) { respondError(id, -32603, err instanceof Error ? err.message : String(err)); } -}); +}); \ No newline at end of file diff --git a/adapters/session/index.ts b/adapters/session/index.ts index d293a9b..65ab24b 100644 --- a/adapters/session/index.ts +++ b/adapters/session/index.ts @@ -22,6 +22,11 @@ const TOOLS = [ name: 'batch', description: 'Execute a batch of multiple tool operations in sequence.', inputSchema: { type: 'object', properties: { operations: { type: 'array', items: { type: 'object', properties: { tool: { type: 'string' }, arguments: { type: 'object' } } }, description: 'Tool operations to execute' } }, required: ['operations'] } + }, + { + name: 'mcphub_checkpoint', + description: 'Save current session state (tasks, nexus issues, handover note) as a persistent checkpoint for next-session handoff. Modifies local state.', + inputSchema: { type: 'object', properties: { message: { type: 'string', description: 'Handover message — what was done, what remains, context for next session' }, project: { type: 'string', description: 'Project name for nexus issue listing (optional)' } }, required: ['message'] } } ]; diff --git a/adapters/tsconfig.json b/adapters/tsconfig.json index 8c635d1..f056249 100644 --- a/adapters/tsconfig.json +++ b/adapters/tsconfig.json @@ -10,5 +10,5 @@ "resolveJsonModule": true, "skipLibCheck": true }, - "include": ["web/**/*", "edit/**/*", "project/**/*", "session/**/*", "synthetic/**/*"] + "include": ["web/**/*", "edit/**/*", "project/**/*", "session/**/*", "synthetic/**/*", "nexus/**/*"] } diff --git a/adapters/web/index.ts b/adapters/web/index.ts index de06582..3b61c6f 100644 --- a/adapters/web/index.ts +++ b/adapters/web/index.ts @@ -66,6 +66,17 @@ function httpRequest( maxRedirects = MAX_REDIRECTS ): Promise { return new Promise((resolve, reject) => { + let settled = false; + const safeResolve = (resp: HttpResponse) => { + if (settled) return; + settled = true; + resolve(resp); + }; + const safeReject = (err: Error) => { + if (settled) return; + settled = true; + reject(err); + }; const url = new URL(urlStr); const lib = url.protocol === 'https:' ? https : http; const headers: Record = { @@ -91,37 +102,29 @@ function httpRequest( redirectUrl = `${url.protocol}//${url.host}${redirectUrl}`; } res.resume(); // consume response body - httpRequest(redirectUrl, method, body, extraHeaders, maxRedirects - 1).then(resolve, reject); + httpRequest(redirectUrl, method, body, extraHeaders, maxRedirects - 1).then(safeResolve, safeReject); return; } let data = ''; - let truncated = false; + const complete = () => safeResolve({ + statusCode: res.statusCode || 0, + headers: res.headers as Record, + body: data, + finalUrl: urlStr + }); res.on('data', (chunk: Buffer) => { - if (truncated) return; data += chunk.toString(); + // Truncate early to avoid memory issues if (data.length > MAX_BODY_CHARS * 2) { - truncated = true; + complete(); res.destroy(); - resolve({ - statusCode: res.statusCode || 0, - headers: res.headers as Record, - body: data.slice(0, MAX_BODY_CHARS * 2), - finalUrl: urlStr - }); } }); - res.on('end', () => { - if (truncated) return; - resolve({ - statusCode: res.statusCode || 0, - headers: res.headers as Record, - body: data, - finalUrl: urlStr - }); - }); + res.on('end', complete); + res.on('error', safeReject); }); - req.on('error', reject); - req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out (30s)')); }); + req.on('error', safeReject); + req.on('timeout', () => { req.destroy(); safeReject(new Error('Request timed out (30s)')); }); if (body) req.write(body); req.end(); }); diff --git a/cmd/mcphub/main.go b/cmd/mcphub/main.go index 536ab22..343f51b 100644 --- a/cmd/mcphub/main.go +++ b/cmd/mcphub/main.go @@ -62,6 +62,7 @@ func execJava(javaArgs []string) { env = append(env, "MCPHUB_ADAPTER_DIR="+adapterDir) } } + env = withDefaultCliCommand(env) err := syscall.Exec(jre, args, env) // If exec fails, fall back to os/exec @@ -70,6 +71,7 @@ func execJava(javaArgs []string) { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + cmd.Env = env if err := cmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "mcphub: %v\n", err) os.Exit(1) @@ -84,6 +86,8 @@ func startDaemon() { cmd.Stdin = nil cmd.Stdout = nil cmd.Stderr = nil + env := withDefaultCliCommand(os.Environ()) + cmd.Env = env if err := cmd.Start(); err != nil { fmt.Fprintf(os.Stderr, "mcphub: failed to start daemon: %v\n", err) os.Exit(1) @@ -141,6 +145,18 @@ func stopDaemon() { os.Exit(1) } +func withDefaultCliCommand(env []string) []string { + for _, entry := range env { + if strings.HasPrefix(entry, "MCPHUB_CLI_COMMAND=") { + return env + } + } + if exe, err := os.Executable(); err == nil && exe != "" { + return append(env, "MCPHUB_CLI_COMMAND="+exe) + } + return env +} + // findArtifacts locates the bundled JRE and mcphub-core.jar. func findArtifacts() (jrePath, jarPath string) { exe, _ := os.Executable() diff --git a/cmd/mcphub/main_test.go b/cmd/mcphub/main_test.go new file mode 100644 index 0000000..b49d645 --- /dev/null +++ b/cmd/mcphub/main_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "strings" + "testing" +) + +func TestWithDefaultCliCommandPreservesExisting(t *testing.T) { + env := withDefaultCliCommand([]string{"OTHER=value", "MCPHUB_CLI_COMMAND=/custom/mcphub"}) + + count := 0 + for _, entry := range env { + if strings.HasPrefix(entry, "MCPHUB_CLI_COMMAND=") { + count++ + if entry != "MCPHUB_CLI_COMMAND=/custom/mcphub" { + t.Fatalf("existing MCPHUB_CLI_COMMAND was not preserved: %s", entry) + } + } + } + if count != 1 { + t.Fatalf("expected exactly one MCPHUB_CLI_COMMAND, got %d in %v", count, env) + } +} + +func TestWithDefaultCliCommandInjectsWhenMissing(t *testing.T) { + env := withDefaultCliCommand([]string{"OTHER=value"}) + + found := false + for _, entry := range env { + if strings.HasPrefix(entry, "MCPHUB_CLI_COMMAND=") { + found = true + if strings.TrimPrefix(entry, "MCPHUB_CLI_COMMAND=") == "" { + t.Fatalf("injected MCPHUB_CLI_COMMAND is empty") + } + } + } + if !found { + t.Fatalf("expected MCPHUB_CLI_COMMAND to be injected into %v", env) + } +} diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md new file mode 100644 index 0000000..c9adc83 --- /dev/null +++ b/docs/guide/api-reference.md @@ -0,0 +1,583 @@ +# API Reference + +MCPHUB exposes two protocol surfaces: a **control surface** for daemon management (over UDS) and an **MCP surface** for AI tool access (over stdio). + +## Architecture overview + +``` +CLI commands AI Agent + | | + | UDS | stdio (JSON-RPC 2.0) + v v ++-- Java Daemon ----------- Java Bridge --------+ +| (long-lived) (per-session) | +| Control Surface MCP Surface | +| mcphub.control.* initialize | +| tools/list | +| tools/call | ++------------------------------------------------+ +``` + +- **Control surface**: UDS at `$XDG_RUNTIME_DIR/mcphub/daemon.sock` (or `$TMPDIR/mcphub/daemon.sock`). Override with `MCPHUB_SOCKET_PATH`. +- **MCP surface**: stdio (stdin/stdout) via `mcphub bridge`. The bridge connects to the daemon via UDS internally. + +All messages use JSON-RPC 2.0 format. + +--- + +## CLI Commands + +The Go launcher binary (`mcphub`) passes all subcommands through to Java. + +| Command | Description | Category | +|---------|-------------|----------| +| `mcphub start` | Start the daemon (backgrounds by default) | Lifecycle | +| `mcphub start --no-daemon` | Start in foreground | Lifecycle | +| `mcphub stop` | Stop the daemon gracefully | Lifecycle | +| `mcphub restart` | Stop then start | Lifecycle | +| `mcphub health` | Liveness check | Observability | +| `mcphub status` | State, session, uptime | Observability | +| `mcphub open` | Arm + open session (idempotent) | Session | +| `mcphub close` | Close session | Session | +| `mcphub lock` | Emergency lock | Session | +| `mcphub unlock` | Clear emergency lock | Session | +| `mcphub capabilities` | List all registered tools | Registry | +| `mcphub bridge` | Run stdio bridge for AI client | Transport | +| `mcphub version` | Print version | Info | +| `mcphub query --sql "..."` | Read-only SQL against mcphub.db | Debug | +| `mcphub config validate` | Validate config file | Config | + +All commands accept `--json` for machine-readable output. + +--- + +## Control Surface Methods + +Accessible via UDS (used by CLI and bridge internally). + +### mcphub.control.health + +Liveness check. + +**Response:** + +```json +{"status": "ok", "state": "CLOSED"} +``` + +### mcphub.control.status + +Full daemon status. + +**Response:** + +```json +{ + "state": "OPEN", + "session_id": "a1b2c3d4-...", + "seconds_since_transition": 42, + "in_flight_count": 0, + "locked_until_unlock": false, + "uptime_seconds": 3600 +} +``` + +### mcphub.control.arm + +Transition: `CLOSED -> ARMED`. Creates a new session. + +**Response:** + +```json +{"state": "ARMED", "session_id": "a1b2c3d4-..."} +``` + +**Errors:** +- `-32001`: `Cannot arm: hub is locked.` (if emergency lock is active) +- `-32001`: `Illegal transition` (if not in CLOSED state) + +### mcphub.control.open + +Transition: `ARMED -> OPEN`. Starts provider processes. + +**Response:** + +```json +{"state": "OPEN", "session_id": "a1b2c3d4-..."} +``` + +### mcphub.control.close + +Transition: `OPEN -> COOLING_DOWN -> CLOSED` or `ARMED -> CLOSED`. + +**Response:** + +```json +{"state": "CLOSED"} +``` + +### mcphub.control.lock + +Emergency lock. Immediately transitions to CLOSED from any state. Prevents new sessions until unlocked. + +**Params (optional):** + +```json +{"lock_reason": "manual"} +``` + +**Response:** + +```json +{"state": "CLOSED", "locked_until_unlock": true, "lock_reason": "manual"} +``` + +The lock persists across daemon restarts. + +### mcphub.control.unlock + +Clear emergency lock. + +**Response:** + +```json +{"state": "CLOSED", "locked_until_unlock": false} +``` + +### mcphub.control.capabilities + +List all registered tools with full contract and health information. + +**Response:** + +```json +{ + "capabilities": [ + { + "capability_id": "webfetch", + "display_name": "webfetch", + "provider_id": "builtin-hatch", + "access_class": "restricted", + "rw_boundary": "execute", + "enabled": true, + "provider_health": "running", + "policy_decision": "allowed", + "policy_rule_id": "default-allow-all", + "contract": { + "purpose": "Fetch content from a URL...", + "side_effect_class": "external_state", + "may_do": ["..."], + "must_not_do": ["..."], + "when_to_call": ["..."], + "when_not_to_call": ["..."], + "disambiguates_from": [ + {"capability_id": "websearch", "distinction": "..."} + ] + } + } + ], + "loaded_count": 11, + "rejected_count": 0 +} +``` + +### mcphub.control.bridge_detach + +Signals that a bridge process has disconnected. When the last bridge disconnects, the session returns to idle-timeout tracking. The default idle timeout is 300 seconds. + +**Response:** + +```json +{"status": "ok", "active_bridges": 0} +``` + +--- + +## MCP Surface (AI-facing) + +Accessible via stdio through `mcphub bridge`. Follows the [MCP protocol spec](https://modelcontextprotocol.io/). + +### initialize + +MCP handshake. + +**Response:** + +```json +{ + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "mcphub", "version": "0.2.0-alpha"} +} +``` + +The `serverInfo.name` can be overridden via `server_name` in the config file. + +### tools/list + +Returns tools visible to the AI. Only returns results when the session is `OPEN`. + +Filtering applied: +1. Tool must be `enabled` in the registry +2. Tool's provider must be confirmed (adapter registered via `tools/list`) +3. Policy decision must be `ALLOW` (denied and hidden tools are excluded) + +**Response:** + +```json +{ + "tools": [ + { + "name": "webfetch", + "description": "Fetch content from a URL... [modifies external state]", + "inputSchema": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "URL to fetch"} + }, + "required": ["url"] + } + }, + { + "name": "mcphub_disambiguate", + "description": "Query MCPHUB to determine which tool is most appropriate...", + "inputSchema": {"type": "object", "properties": {...}} + } + ] +} +``` + +Description annotations: +- `[modifies external state]` is appended for tools with `access_class: restricted` +- `(read-only)` is appended for tools with `rw_boundary: read` + +### tools/call + +Route a tool call to the appropriate provider. + +**Request params:** + +```json +{ + "name": "webfetch", + "arguments": {"url": "https://example.com"}, + "_intent": "Fetching the project homepage to check current status" +} +``` + +The optional `_intent` field is logged for debugging (max 500 chars). + +**Success response:** Provider-dependent. Typically: + +```json +{ + "content": [ + {"type": "text", "text": "...result..."} + ] +} +``` + +**Error responses:** + +| Error code | Meaning | `next_action` hint | +|------------|---------|-------------------| +| `session_not_open` | Session is not in OPEN state | `call_mcphub_session_open` | +| `tool_not_found` | Tool is not registered (or hidden) | `use_alternative` | +| `tool_denied` | Tool is denied by policy | `abort` | +| `provider_unreachable` | Provider process could not be reached | `wait_session` | +| `provider_error` | Provider returned an error | `retry` | +| `internal_error` | Hub-internal failure | `abort` | + +Error response format: + +```json +{ + "content": [ + { + "type": "text", + "text": "[MCPHUB error] tool_denied: Tool 'websearch' is denied by policy rule: deny-websearch | next_action: abort | policy: deny-websearch" + }, + { + "type": "text", + "text": "[MCPHUB capability_gap] {\"gap_type\":\"policy_restriction\",\"explanation\":\"...\",\"premium_feature_id\":\"policy_governance\"}" + } + ], + "isError": true, + "capability_gap": { + "gap_type": "policy_restriction", + "explanation": "Tool 'websearch' is registered but denied by policy rule: deny-websearch", + "premium_feature_id": "policy_governance" + } +} +``` + +When `tool_not_found` or `provider_unreachable`, the error includes `available_tools` listing currently accessible tools. When the failure class maps deterministically to a known capability difference, the response may include `capability_gap` (OS-15 restored Alpha scope). + +### mcphub_disambiguate (via tools/call) + +AI asks MCPHUB which tool to use for a task. + +**Request:** + +```json +{ + "name": "mcphub_disambiguate", + "arguments": { + "task_description": "I need to search for files containing 'TODO'", + "candidate_tools": ["codesearch", "websearch"] + } +} +``` + +**Response (deterministic -- exactly one candidate):** + +```json +{ + "recommended_tool": "codesearch", + "confidence": "deterministic", + "reason": "Exactly one tool is available...", + "alternatives": [] +} +``` + +**Response (multiple candidates -- no heuristic guessing):** + +```json +{ + "recommended_tool": null, + "confidence": "none", + "reason": "2 tools are available. Hub cannot select without heuristic guessing...", + "alternatives": [ + {"tool": "codesearch", "reason": "Search the local codebase..."}, + {"tool": "websearch", "reason": "Search the web..."} + ] +} +``` + +MCPHUB never guesses. If there is exactly one candidate, it returns a deterministic answer. Otherwise, it returns all alternatives and lets the AI decide. + +--- + +## Configuration + +### Config file + +Path: `~/.local/share/mcphub/config.yaml` (override with `MCPHUB_DATA_DIR`) + +```yaml +# MCP server name (shown in initialize response) +server_name: "mcphub" + +# Session timeouts +session: + idle_timeout_seconds: 300 # Auto-close after inactivity (default: 300) + armed_timeout_seconds: 60 # Auto-close ARMED if not opened (default: 60) + +# Body-budget monitoring thresholds +body_budget: + warning_tool_count: 20 + critical_tool_count: 30 + warning_byte_size: 40000 + critical_byte_size: 60000 + total_registered_hatch_tools: 19 + baseline_schema_bytes_per_tool: 2048 +``` + +### Relay config + +Path: `~/.config/mcphub/relays.yaml` (override with `MCPHUB_RELAYS_PATH`) + +See [Relay Provider Setup](relay-setup.md). + +### Environment variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `MCPHUB_DATA_DIR` | Data directory (DB, config, PID) | `~/.local/share/mcphub` | +| `MCPHUB_SOCKET_PATH` | UDS socket path | `$XDG_RUNTIME_DIR/mcphub/daemon.sock` | +| `MCPHUB_RELAYS_PATH` | Relay config file path | `~/.config/mcphub/relays.yaml` | +| `MCPHUB_ADAPTER_DIR` | TypeScript adapter directory | Auto-detected | +| `MCPHUB_TEST_FIXTURE` | Additional capabilities YAML (testing only) | None | + +--- + +## Database + +SQLite at `$MCPHUB_DATA_DIR/mcphub.db`. + +### Tables + +| Table | Purpose | +|-------|---------| +| `schema_version` | Migration tracking | +| `route_log` | Every tool call with routing decision, latency, policy | +| `failure_log` | Provider failures with recovery action | +| `body_budget_snapshot` | Context size metrics on session open/close | +| `session_log` | State transitions with timestamps | +| `lock_metadata` | Write lock and emergency lock persistence | +| `rollback_checkpoint` | Config snapshots (reserved) | +| `loaded_state` | Provider loaded state per session (reserved) | + +### Querying + +```bash +# Recent tool calls +./mcphub query --sql "SELECT timestamp_utc, tool_name, route_decision, latency_ms FROM route_log ORDER BY id DESC LIMIT 20" + +# Failed calls +./mcphub query --sql "SELECT * FROM route_log WHERE route_decision != 'allowed' ORDER BY id DESC" + +# Session history +./mcphub query --sql "SELECT * FROM session_log ORDER BY id DESC LIMIT 20" + +# Body budget snapshots +./mcphub query --sql "SELECT * FROM body_budget_snapshot ORDER BY id DESC LIMIT 5" + +# Provider failures +./mcphub query --sql "SELECT * FROM failure_log ORDER BY id DESC LIMIT 10" +``` + +--- + +## Tool Surface: Session Recovery + +When the MCPHUB session is CLOSED or ARMED, the tool list returns exactly one tool: `mcphub.session.open`. If a client/model cannot call that MCP tool directly, run `mcphub open` from the CLI. + +### mcphub.session.open + +Open the MCPHUB session to make all tools available. CLI fallback: `mcphub open`. + +**Request params:** `{}` (empty object) + +**Response (success):** + +```json +{ + "content": [{"type": "text", "text": "{\"state\":\"OPEN\",\"session_id\":\"...\",\"message\":\"Session opened. All tools are now available.\"}"}], + "mcphub_providers": "start" +} +``` + +If the session is already OPEN, the response is idempotent: `{"state":"OPEN","message":"Session already open."}`. + +If the session is in COOLING_DOWN or LOCKED state, the response includes an actionable error: + +```json +{ + "content": [{"type": "text", "text": "[MCPHUB error] session_not_open: Cannot open session in state: COOLING_DOWN. Wait and retry. | next_action: wait_session"}], + "isError": true +} +``` + +### Session State Error Recovery + +When any tool is called while the session is CLOSED or ARMED, the error response provides an actionable recovery path: + +```json +{ + "content": [{"type": "text", "text": "[MCPHUB error] session_not_open: Session is not Open. Call mcphub.session.open to reopen the session. If the client cannot call that MCP tool, run CLI: mcphub open | cli_recovery_command: mcphub open | next_action: call_mcphub_session_open"}], + "isError": true +} +``` + +**Key change from prior versions:** The `next_action` is now `call_mcphub_session_open` (pointing to the available AI-facing tool) and the text includes `cli_recovery_command: mcphub open` for clients that cannot invoke the recovery tool. + +--- + +## Nexus Issue Tools + +MCPHUB provides persistent cross-session issue tracking via the Nexus adapter. + +### nexus_issue_create + +Create a new issue in `~/issue-store//`. + +**Request params:** + +```json +{ + "project": "mcphub", + "title": "Critical bug found", + "body": "Description of the issue...", + "priority": "HIGH" +} +``` + +**Response:** File path of the created issue. + +### nexus_issue_list + +List open issues for a project. Read-only. + +```json +{"project": "mcphub"} +``` + +### nexus_issue_close + +Close an issue by moving it to the `closed/` subdirectory. + +```json +{"project": "mcphub", "file": "ISSUE_CRITICAL_BUG_FOUND.md", "resolution": "Fixed in v0.2.1"} +``` + +### nexus_issue_update + +Append a timestamped update to an existing issue. + +```json +{"project": "mcphub", "file": "ISSUE_CRITICAL_BUG_FOUND.md", "addition": "Verified fix in staging environment"} +``` + +--- + +## Task Management Tools + +Persistent cross-session task tracking via `~/.config/mcphub/tasks.json`. + +### task_create + +```json +{"title": "Fix login flow", "description": "Users report intermittent login failures", "priority": "HIGH"} +``` + +### task_list + +```json +{"status": "all"} +``` + +Returns `{tasks: [...], counts: {open, in_progress, done, total}}`. + +### task_update + +```json +{"id": "task_001", "status": "done", "note": "Deployed to production"} +``` + +### task_delete + +```json +{"id": "task_001"} +``` + +--- + +## mcphub_checkpoint + +Save current session state for next-session handoff. Reads tasks and nexus issues, writes a checkpoint file. + +**Request params:** + +```json +{"message": "Completed login fix. Remaining: deploy monitoring dashboard.", "project": "mcphub"} +``` + +- `message` (required): Handover note +- `project` (optional): Project name to include nexus issue listing + +**Response:** Checkpoint file path and session state summary. + +--- + +*MCPHUB API Reference -- v0.2.0-alpha with session recovery and operational tools* diff --git a/docs/guide/benchmark-report.md b/docs/guide/benchmark-report.md new file mode 100644 index 0000000..98ed320 --- /dev/null +++ b/docs/guide/benchmark-report.md @@ -0,0 +1,78 @@ +# MCPHUB Performance Benchmark Report + +## Methodology + +Benchmarks use the automated VT-016 integration test (`integration/vt_test.go:TestVT_016_LatencyP50P99`). + +**Procedure:** +1. Start a fresh MCPHUB daemon with default config +2. Open a session (CLOSED -> ARMED -> OPEN) +3. Execute 100 sequential `tools/call` invocations of the `list` tool (path: `/tmp`) +4. Read `latency_ms` from the `route_log` SQLite table (sorted ascending) +5. Compute p50 and p99 from the latency distribution + +**What is measured:** +- MCPHUB internal overhead only: request receipt at Java bridge -> daemon routing -> policy check -> provider dispatch initiation +- Provider execution time is excluded (measured separately in VT-017) + +**What is NOT measured:** +- Network latency (there is none -- all communication is local UDS/stdio) +- Provider execution time (adapter I/O, external API calls, disk access) +- AI client MCP protocol overhead + +### Reproducing the benchmark + +```bash +cd /path/to/MCPHUB +make +go test -v -run TestVT_016 ./integration/ +``` + +Output includes `VT-016: p50=Xms p99=Xms (N=100)`. + +## Results + +### Latency (VT-016) + +| Metric | Measured | Target (Spec REQ-8.10.3) | Status | +|--------|----------|--------------------------|--------| +| p50 | 1ms | < 50ms | PASS (50x headroom) | +| p99 | 3ms | < 100ms | PASS (33x headroom) | +| Sample size | 100 calls | -- | -- | + +### Provider isolation (VT-017) + +Verifies that MCPHUB latency does NOT include provider execution time. + +| Metric | Value | +|--------|-------| +| Synthetic provider delay | 500ms | +| Total elapsed time | ~500ms | +| MCPHUB `latency_ms` recorded | < 100ms | +| Provider time excluded | Confirmed | + +### Reproducing VT-017 + +```bash +go test -v -run TestVT_017 ./integration/ +``` + +## Environment + +| Item | Value | +|------|-------| +| Platform | Linux (WSL2) | +| Java | OpenJDK 21 (Temurin) | +| Go | 1.26.2 | +| IPC | Unix domain socket | +| Storage | SQLite 3.45.x (WAL mode) | + +## Interpretation + +MCPHUB adds negligible overhead (1-3ms) to tool calls. The dominant latency in any real tool invocation is the provider's own execution (HTTP calls, file I/O, etc.), not MCPHUB's routing layer. + +The 50x headroom on p50 means MCPHUB could serve significantly more complex routing logic before approaching the performance budget. + +--- + +*MCPHUB Benchmark Report -- BL-12* diff --git a/docs/guide/context-reduction-report.md b/docs/guide/context-reduction-report.md new file mode 100644 index 0000000..bcd71b7 --- /dev/null +++ b/docs/guide/context-reduction-report.md @@ -0,0 +1,95 @@ +# MCPHUB Context Reduction Report + +## Background + +AI coding agents send all tool schemas to the LLM API on every request. As the number of MCP servers grows, tool schemas consume an increasing share of the context window. + +MCPHUB reduces this pressure by hosting tools behind a single MCP endpoint. Instead of each tool's full schema appearing in the API request body, the AI connects to one MCP server (MCPHUB) that routes requests internally. + +## Measurement methodology + +### Without MCPHUB + +Each tool's JSON schema is included inline in every API request as a builtin tool definition. The total "schema mass" is the sum of all tool schema bytes sent per request. + +**Source data:** Verified measurements from pre-MCPHUB Hatch operation (F1/F2 in Proposal v0.1). + +### With MCPHUB + +The 11 deferred tools are hosted by MCPHUB. The AI client sees one MCP server connection. The `tools/list` response is sent once during MCP handshake, not on every LLM API request. + +**Source data:** MCPHUB `tools/list` response size measured via bridge protocol test. + +## Results + +### Schema mass comparison + +| Configuration | Tools in API body | Schema bytes per request | Notes | +|--------------|-------------------|--------------------------|-------| +| Pre-mitigation (all builtin) | 27 | ~61,000 | Caused Anthropic API 400 (F1) | +| Probe E mitigation (8 builtin + ToolSearch defer) | 8 | ~16,000 | 11 tools deferred with 3-8s latency | +| MCPHUB (8 builtin + 11 via MCP) | 8 + 1 MCP connection | ~18,000 | 11 tools available instantly, no defer | + +### Token savings per request + +| Metric | Value | +|--------|-------| +| Schema bytes removed from API body | ~43,000 bytes (27 tools -> 8 builtin + MCP) | +| Approximate token savings per request | ~10,750 tokens (at ~4 bytes/token) | +| Reduction percentage | ~70% of tool schema mass | + +### Deferred loading elimination + +| Metric | Without MCPHUB | With MCPHUB | +|--------|----------------|-------------| +| ToolSearch defer latency | 3-8 seconds per tool use | 0ms (direct MCP call) | +| Tools requiring defer | 11 | 0 | +| Round-trips per deferred tool | 2 (search + load) | 1 (direct call) | + +### Context window impact + +| Metric | Without MCPHUB | With MCPHUB | +|--------|----------------|-------------| +| Tool schema as % of 200K context | 30.5% (61K/200K) | 9% (18K/200K) | +| Available context for actual work | 139K tokens | 182K tokens | +| Net context recovered | -- | +43K tokens | + +## MCPHUB tools/list size + +The MCPHUB `tools/list` response (sent once during MCP handshake, not per API request): + +| Metric | Value | +|--------|-------| +| Total tools in response | 31 (11 builtin + 14 Coffer + 5 AXIS + 1 disambiguate) | +| Response size | ~15 KB | +| Warning threshold (REQ-4.4.5) | 20 KB | +| Status | Within budget | + +## Body budget monitoring + +MCPHUB tracks context pressure via the `body_budget_snapshot` table: + +```bash +./mcphub query --sql "SELECT effective_tier, inline_builtin_count, request_body_tool_schema_bytes, mcphub_hosted_tool_count FROM body_budget_snapshot ORDER BY id DESC LIMIT 5" +``` + +Default alert thresholds (configurable in `config.yaml`): + +| Tier | Tool count | Byte size | +|------|-----------|-----------| +| Normal | < 20 | < 40,000 | +| Warning | 20-29 | 40,000-59,999 | +| Critical | >= 30 | >= 60,000 | + +## Conclusion + +MCPHUB achieves a ~70% reduction in per-request tool schema mass by moving 11 tools from inline builtin definitions to MCP-hosted tools. This: + +1. Eliminates the API 400 error that occurred at 27 tools / 61KB +2. Recovers ~43K tokens of context window per request +3. Removes the 3-8 second ToolSearch defer latency for 11 tools +4. Provides body-budget monitoring to detect future regressions before they become user-visible + +--- + +*MCPHUB Context Reduction Report -- BL-13* diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md new file mode 100644 index 0000000..383c1b8 --- /dev/null +++ b/docs/guide/getting-started.md @@ -0,0 +1,142 @@ +# Getting Started with MCPHUB + +Get MCPHUB running in under 5 minutes. + +## Prerequisites + +- Java 21+ (JRE or JDK) +- Go 1.25+ (for building from source) +- Node.js 20+ (for TypeScript adapters) + +## 1. Build + +```bash +git clone https://github.com/OWNER/REPO.git +cd MCPHUB +make +``` + +This builds three components: +- Java fat-JAR (`java/build/libs/mcphub-core.jar`) +- Go launcher binary (`./mcphub`) +- TypeScript adapters (`adapters/dist/`) + +## 2. Start the daemon + +```bash +./mcphub start +``` + +Verify it's running: + +```bash +./mcphub health +# {"status":"ok","state":"CLOSED"} +``` + +The daemon starts in `CLOSED` state. No tools are exposed until you open a session. + +## 3. Connect your AI agent + +Add MCPHUB as an MCP server in your agent config. The shell command handles daemon auto-start, session opening, and bridge attachment. + +Replace `/path/to/MCPHUB` with your actual MCPHUB directory. + +### OpenCode / Hatch + +File: `opencode.jsonc` or `~/.config/opencode/opencode.jsonc` + +```jsonc +{ + "mcp": { + "mcphub": { + "type": "local", + "command": ["sh", "-c", "cd /path/to/MCPHUB && (./mcphub health >/dev/null 2>&1 || (./mcphub _daemon /dev/null 2>/dev/null & sleep 3)) && ./mcphub open >/dev/null 2>&1 && exec ./mcphub bridge"], + "enabled": true + } + } +} +``` + +### Claude Code + +File: `~/.claude.json` + +```json +{ + "mcpServers": { + "mcphub": { + "command": "sh", + "args": ["-c", "cd /path/to/MCPHUB && (./mcphub health >/dev/null 2>&1 || (./mcphub _daemon /dev/null 2>/dev/null & sleep 3)) && ./mcphub open >/dev/null 2>&1 && exec ./mcphub bridge"] + } + } +} +``` + +### Cursor + +In Cursor Settings > MCP > Add Server: + +- Type: `stdio` +- Command: `sh` +- Args: `-c`, `cd /path/to/MCPHUB && (./mcphub health >/dev/null 2>&1 || (./mcphub _daemon /dev/null 2>/dev/null & sleep 3)) && ./mcphub open >/dev/null 2>&1 && exec ./mcphub bridge` + +## 4. Verify tools are available + +Once connected, the AI agent should see MCPHUB's tools. The default configuration includes 11 hosted tools plus `mcphub_disambiguate`. + +You can manually verify the tool list: + +```bash +./mcphub open +./mcphub capabilities +``` + +## 5. Optional: Web search + +MCPHUB includes a `websearch` tool powered by Brave Search API. + +```bash +# Get a free API key at https://api-dashboard.search.brave.com/ +mkdir -p ~/.config/mcphub +echo "YOUR_BRAVE_API_KEY" > ~/.config/mcphub/brave-api-key +chmod 600 ~/.config/mcphub/brave-api-key +``` + +## Session lifecycle + +MCPHUB uses an explicit session lifecycle: + +``` +CLOSED --> ARMED --> OPEN --> COOLING_DOWN --> CLOSED +``` + +- **CLOSED**: Daemon running, no tools exposed. Default state on startup. +- **ARMED**: Session prepared. Providers are starting. Auto-closes after 60s if not opened. +- **OPEN**: Tools are available to the AI agent. Auto-closes after 5 minutes of inactivity. +- **COOLING_DOWN**: Session ending, in-flight requests draining. Transitions to CLOSED. + +The shell command in the agent config handles `open` automatically. For manual control: + +```bash +./mcphub open # CLOSED -> ARMED -> OPEN (idempotent) +./mcphub close # OPEN -> COOLING_DOWN -> CLOSED +./mcphub lock # Emergency: immediately CLOSED + locked +./mcphub unlock # Clear emergency lock +``` + +## Stopping the daemon + +```bash +./mcphub stop +``` + +## What's next + +- [Relay Provider Setup](relay-setup.md) -- Connect external MCP servers +- [Policy Configuration](policy-guide.md) -- Allow/deny/hide rules per tool +- [API Reference](api-reference.md) -- Full control surface and MCP endpoints + +--- + +*MCPHUB Getting Started Guide -- BL-08* diff --git a/docs/guide/policy-guide.md b/docs/guide/policy-guide.md new file mode 100644 index 0000000..261407d --- /dev/null +++ b/docs/guide/policy-guide.md @@ -0,0 +1,239 @@ +# Policy Configuration + +MCPHUB's policy engine controls which tools the AI can access. Every tool call passes through policy evaluation before reaching a provider. + +## Policy decisions + +There are three possible outcomes for any tool: + +| Decision | Effect on AI (`tools/list`) | Effect on `tools/call` | +|----------|-----------------------------|------------------------| +| **ALLOW** | Tool is visible | Call is routed to provider | +| **DENY** | Tool is hidden from AI | Call returns `tool_denied` error | +| **HIDE** | Tool is hidden from AI | Call returns `tool_not_found` (tool appears nonexistent) | + +The difference between DENY and HIDE: a denied tool tells the AI "this tool exists but you can't use it." A hidden tool pretends the tool doesn't exist at all. + +## Default policy + +Out of the box, MCPHUB allows all tools: + +```yaml +policy: + rules: + - rule_id: "default-allow-all" + tool_pattern: "*" + action: "allow" + priority: 1 + scope: "global" +``` + +This default rule exists in `capabilities.yaml` and applies to all tools with the lowest priority (1). + +## Adding policy rules + +Policy rules are defined in `capabilities.yaml` under the `policy.rules` key: + +```yaml +policy: + rules: + - rule_id: "default-allow-all" + tool_pattern: "*" + action: "allow" + priority: 1 + scope: "global" + + - rule_id: "deny-websearch" + tool_pattern: "websearch" + action: "deny" + priority: 10 + scope: "global" + + - rule_id: "hide-batch" + tool_pattern: "batch" + action: "hide" + priority: 10 + scope: "global" +``` + +Restart the daemon after editing `capabilities.yaml` for changes to take effect. + +## Rule format + +| Field | Required | Description | +|-------|----------|-------------| +| `rule_id` | Yes | Unique identifier for the rule (for logging and debugging) | +| `tool_pattern` | Yes | Tool name pattern to match (see below) | +| `action` | Yes | `allow`, `deny`, or `hide` | +| `priority` | Yes | Integer. Higher numbers win over lower numbers | +| `scope` | Yes | `global` (persistent) or `session` (cleared on session close) | + +## Pattern matching + +| Pattern | Matches | +|---------|---------| +| `*` | All tools | +| `webfetch` | Exact tool name | +| `web*` | Any tool starting with "web" (e.g., `webfetch`, `websearch`) | + +## Conflict resolution + +When multiple rules match the same tool: + +1. **Highest priority wins.** A rule with `priority: 10` beats `priority: 1`. +2. **On equal priority, more specific pattern wins.** `webfetch` (exact) beats `web*` (prefix) beats `*` (wildcard). +3. **On equal priority and specificity:** `hide` > `deny` > `allow`. + +### Example + +```yaml +policy: + rules: + - rule_id: "allow-all" + tool_pattern: "*" + action: "allow" + priority: 1 + scope: "global" + + - rule_id: "deny-web" + tool_pattern: "web*" + action: "deny" + priority: 5 + scope: "global" + + - rule_id: "allow-webfetch" + tool_pattern: "webfetch" + action: "allow" + priority: 10 + scope: "global" +``` + +Result: +- `webfetch` -> ALLOW (priority 10, exact match) +- `websearch` -> DENY (priority 5, prefix match) +- `todowrite` -> ALLOW (priority 1, wildcard) + +## Session-scoped rules + +Session rules are temporary and automatically cleared when the session ends (transitions to `COOLING_DOWN`). They are injected programmatically, not via config file. + +Session rules effectively override global rules because they receive a +10000 priority boost internally. This means any session rule will beat any global rule. + +## Operator vs AI visibility + +The policy filter applies differently depending on the viewer: + +| Viewer | ALLOW | DENY | HIDE | +|--------|-------|------|------| +| AI (`tools/list`) | Visible | Hidden | Hidden | +| Operator (`capabilities`) | Visible | Visible + shows `policy_decision: denied` | Hidden | + +This means operators can always see denied tools and their policy decisions via `mcphub capabilities`, but the AI only sees allowed tools. + +## Practical patterns + +### Allow only specific tools + +```yaml +policy: + rules: + - rule_id: "deny-all" + tool_pattern: "*" + action: "deny" + priority: 1 + scope: "global" + + - rule_id: "allow-webfetch" + tool_pattern: "webfetch" + action: "allow" + priority: 10 + scope: "global" + + - rule_id: "allow-codesearch" + tool_pattern: "codesearch" + action: "allow" + priority: 10 + scope: "global" +``` + +### Deny destructive tools + +```yaml +policy: + rules: + - rule_id: "default-allow-all" + tool_pattern: "*" + action: "allow" + priority: 1 + scope: "global" + + - rule_id: "deny-apply-patch" + tool_pattern: "apply_patch" + action: "deny" + priority: 10 + scope: "global" + + - rule_id: "deny-batch" + tool_pattern: "batch" + action: "deny" + priority: 10 + scope: "global" +``` + +### Hide all relay tools + +```yaml +policy: + rules: + - rule_id: "default-allow-all" + tool_pattern: "*" + action: "allow" + priority: 1 + scope: "global" + + - rule_id: "hide-coffer" + tool_pattern: "coffer_*" + action: "hide" + priority: 10 + scope: "global" +``` + +## Verifying policy + +Check which tools are visible and their policy decisions: + +```bash +./mcphub open +./mcphub capabilities --json +``` + +Each capability entry includes: + +```json +{ + "capability_id": "webfetch", + "policy_decision": "allowed", + "policy_rule_id": "default-allow-all" +} +``` + +Check the route log for denied calls: + +```bash +./mcphub query --sql "SELECT tool_name, route_decision, policy_rule_id, error_code FROM route_log WHERE route_decision = 'denied'" +``` + +## Emergency lock + +The emergency lock is a global override that prevents any session from opening: + +```bash +./mcphub lock # Lock: no sessions can start +./mcphub unlock # Clear lock +``` + +The lock persists across daemon restarts (stored in SQLite). While locked, `mcphub open` returns an error: `Cannot arm: hub is locked.` + +--- + +*MCPHUB Policy Configuration Guide -- BL-10* diff --git a/docs/guide/relay-setup.md b/docs/guide/relay-setup.md new file mode 100644 index 0000000..4040330 --- /dev/null +++ b/docs/guide/relay-setup.md @@ -0,0 +1,263 @@ +# Relay Provider Setup + +MCPHUB can aggregate external MCP servers behind its single endpoint. Any MCP-compatible server that speaks stdio JSON-RPC can be added as a relay provider. + +## How relay providers work + +``` +AI Agent + | + | stdio + v +MCPHUB (Java daemon) + | + |-- builtin adapters (web, edit, project, session) + |-- relay: Coffer (secret vault) + |-- relay: AXIS. (context engine) + |-- relay: your-custom-server <-- this guide + +-- relay: any MCP server +``` + +When the AI calls a tool, MCPHUB routes the request to the correct provider based on tool-name-to-group mapping. The AI sees a single flat tool list regardless of how many providers exist behind MCPHUB. + +## Configuration file + +Relay providers are defined in: + +``` +~/.config/mcphub/relays.yaml +``` + +Override with the `MCPHUB_RELAYS_PATH` environment variable. + +A starter example is bundled at `java/src/main/resources/relays-example.yaml`. + +## Minimal example + +Connect the official MCP filesystem server: + +```bash +npm install -g @modelcontextprotocol/server-filesystem +``` + +Add to `~/.config/mcphub/relays.yaml`: + +```yaml +relays: + - id: "filesystem" + command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp/test"] + tools: + - "read_file" + - "write_file" + - "list_directory" + capabilities: + - capability_id: "read_file" + display_name: "read_file" + provider_id: "filesystem" + access_class: "safe" + rw_boundary: "read" + enabled: true + priority: 10 + schema: + type: object + properties: + path: + type: string + description: "File path to read" + required: [path] + contract: + purpose: "Read a file from the configured filesystem root." + may_do: ["Return file contents from the configured root"] + must_not_do: ["Read outside the configured root"] + when_to_call: ["A file from the relay root needs to be read"] + side_effect_class: "none" + timeout_hint_ms: 10000 + - capability_id: "write_file" + display_name: "write_file" + provider_id: "filesystem" + access_class: "guarded" + rw_boundary: "write" + enabled: true + priority: 10 + schema: + type: object + properties: + path: + type: string + description: "File path to write" + content: + type: string + description: "File contents to write" + required: [path, content] + contract: + purpose: "Write a file under the configured filesystem root." + may_do: ["Create or replace files under the configured root"] + must_not_do: ["Write outside the configured root"] + when_to_call: ["The user explicitly wants to write a file through this relay"] + side_effect_class: "local_state" + timeout_hint_ms: 10000 + - capability_id: "list_directory" + display_name: "list_directory" + provider_id: "filesystem" + access_class: "safe" + rw_boundary: "read" + enabled: true + priority: 10 + schema: + type: object + properties: + path: + type: string + description: "Directory path to list" + required: [path] + contract: + purpose: "List a directory under the configured filesystem root." + may_do: ["Return directory entries under the configured root"] + must_not_do: ["List outside the configured root"] + when_to_call: ["Directory contents under the relay root are needed"] + side_effect_class: "none" + timeout_hint_ms: 10000 +``` + +Restart MCPHUB to pick up the change: + +```bash +./mcphub stop && ./mcphub start +``` + +Verify: + +```bash +./mcphub open +./mcphub capabilities | grep filesystem +``` + +## Full format + +Each relay entry supports: + +```yaml +relays: + - id: "unique-relay-id" # Required. Group identifier. + command: ["/path/to/binary", "arg1", "arg2"] # Required. Stdio MCP server command. + tools: ["tool_a", "tool_b"] # Required. Tool names this relay provides. + capabilities: # Required for AI-visible tools not built into MCPHUB. + - capability_id: "tool_a" + display_name: "tool_a" + provider_id: "unique-relay-id" + access_class: "safe" # safe | guarded | restricted + rw_boundary: "read" # read | write | execute + enabled: true + priority: 10 + schema: + type: object + properties: + query: + type: string + description: "Search query" + required: [query] + contract: + purpose: "What this tool does" + may_do: + - "Allowed action 1" + must_not_do: + - "Forbidden action 1" + when_to_call: + - "Use when..." + when_not_to_call: + - "Don't use when..." + side_effect_class: "none" # none | local_state | external_state + timeout_hint_ms: 30000 + disambiguates_from: + - capability_id: "other_tool" + distinction: "This tool does X; other_tool does Y" +``` + +### Field reference + +| Field | Required | Description | +|-------|----------|-------------| +| `id` | Yes | Unique identifier for this relay group | +| `command` | Yes | Array of strings: the command to spawn the MCP server process | +| `tools` | Yes | Array of tool names this relay provides | +| `capabilities` | For external tools | Full capability definitions with schema, contract, and policy metadata. External relay tools that are not already in MCPHUB's embedded registry need this block to appear in `tools/list`. | + +### Capability fields + +| Field | Description | +|-------|-------------| +| `capability_id` | Unique tool identifier (must match a name in `tools`) | +| `display_name` | Name exposed to the AI in `tools/list` | +| `provider_id` | Provider group identifier (use the relay `id`) | +| `access_class` | Risk classification: `safe`, `guarded`, or `restricted` | +| `rw_boundary` | Operation type: `read`, `write`, or `execute` | +| `enabled` | Whether the tool is active (`true` / `false`) | +| `priority` | Numeric priority for routing (higher = preferred) | +| `schema` | JSON Schema for the tool's input parameters | +| `contract` | Capability contract (purpose, may_do, must_not_do, etc.) | + +### Contract fields + +The contract helps MCPHUB and the AI understand when and how to use the tool: + +| Field | Description | +|-------|-------------| +| `purpose` | One-line description shown to the AI | +| `may_do` | List of permitted actions | +| `must_not_do` | List of forbidden actions | +| `when_to_call` | Guidance on when the AI should use this tool | +| `when_not_to_call` | Guidance on when the AI should NOT use this tool | +| `side_effect_class` | `none`, `local_state`, or `external_state` | +| `timeout_hint_ms` | Expected max execution time in milliseconds | +| `disambiguates_from` | Array of `{capability_id, distinction}` pairs for conflict resolution | + +## Without capability definitions + +Do not omit the `capabilities` block for new external tools. MCPHUB confirms relay tools at session open, but it does not dynamically create AI-visible registry entries from a relay `tools/list` response. A tool that is listed under `tools` but lacks a matching capability entry will not be exposed in MCPHUB `tools/list` unless it is already present in the embedded registry. + +For each external tool, define at least `schema` and `contract.purpose`; for production use, provide the full contract vocabulary so policy, disambiguation, and capability-gap diagnostics remain accurate. + +When a relay provider updates its own `tools/list` schemas, refresh the static capability schemas in `~/.config/mcphub/relays.yaml` and restart MCPHUB so the AI-visible tool signatures match the provider. + +## Provider lifecycle + +Relay providers follow the same lifecycle as builtin adapters: + +1. **Session Open**: MCPHUB spawns the relay process and calls `tools/list` to confirm available tools +2. **Active session**: MCPHUB routes `tools/call` requests to the relay via stdio JSON-RPC +3. **Session Close**: MCPHUB sends SIGTERM, waits 3 seconds, then SIGKILL if needed +4. **Crash recovery**: If the relay exits unexpectedly, MCPHUB retries up to 3 times within 60 seconds. After 3 failures, the provider is marked `degraded`. + +## Error handling + +If a relay is unreachable or its binary doesn't exist, MCPHUB returns a structured error: + +```json +{ + "content": [{"type": "text", "text": "[MCPHUB error] provider_unavailable: ..."}], + "isError": true +} +``` + +The AI receives this error and can suggest corrective action. + +## Debugging + +Check provider health: + +```bash +./mcphub open +./mcphub capabilities --json | python3 -m json.tool +``` + +Each capability entry includes a `provider_health` field: `running`, `stopped`, or `unavailable`. + +Check route logs for relay calls: + +```bash +./mcphub query --sql "SELECT tool_name, provider_id, route_decision, error_code FROM route_log ORDER BY id DESC LIMIT 10" +``` + +--- + +*MCPHUB Relay Provider Setup Guide -- BL-09* diff --git a/install-remote.sh b/install-remote.sh new file mode 100644 index 0000000..f2ce5cd --- /dev/null +++ b/install-remote.sh @@ -0,0 +1,234 @@ +#!/bin/sh +set -eu + +# curl|sh-friendly release installer. +# +# Defaults target the current internal pre-rename artifact shape, but every public +# identity/distribution value can be overridden by environment variables: +# +# PRODUCT_NAME="MCPHUB" PRODUCT_SLUG="mcphub" BINARY_NAME="mcphub" \ +# REPO_URL="https://github.com/OWNER/mcphub-internal" \ +# DISTRIBUTION_URL="https://github.com/OWNER/mcphub-internal/releases/download" \ +# RELEASE_TAG="v0.2.0-alpha" sh install-remote.sh +# +# For local/internal artifact verification: +# DISTRIBUTION_URL="file:///path/to/dist/release" VERSION="0.2.0-alpha" sh install-remote.sh + +PRODUCT_NAME=${PRODUCT_NAME:-MCPHUB} +PRODUCT_SLUG=${PRODUCT_SLUG:-mcphub} +BINARY_NAME=${BINARY_NAME:-$PRODUCT_SLUG} +ARTIFACT_NAME=${ARTIFACT_NAME:-$PRODUCT_SLUG} +REPO_URL=${REPO_URL:-https://github.com/OWNER/mcphub-internal} +DISTRIBUTION_URL=${DISTRIBUTION_URL:-$REPO_URL/releases/download} +RELEASE_TAG=${RELEASE_TAG:-latest} +VERSION=${VERSION:-} +INSTALL_DIR=${INSTALL_DIR:-$HOME/.local/share/$PRODUCT_SLUG} +BIN_DIR=${BIN_DIR:-$HOME/.local/bin} +RUN_DOCTOR=${RUN_DOCTOR:-1} +DRY_RUN=${DRY_RUN:-0} +KEEP_TMP=${KEEP_TMP:-0} +VERIFY_COMMAND=${VERIFY_COMMAND:-} +ARTIFACT_URL=${ARTIFACT_URL:-} +CHECKSUM_URL=${CHECKSUM_URL:-} +USE_SYSTEM_JAVA_LINK=${USE_SYSTEM_JAVA_LINK:-1} + +log() { printf '%s\n' "$*"; } +err() { printf 'ERROR: %s\n' "$*" >&2; } +run() { + if [ "$DRY_RUN" = "1" ]; then + printf '[dry-run]' + printf ' %s' "$@" + printf '\n' + else + "$@" + fi +} + +usage() { + cat </dev/null 2>&1; then + curl -fL --retry 3 --connect-timeout 20 -o "$archive_path" "$ARTIFACT_URL" + elif command -v wget >/dev/null 2>&1; then + wget -O "$archive_path" "$ARTIFACT_URL" + else + err "curl or wget is required" + exit 1 + fi + ;; + *) + [ -f "$ARTIFACT_URL" ] || { err "artifact not found: $ARTIFACT_URL"; exit 1; } + cp "$ARTIFACT_URL" "$archive_path" + ;; +esac + +if [ -n "$CHECKSUM_URL" ]; then + sums=$tmp/checksums-sha256.txt + case "$CHECKSUM_URL" in + file://*) cp "${CHECKSUM_URL#file://}" "$sums" ;; + http://*|https://*) + if command -v curl >/dev/null 2>&1; then curl -fL -o "$sums" "$CHECKSUM_URL"; else wget -O "$sums" "$CHECKSUM_URL"; fi + ;; + *) cp "$CHECKSUM_URL" "$sums" ;; + esac + if command -v sha256sum >/dev/null 2>&1; then + (cd "$tmp" && grep " $archive\$" checksums-sha256.txt | sha256sum -c -) + elif command -v shasum >/dev/null 2>&1; then + expected=$(grep " $archive\$" "$sums" | awk '{print $1}') + actual=$(shasum -a 256 "$archive_path" | awk '{print $1}') + [ "$expected" = "$actual" ] || { err "checksum mismatch"; exit 1; } + else + err "checksum requested but neither sha256sum nor shasum is available" + exit 1 + fi +fi + +mkdir -p "$tmp/extract" +tar -xzf "$archive_path" -C "$tmp/extract" + +payload="" +for candidate in "$tmp/extract/$PRODUCT_SLUG" "$tmp/extract/$ARTIFACT_NAME" "$tmp/extract"/*; do + if [ -d "$candidate/bin" ] && [ -d "$candidate/lib" ]; then + payload=$candidate + break + fi +done +[ -n "$payload" ] || { err "archive payload with bin/ and lib/ not found"; exit 1; } +[ -f "$payload/bin/$BINARY_NAME" ] || { err "binary not found in archive: bin/$BINARY_NAME"; exit 1; } +[ -d "$payload/adapters" ] || { err "adapters directory not found in archive"; exit 1; } + +mkdir -p "$INSTALL_DIR" "$BIN_DIR" +rm -rf "$INSTALL_DIR/bin" "$INSTALL_DIR/lib" "$INSTALL_DIR/adapters" +cp -R "$payload/bin" "$payload/lib" "$payload/adapters" "$INSTALL_DIR/" +chmod +x "$INSTALL_DIR/bin/$BINARY_NAME" + +# Current pre-rename internal artifacts contain the JAR and adapters but may not +# bundle a JRE. Add a local jre/bin/java symlink to the system Java so the Go +# launcher can still use the distribution layout without source checkout state. +if [ ! -x "$INSTALL_DIR/jre/bin/java" ] && [ "$USE_SYSTEM_JAVA_LINK" = "1" ]; then + java_path=$(command -v java || true) + if [ -n "$java_path" ]; then + mkdir -p "$INSTALL_DIR/jre/bin" + ln -sf "$java_path" "$INSTALL_DIR/jre/bin/java" + fi +fi + +wrapper=$BIN_DIR/$BINARY_NAME +mkdir -p "$INSTALL_DIR/data" +cat > "$wrapper" <&2; exit 1 ;; esac done @@ -39,11 +42,18 @@ echo "===================" echo "Platform: $PLATFORM" echo "Install prefix: $PREFIX" echo "Binary prefix: $BIN_PREFIX" +if [[ "$UPDATE_ONLY" -eq 1 ]]; then + echo "Mode: update (binary + JAR only, data preserved)" +fi +echo "" -# Step 1: Create data dir -mkdir -p "$DATA_DIR" -mkdir -p "$DATA_DIR/logs" -chmod 700 "$DATA_DIR" +# Step 1: Create data dir (unless update-only) +if [[ "$UPDATE_ONLY" -eq 0 ]]; then + mkdir -p "$DATA_DIR" + mkdir -p "$DATA_DIR/logs" + chmod 700 "$DATA_DIR" + echo "Created data directory: $DATA_DIR" +fi # Step 2: Copy binaries (assume we are in the MCPHUB repo dir) REPO_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -68,11 +78,25 @@ else echo "WARNING: mcphub-core JAR not found — build with './gradlew jar' first" fi -# Copy adapters -if [[ -d "$REPO_DIR/adapters/dist" ]]; then - mkdir -p "$PREFIX/adapters" - cp -r "$REPO_DIR/adapters/dist/"* "$PREFIX/adapters/" - echo "Installed adapters to: $PREFIX/adapters" +# Copy adapters (unless update-only) +if [[ "$UPDATE_ONLY" -eq 0 ]]; then + if [[ -d "$REPO_DIR/adapters/dist" ]]; then + rm -rf "$PREFIX/adapters" 2>/dev/null || true + mkdir -p "$PREFIX/adapters" + cp -r "$REPO_DIR/adapters/dist/"* "$PREFIX/adapters/" + echo "Installed adapters to: $PREFIX/adapters" + else + echo "WARNING: adapters/dist not found — build with 'npx tsc --build' in adapters/ first" + fi +fi + +# Copy dashboard HTML (unless update-only) +if [[ "$UPDATE_ONLY" -eq 0 ]]; then + if [[ -f "$REPO_DIR/java/src/main/resources/dashboard.html" ]]; then + # The HTML is bundled inside the JAR, so no need to copy separately. + # Present for reference only. + : + fi fi # Step 3: Optional service install @@ -81,18 +105,33 @@ if [[ "$INSTALL_SERVICE" -eq 1 ]]; then linux) UNIT_DIR="$HOME/.config/systemd/user" mkdir -p "$UNIT_DIR" - sed "s|{{MCPHUB_INSTALL_PATH}}|$BIN_PREFIX/mcphub|g" \ + sed -e "s|{{MCPHUB_INSTALL_PATH}}|$BIN_PREFIX/mcphub|g" \ + -e "s|{{MCPHUB_PREFIX}}|$PREFIX|g" \ "$REPO_DIR/packaging/systemd/mcphub.service" > "$UNIT_DIR/mcphub.service" echo "Installed systemd unit: $UNIT_DIR/mcphub.service" - echo "Enable with: systemctl --user daemon-reload && systemctl --user enable --now mcphub" + echo "" + echo "Enable and start with:" + echo " systemctl --user daemon-reload" + echo " systemctl --user enable --now mcphub" + echo "" + echo "Check status:" + echo " systemctl --user status mcphub" + echo " mcphub health" + echo "" + echo "Dashboard: http://localhost:9741" ;; darwin) PLIST_DIR="$HOME/Library/LaunchAgents" mkdir -p "$PLIST_DIR" - sed "s|{{MCPHUB_INSTALL_PATH}}|$BIN_PREFIX/mcphub|g" \ + sed -e "s|{{MCPHUB_INSTALL_PATH}}|$BIN_PREFIX/mcphub|g" \ + -e "s|{{MCPHUB_PREFIX}}|$PREFIX|g" \ "$REPO_DIR/packaging/launchd/dev.sorted.mcphub.plist" > "$PLIST_DIR/dev.sorted.mcphub.plist" echo "Installed launchd agent: $PLIST_DIR/dev.sorted.mcphub.plist" - echo "Load with: launchctl load $PLIST_DIR/dev.sorted.mcphub.plist" + echo "" + echo "Load with:" + echo " launchctl load $PLIST_DIR/dev.sorted.mcphub.plist" + echo "" + echo "Dashboard: http://localhost:9741" ;; esac fi diff --git a/integration/vt_test.go b/integration/vt_test.go index 51719da..2d959ad 100644 --- a/integration/vt_test.go +++ b/integration/vt_test.go @@ -19,8 +19,9 @@ var mcphubRoutingFailureCodes = map[string]struct{}{ "tool_not_found": {}, "tool_denied": {}, "provider_unreachable": {}, - "provider_error": {}, "internal_error": {}, + // NOTE: provider_error is intentionally excluded — it represents downstream + // provider execution failure, not a hub routing or state machine failure. } var vtTools = []struct { @@ -53,9 +54,6 @@ func findRepoRoot(t *testing.T) string { func ensureJavaJar(t *testing.T, repoRoot string) { t.Helper() - if jars, _ := filepath.Glob(filepath.Join(repoRoot, "java", "build", "libs", "*.jar")); len(jars) > 0 { - return - } cmd := exec.Command("./gradlew", "jar") cmd.Dir = filepath.Join(repoRoot, "java") out, err := cmd.CombinedOutput() @@ -635,7 +633,7 @@ func TestVT_019_IdleTimeoutAutoClose(t *testing.T) { } } -func TestVT_020_ParentExitAutoClose(t *testing.T) { +func TestVT_020_ParentExitResumesIdleAutoClose(t *testing.T) { if testing.Short() { t.Skip("skipping integration VT test in short mode") } @@ -643,10 +641,39 @@ func TestVT_020_ParentExitAutoClose(t *testing.T) { repoRoot := findRepoRoot(t) ensureJavaJar(t, repoRoot) binPath := buildBinary(t, repoRoot) - sockPath, dataDir, cleanup := startDaemon(t, binPath, repoRoot) - defer cleanup() + dataDir := t.TempDir() + + configPath := filepath.Join(dataDir, "config.yaml") + if err := os.WriteFile(configPath, []byte("session:\n idle_timeout_seconds: 3\n armed_timeout_seconds: 3\n"), 0644); err != nil { + t.Fatalf("VT-020: write config.yaml: %v", err) + } + + sockPath := filepath.Join(t.TempDir(), "mcphub.sock") + homeDir := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(ctx, binPath, "_daemon") + cmd.Env = append(os.Environ(), + "MCPHUB_SOCKET_PATH="+sockPath, + "MCPHUB_DATA_DIR="+dataDir, + "MCPHUB_ADAPTER_DIR="+filepath.Join(repoRoot, "adapters", "dist"), + "HOME="+homeDir, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatalf("VT-020: daemon start failed: %v", err) + } + defer func() { + cancel() + _ = cmd.Wait() + }() + + waitForSocketVT(t, sockPath) armAndOpen(t, sockPath) + if got := statusState(t, sockPath); got != "OPEN" { + t.Fatalf("VT-020: expected OPEN before bridge exit probe, got %s", got) + } bridge := exec.Command(binPath, "bridge") bridge.Env = append(os.Environ(), "MCPHUB_SOCKET_PATH="+sockPath) @@ -671,13 +698,19 @@ func TestVT_020_ParentExitAutoClose(t *testing.T) { t.Fatalf("VT-020: bridge wait failed: %v", err) } - waitForState(t, sockPath, "CLOSED", 4*time.Second) + if got := statusState(t, sockPath); got != "OPEN" { + t.Fatalf("VT-020: expected OPEN immediately after bridge exit, got %s", got) + } + time.Sleep(6 * time.Second) + if got := statusState(t, sockPath); got != "CLOSED" { + t.Fatalf("VT-020: expected CLOSED after resumed idle timeout, got %s", got) + } transitions := readStateTransitions(t, dataDir) t.Logf("VT-020: recorded transitions: %v", transitions) joined := strings.Join(transitions, ",") if !strings.Contains(joined, "COOLING_DOWN") || !strings.Contains(joined, "CLOSED") { - t.Fatalf("VT-020: expected COOLING_DOWN and CLOSED transitions after bridge exit, got %v", transitions) + t.Fatalf("VT-020: expected COOLING_DOWN and CLOSED transitions after resumed idle timeout, got %v", transitions) } } @@ -731,8 +764,14 @@ func TestVT_020a_ArmedTimeoutAutoClose(t *testing.T) { } } -// TestSessionOpenRecoveryTool verifies the AI-facing recovery path for closed sessions. -func TestSessionOpenRecoveryTool(t *testing.T) { +// TestNewToolSurface_SessionRecovery verifies: +// - Closed session returns session_not_open with mcphub.session.open recovery path +// - mcphub.session.open works to recover +// - nexus_issue_list works in OPEN state +// - task_list works in OPEN state +// - mcphub_checkpoint works in OPEN state +// - apply_patch works in OPEN state +func TestNewToolSurface_SessionRecovery(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } @@ -743,6 +782,7 @@ func TestSessionOpenRecoveryTool(t *testing.T) { sockPath, _, cleanup := startDaemon(t, binPath, repoRoot) defer cleanup() + // --- CLOSED state: tools/list shows only mcphub.session.open --- t.Run("closed_tools_list_shows_session_open_only", func(t *testing.T) { result, rpcErr := call(t, sockPath, "tools/list", nil) if rpcErr != nil { @@ -764,8 +804,12 @@ func TestSessionOpenRecoveryTool(t *testing.T) { } }) - t.Run("closed_tool_call_returns_actionable_recovery", func(t *testing.T) { - params := map[string]interface{}{"name": "webfetch", "arguments": map[string]interface{}{"url": "https://example.com"}} + // --- CLOSED state: nexus_issue_list returns session_not_open with actionable recovery --- + t.Run("closed_nexus_returns_session_not_open_with_recovery", func(t *testing.T) { + params := map[string]interface{}{ + "name": "nexus_issue_list", + "arguments": map[string]interface{}{"project": "testproj"}, + } result, rpcErr := call(t, sockPath, "tools/call", params) if rpcErr != nil { t.Fatalf("tools/call RPC error: %d %s", rpcErr.Code, rpcErr.Message) @@ -781,20 +825,26 @@ func TestSessionOpenRecoveryTool(t *testing.T) { if !ok || len(contentArr) == 0 { t.Fatalf("expected content array, got %v", out["content"]) } - textContent, ok := contentArr[0].(map[string]interface{})["text"].(string) - if !ok { - t.Fatalf("content[0].text not a string: %T", contentArr[0]) - } + textContent := contentArr[0].(map[string]interface{})["text"].(string) + // Verify actionable recovery: must mention mcphub.session.open if !strings.Contains(textContent, "mcphub.session.open") { - t.Errorf("error must mention mcphub.session.open, got: %s", textContent) + t.Errorf("session_not_open error must mention mcphub.session.open for actionable recovery, got: %s", textContent) + } + if !strings.Contains(textContent, "cli_recovery_command:") || !strings.Contains(textContent, "mcphub open") { + t.Errorf("session_not_open error must include exact CLI recovery command for clients that cannot call mcphub.session.open, got: %s", textContent) } - if !strings.Contains(textContent, "call_mcphub_session_open") { - t.Errorf("error must include call_mcphub_session_open next_action, got: %s", textContent) + // Verify next_action is not just "wait_session" + if strings.Contains(textContent, "wait_session") && !strings.Contains(textContent, "mcphub.session.open") { + t.Errorf("session_not_open error with wait_session alone (no actionable path) is not acceptable, got: %s", textContent) } }) + // --- Open session via mcphub.session.open --- t.Run("session_open_recovers", func(t *testing.T) { - params := map[string]interface{}{"name": "mcphub.session.open", "arguments": map[string]interface{}{}} + params := map[string]interface{}{ + "name": "mcphub.session.open", + "arguments": map[string]interface{}{}, + } result, rpcErr := call(t, sockPath, "tools/call", params) if rpcErr != nil { t.Fatalf("mcphub.session.open RPC error: %d %s", rpcErr.Code, rpcErr.Message) @@ -803,16 +853,121 @@ func TestSessionOpenRecoveryTool(t *testing.T) { if err := json.Unmarshal(result, &out); err != nil { t.Fatalf("unmarshal session.open result: %v", err) } - if isError, _ := out["isError"].(bool); isError { - t.Fatalf("mcphub.session.open should not return an error: %v", out) + contentArr, ok := out["content"].([]interface{}) + if !ok || len(contentArr) == 0 { + t.Fatalf("expected content in session.open result, got %v", out) + } + textContent := contentArr[0].(map[string]interface{})["text"].(string) + if !strings.Contains(textContent, "OPEN") { + t.Fatalf("expected OPEN state in session.open response, got: %s", textContent) } }) + // Wait for adapter registration time.Sleep(2 * time.Second) - if got := statusState(t, sockPath); got != "OPEN" { - t.Fatalf("expected OPEN after recovery, got %s", got) - } + // --- OPEN state: nexus_issue_list works --- + t.Run("open_nexus_issue_list_works", func(t *testing.T) { + params := map[string]interface{}{ + "name": "nexus_issue_list", + "arguments": map[string]interface{}{"project": "testproj"}, + } + result, rpcErr := call(t, sockPath, "tools/call", params) + if rpcErr != nil { + t.Fatalf("nexus_issue_list RPC error: %d %s", rpcErr.Code, rpcErr.Message) + } + var out map[string]interface{} + if err := json.Unmarshal(result, &out); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + // Should not be an error + if isError, _ := out["isError"].(bool); isError { + contentArr, _ := out["content"].([]interface{}) + if len(contentArr) > 0 { + t.Fatalf("nexus_issue_list failed in OPEN state: %v", contentArr[0]) + } + } + if out["content"] == nil { + t.Fatalf("nexus_issue_list returned no content") + } + }) + + // --- OPEN state: task_list works --- + t.Run("open_task_list_works", func(t *testing.T) { + params := map[string]interface{}{ + "name": "task_list", + "arguments": map[string]interface{}{"status": "all"}, + } + result, rpcErr := call(t, sockPath, "tools/call", params) + if rpcErr != nil { + t.Fatalf("task_list RPC error: %d %s", rpcErr.Code, rpcErr.Message) + } + var out map[string]interface{} + if err := json.Unmarshal(result, &out); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + if isError, _ := out["isError"].(bool); isError { + t.Fatalf("task_list should succeed in OPEN state") + } + if out["content"] == nil { + t.Fatalf("task_list returned no content") + } + }) + + // --- OPEN state: mcphub_checkpoint works --- + t.Run("open_checkpoint_works", func(t *testing.T) { + params := map[string]interface{}{ + "name": "mcphub_checkpoint", + "arguments": map[string]interface{}{"message": "Test checkpoint from integration", "project": "testproj"}, + } + result, rpcErr := call(t, sockPath, "tools/call", params) + if rpcErr != nil { + t.Fatalf("mcphub_checkpoint RPC error: %d %s", rpcErr.Code, rpcErr.Message) + } + var out map[string]interface{} + if err := json.Unmarshal(result, &out); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + if isError, _ := out["isError"].(bool); isError { + t.Fatalf("mcphub_checkpoint should succeed in OPEN state") + } + contentArr, ok := out["content"].([]interface{}) + if !ok || len(contentArr) == 0 { + t.Fatalf("mcphub_checkpoint returned no content") + } + textContent := contentArr[0].(map[string]interface{})["text"].(string) + if !strings.Contains(textContent, "checkpoint_time") { + t.Errorf("mcphub_checkpoint response should contain checkpoint_time, got: %s", textContent[:200]) + } + }) + + // --- OPEN state: apply_patch still works --- + t.Run("open_apply_patch_works", func(t *testing.T) { + tmpPatch := "--- /dev/null\n+++ b/vt_surface_test.txt\n@@ -0,0 +1 @@\n+surface-test\n" + params := map[string]interface{}{ + "name": "apply_patch", + "arguments": map[string]interface{}{"patch": tmpPatch, "cwd": "/tmp"}, + } + result, rpcErr := call(t, sockPath, "tools/call", params) + if rpcErr != nil { + t.Fatalf("apply_patch RPC error: %d %s", rpcErr.Code, rpcErr.Message) + } + var out map[string]interface{} + if err := json.Unmarshal(result, &out); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + // apply_patch can succeed or fail depending on patch state; just verify it was routed + if errorCode, ok := out["error_code"].(string); ok { + if _, blocked := mcphubRoutingFailureCodes[errorCode]; blocked { + t.Fatalf("apply_patch routing failure: %v", out) + } + } + if out["content"] == nil { + t.Fatalf("apply_patch returned no content") + } + }) + + // Close closeSession(t, sockPath) } @@ -859,11 +1014,13 @@ func TestApplyPatch_Classifications(t *testing.T) { isError: false, }, { - name: "plain_headers_detected", + // Plain headers are now auto-detected and applied with patch -p0. + // file.txt does not exist in scratchDir, so patch will report can't find file. + name: "plain_headers_p0_file_not_found", patchContent: "--- file.txt\n+++ file.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n", cwd: scratchDir, - wantText: "git-style", - wantNextAction: "abort", + wantText: "Cannot find file", + wantNextAction: "disambiguate", isError: true, }, { @@ -882,6 +1039,38 @@ func TestApplyPatch_Classifications(t *testing.T) { wantNextAction: "retry", isError: true, }, + { + // QA-F5: plain unified diff with ../escape path — preflight must reject before invoking patch. + // wantText is specific to the safety error, not just "abort". + name: "plain_traversal_preflight_rejected", + patchContent: "--- ../escape.txt\n+++ ../escape.txt\n@@ -1,1 +1,1 @@\n-x\n+y\n", + cwd: scratchDir, + wantText: "Unsafe path", + wantNextAction: "abort", + isError: true, + }, + { + // QA-F5: git-style unified diff with a/../escape path — strip first component leaves + // ../escape which must be rejected by preflight path validation. + // wantText is specific to the safety error, not just "abort". + name: "git_traversal_preflight_rejected", + patchContent: "--- a/../escape.txt\n+++ b/../escape.txt\n@@ -1,1 +1,1 @@\n-x\n+y\n", + cwd: scratchDir, + wantText: "Unsafe path", + wantNextAction: "abort", + isError: true, + }, + { + // QA-Fix1: mixed git-style and plain-style headers in same patch — must reject without + // invoking patch at a wrong strip level. + name: "mixed_unified_diff_headers_rejected", + patchContent: "--- a/foo.txt\n+++ b/foo.txt\n@@ -1 +1 @@\n-x\n+y\n" + + "--- bar.txt\n+++ bar.txt\n@@ -1 +1 @@\n-a\n+b\n", + cwd: scratchDir, + wantText: "mixed", + wantNextAction: "abort", + isError: true, + }, } for _, tc := range tests { @@ -940,3 +1129,508 @@ func TestApplyPatch_Classifications(t *testing.T) { _ = os.Remove(filepath.Join(scratchDir, "success_test.txt")) } + +// TestApplyPatch_BeginPatch verifies the OpenAI/GPT-style "*** Begin Patch" format support. +// Tests: Add File, Update File, Delete File, context mismatch, path traversal rejection, +// plain unified diff (-p0), and git-style unified diff regression — all direct-to-adapter. +func TestApplyPatch_BeginPatch(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + repoRoot := findRepoRoot(t) + ensureJavaJar(t, repoRoot) + binPath := buildBinary(t, repoRoot) + sockPath, _, cleanup := startDaemon(t, binPath, repoRoot) + defer cleanup() + + armAndOpen(t, sockPath) + defer closeSession(t, sockPath) + + scratchDir := t.TempDir() + + callApplyPatch := func(t *testing.T, patchContent, cwd string) map[string]interface{} { + t.Helper() + params := map[string]interface{}{ + "name": "apply_patch", + "arguments": map[string]interface{}{"patch": patchContent, "cwd": cwd}, + } + result, rpcErr := call(t, sockPath, "tools/call", params) + if rpcErr != nil { + t.Fatalf("RPC error: %d %s", rpcErr.Code, rpcErr.Message) + } + var out map[string]interface{} + if err := json.Unmarshal(result, &out); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + return out + } + + getText := func(t *testing.T, out map[string]interface{}) string { + t.Helper() + contentArr, ok := out["content"].([]interface{}) + if !ok || len(contentArr) == 0 { + t.Fatalf("expected content array, got %v", out) + } + text, ok := contentArr[0].(map[string]interface{})["text"].(string) + if !ok { + t.Fatalf("content[0].text not string") + } + return text + } + + // --- Begin Patch: Add File --- + t.Run("begin_patch_add_file", func(t *testing.T) { + targetFile := filepath.Join(scratchDir, "bp_add.txt") + _ = os.Remove(targetFile) // ensure clean + patch := "*** Begin Patch\n*** Add File: bp_add.txt\n@@\n+line one\n+line two\n*** End Patch\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); isError { + t.Fatalf("Begin Patch Add File should succeed, got error: %s", getText(t, out)) + } + got, err := os.ReadFile(targetFile) + if err != nil { + t.Fatalf("added file not found: %v", err) + } + content := string(got) + if !strings.Contains(content, "line one") || !strings.Contains(content, "line two") { + t.Errorf("added file content unexpected: %q", content) + } + _ = os.Remove(targetFile) + }) + + // --- Begin Patch: Add File already exists --- + t.Run("begin_patch_add_file_already_exists", func(t *testing.T) { + targetFile := filepath.Join(scratchDir, "bp_exists.txt") + if err := os.WriteFile(targetFile, []byte("existing\n"), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + defer os.Remove(targetFile) + patch := "*** Begin Patch\n*** Add File: bp_exists.txt\n@@\n+new content\n*** End Patch\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); !isError { + t.Fatalf("Expected error when Add File target already exists") + } + text := getText(t, out) + if !strings.Contains(text, "already exists") { + t.Errorf("expected 'already exists' in error text, got: %s", text) + } + }) + + // --- Begin Patch: Update File --- + t.Run("begin_patch_update_file", func(t *testing.T) { + targetFile := filepath.Join(scratchDir, "bp_update.txt") + if err := os.WriteFile(targetFile, []byte("alpha\nbeta\ngamma\n"), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + defer os.Remove(targetFile) + patch := "*** Begin Patch\n*** Update File: bp_update.txt\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n*** End Patch\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); isError { + t.Fatalf("Begin Patch Update File should succeed, got error: %s", getText(t, out)) + } + got, err := os.ReadFile(targetFile) + if err != nil { + t.Fatalf("updated file not readable: %v", err) + } + content := string(got) + if !strings.Contains(content, "BETA") || strings.Contains(content, "\nbeta\n") { + t.Errorf("Update File content unexpected: %q", content) + } + }) + + // --- Begin Patch: Delete File --- + t.Run("begin_patch_delete_file", func(t *testing.T) { + targetFile := filepath.Join(scratchDir, "bp_delete.txt") + if err := os.WriteFile(targetFile, []byte("to be deleted\n"), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + patch := "*** Begin Patch\n*** Delete File: bp_delete.txt\n*** End Patch\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); isError { + t.Fatalf("Begin Patch Delete File should succeed, got error: %s", getText(t, out)) + } + if _, err := os.Stat(targetFile); !os.IsNotExist(err) { + t.Errorf("deleted file still exists at %s", targetFile) + _ = os.Remove(targetFile) + } + }) + + // --- Begin Patch: Delete File not found --- + t.Run("begin_patch_delete_file_not_found", func(t *testing.T) { + patch := "*** Begin Patch\n*** Delete File: bp_nonexistent.txt\n*** End Patch\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); !isError { + t.Fatalf("Expected error when Delete File target does not exist") + } + text := getText(t, out) + if !strings.Contains(text, "does not exist") { + t.Errorf("expected 'does not exist' in error text, got: %s", text) + } + }) + + // --- Begin Patch: Context mismatch --- + t.Run("begin_patch_context_mismatch", func(t *testing.T) { + targetFile := filepath.Join(scratchDir, "bp_mismatch.txt") + if err := os.WriteFile(targetFile, []byte("lineA\nlineB\nlineC\n"), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + defer os.Remove(targetFile) + // Patch refers to context lines that don't match actual file content + patch := "*** Begin Patch\n*** Update File: bp_mismatch.txt\n@@ -1,3 +1,3 @@\n wrongCtx\n-lineB\n+LINEB\n lineC\n*** End Patch\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); !isError { + t.Fatalf("Expected error for context mismatch in Begin Patch Update File") + } + text := getText(t, out) + if !strings.Contains(text, "Context not found") && !strings.Contains(text, "Hunk failed") && !strings.Contains(text, "hunk") { + t.Errorf("expected context mismatch error, got: %s", text) + } + }) + + // --- Begin Patch: Path traversal rejection --- + t.Run("begin_patch_path_traversal", func(t *testing.T) { + patch := "*** Begin Patch\n*** Add File: ../escape.txt\n@@\n+evil\n*** End Patch\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); !isError { + t.Fatalf("Expected path traversal to be rejected") + } + text := getText(t, out) + if !strings.Contains(text, "..") && !strings.Contains(text, "traversal") && !strings.Contains(text, "safety") { + t.Errorf("expected path safety error, got: %s", text) + } + }) + + // --- Begin Patch: Absolute path rejection --- + t.Run("begin_patch_absolute_path_rejected", func(t *testing.T) { + patch := "*** Begin Patch\n*** Add File: /tmp/absolute_escape.txt\n@@\n+evil\n*** End Patch\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); !isError { + t.Fatalf("Expected absolute path to be rejected") + } + text := getText(t, out) + if !strings.Contains(text, "absolute") && !strings.Contains(text, "safety") { + t.Errorf("expected absolute path error, got: %s", text) + } + }) + + // --- Plain unified diff: success with -p0 (relative paths) --- + t.Run("plain_unified_diff_p0_success", func(t *testing.T) { + targetFile := filepath.Join(scratchDir, "p0_target.txt") + if err := os.WriteFile(targetFile, []byte("hello\nworld\n"), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + defer os.Remove(targetFile) + // Plain header: no a/ b/ prefix — patch -p0 with relative path + patch := "--- p0_target.txt\n+++ p0_target.txt\n@@ -1,2 +1,2 @@\n-hello\n+HELLO\n world\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); isError { + t.Fatalf("Plain unified diff should succeed with -p0 (relative path), got error: %s", getText(t, out)) + } + got, err := os.ReadFile(targetFile) + if err != nil { + t.Fatalf("target file not readable: %v", err) + } + if !strings.Contains(string(got), "HELLO") { + t.Errorf("expected HELLO in patched file, got: %q", string(got)) + } + }) + + // --- Git-style unified diff: regression --- + t.Run("git_style_unified_diff_regression", func(t *testing.T) { + patch := "--- /dev/null\n+++ b/git_regression.txt\n@@ -0,0 +1 @@\n+regression-check\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); isError { + t.Fatalf("Git-style unified diff should succeed, got error: %s", getText(t, out)) + } + got, err := os.ReadFile(filepath.Join(scratchDir, "git_regression.txt")) + if err != nil { + t.Fatalf("created file not found: %v", err) + } + if !strings.Contains(string(got), "regression-check") { + t.Errorf("expected 'regression-check' in file, got: %q", string(got)) + } + _ = os.Remove(filepath.Join(scratchDir, "git_regression.txt")) + }) + + // --- F1: Begin Patch Add File without @@ header (bare + lines) --- + t.Run("begin_patch_add_file_no_hunk_header", func(t *testing.T) { + targetFile := filepath.Join(scratchDir, "bp_bare.txt") + _ = os.Remove(targetFile) + // GPT/OpenAI style: bare + lines, no @@ header + patch := "*** Begin Patch\n*** Add File: bp_bare.txt\n+line alpha\n+line beta\n*** End Patch\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); isError { + t.Fatalf("Add File bare + lines should succeed, got error: %s", getText(t, out)) + } + got, err := os.ReadFile(targetFile) + if err != nil { + t.Fatalf("added file not found: %v", err) + } + content := string(got) + if !strings.Contains(content, "line alpha") || !strings.Contains(content, "line beta") { + t.Errorf("bare Add File content unexpected: %q", content) + } + _ = os.Remove(targetFile) + }) + + // --- F2: Begin Patch Update File with no hunks must fail --- + t.Run("begin_patch_update_file_no_hunks", func(t *testing.T) { + targetFile := filepath.Join(scratchDir, "bp_nohunks.txt") + if err := os.WriteFile(targetFile, []byte("data\n"), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + defer os.Remove(targetFile) + // Update File directive with no @@ hunk content + patch := "*** Begin Patch\n*** Update File: bp_nohunks.txt\n*** End Patch\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); !isError { + t.Fatalf("Update File with no hunks should fail, but succeeded") + } + text := getText(t, out) + if !strings.Contains(text, "no hunks") && !strings.Contains(text, "hunk") { + t.Errorf("expected 'no hunks' in error, got: %s", text) + } + }) + + // --- F3: Missing *** End Patch marker must fail --- + t.Run("begin_patch_missing_end_marker", func(t *testing.T) { + patch := "*** Begin Patch\n*** Add File: bp_noend.txt\n+hello\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); !isError { + t.Fatalf("Missing End Patch should fail, but succeeded") + } + text := getText(t, out) + if !strings.Contains(text, "End Patch") { + t.Errorf("expected 'End Patch' in error, got: %s", text) + } + // Ensure the file was NOT created (incomplete patch must not be applied) + if _, err := os.Stat(filepath.Join(scratchDir, "bp_noend.txt")); err == nil { + t.Errorf("file bp_noend.txt must not be created from incomplete patch") + _ = os.Remove(filepath.Join(scratchDir, "bp_noend.txt")) + } + }) + + // --- F4: Filename with .. as part of a segment (not a traversal) must be allowed --- + t.Run("begin_patch_dotdot_in_filename_allowed", func(t *testing.T) { + targetFile := filepath.Join(scratchDir, "version..txt") + _ = os.Remove(targetFile) + patch := "*** Begin Patch\n*** Add File: version..txt\n+v2.0\n*** End Patch\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); isError { + t.Fatalf("Filename 'version..txt' should be allowed, got error: %s", getText(t, out)) + } + _ = os.Remove(targetFile) + }) + + // --- F6: Top-level noise in Begin Patch block must fail --- + t.Run("begin_patch_top_level_noise_rejected", func(t *testing.T) { + patch := "*** Begin Patch\nsome unexpected content\n*** End Patch\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); !isError { + t.Fatalf("Top-level noise in Begin Patch should fail, but succeeded") + } + text := getText(t, out) + if !strings.Contains(text, "Unexpected") && !strings.Contains(text, "malformed") && !strings.Contains(text, "parse error") { + t.Errorf("expected parse error for top-level noise, got: %s", text) + } + }) + + // --- QA-Fix2: Begin Patch Update File with pure insertion hunk (no context) must fail --- + // A hunk with only + lines and no context/removal lines provides no anchor for placement. + // Silently appending to EOF would be incorrect behaviour for an update operation. + t.Run("begin_patch_update_file_pure_insertion_rejected", func(t *testing.T) { + targetFile := filepath.Join(scratchDir, "bp_pureins.txt") + originalContent := "line1\nline2\n" + if err := os.WriteFile(targetFile, []byte(originalContent), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + defer os.Remove(targetFile) + // Hunk with only + lines and no context or - lines — no anchor + patch := "*** Begin Patch\n*** Update File: bp_pureins.txt\n@@ -1,0 +1,1 @@\n+inserted line\n*** End Patch\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); !isError { + t.Fatalf("Pure insertion hunk in Update File should fail, but succeeded") + } + text := getText(t, out) + if !strings.Contains(text, "context") && !strings.Contains(text, "insertion") { + t.Errorf("expected context/insertion error, got: %s", text) + } + // File must be unchanged + got, err := os.ReadFile(targetFile) + if err != nil { + t.Fatalf("could not read target file after rejection: %v", err) + } + if string(got) != originalContent { + t.Errorf("file must be unchanged after pure insertion rejection, got: %q", string(got)) + } + }) + + // --- Atomicity: unified diff multi-file failure must rollback earlier success and artifacts --- + t.Run("unified_diff_multi_file_failure_rolls_back_all", func(t *testing.T) { + aFile := filepath.Join(scratchDir, "atomic_a.txt") + bFile := filepath.Join(scratchDir, "atomic_b.txt") + if err := os.WriteFile(aFile, []byte("alpha\n"), 0644); err != nil { + t.Fatalf("setup a: %v", err) + } + if err := os.WriteFile(bFile, []byte("beta\n"), 0644); err != nil { + t.Fatalf("setup b: %v", err) + } + defer os.Remove(aFile) + defer os.Remove(bFile) + + patch := "--- a/atomic_a.txt\n+++ b/atomic_a.txt\n@@ -1 +1 @@\n-alpha\n+ALPHA\n" + + "--- a/atomic_b.txt\n+++ b/atomic_b.txt\n@@ -1 +1 @@\n-not-beta\n+BETA\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); !isError { + t.Fatalf("Expected stale second hunk to fail") + } + text := getText(t, out) + if !strings.Contains(text, "rollback: restored pre-apply state") || !strings.Contains(text, "confirm no .rej/.orig") { + t.Fatalf("expected rollback and artifact verification guidance, got: %s", text) + } + gotA, err := os.ReadFile(aFile) + if err != nil { + t.Fatalf("read a after rollback: %v", err) + } + gotB, err := os.ReadFile(bFile) + if err != nil { + t.Fatalf("read b after rollback: %v", err) + } + if string(gotA) != "alpha\n" || string(gotB) != "beta\n" { + t.Fatalf("files must be restored after failed unified diff, got a=%q b=%q", string(gotA), string(gotB)) + } + for _, suffix := range []string{".orig", ".rej"} { + if _, err := os.Stat(bFile + suffix); !os.IsNotExist(err) { + t.Fatalf("artifact %s must not remain after rollback", bFile+suffix) + } + } + }) + + // --- Atomicity: unified diff create then fail must remove created file --- + t.Run("unified_diff_create_then_fail_removes_created_file", func(t *testing.T) { + createdFile := filepath.Join(scratchDir, "atomic_created.txt") + bFile := filepath.Join(scratchDir, "atomic_create_b.txt") + _ = os.Remove(createdFile) + if err := os.WriteFile(bFile, []byte("beta\n"), 0644); err != nil { + t.Fatalf("setup b: %v", err) + } + defer os.Remove(bFile) + + patch := "--- /dev/null\n+++ b/atomic_created.txt\n@@ -0,0 +1 @@\n+created\n" + + "--- a/atomic_create_b.txt\n+++ b/atomic_create_b.txt\n@@ -1 +1 @@\n-not-beta\n+BETA\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); !isError { + t.Fatalf("Expected stale second hunk to fail") + } + text := getText(t, out) + if !strings.Contains(text, "rollback: restored pre-apply state") { + t.Fatalf("expected rollback guidance, got: %s", text) + } + if _, err := os.Stat(createdFile); !os.IsNotExist(err) { + t.Fatalf("created file must be removed after rollback") + } + gotB, err := os.ReadFile(bFile) + if err != nil { + t.Fatalf("read b after rollback: %v", err) + } + if string(gotB) != "beta\n" { + t.Fatalf("existing file must be restored after rollback, got %q", string(gotB)) + } + }) + + // --- Atomicity: Begin Patch plan failure must leave earlier planned writes unapplied --- + t.Run("begin_patch_multi_op_failure_leaves_no_earlier_writes", func(t *testing.T) { + createdFile := filepath.Join(scratchDir, "bp_atomic_created.txt") + bFile := filepath.Join(scratchDir, "bp_atomic_b.txt") + _ = os.Remove(createdFile) + if err := os.WriteFile(bFile, []byte("beta\n"), 0644); err != nil { + t.Fatalf("setup b: %v", err) + } + defer os.Remove(bFile) + + patch := "*** Begin Patch\n*** Add File: bp_atomic_created.txt\n+alpha\n*** Update File: bp_atomic_b.txt\n@@\n wrong\n-beta\n+BETA\n*** End Patch\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); !isError { + t.Fatalf("Expected Begin Patch hunk failure") + } + text := getText(t, out) + if !strings.Contains(text, "rollback: not_needed_no_files_modified") { + t.Fatalf("expected no-write rollback guidance, got: %s", text) + } + if _, err := os.Stat(createdFile); !os.IsNotExist(err) { + t.Fatalf("Begin Patch planned Add File must not be written after later failure") + } + gotB, err := os.ReadFile(bFile) + if err != nil { + t.Fatalf("read b after Begin Patch failure: %v", err) + } + if string(gotB) != "beta\n" { + t.Fatalf("Begin Patch target must remain unchanged, got %q", string(gotB)) + } + }) + + // --- Safety: symlink targets must be rejected before snapshot/rollback follows them --- + t.Run("unified_diff_symlink_target_rejected", func(t *testing.T) { + outsideDir := t.TempDir() + outsideFile := filepath.Join(outsideDir, "outside.txt") + if err := os.WriteFile(outsideFile, []byte("outside\n"), 0644); err != nil { + t.Fatalf("setup outside: %v", err) + } + linkFile := filepath.Join(scratchDir, "atomic_link.txt") + _ = os.Remove(linkFile) + if err := os.Symlink(outsideFile, linkFile); err != nil { + t.Skipf("symlink unavailable on this platform: %v", err) + } + defer os.Remove(linkFile) + + patch := "--- a/atomic_link.txt\n+++ b/atomic_link.txt\n@@ -1 +1 @@\n-outside\n+changed\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); !isError { + t.Fatalf("Expected symlink patch target to be rejected") + } + text := getText(t, out) + if !strings.Contains(text, "symlink") || !strings.Contains(text, "rollback: not_needed_no_files_modified") { + t.Fatalf("expected symlink rejection and no-write guidance, got: %s", text) + } + gotOutside, err := os.ReadFile(outsideFile) + if err != nil { + t.Fatalf("read outside file after rejection: %v", err) + } + if string(gotOutside) != "outside\n" { + t.Fatalf("outside symlink target must remain unchanged, got %q", string(gotOutside)) + } + }) + + // --- Safety: Begin Patch must reject symlink targets during planning, before reads/writes --- + t.Run("begin_patch_symlink_target_rejected", func(t *testing.T) { + outsideDir := t.TempDir() + outsideFile := filepath.Join(outsideDir, "outside_begin.txt") + if err := os.WriteFile(outsideFile, []byte("outside\n"), 0644); err != nil { + t.Fatalf("setup outside: %v", err) + } + linkFile := filepath.Join(scratchDir, "bp_atomic_link.txt") + _ = os.Remove(linkFile) + if err := os.Symlink(outsideFile, linkFile); err != nil { + t.Skipf("symlink unavailable on this platform: %v", err) + } + defer os.Remove(linkFile) + + patch := "*** Begin Patch\n*** Update File: bp_atomic_link.txt\n@@\n-outside\n+changed\n*** End Patch\n" + out := callApplyPatch(t, patch, scratchDir) + if isError, _ := out["isError"].(bool); !isError { + t.Fatalf("Expected Begin Patch symlink target to be rejected") + } + text := getText(t, out) + if !strings.Contains(text, "symlink") || !strings.Contains(text, "rollback: not_needed_no_files_modified") { + t.Fatalf("expected symlink rejection and no-write guidance, got: %s", text) + } + gotOutside, err := os.ReadFile(outsideFile) + if err != nil { + t.Fatalf("read outside file after Begin Patch rejection: %v", err) + } + if string(gotOutside) != "outside\n" { + t.Fatalf("outside Begin Patch symlink target must remain unchanged, got %q", string(gotOutside)) + } + }) +} diff --git a/java/src/main/java/dev/sorted/mcphub/CapabilityGapExplainer.java b/java/src/main/java/dev/sorted/mcphub/CapabilityGapExplainer.java new file mode 100644 index 0000000..9da2035 --- /dev/null +++ b/java/src/main/java/dev/sorted/mcphub/CapabilityGapExplainer.java @@ -0,0 +1,156 @@ +package dev.sorted.mcphub; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Capability-gap explanation service. + * Proposal §6.5 / OS-15 → Alpha scope (CEO 2026-04-26). + * + * When a tool call fails due to policy denial, tool not found, or provider unavailability, + * this service generates a structured explanation of: + * - What capability was missing or blocked + * - Why it was blocked (policy rule, provider state) + * - What alternative capabilities exist + * - What the operator could do to resolve the gap + * + * REQ (from Proposal §6.5): "When a miss would have been prevented by a premium capability, + * the hub can expose a factual explanation the AI may relay to the human operator." + * + * REQ-1.10.6: explanations MUST be factual structured feedback, not upsell. + * REQ-1.10.7: MAY only be emitted when hub can map failure to known capability gap deterministically. + */ +public class CapabilityGapExplainer { + private static final Logger log = LoggerFactory.getLogger(CapabilityGapExplainer.class); + private static final ObjectMapper mapper = new ObjectMapper(); + + /** + * Generate a capability-gap explanation for a denied tool. + * + * @param toolName the denied tool name + * @param errorCode the MCPHUB error code (tool_denied, tool_not_found, etc.) + * @param policyRuleId the policy rule that caused the denial (may be null) + * @param entry the capability entry (may be null if tool_not_found) + * @param alternatives list of available alternative tools + * @return structured explanation as JSON, or null if no deterministic explanation possible + */ + public static ObjectNode explain(String toolName, String errorCode, + String policyRuleId, CapabilityEntry entry, + List alternatives) { + ObjectNode explanation = mapper.createObjectNode(); + explanation.put("tool_requested", toolName); + explanation.put("error_code", errorCode); + + switch (errorCode) { + case "tool_denied" -> { + String explanationText = + "Tool '" + toolName + "' is registered but denied by policy rule: " + policyRuleId; + explanation.put("gap_type", "policy_restriction"); + explanation.put("explanation", explanationText); + explanation.put("premium_feature_id", "policy_governance"); + explanation.put("reason", explanationText); + explanation.put("policy_rule_id", policyRuleId); + if (entry != null) { + addCapabilityInfo(explanation, entry); + } + explanation.put("operator_action", + "Review policy rule '" + policyRuleId + "'. " + + "To allow this tool, update the policy in capabilities.yaml or add a session rule with higher priority."); + } + case "tool_not_found" -> { + if (isTaskContextHide(policyRuleId)) { + String explanationText = + "Tool '" + toolName + "' is registered but currently hidden by task-context filtering."; + explanation.put("gap_type", "task_context_filter"); + explanation.put("explanation", explanationText); + explanation.put("premium_feature_id", "task_context_filtering"); + explanation.put("reason", explanationText); + explanation.put("policy_rule_id", policyRuleId); + if (entry != null) { + addCapabilityInfo(explanation, entry); + } + explanation.put("operator_action", + "Call mcphub_set_task_context with {\"context\":\"all\"} to restore the full tool list, " + + "or switch to a task context where this tool is primary."); + } else { + String explanationText = + "Tool '" + toolName + "' is not registered in the MCPHUB capability registry. " + + "It may need to be added as a relay provider or is not part of the current configuration."; + explanation.put("gap_type", "capability_not_registered"); + explanation.put("explanation", explanationText); + explanation.put("premium_feature_id", "capability_registry_governance"); + explanation.put("reason", explanationText); + explanation.put("operator_action", + "If this tool should be available, add it as a relay provider in ~/.config/mcphub/relays.yaml " + + "with its command, tools list, and capability definitions."); + } + } + case "provider_unreachable" -> { + String explanationText = + "Tool '" + toolName + "' is registered but its provider is not reachable."; + explanation.put("gap_type", "provider_not_running"); + explanation.put("explanation", explanationText); + explanation.put("premium_feature_id", "provider_lifecycle_governance"); + explanation.put("reason", explanationText); + if (entry != null) { + addCapabilityInfo(explanation, entry); + } + explanation.put("operator_action", + "The provider may have crashed or failed to start. " + + "Check daemon logs and try reopening the session with 'mcphub close && mcphub open'."); + } + default -> { + return null; // No deterministic explanation for unknown error types + } + } + + // Add alternatives + if (alternatives != null && !alternatives.isEmpty()) { + ArrayNode alts = mapper.createArrayNode(); + for (CapabilityEntry alt : alternatives) { + ObjectNode a = mapper.createObjectNode(); + a.put("tool", alt.displayName); + if (alt.contract != null && alt.contract.purpose != null) { + a.put("purpose", alt.contract.purpose); + } + a.put("access_class", alt.accessClass != null ? alt.accessClass : "unknown"); + + // If the denied tool has disambiguation info against this alternative, include it + if (entry != null && entry.contract != null && entry.contract.disambiguatesFrom != null) { + for (var df : entry.contract.disambiguatesFrom) { + if (alt.capabilityId != null && alt.capabilityId.equals(df.capabilityId)) { + a.put("distinction", df.distinction); + } + } + } + alts.add(a); + } + explanation.set("alternatives", alts); + } + + return explanation; + } + + private static void addCapabilityInfo(ObjectNode explanation, CapabilityEntry entry) { + explanation.put("access_class", entry.accessClass != null ? entry.accessClass : "unknown"); + explanation.put("rw_boundary", entry.rwBoundary != null ? entry.rwBoundary : "unknown"); + if (entry.contract != null) { + if (entry.contract.purpose != null) { + explanation.put("capability_purpose", entry.contract.purpose); + } + if (entry.contract.sideEffectClass != null) { + explanation.put("side_effect_class", entry.contract.sideEffectClass); + } + } + } + + private static boolean isTaskContextHide(String policyRuleId) { + return policyRuleId != null && policyRuleId.startsWith("task-ctx-hide-"); + } +} diff --git a/java/src/main/java/dev/sorted/mcphub/CapabilityRegistry.java b/java/src/main/java/dev/sorted/mcphub/CapabilityRegistry.java index ba3a40b..772d325 100644 --- a/java/src/main/java/dev/sorted/mcphub/CapabilityRegistry.java +++ b/java/src/main/java/dev/sorted/mcphub/CapabilityRegistry.java @@ -291,13 +291,6 @@ public Optional findByDisplayName(String displayName) { .findFirst(); } - /** Finds a capability by its capability_id. */ - public Optional findByCapabilityId(String capabilityId) { - return entries.stream() - .filter(e -> e.capabilityId.equals(capabilityId)) - .findFirst(); - } - /** Returns all entries where enabled=true. */ public List getEnabled() { return entries.stream() @@ -340,8 +333,12 @@ public static String groupForTool(String displayName) { return switch (displayName) { case "webfetch", "websearch" -> "web"; case "apply_patch" -> "edit"; - case "todowrite", "list", "codesearch", "lsp" -> "project"; - case "plan_enter", "plan_exit", "skill", "batch" -> "session"; + case "todowrite", "list", "codesearch", "lsp", + "task_create", "task_list", "task_update", "task_delete" -> "project"; + case "nexus_issue_create", "nexus_issue_list", + "nexus_issue_close", "nexus_issue_update" -> "nexus"; + case "plan_enter", "plan_exit", "skill", "batch", + "mcphub_checkpoint" -> "session"; case "synthetic_delay" -> "synthetic"; default -> null; }; diff --git a/java/src/main/java/dev/sorted/mcphub/ControlHandler.java b/java/src/main/java/dev/sorted/mcphub/ControlHandler.java index d927bd7..ef9a14c 100644 --- a/java/src/main/java/dev/sorted/mcphub/ControlHandler.java +++ b/java/src/main/java/dev/sorted/mcphub/ControlHandler.java @@ -46,11 +46,24 @@ public class ControlHandler implements JsonRpcServer.MethodHandler { private PolicyEngine policy; private BodyBudgetService bodyBudget; private ProviderHealthTracker healthTracker; + private McpHandler mcpHandler; // OS-12: for cache clear on session close private final AtomicInteger activeBridgeCount = new AtomicInteger(0); private final Set bridgePids = ConcurrentHashMap.newKeySet(); private final Map bridgeLastPing = new ConcurrentHashMap<>(); + // REQ-3.7.6/3.7.7: tracks when a bridge pid was first observed dead. + // Keyed by pid; entry is removed when bridge becomes alive again or is cleaned up. + private final Map bridgeDeadSince = new ConcurrentHashMap<>(); private final ScheduledExecutorService janitorExecutor; + // REQ-3.7.6/3.7.7: janitor runs every JANITOR_INTERVAL_SEC to detect dead bridges + // using ProcessHandle.isAlive(). A bridge is not removed until it has been continuously + // dead for at least BRIDGE_GRACE_SEC seconds. A live bridge is never removed based on + // ping age alone — isAlive() is the primary signal. + // JANITOR_INTERVAL_SEC=1 with BRIDGE_GRACE_SEC=5 gives ~5s detection granularity + // with minimal overhead (local ProcessHandle checks only). + private static final int JANITOR_INTERVAL_SEC = 1; + private static final int BRIDGE_GRACE_SEC = 5; + /** Backward-compatible constructor (Session 1 tests). */ public ControlHandler(StateMachine stateMachine, SessionManager sessionManager, DatabaseManager db) { @@ -67,7 +80,7 @@ public ControlHandler(StateMachine stateMachine, SessionManager sessionManager, return t; }); wireCallbacks(); - janitorExecutor.scheduleWithFixedDelay(this::janitorTask, 60, 60, TimeUnit.SECONDS); + janitorExecutor.scheduleWithFixedDelay(this::janitorTask, JANITOR_INTERVAL_SEC, JANITOR_INTERVAL_SEC, TimeUnit.SECONDS); } /** Full constructor for Session 2+. */ @@ -87,7 +100,7 @@ public ControlHandler(StateMachine stateMachine, SessionManager sessionManager, return t; }); wireCallbacks(); - janitorExecutor.scheduleWithFixedDelay(this::janitorTask, 60, 60, TimeUnit.SECONDS); + janitorExecutor.scheduleWithFixedDelay(this::janitorTask, JANITOR_INTERVAL_SEC, JANITOR_INTERVAL_SEC, TimeUnit.SECONDS); } /** Shutdown the bridge janitor executor. Call on daemon shutdown or test teardown. */ @@ -100,6 +113,11 @@ public void setHealthTracker(ProviderHealthTracker healthTracker) { this.healthTracker = healthTracker; } + /** OS-12: Wire McpHandler for result cache clear on session close. */ + public void setMcpHandler(McpHandler mcpHandler) { + this.mcpHandler = mcpHandler; + } + private void wireCallbacks() { // Wire state-change listener to DB logging (REQ-3.10.2) stateMachine.setStateChangeListener((from, to, trigger, sessionId) -> { @@ -122,6 +140,12 @@ private void wireCallbacks() { if (policy != null && to == StateMachine.State.COOLING_DOWN) { policy.clearSessionRules(); } + // OS-12: Clear result cache on session close + if (mcpHandler != null && to == StateMachine.State.COOLING_DOWN) { + mcpHandler.getResultCache().clear(); + // OS-14: Reset task context on session close + mcpHandler.getTaskContextFilter().reset(); + } // Reset adapter confirmation runtime state on session close. if (registry != null && to == StateMachine.State.COOLING_DOWN) { registry.resetRuntimeStates(); @@ -135,6 +159,7 @@ private void wireCallbacks() { activeBridgeCount.set(0); bridgePids.clear(); bridgeLastPing.clear(); + bridgeDeadSince.clear(); log.info("Session closed, active bridges reset to 0"); } }); @@ -181,18 +206,17 @@ public JsonNode handle(String method, JsonNode params) sessionManager.resetActivity(); } return switch (method) { - case "mcphub.control.status" -> handleStatus(); - case "mcphub.control.arm" -> handleArm(); - case "mcphub.control.open" -> handleOpen(); - case "mcphub.control.close" -> handleClose(); + case "mcphub.control.status" -> handleStatus(); + case "mcphub.control.arm" -> handleArm(); + case "mcphub.control.open" -> handleOpen(); + case "mcphub.control.close" -> handleClose(); case "mcphub.control.bridge_attach"-> handleBridgeAttach(params); case "mcphub.control.bridge_ping" -> handleBridgePing(params); case "mcphub.control.bridge_detach"-> handleBridgeDetach(params); - case "mcphub.control.lock" -> handleLock(params); - case "mcphub.control.unlock" -> handleUnlock(); - case "mcphub.control.health" -> handleHealth(); - case "mcphub.control.capabilities" -> handleCapabilities(); - case "mcphub.control.add_session_rule" -> handleAddSessionRule(params); + case "mcphub.control.lock" -> handleLock(params); + case "mcphub.control.unlock" -> handleUnlock(); + case "mcphub.control.health" -> handleHealth(); + case "mcphub.control.capabilities" -> handleCapabilities(); default -> throw new JsonRpcServer.JsonRpcException( JsonRpcServer.ERR_NOT_FOUND, "Unknown method: " + method); }; @@ -430,70 +454,67 @@ private JsonNode handleBridgePing(JsonNode params) { return r; } - /** mcphub.control.bridge_detach — decrement bridge count; resume idle timer when last bridge leaves. */ + /** + * mcphub.control.bridge_detach: decrement bridge count. When the last + * bridge leaves, return the session to idle-timeout tracking instead of + * closing immediately. This keeps short-lived bridge clients usable while + * preserving REQ-3.7.4's default 300s idle-close behavior. + */ private JsonNode handleBridgeDetach(JsonNode params) { long pid = params != null && params.has("pid") ? params.get("pid").asLong(-1) : -1; - // Only decrement if this PID was a known attached bridge boolean wasKnown = pid >= 0 && bridgePids.remove(pid); - if (wasKnown) { - bridgeLastPing.remove(pid); - int count = activeBridgeCount.updateAndGet(c -> c > 0 ? c - 1 : 0); - log.info("Bridge detached (pid={}). Active bridges: {}", pid, count); - if (count == 0) { - sessionManager.resumeIdleTimer(); - bridgePids.clear(); - bridgeLastPing.clear(); - } - } else { + if (!wasKnown) { log.debug("Bridge detach for unknown pid={}, ignoring", pid); + ObjectNode r = mapper.createObjectNode(); + r.put("status", "ok"); + r.put("active_bridges", activeBridgeCount.get()); + return r; } - ObjectNode r = mapper.createObjectNode(); - r.put("status", "ok"); - r.put("active_bridges", activeBridgeCount.get()); - return r; - } - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- + bridgeLastPing.remove(pid); + bridgeDeadSince.remove(pid); + int count = activeBridgeCount.updateAndGet(c -> c > 0 ? c - 1 : 0); + log.info("Bridge detached (pid={}). Active bridges: {}", pid, count); - /** mcphub.control.add_session_rule — REQ-7.5.1 */ - private JsonNode handleAddSessionRule(JsonNode params) throws JsonRpcServer.JsonRpcException { - StateMachine.State current = stateMachine.getState(); - if (current != StateMachine.State.ARMED && current != StateMachine.State.OPEN) { - throw new JsonRpcServer.JsonRpcException(-32001, - "add_session_rule requires Armed or Open state"); - } - if (params == null || !params.has("tool_pattern") || !params.has("action")) { - throw new JsonRpcServer.JsonRpcException(-32602, - "Missing required params: tool_pattern and action"); - } - String toolPattern = params.get("tool_pattern").asText(); - String action = params.get("action").asText().toLowerCase(); - if (!"allow".equals(action) && !"deny".equals(action) && !"hide".equals(action)) { - throw new JsonRpcServer.JsonRpcException(-32602, - "Invalid action: " + action + ". Must be allow, deny, or hide."); + if (count == 0) { + bridgePids.clear(); + bridgeLastPing.clear(); + bridgeDeadSince.clear(); } - PolicyRule rule = new PolicyRule(); - rule.ruleId = params.has("rule_id") ? params.get("rule_id").asText() - : ("session-" + toolPattern + "-" + action); - rule.toolPattern = toolPattern; - rule.action = action; - rule.priority = params.has("priority") ? params.get("priority").asInt(100) : 100; - rule.scope = "session"; - - policy.addSessionRule(rule); - log.info("Added session rule: id={}, pattern={}, action={}", rule.ruleId, toolPattern, action); + StateMachine.State current = stateMachine.getState(); + boolean failed = false; + String failureReason = null; + if (count == 0 && current == StateMachine.State.ARMED) { + String sessionId = sessionManager.getCurrentSessionId(); + try { + stateMachine.transition(StateMachine.Trigger.CLOSE, sessionId); + sessionManager.endSession(); + } catch (StateMachine.TransitionException e) { + log.warn("ARMED bridge detach transition failed: {} (state={})", e.getMessage(), current); + failed = true; + failureReason = "transition_failed: " + e.getMessage(); + } + } else if (count == 0 && current == StateMachine.State.OPEN) { + sessionManager.resumeIdleTimer(); + log.info("Last bridge detached; idle timer resumed"); + } ObjectNode r = mapper.createObjectNode(); - r.put("status", "ok"); - r.put("rule_id", rule.ruleId); - r.put("scope", "session"); - r.put("state", current.name()); + r.put("active_bridges", activeBridgeCount.get()); + if (failed) { + r.put("status", "error"); + r.put("reason", failureReason); + } else { + r.put("status", "ok"); + } return r; } + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + /** Simulate drain and transition to CLOSED. (Session 1: immediate drain) */ private void doCoolingDownAndClose(String sessionId, String trigger) { try { @@ -505,24 +526,57 @@ private void doCoolingDownAndClose(String sessionId, String trigger) { } } - /** Janitor: detect crashed bridges and clean up counts. */ + /** Janitor: detect crashed bridges and clean up counts. REQ-3.7.6/3.7.7 + * + * Three-state dead-bridge state machine per pid: + * 1. ALIVE: ProcessHandle.isAlive()==true → clear bridgeDeadSince marker if set. + * 2. NEWLY_DEAD: isAlive()==false && no bridgeDeadSince entry → record now. + * 3. READY_TO_REMOVE: isAlive()==false && (now - bridgeDeadSince) >= BRIDGE_GRACE_SEC + * -> remove pid/ping/dead marker, decrement count, resume idle timer if count==0. + */ private void janitorTask() { try { Instant now = Instant.now(); for (Long pid : new ArrayList<>(bridgePids)) { - Instant lastPing = bridgeLastPing.get(pid); - if (lastPing == null) { - lastPing = Instant.now(); + // REQ-3.7.6: check ProcessHandle.isAlive() every JANITOR_INTERVAL_SEC. + // This is cheap enough to call on every bridge every pass. + boolean alive = ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false); + if (alive) { + // Case 1: bridge is alive — clear any dead marker. + bridgeDeadSince.remove(pid); + continue; + } + // Case 2: bridge is dead. + Instant deadSince = bridgeDeadSince.get(pid); + if (deadSince == null) { + // First observation of death — mark and wait for grace period. + bridgeDeadSince.put(pid, now); + log.debug("Bridge pid {} observed dead, starting {}s grace timer", pid, BRIDGE_GRACE_SEC); + continue; } - if (now.getEpochSecond() - lastPing.getEpochSecond() > 180) { - boolean alive = ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false); - if (!alive) { - log.warn("Bridge pid {} appears dead, removing", pid); - bridgePids.remove(pid); - bridgeLastPing.remove(pid); - int count = activeBridgeCount.updateAndGet(c -> c > 0 ? c - 1 : 0); - if (count == 0) { + // Case 3: bridge has been dead since deadSince — check grace period. + if (now.getEpochSecond() - deadSince.getEpochSecond() >= BRIDGE_GRACE_SEC) { + log.warn("Bridge pid {} dead for >= {}s, removing", pid, BRIDGE_GRACE_SEC); + bridgePids.remove(pid); + bridgeLastPing.remove(pid); + bridgeDeadSince.remove(pid); + int count = activeBridgeCount.updateAndGet(c -> c > 0 ? c - 1 : 0); + if (count == 0) { + bridgePids.clear(); + bridgeLastPing.clear(); + bridgeDeadSince.clear(); + StateMachine.State current = stateMachine.getState(); + if (current == StateMachine.State.ARMED) { + String sessionId = sessionManager.getCurrentSessionId(); + try { + stateMachine.transition(StateMachine.Trigger.CLOSE, sessionId); + sessionManager.endSession(); + } catch (StateMachine.TransitionException e) { + log.warn("Janitor ARMED->Closed transition failed: {}", e.getMessage()); + } + } else if (current == StateMachine.State.OPEN) { sessionManager.resumeIdleTimer(); + log.info("Last dead bridge removed; idle timer resumed"); } } } diff --git a/java/src/main/java/dev/sorted/mcphub/DashboardServer.java b/java/src/main/java/dev/sorted/mcphub/DashboardServer.java new file mode 100644 index 0000000..03b4ee5 --- /dev/null +++ b/java/src/main/java/dev/sorted/mcphub/DashboardServer.java @@ -0,0 +1,523 @@ +package dev.sorted.mcphub; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.Desktop; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.sql.*; +import java.util.*; + +/** + * Embedded HTTP dashboard for MCPHUB operational visibility. + * Lives inside the daemon JVM. Serves a self-contained HTML dashboard + * with live provider health, tool call stats, session history, body budget, + * provider restart, log tail, and error details. + * + * Endpoints: + * / — Dashboard HTML + * /api/summary[?since=ISO&until=ISO]— Aggregated stats (SQL) + * /api/tool-stats[?since=ISO&until=ISO] — Per-tool frequency + latency (SQL) + * /api/sessions[?since=ISO&until=ISO] — Recent session events (SQL) + * /api/budget — Body budget snapshot history (SQL) + * /api/providers — Live provider health (in-memory) + * /api/providers/restart?group=NAME — Restart a specific provider group + * /api/state — Live daemon state (in-memory) + * /api/models — Client model analytics (SQL) + * /api/logs/tail?lines=N — Recent route_log entries with error detail + * /api/errors — Error breakdown report (SQL) + * /api/export/tool-stats[?format=csv] — CSV export + */ +public class DashboardServer { + private static final Logger log = LoggerFactory.getLogger(DashboardServer.class); + private static final ObjectMapper mapper = new ObjectMapper(); + + private final DatabaseManager db; + private final String dbPath; + private final ProviderHealthTracker healthTracker; + private final StateMachine stateMachine; + private final CapabilityRegistry registry; + private final ProviderManager providerManager; + private final int port; + private final String bindAddr; + private HttpServer server; + + public DashboardServer(DatabaseManager db, ProviderHealthTracker healthTracker, + StateMachine stateMachine, CapabilityRegistry registry, + ProviderManager providerManager, int port, String bindAddr) { + this.db = db; + this.healthTracker = healthTracker; + this.stateMachine = stateMachine; + this.registry = registry; + this.providerManager = providerManager; + this.port = port; + this.bindAddr = bindAddr; + this.dbPath = DatabaseManager.defaultDbPath(); + } + + public void start() throws IOException { + server = HttpServer.create(new InetSocketAddress(bindAddr, port), 0); + server.createContext("/", this::handleIndex); + server.createContext("/api/summary", this::handleSummary); + server.createContext("/api/tool-stats", this::handleToolStats); + server.createContext("/api/sessions", this::handleSessions); + server.createContext("/api/budget", this::handleBudget); + server.createContext("/api/providers", this::handleProviders); + server.createContext("/api/providers/restart", this::handleProviderRestart); + server.createContext("/api/state", this::handleState); + server.createContext("/api/models", this::handleModels); + server.createContext("/api/logs/tail", this::handleLogTail); + server.createContext("/api/errors", this::handleErrors); + server.createContext("/api/export/tool-stats", this::handleExportToolStats); + server.setExecutor(null); + server.start(); + String url = "http://" + bindAddr + ":" + port + "/"; + log.info("Dashboard available at {}", url); + tryOpenBrowser(url); + } + + public void stop() { + if (server != null) server.stop(1); + } + + private void tryOpenBrowser(String url) { + if (!"0".equals(System.getenv("MCPHUB_DASHBOARD_NO_BROWSER"))) { + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(new URI(url)); + } + } catch (Exception ignored) {} + } + } + + // ---- HTTP handlers ---- + + private void handleIndex(HttpExchange ex) throws IOException { + byte[] html = loadDashboardHtml(); + ex.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); + ex.sendResponseHeaders(200, html.length); + ex.getResponseBody().write(html); + ex.getResponseBody().close(); + } + + private void handleSummary(HttpExchange ex) throws IOException { + String since = getQueryParam(ex, "since"); + String until = getQueryParam(ex, "until"); + String where = timeFilter(since, until); + try { + ObjectNode summary = mapper.createObjectNode(); + execQuery("SELECT COUNT(*) FROM route_log" + where, rs -> + summary.put("totalCalls", rs.next() ? rs.getInt(1) : 0)); + execQuery("SELECT COUNT(*) FROM session_log" + where, rs -> + summary.put("totalSessions", rs.next() ? rs.getInt(1) : 0)); + execQuery("SELECT ROUND(AVG(latency_ms)) FROM route_log" + where, rs -> + summary.put("avgLatencyMs", rs.next() ? rs.getInt(1) : 0)); + execQuery("SELECT COUNT(*) FROM route_log WHERE error_code IS NOT NULL" + + (since != null ? " AND timestamp_utc >= '" + since + "'" : "") + + (until != null ? " AND timestamp_utc <= '" + until + "'" : ""), rs -> + summary.put("errorCalls", rs.next() ? rs.getInt(1) : 0)); + execQuery("SELECT COUNT(*) FROM route_log WHERE timestamp_utc > datetime('now','-1 day')", rs -> + summary.put("calls24h", rs.next() ? rs.getInt(1) : 0)); + execQuery("SELECT COUNT(*) FROM session_log WHERE timestamp_utc > datetime('now','-1 day')", rs -> + summary.put("sessions24h", rs.next() ? rs.getInt(1) : 0)); + execQuery("SELECT COALESCE(SUM(request_size_bytes),0) FROM route_log" + where, rs -> + summary.put("totalRequestBytes", rs.next() ? rs.getLong(1) : 0L)); + execQuery("SELECT COALESCE(SUM(response_size_bytes),0) FROM route_log" + where, rs -> + summary.put("totalResponseBytes", rs.next() ? rs.getLong(1) : 0L)); + sendJson(ex, 200, summary); + } catch (Exception e) { + sendError(ex, 500, e.getMessage()); + } + } + + private void handleToolStats(HttpExchange ex) throws IOException { + String since = getQueryParam(ex, "since"); + String until = getQueryParam(ex, "until"); + String where = timeFilter(since, until); + try { + ArrayNode arr = mapper.createArrayNode(); + execQuery("SELECT tool_name, COUNT(*) as cnt, ROUND(AVG(latency_ms)) as avg_ms, " + + "SUM(CASE WHEN error_code IS NOT NULL THEN 1 ELSE 0 END) as errors " + + "FROM route_log" + where + " GROUP BY tool_name ORDER BY cnt DESC", rs -> { + while (rs.next()) { + ObjectNode t = mapper.createObjectNode(); + t.put("name", rs.getString("tool_name")); + t.put("count", rs.getInt("cnt")); + t.put("avgMs", rs.getInt("avg_ms")); + t.put("errors", rs.getInt("errors")); + arr.add(t); + } + }); + sendJson(ex, 200, arr); + } catch (Exception e) { + sendError(ex, 500, e.getMessage()); + } + } + + private void handleSessions(HttpExchange ex) throws IOException { + String since = getQueryParam(ex, "since"); + String until = getQueryParam(ex, "until"); + String where = timeFilter(since, until); + try { + ArrayNode arr = mapper.createArrayNode(); + execQuery("SELECT sl.session_id, sl.timestamp_utc, sl.event_type as event, " + + "sl.from_state, sl.to_state, " + + "(SELECT COUNT(*) FROM route_log rl WHERE rl.session_id = sl.session_id) as calls " + + "FROM session_log sl" + where + " ORDER BY sl.timestamp_utc DESC LIMIT 100", rs -> { + while (rs.next()) { + ObjectNode sess = mapper.createObjectNode(); + sess.put("sessionId", rs.getString("session_id")); + sess.put("timestamp", rs.getString("timestamp_utc")); + sess.put("event", rs.getString("event")); + sess.put("fromState", rs.getString("from_state")); + sess.put("toState", rs.getString("to_state")); + sess.put("toolCalls", rs.getInt("calls")); + arr.add(sess); + } + }); + sendJson(ex, 200, arr); + } catch (Exception e) { + sendError(ex, 500, e.getMessage()); + } + } + + private void handleBudget(HttpExchange ex) throws IOException { + try { + ArrayNode arr = mapper.createArrayNode(); + execQuery("SELECT timestamp_utc, session_id, effective_tier, inline_builtin_count, " + + "mcphub_hosted_tool_count, request_body_tool_schema_bytes " + + "FROM body_budget_snapshot ORDER BY timestamp_utc DESC LIMIT 50", rs -> { + while (rs.next()) { + ObjectNode b = mapper.createObjectNode(); + b.put("timestamp", rs.getString("timestamp_utc")); + b.put("sessionId", rs.getString("session_id")); + b.put("effectiveTier", rs.getString("effective_tier")); + b.put("inlineBuiltinCount", rs.getInt("inline_builtin_count")); + b.put("mcphubHostedCount", rs.getInt("mcphub_hosted_tool_count")); + b.put("requestBodyBytes", rs.getInt("request_body_tool_schema_bytes")); + arr.add(b); + } + }); + sendJson(ex, 200, arr); + } catch (Exception e) { + sendError(ex, 500, e.getMessage()); + } + } + + private void handleProviders(HttpExchange ex) throws IOException { + ObjectNode result = mapper.createObjectNode(); + Map health = healthTracker != null ? healthTracker.snapshot() : Map.of(); + ArrayNode groups = mapper.createArrayNode(); + for (var e : health.entrySet()) { + ObjectNode g = mapper.createObjectNode(); + g.put("group", e.getKey()); + g.put("status", e.getValue()); + g.put("running", providerManager != null && providerManager.isRunning(e.getKey())); + groups.add(g); + } + result.set("providers", groups); + result.put("total", health.size()); + result.put("running", health.values().stream().filter(s -> "running".equals(s)).count()); + result.put("stopped", health.values().stream().filter(s -> "stopped".equals(s)).count()); + sendJson(ex, 200, result); + } + + /** Restart a specific provider group. POST expected from dashboard UI. */ + private void handleProviderRestart(HttpExchange ex) throws IOException { + if (!"POST".equalsIgnoreCase(ex.getRequestMethod())) { + sendError(ex, 405, "Use POST to restart a provider"); + return; + } + String group = getQueryParam(ex, "group"); + if (group == null || group.isBlank()) { + sendError(ex, 400, "Missing ?group= query parameter"); + return; + } + ObjectNode result = mapper.createObjectNode(); + result.put("group", group); + try { + if (providerManager == null) { + result.put("status", "error"); + result.put("message", "Provider manager not available"); + sendJson(ex, 503, result); + return; + } + boolean ok = providerManager.restartGroup(group); + result.put("status", ok ? "restarted" : "error"); + result.put("message", ok ? "Provider group '" + group + "' restarted" + : "Failed to restart group '" + group + "'"); + sendJson(ex, ok ? 200 : 500, result); + } catch (Exception e) { + result.put("status", "error"); + result.put("message", e.getMessage()); + sendJson(ex, 500, result); + } + } + + private void handleState(HttpExchange ex) throws IOException { + ObjectNode s = mapper.createObjectNode(); + if (stateMachine != null) { + s.put("state", stateMachine.getState().name()); + s.put("isLocked", stateMachine.isLockedUntilUnlock()); + } + if (registry != null) { + s.put("capabilitiesLoaded", registry.getLoadedCount()); + s.put("capabilitiesRejected", registry.getRejectedCount()); + } + sendJson(ex, 200, s); + } + + private void handleModels(HttpExchange ex) throws IOException { + try { + ArrayNode arr = mapper.createArrayNode(); + execQuery("SELECT COALESCE(client_name,'unknown') as name, " + + "COALESCE(client_version,'-') as version, COUNT(*) as calls, " + + "ROUND(AVG(latency_ms)) as avg_ms, " + + "SUM(CASE WHEN error_code IS NOT NULL THEN 1 ELSE 0 END) as errors " + + "FROM route_log GROUP BY client_name, client_version ORDER BY calls DESC", rs -> { + while (rs.next()) { + ObjectNode m = mapper.createObjectNode(); + m.put("name", rs.getString("name")); + m.put("version", rs.getString("version")); + m.put("calls", rs.getInt("calls")); + m.put("avgMs", rs.getInt("avg_ms")); + m.put("errors", rs.getInt("errors")); + arr.add(m); + } + }); + ObjectNode resp = mapper.createObjectNode(); + resp.set("models", arr); + resp.put("totalModels", arr.size()); + sendJson(ex, 200, resp); + } catch (Exception e) { + sendError(ex, 500, e.getMessage()); + } + } + + /** Recent route_log entries — tail view with error details. */ + private void handleLogTail(HttpExchange ex) throws IOException { + int lines; + try { + lines = Integer.parseInt(getQueryParamOrDefault(ex, "lines", "50")); + if (lines < 1) lines = 1; + if (lines > 500) lines = 500; + } catch (NumberFormatException e) { + lines = 50; + } + try { + ArrayNode arr = mapper.createArrayNode(); + execQuery("SELECT timestamp_utc, session_id, tool_name, provider_type, " + + "route_decision, latency_ms, request_size_bytes, response_size_bytes, " + + "error_code, COALESCE(client_name,'-') as client_name " + + "FROM route_log ORDER BY id DESC LIMIT " + lines, rs -> { + while (rs.next()) { + ObjectNode entry = mapper.createObjectNode(); + entry.put("ts", rs.getString("timestamp_utc")); + entry.put("sessionId", abbrev(rs.getString("session_id"), 8)); + entry.put("tool", rs.getString("tool_name")); + entry.put("provider", rs.getString("provider_type")); + entry.put("decision", rs.getString("route_decision")); + entry.put("latencyMs", rs.getInt("latency_ms")); + entry.put("reqBytes", rs.getInt("request_size_bytes")); + entry.put("respBytes", rs.getInt("response_size_bytes")); + String err = rs.getString("error_code"); + entry.put("error", err != null ? err : ""); + entry.put("client", rs.getString("client_name")); + arr.add(entry); + } + }); + sendJson(ex, 200, arr); + } catch (Exception e) { + sendError(ex, 500, e.getMessage()); + } + } + + /** Error breakdown report — per-tool error counts with detail. */ + private void handleErrors(HttpExchange ex) throws IOException { + try { + ObjectNode report = mapper.createObjectNode(); + + // Per-tool error breakdown + ArrayNode perTool = mapper.createArrayNode(); + execQuery("SELECT tool_name, error_code, COUNT(*) as cnt " + + "FROM route_log WHERE error_code IS NOT NULL " + + "GROUP BY tool_name, error_code ORDER BY cnt DESC", rs -> { + while (rs.next()) { + ObjectNode e = mapper.createObjectNode(); + e.put("tool", rs.getString("tool_name")); + e.put("errorCode", rs.getString("error_code")); + e.put("count", rs.getInt("cnt")); + perTool.add(e); + } + }); + report.set("perTool", perTool); + + // Total error summary + execQuery("SELECT COUNT(*) as total, COUNT(DISTINCT tool_name) as affected_tools " + + "FROM route_log WHERE error_code IS NOT NULL", rs -> { + if (rs.next()) { + report.put("totalErrors", rs.getInt("total")); + report.put("affectedTools", rs.getInt("affected_tools")); + } + }); + + // Recent errors with detail + ArrayNode recent = mapper.createArrayNode(); + execQuery("SELECT timestamp_utc, session_id, tool_name, error_code, " + + "route_decision, provider_type " + + "FROM route_log WHERE error_code IS NOT NULL " + + "ORDER BY id DESC LIMIT 20", rs -> { + while (rs.next()) { + ObjectNode r = mapper.createObjectNode(); + r.put("ts", rs.getString("timestamp_utc")); + r.put("sessionId", abbrev(rs.getString("session_id"), 8)); + r.put("tool", rs.getString("tool_name")); + r.put("error", rs.getString("error_code")); + r.put("decision", rs.getString("route_decision")); + r.put("provider", rs.getString("provider_type")); + recent.add(r); + } + }); + report.set("recentErrors", recent); + + sendJson(ex, 200, report); + } catch (Exception e) { + sendError(ex, 500, e.getMessage()); + } + } + + private void handleExportToolStats(HttpExchange ex) throws IOException { + String format = getQueryParam(ex, "format"); + boolean csv = "csv".equalsIgnoreCase(format); + String since = getQueryParam(ex, "since"); + String until = getQueryParam(ex, "until"); + String where = timeFilter(since, until); + + StringBuilder sb = new StringBuilder(); + try { + execQuery("SELECT tool_name, COUNT(*) as cnt, ROUND(AVG(latency_ms)) as avg_ms, " + + "SUM(CASE WHEN error_code IS NOT NULL THEN 1 ELSE 0 END) as errors " + + "FROM route_log" + where + " GROUP BY tool_name ORDER BY cnt DESC", rs -> { + if (csv) { + sb.append("tool_name,count,avg_ms,errors\n"); + while (rs.next()) { + sb.append(rs.getString("tool_name")).append(',') + .append(rs.getInt("cnt")).append(',') + .append(rs.getInt("avg_ms")).append(',') + .append(rs.getInt("errors")).append('\n'); + } + } else { + ArrayNode arr = buildToolStatsArray(rs); + try { + sb.append(mapper.writeValueAsString(arr)); + } catch (com.fasterxml.jackson.core.JsonProcessingException jpe) { + sb.append("[]"); + } + } + }); + } catch (Exception e) { + sendError(ex, 500, e.getMessage()); + return; + } + + byte[] bytes = sb.toString().getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().set("Content-Type", + csv ? "text/csv; charset=utf-8" : "application/json"); + ex.getResponseHeaders().set("Content-Disposition", + csv ? "attachment; filename=mcphub-tool-stats.csv" : "inline"); + ex.sendResponseHeaders(200, bytes.length); + ex.getResponseBody().write(bytes); + ex.getResponseBody().close(); + } + + private ArrayNode buildToolStatsArray(ResultSet rs) throws SQLException { + ArrayNode arr = mapper.createArrayNode(); + while (rs.next()) { + ObjectNode t = mapper.createObjectNode(); + t.put("name", rs.getString("tool_name")); + t.put("count", rs.getInt("cnt")); + t.put("avgMs", rs.getInt("avg_ms")); + t.put("errors", rs.getInt("errors")); + arr.add(t); + } + return arr; + } + + // ---- helpers ---- + + private String getQueryParam(HttpExchange ex, String key) { + String q = ex.getRequestURI().getQuery(); + if (q == null) return null; + for (String p : q.split("&")) { + String[] kv = p.split("=", 2); + if (kv.length == 2 && kv[0].equals(key)) return kv[1]; + } + return null; + } + + private String getQueryParamOrDefault(HttpExchange ex, String key, String defaultValue) { + String val = getQueryParam(ex, key); + return val != null ? val : defaultValue; + } + + private String timeFilter(String since, String until) { + StringBuilder w = new StringBuilder(); + if (since != null && !since.isBlank()) { + w.append(" WHERE timestamp_utc >= '").append(since).append("'"); + } + if (until != null && !until.isBlank()) { + w.append(w.isEmpty() ? " WHERE " : " AND ") + .append("timestamp_utc <= '").append(until).append("'"); + } + return w.toString(); + } + + private static String abbrev(String s, int maxLen) { + if (s == null) return ""; + return s.length() <= maxLen ? s : s.substring(0, maxLen) + "..."; + } + + @FunctionalInterface + private interface ResultSetConsumer { + void accept(ResultSet rs) throws SQLException; + } + + private void execQuery(String sql, ResultSetConsumer consumer) throws SQLException { + try (Connection c = DriverManager.getConnection("jdbc:sqlite:" + dbPath); + Statement s = c.createStatement(); + ResultSet rs = s.executeQuery(sql)) { + consumer.accept(rs); + } + } + + private void sendJson(HttpExchange ex, int code, Object data) throws IOException { + byte[] bytes = mapper.writeValueAsBytes(data); + ex.getResponseHeaders().set("Content-Type", "application/json"); + ex.sendResponseHeaders(code, bytes.length); + ex.getResponseBody().write(bytes); + ex.getResponseBody().close(); + } + + private void sendError(HttpExchange ex, int code, String msg) throws IOException { + ObjectNode err = mapper.createObjectNode(); + err.put("error", msg); + sendJson(ex, code, err); + } + + private byte[] loadDashboardHtml() { + try (InputStream is = getClass().getResourceAsStream("/dashboard.html")) { + if (is != null) return is.readAllBytes(); + } catch (Exception ignored) {} + return ("

MCPHUB Dashboard

Dashboard template not found.

") + .getBytes(StandardCharsets.UTF_8); + } +} diff --git a/java/src/main/java/dev/sorted/mcphub/DatabaseManager.java b/java/src/main/java/dev/sorted/mcphub/DatabaseManager.java index a33a692..edce8a9 100644 --- a/java/src/main/java/dev/sorted/mcphub/DatabaseManager.java +++ b/java/src/main/java/dev/sorted/mcphub/DatabaseManager.java @@ -18,7 +18,7 @@ */ public class DatabaseManager implements AutoCloseable { private static final Logger log = LoggerFactory.getLogger(DatabaseManager.class); - private static final int ALPHA_SCHEMA_VERSION = 1; + private static final int ALPHA_SCHEMA_VERSION = 2; private final String dbPath; private Connection connection; @@ -74,7 +74,8 @@ private void runMigrations() throws SQLException { int current = getCurrentSchemaVersion(); if (current < ALPHA_SCHEMA_VERSION) { log.info("Running schema migration: {} -> {}", current, ALPHA_SCHEMA_VERSION); - migrateV1(); + if (current < 1) migrateV1(); + if (current < 2) migrateV2(); setSchemaVersion(ALPHA_SCHEMA_VERSION); log.info("Schema migration complete. Version = {}", ALPHA_SCHEMA_VERSION); } else { @@ -236,6 +237,25 @@ PRIMARY KEY (session_id, provider_id) """); } + // ------------------------------------------------------------------------- + // Schema version 2 — Model analytics columns (2026-05-05) + // ------------------------------------------------------------------------- + + private void migrateV2() throws SQLException { + // Add client_name and client_version to route_log for model analytics + try (Statement st = connection.createStatement()) { + st.execute("ALTER TABLE route_log ADD COLUMN client_name TEXT"); + } catch (SQLException e) { + if (!e.getMessage().contains("duplicate column")) throw e; + } + try (Statement st = connection.createStatement()) { + st.execute("ALTER TABLE route_log ADD COLUMN client_version TEXT"); + } catch (SQLException e) { + if (!e.getMessage().contains("duplicate column")) throw e; + } + log.info("V2 migration complete: added client_name, client_version to route_log"); + } + // ------------------------------------------------------------------------- // Write-lock management (REQ-8.8.1, REQ-8.8.4) // ------------------------------------------------------------------------- diff --git a/java/src/main/java/dev/sorted/mcphub/DryRunService.java b/java/src/main/java/dev/sorted/mcphub/DryRunService.java new file mode 100644 index 0000000..20864df --- /dev/null +++ b/java/src/main/java/dev/sorted/mcphub/DryRunService.java @@ -0,0 +1,91 @@ +package dev.sorted.mcphub; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Dry-run mode for destructive tools. + * Proposal §6.5 / OS-13 → Alpha scope (CEO 2026-04-26). + * + * When _dry_run=true is passed in a tools/call request, MCPHUB returns + * a preview of what WOULD happen without actually calling the provider: + * - Policy decision (allow/deny/hide) + * - Contract metadata (purpose, may_do, must_not_do, side_effect_class) + * - Access class and rw_boundary + * - Provider routing info (group_id, provider_type) + * + * Dry-run is available for all tools but is most useful for guarded/restricted tools + * where side effects are non-trivial. + */ +public class DryRunService { + private static final ObjectMapper mapper = new ObjectMapper(); + + /** + * Generate a dry-run preview response for a tool call. + * + * @param entry the capability entry for the tool + * @param policyResult the policy evaluation result + * @param groupId the resolved provider group ID + * @param providerType the provider type (builtin_hosted or relay) + * @return MCP-compliant CallToolResult with dry-run preview + */ + public static ObjectNode preview(CapabilityEntry entry, PolicyEngine.PolicyResult policyResult, + String groupId, String providerType) { + ObjectNode r = mapper.createObjectNode(); + ArrayNode content = mapper.createArrayNode(); + ObjectNode textItem = mapper.createObjectNode(); + textItem.put("type", "text"); + + ObjectNode preview = mapper.createObjectNode(); + preview.put("dry_run", true); + preview.put("tool_name", entry.displayName); + + // Policy + preview.put("policy_decision", policyResult.decision().name().toLowerCase()); + if (policyResult.matchedRuleId() != null) { + preview.put("policy_rule_id", policyResult.matchedRuleId()); + } + + // Access classification + preview.put("access_class", entry.accessClass != null ? entry.accessClass : "unknown"); + preview.put("rw_boundary", entry.rwBoundary != null ? entry.rwBoundary : "unknown"); + + // Routing + preview.put("provider_group", groupId); + preview.put("provider_type", providerType); + preview.put("provider_running", true); + + // Contract details + if (entry.contract != null) { + ObjectNode contract = mapper.createObjectNode(); + if (entry.contract.purpose != null) contract.put("purpose", entry.contract.purpose); + if (entry.contract.sideEffectClass != null) contract.put("side_effect_class", entry.contract.sideEffectClass); + if (entry.contract.mayDo != null) { + ArrayNode mayDo = mapper.createArrayNode(); + entry.contract.mayDo.forEach(mayDo::add); + contract.set("may_do", mayDo); + } + if (entry.contract.mustNotDo != null) { + ArrayNode mustNotDo = mapper.createArrayNode(); + entry.contract.mustNotDo.forEach(mustNotDo::add); + contract.set("must_not_do", mustNotDo); + } + preview.set("contract", contract); + } + + // Safety warning for destructive tools + if ("restricted".equals(entry.accessClass) || "execute".equals(entry.rwBoundary)) { + preview.put("safety_warning", "This tool modifies external state. Review the contract before execution."); + } + if ("guarded".equals(entry.accessClass)) { + preview.put("safety_warning", "This tool requires caution. Side effects may be significant."); + } + + textItem.put("text", preview.toString()); + content.add(textItem); + r.set("content", content); + r.put("isError", false); + return r; + } +} diff --git a/java/src/main/java/dev/sorted/mcphub/Main.java b/java/src/main/java/dev/sorted/mcphub/Main.java index 713d82d..fae3dd4 100644 --- a/java/src/main/java/dev/sorted/mcphub/Main.java +++ b/java/src/main/java/dev/sorted/mcphub/Main.java @@ -25,7 +25,7 @@ * AMD-MCPHUB-001: Java owns everything. Go is just a JRE launcher. * * Subcommands: - * _daemon — run the daemon process (UDS server + provider management) + * _daemon — run the daemon process (UDS server + provider management + dashboard) * bridge — run the stdio bridge (AI client attachment) * status — print daemon state * open — arm + open session @@ -36,6 +36,9 @@ * capabilities — print capability registry * version — print version * query --sql — read-only SQL against mcphub.db + * doctor — preflight local runtime/config checks + * report — text-based CLI dashboard + * dash — print dashboard URL and instructions */ public class Main { private static final Logger log = LoggerFactory.getLogger(Main.class); @@ -109,10 +112,17 @@ public static void main(String[] args) throws Exception { System.err.println("Usage: mcphub config validate"); } } + case "doctor" -> runDoctor(jsonOutput); + case "report" -> runReport(); + case "dash", "dashboard" -> { + System.out.println("Dashboard runs inside the daemon when MCPHUB_DASHBOARD_ENABLED=1."); + System.out.println("Start with: MCPHUB_DASHBOARD_ENABLED=1 mcphub start"); + System.out.println("Then connect to: http://localhost:9741"); + } default -> { System.err.println("mcphub " + VERSION); System.err.println("Usage: mcphub [--json]"); - System.err.println("Commands: _daemon, bridge, status, open, close, lock, unlock, health, capabilities, version, query, config"); + System.err.println("Commands: _daemon, bridge, status, open, close, lock, unlock, health, capabilities, version, query, config, doctor, report, dash"); System.exit(1); } } @@ -223,6 +233,7 @@ private static void runDaemon() throws Exception { McpHandler mcpHandler = new McpHandler( stateMachine, registry, policy, db, bodyBudget); controlHandler.setHealthTracker(healthTracker); + controlHandler.setMcpHandler(mcpHandler); // OS-12: cache clear on session close mcpHandler.setHealthTracker(healthTracker); mcpHandler.setSessionManager(sessionManager); mcpHandler.setProviderManager(providerManager); @@ -239,6 +250,24 @@ private static void runDaemon() throws Exception { return mcpHandler.handle(method, params); }; + // Step 9b: Dashboard HTTP server (embedded in daemon). Opt-in to avoid + // binding a fixed port during tests and headless/service operation. + DashboardServer dashboard = null; + String dashEnabled = System.getenv("MCPHUB_DASHBOARD_ENABLED"); + if ("1".equals(dashEnabled)) { + int dashPort = 9741; + String portEnv = System.getenv("MCPHUB_DASHBOARD_PORT"); + if (portEnv != null && !portEnv.isBlank()) dashPort = Integer.parseInt(portEnv); + String dashBind = System.getenv().getOrDefault("MCPHUB_DASHBOARD_BIND", "127.0.0.1"); + dashboard = new DashboardServer(db, healthTracker, stateMachine, registry, providerManager, + dashPort, dashBind); + try { + dashboard.start(); + } catch (Exception e) { + log.warn("Dashboard failed to start: {}", e.getMessage()); + } + } + // Step 10: UDS daemon server (AMD-MCPHUB-001: replaces Go UDS server) DaemonServer server = new DaemonServer(dispatcher, providerManager); log.info("mcphub daemon ready. Serving on UDS: {}", DaemonServer.socketPath()); @@ -246,6 +275,7 @@ private static void runDaemon() throws Exception { try { server.run(); } finally { + if (dashboard != null) dashboard.stop(); providerManager.stopAll(); sessionManager.shutdown(); db.close(); @@ -341,18 +371,35 @@ private static void runQuery(String sql, boolean jsonOutput) throws Exception { /** Find adapter dist directory relative to JAR or working directory. */ private static String findAdapterDir() { - // Check relative to JAR + // Check MCPHUB_HOME env var (operator override) + String homeEnv = System.getenv("MCPHUB_HOME"); + if (homeEnv != null && !homeEnv.isBlank()) { + Path p = Path.of(homeEnv, "adapters", "dist"); + if (Files.isDirectory(p)) return p.toAbsolutePath().toString(); + } + + // Check relative to JAR (distribution: lib/ is sibling to adapters/) try { String jarDir = new File(Main.class.getProtectionDomain() .getCodeSource().getLocation().toURI()).getParent(); - Path candidate = Path.of(jarDir, "..", "adapters", "dist"); - if (Files.isDirectory(candidate)) return candidate.toAbsolutePath().toString(); + // Distribution: lib/ → adapters/dist (1 level up) + // Gradle build: java/build/libs → adapters/dist (3 levels up) + for (String rel : new String[]{"..", "../../..", "../../../.."}) { + Path candidate = Path.of(jarDir, rel, "adapters", "dist").normalize(); + if (Files.isDirectory(candidate)) return candidate.toAbsolutePath().normalize().toString(); + } } catch (Exception ignored) {} // Check working directory Path cwd = Path.of("adapters", "dist"); if (Files.isDirectory(cwd)) return cwd.toAbsolutePath().toString(); + // Check relative to working directory (1-3 levels up, for build subdirectory execution) + for (String rel : new String[]{"..", "../..", "../../.."}) { + Path candidate = Path.of(rel, "adapters", "dist").normalize(); + if (Files.isDirectory(candidate)) return candidate.toAbsolutePath().normalize().toString(); + } + // Fallback return "adapters/dist"; } @@ -370,4 +417,338 @@ private static String getFlagValue(String[] args, String flag) { } return null; } + + // ========================================================================= + // doctor — local preflight checks for installation/support + // ========================================================================= + + private record DoctorCheck(String name, String status, String message, String detail) {} + + private static void runDoctor(boolean jsonOutput) throws Exception { + List checks = new ArrayList<>(); + checks.add(checkJavaRuntime()); + checks.add(checkCommand("node", "node", "--version")); + checks.add(checkCommand("rg", "rg", "--version")); + checks.add(checkSqliteWritable()); + checks.add(checkAdapterDist()); + checks.add(checkRelaysYaml()); + checks.add(checkBraveKey()); + + boolean hasFail = checks.stream().anyMatch(c -> "fail".equals(c.status())); + boolean hasWarn = checks.stream().anyMatch(c -> "warn".equals(c.status())); + String overall = hasFail ? "error" : hasWarn ? "warning" : "ok"; + + if (jsonOutput) { + ObjectNode root = mapper.createObjectNode(); + root.put("status", overall); + var arr = root.putArray("checks"); + for (DoctorCheck c : checks) { + ObjectNode n = arr.addObject(); + n.put("name", c.name()); + n.put("status", c.status()); + n.put("message", c.message()); + if (c.detail() != null && !c.detail().isBlank()) n.put("detail", c.detail()); + } + System.out.println(mapper.writeValueAsString(root)); + } else { + System.out.println("MCPHUB doctor: " + overall.toUpperCase()); + for (DoctorCheck c : checks) { + String mark = switch (c.status()) { + case "pass" -> "✓"; + case "warn" -> "⚠"; + default -> "✗"; + }; + System.out.printf("%s %-18s %s%n", mark, c.name(), c.message()); + if (c.detail() != null && !c.detail().isBlank()) { + System.out.printf(" %s%n", c.detail()); + } + } + } + + if (hasFail) System.exit(1); + } + + private static DoctorCheck checkJavaRuntime() { + String version = System.getProperty("java.version", "unknown"); + String home = System.getProperty("java.home", ""); + Path javaBin = Path.of(home, "bin", "java"); + if (!Files.isExecutable(javaBin)) { + return new DoctorCheck("java", "fail", "Java runtime is not executable", javaBin.toString()); + } + return new DoctorCheck("java", "pass", "Java " + version, javaBin.toString()); + } + + private static DoctorCheck checkCommand(String name, String command, String versionArg) { + String executable = findOnPath(command); + if (executable == null) { + return new DoctorCheck(name, "fail", command + " not found on PATH", "Install " + command + " and retry."); + } + try { + Process p = new ProcessBuilder(executable, versionArg) + .redirectErrorStream(true) + .start(); + boolean done = p.waitFor(3, java.util.concurrent.TimeUnit.SECONDS); + if (!done) { + p.destroyForcibly(); + return new DoctorCheck(name, "fail", command + " did not answer --version", executable); + } + String output = new String(p.getInputStream().readAllBytes()).strip().split("\\R", 2)[0]; + if (p.exitValue() != 0) { + return new DoctorCheck(name, "fail", command + " --version failed", output); + } + return new DoctorCheck(name, "pass", output.isBlank() ? executable : output, executable); + } catch (Exception e) { + return new DoctorCheck(name, "fail", command + " check failed", e.getMessage()); + } + } + + private static String findOnPath(String command) { + Path direct = Path.of(command); + if (direct.getParent() != null && Files.isExecutable(direct)) return direct.toString(); + String path = System.getenv("PATH"); + if (path == null || path.isBlank()) return null; + for (String part : path.split(File.pathSeparator)) { + if (part.isBlank()) continue; + Path candidate = Path.of(part, command); + if (Files.isRegularFile(candidate) && Files.isExecutable(candidate)) { + return candidate.toAbsolutePath().normalize().toString(); + } + } + return null; + } + + private static DoctorCheck checkSqliteWritable() { + String dbPath = DatabaseManager.defaultDbPath(); + try { + Path path = Path.of(dbPath); + Path parent = path.getParent(); + if (parent != null) Files.createDirectories(parent); + try (java.sql.Connection conn = java.sql.DriverManager.getConnection("jdbc:sqlite:" + dbPath); + java.sql.Statement st = conn.createStatement()) { + conn.setAutoCommit(false); + st.execute("CREATE TABLE IF NOT EXISTS doctor_probe (id INTEGER PRIMARY KEY, checked_utc TEXT NOT NULL)"); + st.execute("INSERT INTO doctor_probe(checked_utc) VALUES(datetime('now'))"); + conn.rollback(); + } + return new DoctorCheck("sqlite_writable", "pass", "database writable", dbPath); + } catch (Exception e) { + return new DoctorCheck("sqlite_writable", "fail", "database is not writable", dbPath + ": " + e.getMessage()); + } + } + + private static DoctorCheck checkAdapterDist() { + String adapterDir = System.getenv("MCPHUB_ADAPTER_DIR"); + if (adapterDir == null || adapterDir.isBlank()) adapterDir = findAdapterDir(); + Path base = Path.of(adapterDir); + if (!Files.isDirectory(base)) { + return new DoctorCheck("adapter_dist", "fail", "adapter dist directory not found", base.toString()); + } + List missing = new ArrayList<>(); + for (ProviderManager.GroupConfig g : ProviderManager.defaultGroups()) { + if (g.script() != null && !Files.isRegularFile(base.resolve(g.script()))) { + missing.add(g.script()); + } + } + if (!missing.isEmpty()) { + return new DoctorCheck("adapter_dist", "fail", "adapter dist incomplete", base + " missing " + String.join(", ", missing)); + } + return new DoctorCheck("adapter_dist", "pass", "adapter dist found", base.toAbsolutePath().normalize().toString()); + } + + private static DoctorCheck checkRelaysYaml() { + Path relaysPath = Path.of(System.getenv().getOrDefault("MCPHUB_RELAYS_PATH", + System.getProperty("user.home") + "/.config/mcphub/relays.yaml")); + if (!Files.exists(relaysPath)) { + return new DoctorCheck("relays_yaml", "warn", "relays.yaml not configured (optional)", relaysPath.toString()); + } + try { + ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + yamlMapper.findAndRegisterModules(); + JsonNode root = yamlMapper.readTree(relaysPath.toFile()); + JsonNode relays = root.path("relays"); + if (!relays.isMissingNode() && !relays.isArray()) { + return new DoctorCheck("relays_yaml", "fail", "relays.yaml invalid", "relays must be an array: " + relaysPath); + } + int count = relays.isArray() ? relays.size() : 0; + return new DoctorCheck("relays_yaml", "pass", "relays.yaml valid (" + count + " relays)", relaysPath.toString()); + } catch (Exception e) { + return new DoctorCheck("relays_yaml", "fail", "relays.yaml invalid", relaysPath + ": " + e.getMessage()); + } + } + + private static DoctorCheck checkBraveKey() { + Path keyPath = Path.of(System.getProperty("user.home"), ".config", "mcphub", "brave-api-key"); + if (!Files.exists(keyPath)) { + return new DoctorCheck("brave_key", "warn", "Brave Search key not configured (websearch optional)", keyPath.toString()); + } + try { + String key = Files.readString(keyPath).trim(); + if (key.isBlank()) { + return new DoctorCheck("brave_key", "fail", "Brave Search key file is empty", keyPath.toString()); + } + return new DoctorCheck("brave_key", "pass", "Brave Search key configured", keyPath.toString()); + } catch (Exception e) { + return new DoctorCheck("brave_key", "fail", "Brave Search key not readable", keyPath + ": " + e.getMessage()); + } + } + + // ========================================================================= + // CLI report — text-based dashboard for terminals (no browser needed) + // ========================================================================= + + private static void runReport() { + try (java.sql.Connection c = java.sql.DriverManager.getConnection( + "jdbc:sqlite:" + DatabaseManager.defaultDbPath())) { + java.sql.Statement s = c.createStatement(); + + // Summary + int totalCalls = 0, totalSessions = 0, avgLatency = 0, errors = 0, warnings = 0; + long totalReqBytes = 0, totalRespBytes = 0; + try (java.sql.ResultSet rs = s.executeQuery( + "SELECT COUNT(*) as calls, ROUND(AVG(latency_ms)) as lat, " + + "SUM(CASE WHEN error_code IS NOT NULL THEN 1 ELSE 0 END) as errs, " + + "SUM(CASE WHEN error_code IS NULL AND route_decision != 'allowed' THEN 1 ELSE 0 END) as warns, " + + "COALESCE(SUM(request_size_bytes),0) as rq, COALESCE(SUM(response_size_bytes),0) as rs FROM route_log")) { + if (rs.next()) { + totalCalls = rs.getInt("calls"); + avgLatency = rs.getInt("lat"); + errors = rs.getInt("errs"); + warnings = rs.getInt("warns"); + totalReqBytes = rs.getLong("rq"); + totalRespBytes = rs.getLong("rs"); + } + } + try (java.sql.ResultSet rs = s.executeQuery("SELECT COUNT(*) FROM session_log")) { + if (rs.next()) totalSessions = rs.getInt(1); + } + // p95 latency + int p95Latency = 0; + java.util.List allLatencies = new java.util.ArrayList<>(); + try (java.sql.ResultSet rs = s.executeQuery("SELECT latency_ms FROM route_log ORDER BY latency_ms")) { + while (rs.next()) allLatencies.add(rs.getInt(1)); + } + if (!allLatencies.isEmpty()) { + int p95idx = (int) (allLatencies.size() * 0.95); + if (p95idx >= allLatencies.size()) p95idx = allLatencies.size() - 1; + p95Latency = allLatencies.get(p95idx); + } + + System.out.println(); + System.out.println("═══ MCPHUB Dashboard ═══"); + System.out.printf(" Sessions: %-6d Tool Calls: %-6d Avg: %dms p95: %dms%n", + totalSessions, totalCalls, avgLatency, p95Latency); + double errRate = totalCalls > 0 ? (errors * 100.0 / totalCalls) : 0; + double warnRate = totalCalls > 0 ? (warnings * 100.0 / totalCalls) : 0; + System.out.printf(" Pass: %-5d (%.1f%%) Warn: %-5d (%.1f%%) Fail: %-5d (%.1f%%)%n", + totalCalls - errors - warnings, 100.0 - errRate - warnRate, + warnings, warnRate, errors, errRate); + System.out.printf(" Request: %s Response: %s%n", + formatBytes(totalReqBytes), formatBytes(totalRespBytes)); + System.out.println(); + + // Tool frequency + System.out.println("── Tool Call Frequency ──"); + System.out.println(" tool count avg p95 err%"); + try (java.sql.ResultSet rs = s.executeQuery( + "SELECT tool_name, COUNT(*) as cnt, ROUND(AVG(latency_ms)) as ms, " + + "SUM(CASE WHEN error_code IS NOT NULL THEN 1 ELSE 0 END) as errs " + + "FROM route_log GROUP BY tool_name ORDER BY cnt DESC LIMIT 20")) { + int max = 0; + java.util.List> rows = new java.util.ArrayList<>(); + while (rs.next()) { + int cnt = rs.getInt("cnt"); + if (cnt > max) max = cnt; + java.util.Map row = new java.util.LinkedHashMap<>(); + row.put("name", rs.getString("tool_name")); + row.put("cnt", cnt); + row.put("ms", rs.getInt("ms")); + row.put("errs", rs.getInt("errs")); + rows.add(row); + } + for (var row : rows) { + String name = (String) row.get("name"); + int cnt = (int) row.get("cnt"); + int bar = Math.min((int) (cnt * 30.0 / Math.max(max, 1)), 30); + int errPerTool = (int) row.get("errs"); + double ePct = cnt > 0 ? (errPerTool * 100.0 / cnt) : 0; + int toolP95 = 0; + java.util.List toolLat = new java.util.ArrayList<>(); + try (java.sql.ResultSet lrs = s.executeQuery( + "SELECT latency_ms FROM route_log WHERE tool_name = '" + + name.replace("'", "''") + "' ORDER BY latency_ms")) { + while (lrs.next()) toolLat.add(lrs.getInt(1)); + } catch (Exception ignored) {} + if (!toolLat.isEmpty()) { + int idx = (int) (toolLat.size() * 0.95); + if (idx >= toolLat.size()) idx = toolLat.size() - 1; + toolP95 = toolLat.get(idx); + } + String errStr = ePct > 0 ? String.format(" %.0f%%", ePct) : ""; + String mark = ePct > 10 ? " ✗" : ePct > 0 ? " ⚠" : " ✓"; + String barStr = "█".repeat(Math.max(bar, 0)); + System.out.printf(" %-30s %s %5d %5dms %5dms %s%s%n", + name.length() > 30 ? name.substring(0, 30) : name, barStr, cnt, + (int) row.get("ms"), toolP95, errStr, mark); + } + } + System.out.println(); + + // Recent sessions + System.out.println("── Recent Sessions ──"); + System.out.println(" session started ended calls result"); + try (java.sql.ResultSet rs = s.executeQuery( + "SELECT session_id, event_type, timestamp_utc, to_state FROM session_log " + + "WHERE event_type = 'state_change' ORDER BY timestamp_utc DESC")) { + java.util.LinkedHashMap> sessions = + new java.util.LinkedHashMap<>(); + while (rs.next()) { + String sid = rs.getString("session_id"); + String ts = rs.getString("timestamp_utc").replace('T',' ').substring(0, 19); + String to = rs.getString("to_state"); + if (!sessions.containsKey(sid)) { + java.util.Map m = new java.util.LinkedHashMap<>(); + m.put("id", sid); + m.put("start", ts); + m.put("end", ts); + m.put("result", to); + sessions.put(sid, m); + } else { + java.util.Map m = sessions.get(sid); + if (!"ARMED".equals(to) && !"OPEN".equals(to)) { + m.put("end", ts); + m.put("result", to); + } + } + } + java.util.Map callCounts = new java.util.HashMap<>(); + try (java.sql.ResultSet crs = s.executeQuery( + "SELECT session_id, COUNT(*) as c FROM route_log GROUP BY session_id")) { + while (crs.next()) callCounts.put(crs.getString(1), crs.getInt(2)); + } + int shown = 0; + for (var e : sessions.entrySet()) { + if (shown++ >= 15) break; + java.util.Map m = e.getValue(); + int calls = callCounts.getOrDefault(m.get("id"), 0); + String result = m.get("result"); + String active = calls == 0 ? " (idle)" : ""; + System.out.printf(" %-10s %s %s %5d %s%s%n", + m.get("id").length() > 10 ? m.get("id").substring(0, 10) : m.get("id"), + m.get("start").length() > 19 ? m.get("start").substring(0, 19) : m.get("start"), + m.get("end").length() > 19 ? m.get("end").substring(0, 19) : m.get("end"), + calls, result, active); + } + } + System.out.println(); + + } catch (Exception e) { + System.err.println("Report error: " + e.getMessage()); + } + } + + private static String formatBytes(long bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0); + return String.format("%.1f MB", bytes / (1024.0 * 1024)); + } } diff --git a/java/src/main/java/dev/sorted/mcphub/McpHandler.java b/java/src/main/java/dev/sorted/mcphub/McpHandler.java index 0867f65..f2ba6a9 100644 --- a/java/src/main/java/dev/sorted/mcphub/McpHandler.java +++ b/java/src/main/java/dev/sorted/mcphub/McpHandler.java @@ -8,10 +8,8 @@ import org.slf4j.LoggerFactory; import java.time.Instant; -import java.util.ArrayList; import java.util.HashSet; import java.util.List; -import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -34,8 +32,11 @@ public class McpHandler implements JsonRpcServer.MethodHandler { private static final Logger log = LoggerFactory.getLogger(McpHandler.class); private static final ObjectMapper mapper = new ObjectMapper(); private static final String DISAMBIGUATION_TOOL = "mcphub_disambiguate"; + private static final String TASK_CONTEXT_TOOL = "mcphub_set_task_context"; private static final String SESSION_OPEN_TOOL = "mcphub.session.open"; - private static final String SERVER_VERSION = "0.1.0-alpha"; + private static final String CLI_COMMAND_ENV = "MCPHUB_CLI_COMMAND"; + private static final String CHECKPOINT_TOOL = "mcphub_checkpoint"; + private static final String SERVER_VERSION = "0.2.0-alpha"; private final StateMachine stateMachine; private final CapabilityRegistry registry; @@ -45,7 +46,10 @@ public class McpHandler implements JsonRpcServer.MethodHandler { private ProviderHealthTracker healthTracker; private SessionManager sessionManager; // nullable; required for REQ-3.7.3 idle reset private ProviderManager providerManager; // AMD-MCPHUB-001: Java-native provider dispatch + private final ResultCache resultCache = new ResultCache(); // OS-12: session-scoped read-only cache + private final TaskContextFilter taskContextFilter = new TaskContextFilter(); // OS-14: task-context filtering private String serverName = "mcphub"; + private String checkpointDir; // MCPHUB_DATA_DIR for mcphub_checkpoint persistence public McpHandler(StateMachine stateMachine, CapabilityRegistry registry, PolicyEngine policy, DatabaseManager db, BodyBudgetService bodyBudget) { @@ -56,6 +60,15 @@ public McpHandler(StateMachine stateMachine, CapabilityRegistry registry, this.bodyBudget = bodyBudget; this.healthTracker = null; this.sessionManager = null; + this.checkpointDir = System.getenv("MCPHUB_DATA_DIR"); + if (this.checkpointDir == null || this.checkpointDir.isBlank()) { + this.checkpointDir = System.getProperty("user.home") + "/.local/share/mcphub"; + } + } + + /** Wire checkpoint directory (used by tests to override). */ + public void setCheckpointDir(String checkpointDir) { + this.checkpointDir = checkpointDir; } /** Optional wiring for REQ-4.7.2 runtime provider health updates. */ @@ -73,6 +86,16 @@ public void setProviderManager(ProviderManager providerManager) { this.providerManager = providerManager; } + /** OS-12: Access the result cache (for session close cleanup). */ + public ResultCache getResultCache() { + return resultCache; + } + + /** OS-14: Access the task context filter (for session close cleanup). */ + public TaskContextFilter getTaskContextFilter() { + return taskContextFilter; + } + /** Optional override for MCP serverInfo.name. Defaults to "mcphub". */ public void setServerName(String name) { if (name != null && !name.isBlank()) { @@ -127,12 +150,13 @@ private JsonNode handleToolsList() { ObjectNode r = mapper.createObjectNode(); ArrayNode tools = mapper.createArrayNode(); - boolean isOpen = stateMachine.getState() == StateMachine.State.OPEN; + StateMachine.State currentState = stateMachine.getState(); + boolean isOpen = currentState == StateMachine.State.OPEN; if (!isOpen) { - // Expose the recovery tool in the same AI-facing surface that reports session_not_open. - StateMachine.State current = stateMachine.getState(); + // Approved amendment: CLOSED/ARMED may expose only mcphub.session.open, + // except when locked_until_unlock is active. if (!stateMachine.isLockedUntilUnlock() - && (current == StateMachine.State.CLOSED || current == StateMachine.State.ARMED)) { + && (currentState == StateMachine.State.CLOSED || currentState == StateMachine.State.ARMED)) { tools.add(buildSessionOpenTool()); } r.set("tools", tools); @@ -148,6 +172,9 @@ private JsonNode handleToolsList() { // Add disambiguation tool (REQ-5.3.1, AXIOM-4 trade-off accepted in Spec) tools.add(buildDisambiguationTool()); + // OS-14: Add task-context filtering tool + tools.add(buildTaskContextTool()); + r.set("tools", tools); // Track MCP surface size (REQ-4.4.4) @@ -176,43 +203,50 @@ private ObjectNode toMcpToolEntry(CapabilityEntry entry) { tool.put("description", desc); // inputSchema from registry entry - JsonNode schemaNode; if (entry.schema != null) { - schemaNode = mapper.valueToTree(entry.schema); + tool.set("inputSchema", mapper.valueToTree(entry.schema)); } else { ObjectNode emptySchema = mapper.createObjectNode(); emptySchema.put("type", "object"); - schemaNode = emptySchema; + tool.set("inputSchema", emptySchema); } - tool.set("inputSchema", schemaNode); + return tool; + } - // REQ-5.5.4: inject _intent into inputSchema so AI clients discover it - if (schemaNode instanceof ObjectNode schemaObj) { - JsonNode propsNode = schemaObj.get("properties"); - ObjectNode props; - if (propsNode instanceof ObjectNode) { - props = (ObjectNode) propsNode; - } else { - props = mapper.createObjectNode(); - schemaObj.set("properties", props); - } - if (!props.has("_intent")) { - ObjectNode intentProp = mapper.createObjectNode(); - intentProp.put("type", "string"); - intentProp.put("description", - "Optional: brief reason WHY you chose this tool. " + - "Persisted in route logs for auditing. Does not affect routing."); - props.set("_intent", intentProp); - } - } + /** Build the task-context MCP tool entry. OS-14 */ + private ObjectNode buildTaskContextTool() { + ObjectNode tool = mapper.createObjectNode(); + tool.put("name", TASK_CONTEXT_TOOL); + tool.put("description", + "Set the current task context to filter visible tools. " + + "Contexts: 'coding' (code editing tools), 'research' (web tools), " + + "'planning' (session/planning tools), 'all' (no filtering, default)."); + ObjectNode schema = mapper.createObjectNode(); + schema.put("type", "object"); + ObjectNode props = mapper.createObjectNode(); + ObjectNode contextProp = mapper.createObjectNode(); + contextProp.put("type", "string"); + contextProp.put("description", "Task context: coding, research, planning, or all"); + ArrayNode enumValues = mapper.createArrayNode(); + enumValues.add("coding"); enumValues.add("research"); + enumValues.add("planning"); enumValues.add("all"); + contextProp.set("enum", enumValues); + props.set("context", contextProp); + schema.set("properties", props); + ArrayNode required = mapper.createArrayNode(); + required.add("context"); + schema.set("required", required); + tool.set("inputSchema", schema); return tool; } - /** Build the MCP-visible session recovery tool. */ + /** Build the mcphub.session.open MCP tool entry (visible in all states). */ private ObjectNode buildSessionOpenTool() { ObjectNode tool = mapper.createObjectNode(); tool.put("name", SESSION_OPEN_TOOL); - tool.put("description", "Open the MCPHUB session so tools become available. Call this when session is CLOSED or ARMED."); + tool.put("description", "Open the MCPHUB session to make all tools available. " + + "Call this when session is CLOSED or ARMED. If the client cannot call this MCP tool, run: " + + cliOpenCommand()); ObjectNode schema = mapper.createObjectNode(); schema.put("type", "object"); schema.set("properties", mapper.createObjectNode()); @@ -220,13 +254,14 @@ private ObjectNode buildSessionOpenTool() { return tool; } - /** Handle mcphub.session.open from tools/call so recovery is executable by AI clients. */ + /** Handle mcphub.session.open: ARM + OPEN the session and start providers. */ private JsonNode handleSessionOpen(long startMs, int requestSizeBytes, String intentAnnotation) { try { StateMachine.State current = stateMachine.getState(); String sessionId = sessionManager != null ? sessionManager.getCurrentSessionId() : null; if (current == StateMachine.State.OPEN) { + // Idempotent: already open logRoute(sessionId, SESSION_OPEN_TOOL, "mcphub-internal", "builtin_hosted", "allowed", null, System.currentTimeMillis() - startMs, requestSizeBytes, 0, intentAnnotation, null); @@ -241,6 +276,7 @@ private JsonNode handleSessionOpen(long startMs, int requestSizeBytes, String in } else if (current == StateMachine.State.ARMED) { sessionId = sessionManager != null ? sessionManager.getCurrentSessionId() : "mcp-session"; } else { + // COOLING_DOWN or LOCKED return failureResponse("session_not_open", "Cannot open session in state: " + current.name() + ". Wait and retry.", "wait_session", null, null); @@ -255,12 +291,14 @@ private JsonNode handleSessionOpen(long startMs, int requestSizeBytes, String in ObjectNode resp = mapper.createObjectNode(); resp.put("mcphub_providers", "start"); - resp.set("content", wrapTextContent("{\"state\":\"OPEN\",\"session_id\":\"" + sessionId + "\",\"message\":\"Session opened. All tools are now available.\"}")); + resp.set("content", wrapTextContent( + "{\"state\":\"OPEN\",\"session_id\":\"" + sessionId + "\",\"message\":\"Session opened. All tools are now available.\"}")); return resp; + } catch (StateMachine.TransitionException e) { return failureResponse("session_not_open", "Failed to open session: " + e.getMessage(), - "retry", null, null); + "wait_session", null, null); } } @@ -285,13 +323,6 @@ private ObjectNode buildDisambiguationTool() { candidateTools.set("items", items); candidateTools.put("description", "Optional: restrict to these tool names"); props.set("candidate_tools", candidateTools); - // REQ-5.5.4: inject _intent so AI clients discover it - ObjectNode intentProp = mapper.createObjectNode(); - intentProp.put("type", "string"); - intentProp.put("description", - "Optional: brief reason WHY you chose this tool. " + - "Persisted in route logs for auditing. Does not affect routing."); - props.set("_intent", intentProp); schema.set("properties", props); ArrayNode required = mapper.createArrayNode(); required.add("task_description"); @@ -312,18 +343,36 @@ private JsonNode handleToolsCall(JsonNode params) { if (intentAnnotation != null && intentAnnotation.length() > 500) { intentAnnotation = intentAnnotation.substring(0, 500); } - // REQ-5.5.5, REQ-5.10.3: scrub secret patterns from intent annotation - if (intentAnnotation != null && SecretScanner.containsSecret(intentAnnotation)) { - intentAnnotation = "[scrubbed: secret pattern detected]"; - } int requestSizeBytes = params != null ? params.toString().length() : 0; + // Retrieve current session ID for route_log (REQ-8.3.1) + String routeSessionId = sessionManager != null ? sessionManager.getCurrentSessionId() : null; + + // --- OS-14: Task-context tool handled specially --- + if (TASK_CONTEXT_TOOL.equals(toolName)) { + if (stateMachine.getState() != StateMachine.State.OPEN) { + return failureResponse("session_not_open", + sessionOpenRecoveryReason("Task context requires an Open session."), + "call_mcphub_session_open", null, null); + } + String contextName = params != null ? params.path("arguments").path("context").asText("all") : "all"; + int beforeCount = policy.filterForAI(registry.getConfirmed()).size(); + String applied = taskContextFilter.setContext(contextName, policy, registry); + int afterCount = policy.filterForAI(registry.getConfirmed()).size(); + int hiddenCount = beforeCount - afterCount; + if (hiddenCount < 0) hiddenCount = 0; + logRoute(routeSessionId, TASK_CONTEXT_TOOL, "mcphub-internal", "builtin_hosted", + "allowed", null, System.currentTimeMillis() - startMs, + requestSizeBytes, 0, intentAnnotation, null); + return TaskContextFilter.buildResponse(applied, hiddenCount, afterCount); + } + // --- Disambiguation tool is handled specially --- if (DISAMBIGUATION_TOOL.equals(toolName)) { String taskDesc = params != null ? params.path("arguments").path("task_description").asText(null) : null; JsonNode disambResult = handleDisambiguate(taskDesc, params); - logRoute(null, DISAMBIGUATION_TOOL, "mcphub-internal", "builtin_hosted", + logRoute(routeSessionId, DISAMBIGUATION_TOOL, "mcphub-internal", "builtin_hosted", "allowed", null, System.currentTimeMillis() - startMs, requestSizeBytes, disambResult.toString().length(), intentAnnotation, null); ObjectNode resp = mapper.createObjectNode(); @@ -331,7 +380,7 @@ private JsonNode handleToolsCall(JsonNode params) { return resp; } - // --- MCP-visible session recovery tool bypasses the state guard --- + // --- mcphub.session.open: bypass state guard, ARM+OPEN from any non-OPEN state --- if (SESSION_OPEN_TOOL.equals(toolName)) { return handleSessionOpen(startMs, requestSizeBytes, intentAnnotation); } @@ -339,11 +388,11 @@ private JsonNode handleToolsCall(JsonNode params) { // --- State guard (REQ-2.4.5, REQ-5.6.2 session_not_open) --- if (stateMachine.getState() != StateMachine.State.OPEN) { long latency = System.currentTimeMillis() - startMs; - logRoute(null, toolName, null, null, + logRoute(routeSessionId, toolName, null, null, "error", null, latency, requestSizeBytes, null, intentAnnotation, "session_not_open"); return failureResponse("session_not_open", - "Session is not Open. Call mcphub.session.open to reopen the session.", + sessionOpenRecoveryReason("Session is not Open. Call mcphub.session.open to reopen the session."), "call_mcphub_session_open", null, null); } @@ -353,11 +402,16 @@ private JsonNode handleToolsCall(JsonNode params) { sessionManager.resetActivity(); } + // --- mcphub_checkpoint: hub tool, handled in Java (after state guard) --- + if (CHECKPOINT_TOOL.equals(toolName)) { + return handleCheckpoint(params, routeSessionId, startMs, requestSizeBytes, intentAnnotation); + } + // --- Tool existence check --- if (toolName == null) { long latency = System.currentTimeMillis() - startMs; // REQ-5.2.7: every tools/call dispatch must produce a route log entry - logRoute(null, "(missing)", null, null, + logRoute(routeSessionId, "(missing)", null, null, "error", null, latency, requestSizeBytes, null, intentAnnotation, "tool_not_found"); List available = policy.filterForAI(registry.getConfirmed()) .stream().map(e -> e.displayName).collect(Collectors.toList()); @@ -368,55 +422,98 @@ private JsonNode handleToolsCall(JsonNode params) { var entryOpt = registry.findByDisplayName(toolName); if (entryOpt.isEmpty()) { // REQ-5.4.2: include available_tools list - List available = policy.filterForAI(registry.getConfirmed()) - .stream().map(e -> e.displayName).collect(Collectors.toList()); + List altEntries = policy.filterForAI(registry.getConfirmed()); + List available = altEntries.stream() + .map(e -> e.displayName).collect(Collectors.toList()); long latency = System.currentTimeMillis() - startMs; - logRoute(null, toolName, null, null, + logRoute(routeSessionId, toolName, null, null, "error", null, latency, requestSizeBytes, null, intentAnnotation, "tool_not_found"); - return failureResponse("tool_not_found", + ObjectNode gapExplanation = CapabilityGapExplainer.explain( + toolName, "tool_not_found", null, null, altEntries); + return failureResponseWithGap("tool_not_found", "Tool '" + toolName + "' is not registered in MCPHUB.", - "use_alternative", available, null); + "use_alternative", available, null, gapExplanation); } CapabilityEntry entry = entryOpt.get(); String providerType = providerTypeForEntry(entry); if (!"confirmed".equals(entry.runtimeState)) { long latency = System.currentTimeMillis() - startMs; - logRoute(null, toolName, entry.providerId, providerType, + logRoute(routeSessionId, toolName, entry.providerId, providerType, "error", null, latency, requestSizeBytes, null, intentAnnotation, "provider_unreachable"); - List available = policy.filterForAI(registry.getConfirmed()) - .stream().map(e -> e.displayName).collect(Collectors.toList()); - return failureResponse("provider_unreachable", + List altEntries = policy.filterForAI(registry.getConfirmed()); + List available = altEntries.stream() + .map(e -> e.displayName).collect(Collectors.toList()); + ObjectNode gapExplanation = CapabilityGapExplainer.explain( + toolName, "provider_unreachable", null, entry, altEntries); + return failureResponseWithGap("provider_unreachable", "Tool '" + toolName + "' is registered but its provider adapter is not running.", - "wait_session", available, null); + "wait_session", available, null, gapExplanation); } // --- Policy check (IS-06, REQ-2.4.3, REQ-5.2.6) --- PolicyEngine.PolicyResult policyResult = policy.evaluate(toolName); if (policyResult.decision() == PolicyEngine.Decision.DENY) { long latency = System.currentTimeMillis() - startMs; - logRoute(null, toolName, entry.providerId, providerType, + logRoute(routeSessionId, toolName, entry.providerId, providerType, "denied", policyResult.matchedRuleId(), latency, requestSizeBytes, null, intentAnnotation, "tool_denied"); - List contractFallbacks = findContractFallbacks(entry); - String nextAction = (contractFallbacks != null && !contractFallbacks.isEmpty()) - ? "use_alternative" : "disambiguate"; - return failureResponse("tool_denied", + // OS-15: Capability-gap explanation for denied tools + List altEntries = policy.filterForAI(registry.getConfirmed()); + ObjectNode gapExplanation = CapabilityGapExplainer.explain( + toolName, "tool_denied", policyResult.matchedRuleId(), entry, altEntries); + return failureResponseWithGap("tool_denied", "Tool '" + toolName + "' is denied by policy rule: " + policyResult.matchedRuleId(), - nextAction, null, policyResult.matchedRuleId(), entry); + "abort", null, policyResult.matchedRuleId(), gapExplanation); } if (policyResult.decision() == PolicyEngine.Decision.HIDE) { long latency = System.currentTimeMillis() - startMs; - logRoute(null, toolName, null, null, + logRoute(routeSessionId, toolName, null, null, "error", null, latency, requestSizeBytes, null, intentAnnotation, "tool_not_found"); // REQ-7.3.4: hidden tools appear as not found to AI // REQ-5.4.2: include available_tools so AI can self-correct - List available = policy.filterForAI(registry.getConfirmed()) - .stream().map(e -> e.displayName).collect(Collectors.toList()); + List altEntries = policy.filterForAI(registry.getConfirmed()); + List available = new java.util.ArrayList<>(altEntries.stream() + .map(e -> e.displayName).toList()); + if (isTaskContextHide(policyResult)) { + addIfMissing(available, TASK_CONTEXT_TOOL); + ObjectNode gapExplanation = CapabilityGapExplainer.explain( + toolName, "tool_not_found", policyResult.matchedRuleId(), entry, altEntries); + return failureResponseWithGap("tool_not_found", + "Tool '" + toolName + "' is currently hidden by task context '" + + taskContextFilter.getCurrentContext().name().toLowerCase() + + "'. Call " + TASK_CONTEXT_TOOL + " with {\"context\":\"all\"} " + + "to restore the full tool list.", + "set_task_context_all", available, policyResult.matchedRuleId(), gapExplanation); + } return failureResponse("tool_not_found", "Tool '" + toolName + "' is not registered in MCPHUB.", "use_alternative", available, null); } + // --- OS-13: Dry-run mode — return preview without calling provider --- + boolean isDryRun = params != null && params.path("arguments").path("_dry_run").asBoolean(false); + if (isDryRun) { + String groupId = resolveGroupId(toolName); + long latency = System.currentTimeMillis() - startMs; + logRoute(routeSessionId, toolName, entry.providerId, providerType, + "allowed", policyResult.matchedRuleId(), latency, + requestSizeBytes, 0, intentAnnotation, "dry_run"); + return DryRunService.preview(entry, policyResult, groupId, providerType); + } + + // --- OS-12: Check read-only result cache before dispatch --- + if (ResultCache.isCacheEligible(entry)) { + JsonNode arguments = params != null ? params.path("arguments") : mapper.createObjectNode(); + JsonNode cached = resultCache.get(toolName, arguments); + if (cached != null) { + long latency = System.currentTimeMillis() - startMs; + logRoute(routeSessionId, toolName, entry.providerId, providerTypeForEntry(entry), + "allowed", policyResult.matchedRuleId(), latency, + requestSizeBytes, cached.toString().length(), intentAnnotation, null); + return cached; + } + } + // --- Dispatch: Java directly calls provider (AMD-MCPHUB-001) --- String groupId = resolveGroupId(toolName); @@ -436,30 +533,38 @@ private JsonNode handleToolsCall(JsonNode params) { JsonNode providerResult = providerManager.call(groupId, "tools/call", forwardParams); long latency = providerStartMs - startMs; int respBytes = providerResult != null ? providerResult.toString().length() : 0; - logRoute(null, toolName, entry.providerId, providerType, + logRoute(routeSessionId, toolName, entry.providerId, providerType, "allowed", policyResult.matchedRuleId(), latency, requestSizeBytes, respBytes, intentAnnotation, null); + // OS-12: Cache result for read-only tools + if (ResultCache.isCacheEligible(entry) && providerResult != null) { + JsonNode cacheArgs = params != null ? params.path("arguments") : mapper.createObjectNode(); + resultCache.put(toolName, cacheArgs, providerResult); + } return providerResult; } catch (Exception e) { long latency = providerStartMs - startMs; - logRoute(null, toolName, entry.providerId, providerType, + logRoute(routeSessionId, toolName, entry.providerId, providerType, "error", policyResult.matchedRuleId(), latency, requestSizeBytes, null, intentAnnotation, "provider_error"); return failureResponse("provider_error", - "Provider '" + groupId + "' returned an error. Retry or check provider health.", + "Provider '" + groupId + "' call failed: " + e.getMessage(), "retry", null, null); } } else { // Provider not running — return structured failure long latency = System.currentTimeMillis() - startMs; - logRoute(null, toolName, entry.providerId, providerType, + logRoute(routeSessionId, toolName, entry.providerId, providerType, "error", policyResult.matchedRuleId(), latency, requestSizeBytes, null, intentAnnotation, "provider_unreachable"); - List available = policy.filterForAI(registry.getConfirmed()) - .stream().map(e2 -> e2.displayName).collect(Collectors.toList()); - return failureResponse("provider_unreachable", + List altEntries = policy.filterForAI(registry.getConfirmed()); + List available = altEntries.stream() + .map(e2 -> e2.displayName).collect(Collectors.toList()); + ObjectNode gapExplanation = CapabilityGapExplainer.explain( + toolName, "provider_unreachable", null, entry, altEntries); + return failureResponseWithGap("provider_unreachable", "Provider group '" + groupId + "' is not running.", - "wait_session", available, null); + "wait_session", available, null, gapExplanation); } } @@ -472,7 +577,8 @@ private JsonNode handleDisambiguate(String taskDescription, JsonNode params) { // REQ-5.10.1: only when Open if (stateMachine.getState() != StateMachine.State.OPEN) { return failureResponse("session_not_open", - "Disambiguation requires an Open session.", "wait_session", null, null); + sessionOpenRecoveryReason("Disambiguation requires an Open session."), + "call_mcphub_session_open", null, null); } if (taskDescription == null || taskDescription.isBlank()) { @@ -515,56 +621,26 @@ private JsonNode handleDisambiguate(String taskDescription, JsonNode params) { result.put("unresolvable_reason", "Registry has no enabled, policy-allowed tools to recommend."); } else { - // Multiple candidates — try contract-based resolution (REQ-5.3.3, REQ-5.3.8) - CapabilityEntry contractWinner = resolveByContracts(taskDescription, candidates); - if (contractWinner != null) { - result.put("recommended_tool", contractWinner.displayName); - result.put("confidence", "deterministic"); - result.put("reason", - "Contract-based disambiguation: '" + contractWinner.displayName + - "' unambiguously covers all other candidates via disambiguates_from entries."); - result.putNull("unresolvable_reason"); - } else { - result.putNull("recommended_tool"); // REQ-5.3.6: MUST be null when not deterministic - result.put("confidence", "none"); - result.put("reason", - candidates.size() + " tools are available. Hub cannot select without heuristic " + - "guessing, which is prohibited by REQ-5.3.3. " + - "Narrow via 'candidate_tools' to a single tool for a deterministic answer."); - result.put("unresolvable_reason", - "Multiple tools match. Use candidate_tools to specify exactly one tool."); - } + // Multiple candidates — cannot determine deterministically (REQ-5.3.3 forbids heuristic) + result.putNull("recommended_tool"); // REQ-5.3.6: MUST be null when not deterministic + result.put("confidence", "none"); + result.put("reason", + candidates.size() + " tools are available. Hub cannot select without heuristic " + + "guessing, which is prohibited by REQ-5.3.3. " + + "Narrow via 'candidate_tools' to a single tool for a deterministic answer."); + result.put("unresolvable_reason", + "Multiple tools match. Use candidate_tools to specify exactly one tool."); } // REQ-5.3.4: alternatives — list all candidates when no deterministic recommendation - // When confidence=deterministic (1 candidate or contract winner), alternatives is empty. + // When confidence=deterministic (1 candidate), alternatives is empty. ArrayNode alts = mapper.createArrayNode(); - boolean deterministic = candidates.size() == 1 - || (candidates.size() > 1 && resolveByContracts(taskDescription, candidates) != null); - if (!deterministic) { + if (candidates.size() != 1) { for (CapabilityEntry e : candidates) { if (e.contract == null || e.contract.purpose == null) continue; ObjectNode alt = mapper.createObjectNode(); alt.put("tool", e.displayName); alt.put("reason", e.contract.purpose); - if (e.contract.sideEffectClass != null) { - alt.put("side_effect_class", e.contract.sideEffectClass); - } - if (e.contract.whenToCall != null) { - ArrayNode wtc = mapper.createArrayNode(); - e.contract.whenToCall.forEach(wtc::add); - alt.set("when_to_call", wtc); - } - if (e.contract.disambiguatesFrom != null) { - ArrayNode dfArr = mapper.createArrayNode(); - for (CapabilityContract.DisambiguatesFrom df : e.contract.disambiguatesFrom) { - ObjectNode d = mapper.createObjectNode(); - d.put("capability_id", df.capabilityId); - d.put("distinction", df.distinction); - dfArr.add(d); - } - alt.set("disambiguates_from", dfArr); - } alts.add(alt); } } @@ -573,39 +649,6 @@ private JsonNode handleDisambiguate(String taskDescription, JsonNode params) { return result; } - /** - * Deterministic contract-based resolution among multiple candidates. - * REQ-5.3.3: no heuristic. REQ-5.3.8: deterministic when contracts unambiguously select one. - * - * @return the single candidate whose disambiguates_from covers ALL other candidates, - * or null if ambiguous. - */ - private CapabilityEntry resolveByContracts(String taskDescription, List candidates) { - if (candidates == null || candidates.size() < 2) return null; - CapabilityEntry winner = null; - int winnerCount = 0; - for (CapabilityEntry candidate : candidates) { - if (candidate.contract == null || candidate.contract.disambiguatesFrom == null) continue; - Set coveredIds = candidate.contract.disambiguatesFrom.stream() - .map(df -> df.capabilityId) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - boolean coversAllOthers = true; - for (CapabilityEntry other : candidates) { - if (other == candidate) continue; - if (!coveredIds.contains(other.capabilityId)) { - coversAllOthers = false; - break; - } - } - if (coversAllOthers) { - winner = candidate; - winnerCount++; - } - } - return winnerCount == 1 ? winner : null; - } - // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- @@ -643,6 +686,105 @@ private JsonNode handleAdapterRegistration(JsonNode params) { return r; } + /** + * mcphub_checkpoint: persist session state for next-session handoff. + * Reads tasks.json and nexus issues, writes checkpoint file to data directory. + */ + private JsonNode handleCheckpoint(JsonNode params, String sessionId, + long startMs, int requestSizeBytes, String intentAnnotation) { + String message = params != null ? params.path("arguments").path("message").asText(null) : null; + String project = params != null ? params.path("arguments").path("project").asText(null) : null; + + if (message == null || message.isBlank()) { + return failureResponse("missing_parameter", + "message is required for mcphub_checkpoint", + "abort", null, null); + } + + ObjectNode checkpoint = mapper.createObjectNode(); + checkpoint.put("checkpoint_time", Instant.now().toString()); + checkpoint.put("session_id", sessionId != null ? sessionId : "no-session"); + checkpoint.put("handover_message", message); + if (project != null && !project.isBlank()) { + checkpoint.put("project", project); + } + + // Read tasks from tasks.json + String tasksPath = System.getProperty("user.home") + "/.config/mcphub/tasks.json"; + java.io.File tasksFile = new java.io.File(tasksPath); + if (tasksFile.exists()) { + try { + JsonNode tasksNode = mapper.readTree(tasksFile); + checkpoint.set("tasks", tasksNode); + } catch (Exception e) { + checkpoint.put("tasks_error", "Failed to read tasks: " + e.getMessage()); + } + } else { + ArrayNode emptyTasks = mapper.createArrayNode(); + checkpoint.set("tasks", emptyTasks); + checkpoint.put("tasks_note", "No tasks.json found — no tasks to save"); + } + + // Read nexus issues for project (if specified) + if (project != null && !project.isBlank()) { + String nexusBase = System.getenv("MCPHUB_NEXUS_BASE"); + if (nexusBase == null || nexusBase.isBlank()) { + nexusBase = System.getProperty("user.home") + "/issue-store"; + } + java.io.File projectDir = new java.io.File(nexusBase, project); + if (projectDir.exists() && projectDir.isDirectory()) { + ArrayNode issues = mapper.createArrayNode(); + for (java.io.File f : projectDir.listFiles((dir, name) -> name.endsWith(".md"))) { + ObjectNode issue = mapper.createObjectNode(); + issue.put("file", f.getName()); + issue.put("size", f.length()); + issue.put("last_modified", Instant.ofEpochMilli(f.lastModified()).toString()); + issues.add(issue); + } + checkpoint.set("nexus_issues", issues); + checkpoint.put("nexus_issue_count", issues.size()); + } else { + checkpoint.put("nexus_note", "No issue-store directory found for project: " + project); + } + } + + // Check for path traversal in checkpoint dir + String resolvedDir; + try { + java.io.File dirFile = new java.io.File(checkpointDir).getCanonicalFile(); + dirFile.mkdirs(); + resolvedDir = dirFile.getAbsolutePath(); + } catch (Exception e) { + return failureResponse("checkpoint_error", + "Cannot create checkpoint directory: " + e.getMessage(), + "abort", null, null); + } + + // Write checkpoint file + String timestamp = java.time.LocalDateTime.now() + .format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); + String checkpointFileName = "checkpoint_" + timestamp + ".json"; + java.io.File checkpointFile = new java.io.File(resolvedDir, checkpointFileName); + try { + mapper.writerWithDefaultPrettyPrinter().writeValue(checkpointFile, checkpoint); + } catch (Exception e) { + return failureResponse("checkpoint_error", + "Failed to write checkpoint: " + e.getMessage(), + "abort", null, null); + } + + // Log route + logRoute(sessionId, CHECKPOINT_TOOL, "mcphub-internal", "builtin_hosted", + "allowed", null, System.currentTimeMillis() - startMs, + requestSizeBytes, checkpoint.toString().length(), intentAnnotation, null); + + // Build response + ObjectNode resp = mapper.createObjectNode(); + resp.put("checkpoint_file", checkpointFile.getAbsolutePath()); + resp.set("content", wrapTextContent(checkpoint.toString())); + return resp; + } + /** * Called by Go daemon on provider state transitions. * Params: { "group_id": "web", "status": "running|stopped|unavailable" } @@ -667,61 +809,72 @@ private JsonNode handleProviderHealthUpdate(JsonNode params) { return r; } - /** Build structured failure response. REQ-5.6.1 (P1-03) — MCP CallToolResult compliant */ - private ObjectNode failureResponse(String errorCode, String reason, - String nextAction, List availableTools, String policyDetail) { - return failureResponse(errorCode, reason, nextAction, availableTools, policyDetail, null); + /** Build structured failure response with capability-gap explanation. OS-15 */ + private ObjectNode failureResponseWithGap(String errorCode, String reason, + String nextAction, List fallbackTools, String policyDetail, + com.fasterxml.jackson.databind.node.ObjectNode gapExplanation) { + ObjectNode r = failureResponse(errorCode, reason, nextAction, fallbackTools, policyDetail); + if (gapExplanation != null) { + r.set("capability_gap", gapExplanation); + // Add gap explanation as a second content item + ArrayNode content = (ArrayNode) r.get("content"); + ObjectNode gapItem = mapper.createObjectNode(); + gapItem.put("type", "text"); + gapItem.put("text", "[MCPHUB capability_gap] " + gapExplanation.toString()); + content.add(gapItem); + } + return r; } + /** Build structured failure response. REQ-5.6.1 (P1-03) — MCP CallToolResult compliant */ private ObjectNode failureResponse(String errorCode, String reason, - String nextAction, List availableTools, String policyDetail, CapabilityEntry entry) { + String nextAction, List fallbackTools, String policyDetail) { ObjectNode r = mapper.createObjectNode(); + // MCP-compliant content array (REQ-5.6.1 structured failure as text) ArrayNode content = mapper.createArrayNode(); ObjectNode textItem = mapper.createObjectNode(); textItem.put("type", "text"); - - ObjectNode structured = mapper.createObjectNode(); - structured.put("error_code", errorCode); - structured.put("reason", reason); - if (nextAction != null) structured.put("next_action", nextAction); - if (policyDetail != null) structured.put("policy_detail", policyDetail); - - List fallbackTools = entry != null ? findContractFallbacks(entry) : null; - if (fallbackTools != null && !fallbackTools.isEmpty()) { - ArrayNode ft = mapper.createArrayNode(); - fallbackTools.forEach(ft::add); - structured.set("fallback_tools", ft); + StringBuilder msg = new StringBuilder(); + msg.append("[MCPHUB error] ").append(errorCode).append(": ").append(reason); + if (nextAction != null) { + msg.append(" | next_action: ").append(nextAction); } - - if (availableTools != null && !availableTools.isEmpty()) { - ArrayNode at = mapper.createArrayNode(); - availableTools.forEach(at::add); - structured.set("available_tools", at); + if (policyDetail != null) { + msg.append(" | policy: ").append(policyDetail); } - - textItem.put("text", structured.toString()); + if (fallbackTools != null && !fallbackTools.isEmpty()) { + msg.append(" | available_tools: ").append(String.join(", ", fallbackTools)); + } + textItem.put("text", msg.toString()); content.add(textItem); r.set("content", content); r.put("isError", true); return r; } - /** Look up disambiguates_from entries and return registered tool names. REQ-5.6.5 */ - private List findContractFallbacks(CapabilityEntry entry) { - if (entry == null || entry.contract == null || entry.contract.disambiguatesFrom == null) { - return null; - } - List result = new ArrayList<>(); - for (CapabilityContract.DisambiguatesFrom df : entry.contract.disambiguatesFrom) { - if (df.capabilityId != null) { - registry.findByCapabilityId(df.capabilityId).ifPresent(e -> { - if (policy.evaluate(e.displayName).decision() == PolicyEngine.Decision.ALLOW) { - result.add(e.displayName); - } - }); - } + private String sessionOpenRecoveryReason(String reason) { + return reason + " If the client cannot call that MCP tool, run CLI: " + + cliOpenCommand() + " | cli_recovery_command: " + cliOpenCommand(); + } + + private String cliOpenCommand() { + String command = System.getenv(CLI_COMMAND_ENV); + if (command == null || command.isBlank()) { + command = "mcphub"; + } + return command + " open"; + } + + private boolean isTaskContextHide(PolicyEngine.PolicyResult policyResult) { + return policyResult != null + && policyResult.matchedRuleId() != null + && policyResult.matchedRuleId().startsWith("task-ctx-hide-"); + } + + private void addIfMissing(List tools, String toolName) { + if (tools != null && !tools.contains(toolName)) { + tools.add(toolName); } - return result.isEmpty() ? null : result; } /** Fire-and-forget route log (IS-05, REQ-8.3.2: async, must not block) */ @@ -754,8 +907,12 @@ private String resolveGroupId(String toolName) { return switch (toolName) { case "webfetch", "websearch" -> "web"; case "apply_patch" -> "edit"; - case "todowrite", "list", "codesearch", "lsp" -> "project"; - case "plan_enter", "plan_exit", "skill", "batch" -> "session"; + case "todowrite", "list", "codesearch", "lsp", + "task_create", "task_list", "task_update", "task_delete" -> "project"; + case "nexus_issue_create", "nexus_issue_list", + "nexus_issue_close", "nexus_issue_update" -> "nexus"; + case "plan_enter", "plan_exit", "skill", "batch", + "mcphub_checkpoint" -> "session"; case "synthetic_delay" -> "synthetic"; default -> "unknown"; }; diff --git a/java/src/main/java/dev/sorted/mcphub/PolicyEngine.java b/java/src/main/java/dev/sorted/mcphub/PolicyEngine.java index c336b2b..1fa6c6c 100644 --- a/java/src/main/java/dev/sorted/mcphub/PolicyEngine.java +++ b/java/src/main/java/dev/sorted/mcphub/PolicyEngine.java @@ -26,12 +26,18 @@ public record PolicyResult(Decision decision, String matchedRuleId) {} private final List globalRules = new ArrayList<>(); private final List sessionRules = new ArrayList<>(); + /** Maximum priority value for user-defined rules. Session rules add PRIORITY_BOOST internally. */ + public static final int MAX_USER_PRIORITY = 9999; + /** Internal boost applied to session rules so they always override global rules (REQ-7.5.2). */ + static final int PRIORITY_BOOST = 10000; + /** Load global rules (from registry config). Replaces any previously loaded global rules. */ public void loadGlobalRules(List rules) { globalRules.clear(); if (rules != null) { for (PolicyRule r : rules) { if ("global".equals(r.scope) || r.scope == null) { + r.priority = clampPriority(r.priority); globalRules.add(r); } } @@ -42,6 +48,7 @@ public void loadGlobalRules(List rules) { /** Add a session-scoped rule. REQ-7.5.1 */ public void addSessionRule(PolicyRule rule) { rule.scope = "session"; + rule.priority = clampPriority(rule.priority); sessionRules.add(rule); } @@ -62,18 +69,16 @@ public void clearSessionRules() { public PolicyResult evaluate(String toolName) { if (toolName == null) return new PolicyResult(Decision.DENY, null); - // Session rules have effective higher priority than global (REQ-7.5.2) - // We model this by adding session rule priority + 10000 offset internally + // Session rules have effective higher priority than global (REQ-7.5.2). + // User priorities are clamped to [0, MAX_USER_PRIORITY]. Session rules get + // PRIORITY_BOOST added internally, guaranteeing they always outrank global rules. List allRules = new ArrayList<>(sessionRules.size() + globalRules.size()); - // Session rules: boost priority by large offset to model REQ-7.5.2 for (PolicyRule r : sessionRules) { PolicyRule boosted = new PolicyRule(); boosted.ruleId = r.ruleId; boosted.toolPattern = r.toolPattern; boosted.action = r.action; - // REQ-7.5.2: session rules have higher default priority than global, - // unless operator explicitly sets negative priority (opt-out of boost) - boosted.priority = r.priority >= 0 ? r.priority + 10000 : r.priority; + boosted.priority = r.priority + PRIORITY_BOOST; boosted.scope = "session"; allRules.add(boosted); } @@ -161,4 +166,22 @@ private int actionRank(String action) { default -> 0; }; } + + /** + * Clamp user-defined priority to [0, MAX_USER_PRIORITY]. + * Negative priorities are set to 0. Values above MAX_USER_PRIORITY are capped. + * This ensures session rules (which add PRIORITY_BOOST) always outrank global rules. + * BL-19: QA NF-2 fix. + */ + private int clampPriority(int priority) { + if (priority < 0) { + log.warn("PolicyEngine: negative priority {} clamped to 0", priority); + return 0; + } + if (priority > MAX_USER_PRIORITY) { + log.warn("PolicyEngine: priority {} exceeds max {}, clamped", priority, MAX_USER_PRIORITY); + return MAX_USER_PRIORITY; + } + return priority; + } } diff --git a/java/src/main/java/dev/sorted/mcphub/ProviderHealthTracker.java b/java/src/main/java/dev/sorted/mcphub/ProviderHealthTracker.java index 9256a0e..0dc61d4 100644 --- a/java/src/main/java/dev/sorted/mcphub/ProviderHealthTracker.java +++ b/java/src/main/java/dev/sorted/mcphub/ProviderHealthTracker.java @@ -1,5 +1,6 @@ package dev.sorted.mcphub; +import java.util.Collection; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -15,6 +16,7 @@ public class ProviderHealthTracker { /** Key: group_id (e.g., "web"). Value: "running" | "stopped" | "unavailable". */ private final Map groupHealth = new ConcurrentHashMap<>(); + private final Map toolGroups = new ConcurrentHashMap<>(); public void updateGroup(String groupId, String status) { if (groupId == null || groupId.isBlank()) return; @@ -26,10 +28,20 @@ public String getGroupHealth(String groupId) { return groupHealth.getOrDefault(groupId, UNAVAILABLE); } + public void mapTools(String groupId, Collection toolNames) { + if (groupId == null || groupId.isBlank() || toolNames == null) return; + for (String toolName : toolNames) { + if (toolName != null && !toolName.isBlank()) { + toolGroups.put(toolName, groupId); + } + } + } + /** Map a tool's display_name to its current health via group mapping. */ public String healthForTool(String toolName) { if (toolName == null || toolName.isBlank()) return UNAVAILABLE; - String group = CapabilityRegistry.groupForTool(toolName); + String group = toolGroups.get(toolName); + if (group == null) group = CapabilityRegistry.groupForTool(toolName); if (group == null) return UNAVAILABLE; return getGroupHealth(group); } @@ -40,6 +52,7 @@ public Map snapshot() { public void clear() { groupHealth.clear(); + toolGroups.clear(); } private String normalize(String status) { diff --git a/java/src/main/java/dev/sorted/mcphub/ProviderManager.java b/java/src/main/java/dev/sorted/mcphub/ProviderManager.java index 40964e9..d013444 100644 --- a/java/src/main/java/dev/sorted/mcphub/ProviderManager.java +++ b/java/src/main/java/dev/sorted/mcphub/ProviderManager.java @@ -87,9 +87,13 @@ public static List defaultGroups() { new GroupConfig("edit", "edit/index.js", null, new String[]{"apply_patch"}, "builtin_hosted"), new GroupConfig("project", "project/index.js", null, - new String[]{"todowrite", "list", "codesearch", "lsp", "task_create", "task_list", "task_update", "task_delete"}, "builtin_hosted"), + new String[]{"todowrite", "list", "codesearch", "lsp", + "task_create", "task_list", "task_update", "task_delete"}, "builtin_hosted"), + new GroupConfig("nexus", "nexus/index.js", null, + new String[]{"nexus_issue_create", "nexus_issue_list", + "nexus_issue_close", "nexus_issue_update"}, "builtin_hosted"), new GroupConfig("session", "session/index.js", null, - new String[]{"plan_enter", "plan_exit", "skill", "batch"}, "builtin_hosted"), + new String[]{"plan_enter", "plan_exit", "skill", "batch", "mcphub_checkpoint"}, "builtin_hosted"), new GroupConfig("synthetic", "synthetic/index.js", null, new String[]{"synthetic_delay"}, "builtin_hosted") ); @@ -272,14 +276,12 @@ public synchronized JsonNode call(String groupId, String method, JsonNode params try { return futureResult.get(CALL_TIMEOUT_MS, TimeUnit.MILLISECONDS); } catch (java.util.concurrent.TimeoutException e) { - restartBrokenProvider(groupId); - throw new IOException("Provider '" + groupId + "' call timed out after " + CALL_TIMEOUT_MS + "ms"); + String message = "Provider '" + groupId + "' call timed out after " + CALL_TIMEOUT_MS + "ms"; + log.warn("{}; restarting provider group to clear stale stdio readers", message); + restartGroup(groupId); + throw new IOException(message); } catch (java.util.concurrent.ExecutionException e) { - String causeMsg = e.getCause() != null ? e.getCause().getMessage() : e.getMessage(); - if (causeMsg != null && causeMsg.contains("closed stdout")) { - restartBrokenProvider(groupId); - } - throw new IOException("Provider '" + groupId + "' call failed: " + causeMsg); + throw new IOException("Provider '" + groupId + "' call failed: " + e.getCause().getMessage()); } } } @@ -290,33 +292,61 @@ public boolean isRunning(String groupId) { return proc != null && proc.process.isAlive(); } - // ------------------------------------------------------------------------- - // Internal - // ------------------------------------------------------------------------- + /** + * Restart a specific provider group. REQ-6.2.8. + * Returns true if restart was successful, false otherwise. + */ + public boolean restartGroup(String groupId) { + GroupConfig cfg = null; + for (GroupConfig g : groups) { + if (g.id.equals(groupId)) { cfg = g; break; } + } + if (cfg == null) { + log.warn("Cannot restart unknown provider group: {}", groupId); + return false; + } - /** Kill and restart a provider whose stdin/stdout sync may be broken - * (e.g., after a call timeout or stdout close). */ - private void restartBrokenProvider(String groupId) { - ProviderProcess broken = processes.remove(groupId); - if (broken != null) { - broken.process.destroy(); - log.warn("Provider group '{}' call failed — destroying and restarting", groupId); - for (GroupConfig g : groups) { - if (g.id.equals(groupId)) { - try { - start(g); - reportHealth(groupId, "running"); - confirmWithRegistry(g); - } catch (Exception restartEx) { - log.error("Failed to restart provider group '{}' after failure: {}", groupId, restartEx.getMessage()); - reportHealth(groupId, "unavailable"); - } - break; + // Stop the group + ProviderProcess existing = processes.get(groupId); + if (existing != null && existing.process.isAlive()) { + log.info("Stopping provider group '{}' for restart (pid {})", groupId, existing.process.pid()); + existing.process.destroy(); + try { + if (!existing.process.waitFor(3, TimeUnit.SECONDS)) { + existing.process.destroyForcibly(); } + } catch (InterruptedException e) { + existing.process.destroyForcibly(); + Thread.currentThread().interrupt(); } + processes.remove(groupId); + } + + // Restart + try { + start(cfg); + reportHealth(groupId, "running"); + confirmWithRegistry(cfg); + log.info("Provider group '{}' restarted successfully", groupId); + return true; + } catch (Exception e) { + log.error("Failed to restart provider group '{}': {}", groupId, e.getMessage()); + reportHealth(groupId, "unavailable"); + return false; } } + /** Return all configured group IDs. */ + public List getGroupIds() { + List ids = new ArrayList<>(); + for (GroupConfig g : groups) ids.add(g.id); + return ids; + } + + // ------------------------------------------------------------------------- + // Internal + // ------------------------------------------------------------------------- + private void start(GroupConfig cfg) throws IOException { // Prevent orphan processes: skip if group already has a running process ProviderProcess existing = processes.get(cfg.id); @@ -410,6 +440,9 @@ private void confirmWithRegistry(GroupConfig cfg) { } } groupTools.put(cfg.id, names); + if (healthTracker != null) { + healthTracker.mapTools(cfg.id, names); + } // Register with capability registry if (registry != null) { diff --git a/java/src/main/java/dev/sorted/mcphub/ResultCache.java b/java/src/main/java/dev/sorted/mcphub/ResultCache.java new file mode 100644 index 0000000..51aeb6c --- /dev/null +++ b/java/src/main/java/dev/sorted/mcphub/ResultCache.java @@ -0,0 +1,127 @@ +package dev.sorted.mcphub; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Session-scoped read-only result cache. + * Proposal §6.5 / OS-12 → Alpha scope (CEO 2026-04-26). + * + * Rules: + * - Only tools with rw_boundary="read" are cache-eligible (REQ: read-only labels stable) + * - Cache key = SHA-256(tool_name + canonical_arguments_json) + * - Cache is session-scoped: cleared on session close/lock + * - TTL per entry: configurable, default 60 seconds + * - Cache does NOT apply to: write/execute tools, disambiguation, control surface + */ +public class ResultCache { + private static final Logger log = LoggerFactory.getLogger(ResultCache.class); + private static final ObjectMapper mapper = new ObjectMapper(); + private static final long DEFAULT_TTL_MS = 60_000; + + private record CacheEntry(JsonNode result, Instant createdAt, long ttlMs) { + boolean isExpired() { + return Instant.now().isAfter(createdAt.plusMillis(ttlMs)); + } + } + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private long ttlMs = DEFAULT_TTL_MS; + private int hits = 0; + private int misses = 0; + + /** Set TTL in milliseconds. */ + public void setTtlMs(long ttlMs) { + this.ttlMs = ttlMs; + } + + /** + * Check if a cached result exists for the given tool + arguments. + * Returns null on miss or expiry. + */ + public JsonNode get(String toolName, JsonNode arguments) { + String key = cacheKey(toolName, arguments); + CacheEntry entry = cache.get(key); + if (entry == null) { + misses++; + return null; + } + if (entry.isExpired()) { + cache.remove(key); + misses++; + log.debug("Cache expired for tool={} key={}", toolName, key.substring(0, 8)); + return null; + } + hits++; + log.debug("Cache hit for tool={} key={}", toolName, key.substring(0, 8)); + return entry.result; + } + + /** + * Store a result in the cache. + */ + public void put(String toolName, JsonNode arguments, JsonNode result) { + String key = cacheKey(toolName, arguments); + cache.put(key, new CacheEntry(result, Instant.now(), ttlMs)); + log.debug("Cache put for tool={} key={} size={}", toolName, key.substring(0, 8), cache.size()); + } + + /** + * Check if a tool is cache-eligible based on its capability entry. + * Only read-only tools are eligible. + */ + public static boolean isCacheEligible(CapabilityEntry entry) { + if (entry == null) return false; + return "read".equals(entry.rwBoundary); + } + + /** Clear all cached entries. Called on session close. */ + public void clear() { + int size = cache.size(); + cache.clear(); + log.info("ResultCache cleared ({} entries, {} hits, {} misses)", size, hits, misses); + hits = 0; + misses = 0; + } + + /** Current cache size. */ + public int size() { + return cache.size(); + } + + /** Cache hit count since last clear. */ + public int getHits() { return hits; } + + /** Cache miss count since last clear. */ + public int getMisses() { return misses; } + + /** + * Generate a deterministic cache key from tool name + arguments. + * Uses SHA-256 of the canonical JSON string. + */ + static String cacheKey(String toolName, JsonNode arguments) { + try { + String canonical = toolName + "|" + + (arguments != null ? mapper.writeValueAsString(arguments) : "{}"); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(canonical.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (byte b : hash) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (Exception e) { + // Fallback: use raw string hash + return toolName + "|" + (arguments != null ? arguments.hashCode() : 0); + } + } +} diff --git a/java/src/main/java/dev/sorted/mcphub/StateMachine.java b/java/src/main/java/dev/sorted/mcphub/StateMachine.java index b59890c..d85b73e 100644 --- a/java/src/main/java/dev/sorted/mcphub/StateMachine.java +++ b/java/src/main/java/dev/sorted/mcphub/StateMachine.java @@ -27,7 +27,10 @@ public TransitionException(String message, String errorCode) { } public enum Trigger { - ARM, OPEN, CLOSE, LOCK, UNLOCK, ARM_TIMEOUT, IDLE_TIMEOUT, BRIDGE_DETACH, DRAIN_COMPLETE + ARM, OPEN, CLOSE, LOCK, UNLOCK, ARM_TIMEOUT, IDLE_TIMEOUT, + // Low-level session.detach semantics. Normal bridge lifecycle detach is + // handled in ControlHandler so OPEN sessions can fall back to idle timeout. + BRIDGE_DETACH, DRAIN_COMPLETE } // Listener for state change events (for logging to DB) diff --git a/java/src/main/java/dev/sorted/mcphub/StdioBridge.java b/java/src/main/java/dev/sorted/mcphub/StdioBridge.java index e680886..4797b0b 100644 --- a/java/src/main/java/dev/sorted/mcphub/StdioBridge.java +++ b/java/src/main/java/dev/sorted/mcphub/StdioBridge.java @@ -11,6 +11,7 @@ import java.net.UnixDomainSocketAddress; import java.nio.channels.SocketChannel; import java.nio.charset.StandardCharsets; +import java.util.Map; /** * MCP stdio bridge: reads JSON-RPC from stdin (AI client), forwards to daemon UDS, @@ -67,8 +68,7 @@ public void run() throws IOException { stdoutWriter.flush(); } else { // REQ-2.3.4: daemon unreachable - writeError(stdoutWriter, id, -32000, - "MCPHUB daemon is not running. Start it with 'mcphub start'."); + writeError(stdoutWriter, id, -32000, daemonUnavailableMessage()); } // Send bridge_attach on first successful initialize @@ -82,7 +82,7 @@ public void run() throws IOException { heartbeat.interrupt(); - // REQ-2.3.5: stdin closed → bridge detaching (session stays open) + // REQ-2.3.5: stdin closed -> bridge detaching; session falls back to idle timeout. log.info("stdin closed, bridge detaching"); sendDetach(); } @@ -118,6 +118,24 @@ private String forwardToDaemon(String requestLine) { } } + static String daemonUnavailableMessage() { + return daemonUnavailableMessage(cliBaseCommand(System.getenv())); + } + + static String daemonUnavailableMessage(String command) { + return "MCPHUB daemon is unreachable. Start the daemon, then open the session. " + + "cli_recovery_start_command: " + command + " start | " + + "cli_recovery_open_command: " + command + " open"; + } + + static String cliBaseCommand(Map env) { + String command = env.get("MCPHUB_CLI_COMMAND"); + if (command == null || command.isBlank()) { + return "mcphub"; + } + return command; + } + /** Send bridge_attach notification to daemon. Returns true on success. */ private boolean sendAttach() { ObjectNode req = mapper.createObjectNode(); @@ -187,7 +205,7 @@ private void sendDetach() { } catch (Exception e) { log.warn("bridge_detach notification failed: {}", e.getMessage()); } - log.info("Bridge detaching (session kept open for other bridges)"); + log.info("Bridge detaching (last bridge resumes session idle timer)"); } private void writeParseError(PrintWriter writer, JsonNode id, String message) { diff --git a/java/src/main/java/dev/sorted/mcphub/TaskContextFilter.java b/java/src/main/java/dev/sorted/mcphub/TaskContextFilter.java new file mode 100644 index 0000000..e727c7e --- /dev/null +++ b/java/src/main/java/dev/sorted/mcphub/TaskContextFilter.java @@ -0,0 +1,159 @@ +package dev.sorted.mcphub; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Task-context-linked filtering. + * Proposal §6.5 / OS-14 → Alpha scope (CEO 2026-04-26). + * + * Reduces visible tools according to current task context. In alpha, + * this operates via explicit context signals rather than AXIS auto-detection: + * + * 1. MCP tool: mcphub_set_task_context — AI sets the current task type + * 2. Context-to-tool mapping: defined in config or programmatically + * 3. Effect: session-scoped HIDE rules for tools not relevant to current task + * + * Task contexts (alpha): + * - "coding" → code tools visible, web tools secondary + * - "research" → web tools visible, edit tools secondary + * - "planning" → session/planning tools visible + * - "all" → no filtering (default) + * + * Future: AXIS integration can auto-set task context based on conversation signals. + */ +public class TaskContextFilter { + private static final Logger log = LoggerFactory.getLogger(TaskContextFilter.class); + private static final ObjectMapper mapper = new ObjectMapper(); + + /** Predefined task context profiles with tool relevance mappings. */ + public enum TaskContext { + ALL, // No filtering + CODING, // Code editing, search, LSP + RESEARCH, // Web fetch, web search + PLANNING // Plan enter/exit, todo, skill + } + + /** Tools considered primary for each context. Tools not in the primary set are hidden. */ + private static final Set CODING_TOOLS = Set.of( + "apply_patch", "codesearch", "lsp", "list", "todowrite"); + private static final Set RESEARCH_TOOLS = Set.of( + "webfetch", "websearch"); + private static final Set PLANNING_TOOLS = Set.of( + "plan_enter", "plan_exit", "skill", "batch", "todowrite"); + + private volatile TaskContext currentContext = TaskContext.ALL; + private final List contextRules = new ArrayList<>(); + + /** Get the current task context. */ + public TaskContext getCurrentContext() { + return currentContext; + } + + /** + * Set the task context and generate session-scoped HIDE rules for irrelevant tools. + * + * @param contextName task context name ("coding", "research", "planning", "all") + * @param policy policy engine to inject session rules + * @param registry capability registry to enumerate tools + * @return the applied context name + */ + public String setContext(String contextName, PolicyEngine policy, CapabilityRegistry registry) { + TaskContext ctx; + try { + ctx = TaskContext.valueOf(contextName.toUpperCase()); + } catch (IllegalArgumentException e) { + log.warn("Unknown task context '{}', defaulting to ALL", contextName); + ctx = TaskContext.ALL; + } + + // Clear previous context rules + clearContextRules(policy); + currentContext = ctx; + + if (ctx == TaskContext.ALL) { + log.info("Task context set to ALL — no tool filtering"); + return "all"; + } + + Set primaryTools = switch (ctx) { + case CODING -> CODING_TOOLS; + case RESEARCH -> RESEARCH_TOOLS; + case PLANNING -> PLANNING_TOOLS; + default -> Set.of(); + }; + + // Generate HIDE rules for tools NOT in the primary set + List allTools = registry.getAll(); + int ruleCount = 0; + for (CapabilityEntry entry : allTools) { + if (!primaryTools.contains(entry.displayName) && entry.enabled) { + PolicyRule hideRule = new PolicyRule(); + hideRule.ruleId = "task-ctx-hide-" + entry.displayName; + hideRule.toolPattern = entry.displayName; + hideRule.action = "hide"; + hideRule.priority = 100; // Session boost will make this override global rules + hideRule.scope = "session"; + policy.addSessionRule(hideRule); + contextRules.add(hideRule); + ruleCount++; + } + } + + log.info("Task context set to {} — {} tools hidden, {} primary tools visible", + ctx, ruleCount, primaryTools.size()); + return ctx.name().toLowerCase(); + } + + /** Clear all context-generated session rules. */ + public void clearContextRules(PolicyEngine policy) { + if (!contextRules.isEmpty()) { + // Session rules are cleared as a batch; we can't selectively remove. + // Context rules are a subset of session rules. On context change, + // we rely on clearSessionRules() being called on session close, + // or we track and remove individually. + // For alpha: context change clears all session rules and re-applies. + // This is acceptable because session rules are lightweight. + policy.clearSessionRules(); + contextRules.clear(); + log.info("Task context rules cleared"); + } + } + + /** Reset to ALL context. Called on session close. */ + public void reset() { + currentContext = TaskContext.ALL; + contextRules.clear(); + } + + /** + * Build the MCP tool response for mcphub_set_task_context. + */ + public static ObjectNode buildResponse(String appliedContext, int hiddenCount, int visibleCount) { + ObjectNode r = mapper.createObjectNode(); + ArrayNode content = mapper.createArrayNode(); + ObjectNode textItem = mapper.createObjectNode(); + textItem.put("type", "text"); + + ObjectNode result = mapper.createObjectNode(); + result.put("task_context", appliedContext); + result.put("hidden_tools", hiddenCount); + result.put("visible_tools", visibleCount); + result.put("note", "Task context applied. tools/list will now return only tools relevant to '" + + appliedContext + "' context. Set context to 'all' to restore full tool list."); + + textItem.put("text", result.toString()); + content.add(textItem); + r.set("content", content); + return r; + } +} diff --git a/java/src/main/resources/capabilities.yaml b/java/src/main/resources/capabilities.yaml index 59bb9fa..abfe8ef 100644 --- a/java/src/main/resources/capabilities.yaml +++ b/java/src/main/resources/capabilities.yaml @@ -147,21 +147,25 @@ capabilities: properties: patch: type: string - description: "Unified diff patch content" + description: "Patch content. Supported formats (auto-detected): (1) git-style unified diff with '--- a/path' / '+++ b/path' headers (preferred, applied with patch -p1); (2) plain unified diff with '--- path' / '+++ path' headers (applied with patch -p0); (3) OpenAI/GPT '*** Begin Patch' format with '*** Add/Update/Delete File: path' directives and @@ hunks." cwd: type: string description: "Working directory to apply patch in (optional, defaults to current)" required: [patch] contract: - purpose: "Apply a unified diff patch to files in the workspace. Modifies local file state." + purpose: "Apply a patch to files in the workspace. Supports git-style unified diff (preferred), plain unified diff, and OpenAI/GPT-style '*** Begin Patch' format. Modifies local file state." may_do: - - "Apply unified diff patches to existing files" + - "Apply git-style unified diff patches (patch -p1)" + - "Apply plain unified diff patches (patch -p0)" + - "Add, update, or delete files via Begin Patch format" - "Create new files if the patch specifies them" must_not_do: - "Apply patches outside the workspace root" + - "Accept absolute paths or '..' path-segment traversal in any supported patch format" - "Make network calls" when_to_call: - "A patch or diff has been prepared and needs to be applied to files" + - "An AI assistant has generated a Begin Patch block for file changes" when_not_to_call: - "Direct file editing is simpler; use edit or write instead" side_effect_class: "local_state" @@ -355,6 +359,145 @@ capabilities: side_effect_class: "local_state" timeout_hint_ms: 60000 + - capability_id: "nexus_issue_create" + display_name: "nexus_issue_create" + provider_id: "builtin-hatch" + access_class: "guarded" + rw_boundary: "write" + enabled: true + priority: 10 + schema: + type: object + properties: + project: + type: string + description: "Project name (e.g. mcphub, hatch, axis)" + title: + type: string + description: "Issue title" + body: + type: string + description: "Markdown body of the issue" + priority: + type: string + enum: [HIGH, MEDIUM, LOW, INFO] + default: MEDIUM + description: "Issue priority" + required: [project, title, body] + contract: + purpose: "Create a new issue in the Nexus issue tracker under a project. Returns the created file path. Modifies local state." + may_do: + - "Create Markdown files under ~/issue-store//" + must_not_do: + - "Delete or modify existing issues" + - "Write outside ~/issue-store/" + when_to_call: + - "A bug, finding, or to-do needs persistent cross-session tracking" + - "Recording a problem discovered during investigation" + when_not_to_call: + - "The task is trivial and does not need cross-session persistence" + side_effect_class: "local_state" + timeout_hint_ms: 5000 + + - capability_id: "nexus_issue_list" + display_name: "nexus_issue_list" + provider_id: "builtin-hatch" + access_class: "safe" + rw_boundary: "read" + enabled: true + priority: 10 + schema: + type: object + properties: + project: + type: string + description: "Project name (e.g. mcphub, hatch, axis)" + required: [project] + contract: + purpose: "List open issues for a project in the Nexus issue tracker. Read-only." + may_do: + - "Read directory listings under ~/issue-store//" + must_not_do: + - "Modify any files" + when_to_call: + - "The session needs to know what issues remain open for a project" + - "Finding candidate issues to close or update" + when_not_to_call: + - "Writing or closing issues" + side_effect_class: "none" + timeout_hint_ms: 5000 + + - capability_id: "nexus_issue_close" + display_name: "nexus_issue_close" + provider_id: "builtin-hatch" + access_class: "guarded" + rw_boundary: "write" + enabled: true + priority: 10 + schema: + type: object + properties: + project: + type: string + description: "Project name" + file: + type: string + description: "Issue filename (from nexus_issue_list)" + resolution: + type: string + description: "Optional resolution note to append before closing" + required: [project, file] + contract: + purpose: "Close an issue by moving it to the closed/ subdirectory with an optional resolution note." + may_do: + - "Move issue files to closed/ subdirectory" + - "Update issue status to CLOSED" + must_not_do: + - "Delete issue files" + - "Close issues outside the project directory" + when_to_call: + - "An issue has been fully resolved and evidence is recorded" + - "The fix has been merged and verified" + when_not_to_call: + - "The issue is still in progress" + side_effect_class: "local_state" + timeout_hint_ms: 5000 + + - capability_id: "nexus_issue_update" + display_name: "nexus_issue_update" + provider_id: "builtin-hatch" + access_class: "guarded" + rw_boundary: "write" + enabled: true + priority: 10 + schema: + type: object + properties: + project: + type: string + description: "Project name" + file: + type: string + description: "Issue filename (from nexus_issue_list)" + addition: + type: string + description: "Text to append to the issue" + required: [project, file, addition] + contract: + purpose: "Append a status update or additional evidence to an existing issue." + may_do: + - "Append timestamped text to issue files" + must_not_do: + - "Delete or truncate existing content" + - "Modify files outside the project directory" + when_to_call: + - "Updating an issue with new findings or progress" + - "Recording verification evidence for an in-progress issue" + when_not_to_call: + - "The issue is ready to close; use nexus_issue_close" + side_effect_class: "local_state" + timeout_hint_ms: 5000 + - capability_id: "task_create" display_name: "task_create" provider_id: "builtin-hatch" @@ -365,16 +508,27 @@ capabilities: schema: type: object properties: - title: { type: string, description: "Task title" } - description: { type: string, description: "Task description or details" } - priority: { type: string, enum: [HIGH, MEDIUM, LOW], default: MEDIUM } + title: + type: string + description: "Task title" + description: + type: string + description: "Task description or details" + priority: + type: string + enum: [HIGH, MEDIUM, LOW] + default: MEDIUM required: [title] contract: purpose: "Create a persistent task for cross-session tracking." - may_do: ["Write tasks to ~/.config/mcphub/tasks.json"] - must_not_do: ["Modify other files"] - when_to_call: ["A new actionable item is discovered that should survive /clear"] - when_not_to_call: ["Volatile in-session reminders; use todowrite"] + may_do: + - "Write tasks to ~/.config/mcphub/tasks.json" + must_not_do: + - "Modify other files" + when_to_call: + - "A new actionable item is discovered that should survive /clear" + when_not_to_call: + - "Volatile in-session reminders; use todowrite" side_effect_class: "local_state" timeout_hint_ms: 5000 @@ -388,13 +542,20 @@ capabilities: schema: type: object properties: - status: { type: string, enum: [open, in_progress, done, all], default: all } + status: + type: string + enum: [open, in_progress, done, all] + default: all contract: purpose: "List persistent tasks with optional status filter." - may_do: ["Read ~/.config/mcphub/tasks.json"] - must_not_do: ["Modify any files"] - when_to_call: ["Session start: review remaining work from previous sessions"] - when_not_to_call: ["Creating or updating tasks"] + may_do: + - "Read ~/.config/mcphub/tasks.json" + must_not_do: + - "Modify any files" + when_to_call: + - "Session start: review remaining work from previous sessions" + when_not_to_call: + - "Creating or updating tasks" side_effect_class: "none" timeout_hint_ms: 5000 @@ -408,16 +569,27 @@ capabilities: schema: type: object properties: - id: { type: string, description: "Task ID from task_list" } - status: { type: string, enum: [open, in_progress, done], description: "New status (done = 消込)" } - note: { type: string, description: "Optional note" } + id: + type: string + description: "Task ID from task_list" + status: + type: string + enum: [open, in_progress, done] + description: "New status (done = 消込)" + note: + type: string + description: "Optional note" required: [id, status] contract: purpose: "Update task status. done = 消込." - may_do: ["Modify tasks.json"] - must_not_do: ["Delete tasks (use task_delete)"] - when_to_call: ["Task completed — mark as done for 消込"] - when_not_to_call: ["Creating new tasks"] + may_do: + - "Modify tasks.json" + must_not_do: + - "Delete tasks (use task_delete)" + when_to_call: + - "Task completed — mark as done for 消込" + when_not_to_call: + - "Creating new tasks" side_effect_class: "local_state" timeout_hint_ms: 5000 @@ -431,17 +603,56 @@ capabilities: schema: type: object properties: - id: { type: string, description: "Task ID from task_list" } + id: + type: string + description: "Task ID from task_list" required: [id] contract: purpose: "Delete a task permanently." - may_do: ["Remove task from tasks.json"] - must_not_do: ["Delete non-task files"] - when_to_call: ["Task is no longer relevant"] - when_not_to_call: ["Task is done — use task_update status=done instead"] + may_do: + - "Remove task from tasks.json" + must_not_do: + - "Delete non-task files" + when_to_call: + - "Task is no longer relevant" + when_not_to_call: + - "Task is done — use task_update status=done instead" side_effect_class: "local_state" timeout_hint_ms: 5000 + - capability_id: "mcphub_checkpoint" + display_name: "mcphub_checkpoint" + provider_id: "mcphub-internal" + access_class: "safe" + rw_boundary: "write" + enabled: true + priority: 10 + schema: + type: object + properties: + message: + type: string + description: "Handover message — what was done, what remains, context for next session" + project: + type: string + description: "Project name for nexus issue listing (optional)" + required: [message] + contract: + purpose: "Save current session state (tasks, nexus issues, handover note) as a persistent checkpoint for next-session handoff." + may_do: + - "Read tasks.json and issue-store directory" + - "Write checkpoint state to MCPHUB data directory" + must_not_do: + - "Modify task state or nexus issues (only reads them)" + - "Delete existing checkpoints" + when_to_call: + - "A session is ending and context needs to persist" + - "Before a /clear or session close" + when_not_to_call: + - "A simple one-step task with no persistent context needed" + side_effect_class: "local_state" + timeout_hint_ms: 10000 + # ----------------------------------------------------------------------- # Relay providers: configure in ~/.config/mcphub/relays.yaml # See README.md for format and examples. @@ -454,3 +665,17 @@ policy: action: "allow" priority: 1 scope: "global" + + # Verdict provider: destructive reset tools denied by default. + # Operators must explicitly grant access via a session rule to use these. + - rule_id: "deny-test-db-reset" + tool_pattern: "test_db_reset" + action: "deny" + priority: 9000 + scope: "global" + + - rule_id: "deny-test-cleanup-reset" + tool_pattern: "test_cleanup_reset" + action: "deny" + priority: 9000 + scope: "global" diff --git a/java/src/main/resources/dashboard.html b/java/src/main/resources/dashboard.html new file mode 100644 index 0000000..d600aac --- /dev/null +++ b/java/src/main/resources/dashboard.html @@ -0,0 +1,286 @@ + + + + + +MCPHUB Dashboard + + + +

MCPHUB Dashboard

+

Loading metrics...

+ +
+ +
+ Overview + Providers + Log Tail + Errors +
+ +
+
+

📊 Tool Call Frequency (all time)

+

Loading...

+
+
+

📅 Recent Sessions

+

Loading...

+
+
+

💠 Body Budget Tiers

+

Loading...

+
+
+ + + + + + + + + + diff --git a/java/src/main/resources/relays-example.yaml b/java/src/main/resources/relays-example.yaml index 83496ff..a61c0b5 100644 --- a/java/src/main/resources/relays-example.yaml +++ b/java/src/main/resources/relays-example.yaml @@ -1,7 +1,7 @@ -# Helm relay provider configuration +# MCPHUB relay provider configuration # Copy to ~/.config/mcphub/relays.yaml and edit for your setup. # -# Each relay entry defines an external MCP server that Helm will +# Each relay entry defines an external MCP server that MCPHUB will # spawn as a subprocess and route tool calls to. # # Format: @@ -9,7 +9,7 @@ # - id: "unique-relay-id" # command: ["/path/to/binary", "arg1", "arg2"] # tools: ["tool_name_1", "tool_name_2"] -# capabilities: # optional: full capability definitions +# capabilities: # required for AI-visible tools not built into MCPHUB # - capability_id: "tool_name_1" # display_name: "tool_name_1" # provider_id: "my-provider" @@ -33,3 +33,63 @@ # MCPHUB_RELAYS_PATH=/path/to/custom-relays.yaml relays: [] + +# --------------------------------------------------------------------------- +# Example: Verdict. test-evidence provider +# --------------------------------------------------------------------------- +# Verdict. exposes 15 test_* MCP tools for scenario execution, evidence +# collection, browser automation, and database probing. +# +# Provider binary: /home/YOU/.local/share/verdict/current/bin/verdict-provider +# Provider ID: mcphub_test_evidence_provider +# +# Use an absolute command path. MCPHUB starts providers with Java +# ProcessBuilder, so shell expansions like ${XDG_DATA_HOME:-...} are not +# expanded. +# +# Two tools are POLICY-DENIED by default in capabilities.yaml: +# test_db_reset — destructive DB baseline reset +# test_cleanup_reset — destructive test-environment teardown +# To authorize them per session, add a session rule via the control API: +# {"action":"allow","tool_pattern":"test_db_reset","priority":9500,"scope":"session"} +# +# Uncomment and copy to ~/.config/mcphub/relays.yaml to activate: +# +# relays: +# - id: "mcphub_test_evidence_provider" +# command: ["/home/YOU/.local/share/verdict/current/bin/verdict-provider", "--stdio"] +# tools: +# - "test_provider_health" +# - "test_provider_capabilities" +# - "test_scenario_validate" +# - "test_scenario_plan" +# - "test_scenario_run" +# - "test_scenario_status" +# - "test_evidence_list" +# - "test_evidence_get" +# - "test_artifact_export" +# - "test_gui_operate" +# - "test_browser_operate" +# - "test_db_probe" +# - "test_db_reset" +# - "test_service_health" +# - "test_cleanup_reset" +# capabilities: +# # Add all 15 capability definitions with the same required fields, +# # preserving the frozen Verdict Spec access_class, rw_boundary, and +# # side_effect_class values. Mirror the installed provider's tools/list +# # schema exactly when provider versions add or rename input fields. +# # Minimally required fields per MCPHUB registry validation (V1): +# - capability_id: "test_provider_health" +# display_name: "test_provider_health" +# provider_id: "mcphub_test_evidence_provider" +# access_class: "safe" +# rw_boundary: "read" +# enabled: true +# priority: 10 +# schema: { type: object } +# contract: +# purpose: "Check Verdict provider liveness and readiness status." +# side_effect_class: "none" +# timeout_hint_ms: 5000 +# # ... repeat for remaining 14 test_* tools. diff --git a/java/src/test/java/dev/sorted/mcphub/CapabilityRegistryTest.java b/java/src/test/java/dev/sorted/mcphub/CapabilityRegistryTest.java index 949f504..0752d56 100644 --- a/java/src/test/java/dev/sorted/mcphub/CapabilityRegistryTest.java +++ b/java/src/test/java/dev/sorted/mcphub/CapabilityRegistryTest.java @@ -25,20 +25,21 @@ void setUp() { } // ----------------------------------------------------------------------- - // Test 1: load() with embedded capabilities.yaml — 15 entries, 0 rejected - // (15 builtin-hosted + task). synthetic_delay is a TEST FIXTURE, + // Test 1: load() with embedded capabilities.yaml — 11 entries, 0 rejected + // (11 builtin-hosted). synthetic_delay is a TEST FIXTURE, // kept out of production capabilities.yaml — loaded via MCPHUB_TEST_FIXTURE. // ----------------------------------------------------------------------- @Test - void loadEmbeddedYaml_15EntriesLoaded_0Rejected() throws Exception { + void loadEmbeddedYaml_20EntriesLoaded_0Rejected() throws Exception { try (InputStream is = getClass().getClassLoader().getResourceAsStream("capabilities.yaml")) { assertNotNull(is, "capabilities.yaml must be on classpath"); registry.load(is); } - assertEquals(15, registry.getLoadedCount(), "Expected 15 capabilities (builtin-hosted + task)"); + // 20 capabilities: 11 original + 4 nexus + 4 task + 1 mcphub_checkpoint + assertEquals(20, registry.getLoadedCount(), "Expected 20 capabilities (builtin-hosted + nexus + task + checkpoint)"); assertEquals(0, registry.getRejectedCount(), "Expected 0 rejected entries"); - assertEquals(15, registry.getAll().size()); + assertEquals(20, registry.getAll().size()); } // ----------------------------------------------------------------------- diff --git a/java/src/test/java/dev/sorted/mcphub/ControlHandlerTest.java b/java/src/test/java/dev/sorted/mcphub/ControlHandlerTest.java index 50069a7..c7ab3bc 100644 --- a/java/src/test/java/dev/sorted/mcphub/ControlHandlerTest.java +++ b/java/src/test/java/dev/sorted/mcphub/ControlHandlerTest.java @@ -2,12 +2,14 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; import java.io.InputStream; import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.jupiter.api.Assertions.*; @@ -189,171 +191,22 @@ void coolingDown_clearsHealthTracker() throws Exception { "Tracker must be cleared on COOLING_DOWN transition"); } - // ------------------------------------------------------------------------- - // AC-1: add_session_rule - // ------------------------------------------------------------------------- - - @Test - void addSessionRule_whenArmed_succeeds() throws Exception { - ControlHandler h = createCapabilityAwareHandler(); - h.handle("mcphub.control.arm", null); - - ObjectNode params = mapper.createObjectNode(); - params.put("tool_pattern", "webfetch"); - params.put("action", "deny"); - params.put("rule_id", "test-deny-webfetch"); - params.put("priority", 500); - - JsonNode r = h.handle("mcphub.control.add_session_rule", params); - assertEquals("ok", r.path("status").asText()); - assertEquals("test-deny-webfetch", r.path("rule_id").asText()); - assertEquals("session", r.path("scope").asText()); - assertEquals("ARMED", r.path("state").asText()); - } - - @Test - void addSessionRule_whenOpen_succeeds() throws Exception { - ControlHandler h = createCapabilityAwareHandler(); - h.handle("mcphub.control.arm", null); - h.handle("mcphub.control.open", null); - - ObjectNode params = mapper.createObjectNode(); - params.put("tool_pattern", "webfetch"); - params.put("action", "hide"); - - JsonNode r = h.handle("mcphub.control.add_session_rule", params); - assertEquals("ok", r.path("status").asText()); - assertEquals("session", r.path("scope").asText()); - assertEquals("OPEN", r.path("state").asText()); - } - - @Test - void addSessionRule_whenClosed_rejects() throws Exception { - ControlHandler h = createCapabilityAwareHandler(); - ObjectNode params = mapper.createObjectNode(); - params.put("tool_pattern", "webfetch"); - params.put("action", "deny"); - - JsonRpcServer.JsonRpcException ex = assertThrows( - JsonRpcServer.JsonRpcException.class, - () -> h.handle("mcphub.control.add_session_rule", params)); - assertTrue(ex.getMessage().contains("Armed or Open")); - } - - @Test - void addSessionRule_invalidAction_rejects() throws Exception { - ControlHandler h = createCapabilityAwareHandler(); - h.handle("mcphub.control.arm", null); - - ObjectNode params = mapper.createObjectNode(); - params.put("tool_pattern", "webfetch"); - params.put("action", "block"); - - JsonRpcServer.JsonRpcException ex = assertThrows( - JsonRpcServer.JsonRpcException.class, - () -> h.handle("mcphub.control.add_session_rule", params)); - assertTrue(ex.getMessage().contains("allow, deny, or hide")); - } - - @Test - void addSessionRule_missingToolPattern_rejects() throws Exception { - ControlHandler h = createCapabilityAwareHandler(); - h.handle("mcphub.control.arm", null); - - ObjectNode params = mapper.createObjectNode(); - params.put("action", "deny"); - - JsonRpcServer.JsonRpcException ex = assertThrows( - JsonRpcServer.JsonRpcException.class, - () -> h.handle("mcphub.control.add_session_rule", params)); - assertTrue(ex.getMessage().contains("Missing required params")); - } - - @Test - void addSessionRule_missingAction_rejects() throws Exception { - ControlHandler h = createCapabilityAwareHandler(); - h.handle("mcphub.control.arm", null); - - ObjectNode params = mapper.createObjectNode(); - params.put("tool_pattern", "webfetch"); - - JsonRpcServer.JsonRpcException ex = assertThrows( - JsonRpcServer.JsonRpcException.class, - () -> h.handle("mcphub.control.add_session_rule", params)); - assertTrue(ex.getMessage().contains("Missing required params")); - } - - @Test - void sessionRule_purgedOnClose_thenToolAllowedAgain() throws Exception { + private ControlHandler createCapabilityAwareHandler() throws Exception { CapabilityRegistry registry = new CapabilityRegistry(); try (InputStream is = ControlHandlerTest.class.getResourceAsStream("/capabilities.yaml")) { assertNotNull(is, "capabilities.yaml must be in classpath"); registry.load(is); } + PolicyEngine policy = new PolicyEngine(); policy.loadGlobalRules(registry.getPolicyRules()); + BodyBudgetService bodyBudget = new BodyBudgetService(db); bodyBudget.setMcphubHostedToolCount(registry.getLoadedCount()); - ControlHandler h = new ControlHandler(sm, session, db, registry, policy, bodyBudget); - - // Arm session - h.handle("mcphub.control.arm", null); - - // Add session rule to deny webfetch - ObjectNode ruleParams = mapper.createObjectNode(); - ruleParams.put("tool_pattern", "webfetch"); - ruleParams.put("action", "deny"); - ruleParams.put("rule_id", "session-deny-webfetch"); - JsonNode addResult = h.handle("mcphub.control.add_session_rule", ruleParams); - assertEquals("ok", addResult.path("status").asText()); - - // Verify tool is denied before close - assertEquals(PolicyEngine.Decision.DENY, policy.evaluate("webfetch").decision()); - - // Open, then close (triggers COOLING_DOWN → clearSessionRules) - h.handle("mcphub.control.open", null); - h.handle("mcphub.control.close", null); - - // Arm and open a new session - h.handle("mcphub.control.arm", null); - h.handle("mcphub.control.open", null); - // Verify previously-denied tool is now allowed (session rule was purged) - assertEquals(PolicyEngine.Decision.ALLOW, policy.evaluate("webfetch").decision()); - } - - @Test - void sessionRule_withExplicitLowPriority_doesNotOverrideGlobalRule() throws Exception { - // Global rule: deny webfetch at priority 1000 - PolicyEngine policy = new PolicyEngine(); - PolicyRule globalDeny = new PolicyRule(); - globalDeny.ruleId = "global-deny-webfetch"; - globalDeny.toolPattern = "webfetch"; - globalDeny.action = "deny"; - globalDeny.priority = 1000; - globalDeny.scope = "global"; - policy.loadGlobalRules(java.util.List.of(globalDeny)); - - // Session rule: allow webfetch at priority -1 (explicitly low, opt-out of boost) - PolicyRule sessionAllow = new PolicyRule(); - sessionAllow.ruleId = "session-allow-webfetch"; - sessionAllow.toolPattern = "webfetch"; - sessionAllow.action = "allow"; - sessionAllow.priority = -1; - sessionAllow.scope = "session"; - policy.addSessionRule(sessionAllow); - - // Global deny should win because session rule has explicit low priority (-1) and is NOT boosted - PolicyEngine.PolicyResult result = policy.evaluate("webfetch"); - assertEquals(PolicyEngine.Decision.DENY, result.decision(), - "Global deny at priority 1000 must override session allow at priority -1"); - assertEquals("global-deny-webfetch", result.matchedRuleId()); + return new ControlHandler(sm, session, db, registry, policy, bodyBudget); } - // ------------------------------------------------------------------------- - // Bridge attach/detach idle timeout suppression - // ------------------------------------------------------------------------- - @Test void bridgeAttach_incrementsCount() throws Exception { JsonNode result = handler.handle("mcphub.control.bridge_attach", @@ -406,12 +259,14 @@ void bridgeDetach_lastBridge_restartsIdleTimer() throws Exception { assertEquals(0, result.get("active_bridges").asInt()); assertEquals(StateMachine.State.OPEN, freshSm.getState(), - "Session should not close immediately after last bridge detaches"); + "Session should remain OPEN immediately after last bridge detaches"); + assertNotNull(shortSession.getCurrentSessionId()); Thread.sleep(3000); assertEquals(StateMachine.State.CLOSED, freshSm.getState(), - "Session should close after resumed idle timer fires"); - assertNull(shortSession.getCurrentSessionId()); + "Session should close only after resumed idle timer fires"); + assertNull(shortSession.getCurrentSessionId(), + "Session ID must be cleared after idle timeout close"); ch.shutdown(); shortSession.shutdown(); @@ -430,11 +285,14 @@ void bridgeDetach_notLastBridge_keepsIdleSuppressed() throws Exception { ControlHandler ch = new ControlHandler(freshSm, shortSession, db); ch.handle("mcphub.control.bridge_attach", mapper.readTree("{\"pid\":12345}")); ch.handle("mcphub.control.bridge_attach", mapper.readTree("{\"pid\":12346}")); - ch.handle("mcphub.control.bridge_detach", mapper.readTree("{\"pid\":12345}")); + + JsonNode result = ch.handle("mcphub.control.bridge_detach", mapper.readTree("{\"pid\":12345}")); + assertEquals("ok", result.get("status").asText()); + assertEquals(1, result.get("active_bridges").asInt()); Thread.sleep(3000); assertEquals(StateMachine.State.OPEN, freshSm.getState(), - "Session should remain OPEN when one bridge detaches but another remains"); + "Session should remain OPEN while another bridge is attached"); assertNotNull(shortSession.getCurrentSessionId()); ch.shutdown(); @@ -442,7 +300,30 @@ void bridgeDetach_notLastBridge_keepsIdleSuppressed() throws Exception { } @Test - void idleTimeout_doesNotFire_whileBridgeAttached() throws Exception { + void bridgeDetach_armedClosesDirectly() throws Exception { + StateMachine freshSm = new StateMachine(); + freshSm.transition(StateMachine.Trigger.ARM, "s1"); + + SessionManager shortSession = new SessionManager(1, 300); + shortSession.startSession(); + + ControlHandler ch = new ControlHandler(freshSm, shortSession, db); + ch.handle("mcphub.control.bridge_attach", mapper.readTree("{\"pid\":12345}")); + + JsonNode result = ch.handle("mcphub.control.bridge_detach", mapper.readTree("{\"pid\":12345}")); + assertEquals("ok", result.get("status").asText()); + assertEquals(0, result.get("active_bridges").asInt()); + assertEquals(StateMachine.State.CLOSED, freshSm.getState(), + "ARMED session must close directly on bridge detach"); + assertNull(shortSession.getCurrentSessionId()); + + ch.shutdown(); + shortSession.shutdown(); + } + + @Test + void bridgeDetach_unknownPid_idempotentNoOp() throws Exception { + // REQ-3.7.2: unknown PID detach is idempotent — no state change, no error. StateMachine freshSm = new StateMachine(); freshSm.transition(StateMachine.Trigger.ARM, "s1"); freshSm.transition(StateMachine.Trigger.OPEN, "s1"); @@ -454,28 +335,38 @@ void idleTimeout_doesNotFire_whileBridgeAttached() throws Exception { ControlHandler ch = new ControlHandler(freshSm, shortSession, db); ch.handle("mcphub.control.bridge_attach", mapper.readTree("{\"pid\":12345}")); - Thread.sleep(3000); + // Detach an unknown PID — must return ok, state unchanged + JsonNode result = ch.handle("mcphub.control.bridge_detach", mapper.readTree("{\"pid\":99999}")); + assertEquals("ok", result.get("status").asText()); + assertEquals(1, result.get("active_bridges").asInt()); + + Thread.sleep(500); assertEquals(StateMachine.State.OPEN, freshSm.getState(), - "Session should remain OPEN while bridge is attached"); - assertNotNull(shortSession.getCurrentSessionId()); + "Unknown PID detach must not change session state"); ch.shutdown(); shortSession.shutdown(); } - private ControlHandler createCapabilityAwareHandler() throws Exception { - CapabilityRegistry registry = new CapabilityRegistry(); - try (InputStream is = ControlHandlerTest.class.getResourceAsStream("/capabilities.yaml")) { - assertNotNull(is, "capabilities.yaml must be in classpath"); - registry.load(is); - } + @Test + void idleTimeout_doesNotFire_whileBridgeAttached() throws Exception { + StateMachine freshSm = new StateMachine(); + freshSm.transition(StateMachine.Trigger.ARM, "s1"); + freshSm.transition(StateMachine.Trigger.OPEN, "s1"); - PolicyEngine policy = new PolicyEngine(); - policy.loadGlobalRules(registry.getPolicyRules()); + SessionManager shortSession = new SessionManager(1, 300); + shortSession.startSession(); + shortSession.onOpen(); - BodyBudgetService bodyBudget = new BodyBudgetService(db); - bodyBudget.setMcphubHostedToolCount(registry.getLoadedCount()); + ControlHandler ch = new ControlHandler(freshSm, shortSession, db); + ch.handle("mcphub.control.bridge_attach", mapper.readTree("{\"pid\":12345}")); - return new ControlHandler(sm, session, db, registry, policy, bodyBudget); + Thread.sleep(3000); + assertEquals(StateMachine.State.OPEN, freshSm.getState(), + "Session should remain OPEN while bridge is attached"); + assertNotNull(shortSession.getCurrentSessionId()); + + ch.shutdown(); + shortSession.shutdown(); } } diff --git a/java/src/test/java/dev/sorted/mcphub/DatabaseManagerTest.java b/java/src/test/java/dev/sorted/mcphub/DatabaseManagerTest.java index 3e61746..2078ae4 100644 --- a/java/src/test/java/dev/sorted/mcphub/DatabaseManagerTest.java +++ b/java/src/test/java/dev/sorted/mcphub/DatabaseManagerTest.java @@ -33,12 +33,12 @@ void tearDown() { } @Test - void schemaVersionIsOne() throws Exception { - // REQ-8.2.5: schema_version = 1 after migration + void schemaVersionIsV2() throws Exception { + // REQ-8.2.5: schema_version = 2 after V2 migration (client_name, client_version) try (Statement st = db.getConnection().createStatement(); ResultSet rs = st.executeQuery("SELECT version FROM schema_version")) { assertTrue(rs.next()); - assertEquals(1, rs.getInt(1)); + assertEquals(2, rs.getInt(1)); } } diff --git a/java/src/test/java/dev/sorted/mcphub/McpHandlerTest.java b/java/src/test/java/dev/sorted/mcphub/McpHandlerTest.java index 73f7da9..deffac4 100644 --- a/java/src/test/java/dev/sorted/mcphub/McpHandlerTest.java +++ b/java/src/test/java/dev/sorted/mcphub/McpHandlerTest.java @@ -71,13 +71,43 @@ void initialize_defaultServerName() throws Exception { // --- tools/list --- @Test - void toolsList_whenClosed_returnsSessionOpenTool() throws Exception { - // Session is CLOSED — recovery must be visible in the same AI-facing surface. + void toolsList_whenClosed_returnsSessionOpenOnly() throws Exception { + // Session is CLOSED — tools/list returns only mcphub.session.open (REQ-7.4.2) + // so the AI can self-reopen the session JsonNode r = handler.handle("tools/list", null); assertEquals(1, r.path("tools").size()); assertEquals("mcphub.session.open", r.path("tools").get(0).path("name").asText()); } + @Test + void toolsList_whenArmed_returnsSessionOpenOnly() throws Exception { + sm.transition(StateMachine.Trigger.ARM, "s1"); + + JsonNode r = handler.handle("tools/list", null); + assertEquals(1, r.path("tools").size()); + assertEquals("mcphub.session.open", r.path("tools").get(0).path("name").asText()); + } + + @Test + void toolsList_whenCoolingDown_returnsEmpty() throws Exception { + sm.transition(StateMachine.Trigger.ARM, "s1"); + sm.transition(StateMachine.Trigger.OPEN, "s1"); + sm.transition(StateMachine.Trigger.CLOSE, "s1"); + + JsonNode r = handler.handle("tools/list", null); + assertEquals(0, r.path("tools").size()); + } + + @Test + void toolsList_whenLocked_returnsEmpty() throws Exception { + sm.transition(StateMachine.Trigger.ARM, "s1"); + sm.transition(StateMachine.Trigger.OPEN, "s1"); + sm.transition(StateMachine.Trigger.LOCK, "s1"); + + JsonNode r = handler.handle("tools/list", null); + assertEquals(0, r.path("tools").size()); + } + @Test void toolsList_whenOpen_returns11PlusDisambiguation() throws Exception { // Arm and open the session @@ -85,13 +115,16 @@ void toolsList_whenOpen_returns11PlusDisambiguation() throws Exception { sm.transition(StateMachine.Trigger.OPEN, "s1"); confirmGroup("web", "webfetch", "websearch"); confirmGroup("edit", "apply_patch"); - confirmGroup("project", "todowrite", "list", "codesearch", "lsp", "task_create", "task_list", "task_update", "task_delete"); - confirmGroup("session", "plan_enter", "plan_exit", "skill", "batch"); + confirmGroup("project", "todowrite", "list", "codesearch", "lsp", + "task_create", "task_list", "task_update", "task_delete"); + confirmGroup("session", "plan_enter", "plan_exit", "skill", "batch", "mcphub_checkpoint"); + confirmGroup("nexus", "nexus_issue_create", "nexus_issue_list", + "nexus_issue_close", "nexus_issue_update"); JsonNode r = handler.handle("tools/list", null); JsonNode tools = r.path("tools"); - // 15 registered tools (builtin-hosted + task) + 1 disambiguation = 16 - assertEquals(16, tools.size(), "Expected 15 capabilities + 1 disambiguation tool"); + // 20 registered tools (builtin-hosted: 11 original + 4 nexus + 4 task + 1 checkpoint) + 1 disambiguation + 1 task_context = 22 + assertEquals(22, tools.size(), "Expected 20 capabilities + 2 hub tools (disambiguate + task_context)"); // Verify disambiguation tool is present boolean hasDisambig = false; @@ -119,8 +152,11 @@ void toolsList_deniedTool_notVisible() throws Exception { sm.transition(StateMachine.Trigger.OPEN, "s1"); confirmGroup("web", "webfetch", "websearch"); confirmGroup("edit", "apply_patch"); - confirmGroup("project", "todowrite", "list", "codesearch", "lsp", "task_create", "task_list", "task_update", "task_delete"); - confirmGroup("session", "plan_enter", "plan_exit", "skill", "batch"); + confirmGroup("project", "todowrite", "list", "codesearch", "lsp", + "task_create", "task_list", "task_update", "task_delete"); + confirmGroup("session", "plan_enter", "plan_exit", "skill", "batch", "mcphub_checkpoint"); + confirmGroup("nexus", "nexus_issue_create", "nexus_issue_list", + "nexus_issue_close", "nexus_issue_update"); JsonNode r = handler.handle("tools/list", null); for (JsonNode t : r.path("tools")) { @@ -129,6 +165,26 @@ void toolsList_deniedTool_notVisible() throws Exception { } } + @Test + void toolsList_taskContextResearch_hidesNonResearchTools() throws Exception { + sm.transition(StateMachine.Trigger.ARM, "s1"); + sm.transition(StateMachine.Trigger.OPEN, "s1"); + confirmGroup("web", "webfetch", "websearch"); + confirmGroup("edit", "apply_patch"); + confirmGroup("project", "todowrite", "list", "codesearch", "lsp", + "task_create", "task_list", "task_update", "task_delete"); + confirmGroup("session", "plan_enter", "plan_exit", "skill", "batch", "mcphub_checkpoint"); + + setTaskContext("research"); + + java.util.Set names = toolNames(handler.handle("tools/list", null)); + assertTrue(names.contains("webfetch")); + assertTrue(names.contains("websearch")); + assertTrue(names.contains("mcphub_set_task_context")); + assertFalse(names.contains("list"), "research context must hide project file listing"); + assertFalse(names.contains("apply_patch"), "research context must hide edit tools"); + } + // --- tools/call --- @Test @@ -139,34 +195,14 @@ void toolsCall_sessionNotOpen_returnsSessionNotOpen() throws Exception { JsonNode r = handler.handle("tools/call", params); assertTrue(r.path("isError").asBoolean(), "isError must be true"); - JsonNode err = parseErrorJson(r); - assertEquals("session_not_open", err.path("error_code").asText()); - assertEquals("call_mcphub_session_open", err.path("next_action").asText()); - assertTrue(err.path("reason").asText().contains("mcphub.session.open")); - } - - @Test - void toolsCall_sessionOpenTool_opensSessionFromClosed() throws Exception { - ObjectNode params = mapper.createObjectNode(); - params.put("name", "mcphub.session.open"); - params.set("arguments", mapper.createObjectNode()); - - JsonNode r = handler.handle("tools/call", params); - - assertFalse(r.path("isError").asBoolean(false), "session recovery should not be an error"); - assertEquals(StateMachine.State.OPEN, sm.getState()); - assertEquals("start", r.path("mcphub_providers").asText()); - - JsonNode listed = handler.handle("tools/list", null); - JsonNode tools = listed.path("tools"); - boolean hasSessionOpen = false; - for (JsonNode t : tools) { - if ("mcphub.session.open".equals(t.path("name").asText())) { - hasSessionOpen = true; - break; - } - } - assertFalse(hasSessionOpen, "session recovery tool should not remain in the OPEN tool surface"); + String text = r.path("content").get(0).path("text").asText(); + assertTrue(text.contains("session_not_open"), "Error text must contain error code"); + assertTrue(text.contains("mcphub.session.open"), "Error text must mention mcphub.session.open as recovery path"); + assertTrue(text.contains("call_mcphub_session_open"), "Error next_action must be call_mcphub_session_open"); + String cliCommand = System.getenv("MCPHUB_CLI_COMMAND"); + String expectedCliOpen = (cliCommand == null || cliCommand.isBlank() ? "mcphub" : cliCommand) + " open"; + assertTrue(text.contains("cli_recovery_command: " + expectedCliOpen), + "Error text must include exact CLI recovery command for clients that cannot call mcphub.session.open"); } @Test @@ -175,8 +211,9 @@ void toolsCall_unknownTool_returnsToolNotFound() throws Exception { sm.transition(StateMachine.Trigger.OPEN, "s1"); confirmGroup("web", "webfetch", "websearch"); confirmGroup("edit", "apply_patch"); - confirmGroup("project", "todowrite", "list", "codesearch", "lsp", "task_create", "task_list", "task_update", "task_delete"); - confirmGroup("session", "plan_enter", "plan_exit", "skill", "batch"); + confirmGroup("project", "todowrite", "list", "codesearch", "lsp", + "task_create", "task_list", "task_update", "task_delete"); + confirmGroup("session", "plan_enter", "plan_exit", "skill", "batch", "mcphub_checkpoint"); ObjectNode params = mapper.createObjectNode(); params.put("name", "nonexistent_tool_xyz"); @@ -184,12 +221,14 @@ void toolsCall_unknownTool_returnsToolNotFound() throws Exception { JsonNode r = handler.handle("tools/call", params); assertTrue(r.path("isError").asBoolean(), "isError must be true"); - JsonNode err = parseErrorJson(r); - assertEquals("tool_not_found", err.path("error_code").asText()); - // REQ-5.4.2: available_tools included - assertTrue(err.has("available_tools"), "Error must contain available_tools array"); - assertTrue(err.path("available_tools").isArray()); - assertTrue(err.path("available_tools").size() > 0); + String text = r.path("content").get(0).path("text").asText(); + assertTrue(text.contains("tool_not_found"), "Error text must contain error code"); + // REQ-5.4.2: available_tools included in error text + assertTrue(text.contains("available_tools"), "Error text must list available tools"); + JsonNode gap = r.path("capability_gap"); + assertEquals("capability_not_registered", gap.path("gap_type").asText()); + assertFalse(gap.path("explanation").asText().isBlank()); + assertEquals("capability_registry_governance", gap.path("premium_feature_id").asText()); } @Test @@ -212,16 +251,75 @@ void toolsCall_deniedTool_returnsToolDenied() throws Exception { JsonNode r = handler.handle("tools/call", params); assertTrue(r.path("isError").asBoolean(), "isError must be true"); - JsonNode err = parseErrorJson(r); - assertEquals("tool_denied", err.path("error_code").asText()); - assertEquals("deny-webfetch", err.path("policy_detail").asText()); + String text = r.path("content").get(0).path("text").asText(); + assertTrue(text.contains("tool_denied"), "Error text must contain error code"); // REQ-5.6.4: MUST NOT suggest retry for policy denial - assertNotEquals("retry", err.path("next_action").asText(), - "MUST NOT suggest retry for policy denial"); + assertFalse(text.contains("next_action: retry"), "MUST NOT suggest retry for policy denial"); + JsonNode gap = r.path("capability_gap"); + assertEquals("policy_restriction", gap.path("gap_type").asText()); + assertFalse(gap.path("explanation").asText().isBlank()); + assertEquals("policy_governance", gap.path("premium_feature_id").asText()); } @Test - void toolsCall_registeredTool_withoutProviderManager_returnsProviderUnreachable() throws Exception { + void toolsCall_hiddenByTaskContext_returnsActionableToolNotFound() throws Exception { + sm.transition(StateMachine.Trigger.ARM, "s1"); + sm.transition(StateMachine.Trigger.OPEN, "s1"); + confirmGroup("web", "webfetch", "websearch"); + confirmGroup("project", "todowrite", "list", "codesearch", "lsp", + "task_create", "task_list", "task_update", "task_delete"); + + setTaskContext("research"); + + ObjectNode params = mapper.createObjectNode(); + params.put("name", "list"); + params.set("arguments", mapper.createObjectNode()); + + JsonNode r = handler.handle("tools/call", params); + assertTrue(r.path("isError").asBoolean(), "isError must be true"); + String text = r.path("content").get(0).path("text").asText(); + assertTrue(text.contains("tool_not_found"), "Hidden tools must still appear as not found"); + assertTrue(text.contains("hidden by task context 'research'")); + assertTrue(text.contains("mcphub_set_task_context")); + assertTrue(text.contains("{\"context\":\"all\"}")); + assertTrue(text.contains("next_action: set_task_context_all")); + assertTrue(text.contains("available_tools")); + + JsonNode gap = r.path("capability_gap"); + assertEquals("task_context_filter", gap.path("gap_type").asText()); + assertEquals("task_context_filtering", gap.path("premium_feature_id").asText()); + assertTrue(gap.path("operator_action").asText().contains("mcphub_set_task_context")); + } + + @Test + void toolsCall_hiddenByGenericPolicy_keepsGenericToolNotFound() throws Exception { + PolicyRule hide = new PolicyRule(); + hide.ruleId = "hide-webfetch"; + hide.toolPattern = "webfetch"; + hide.action = "hide"; + hide.priority = 999; + hide.scope = "global"; + policy.loadGlobalRules(java.util.List.of(hide)); + + sm.transition(StateMachine.Trigger.ARM, "s1"); + sm.transition(StateMachine.Trigger.OPEN, "s1"); + confirmGroup("web", "webfetch", "websearch"); + + ObjectNode params = mapper.createObjectNode(); + params.put("name", "webfetch"); + params.set("arguments", mapper.createObjectNode()); + + JsonNode r = handler.handle("tools/call", params); + assertTrue(r.path("isError").asBoolean(), "isError must be true"); + String text = r.path("content").get(0).path("text").asText(); + assertTrue(text.contains("tool_not_found")); + assertFalse(text.contains("hidden by task context")); + assertFalse(text.contains("set_task_context_all")); + assertFalse(r.has("capability_gap")); + } + + @Test + void toolsCall_registeredTool_withoutProviderManager_returnsUnavailable() throws Exception { // AMD-MCPHUB-001: without a ProviderManager wired, tools/call returns provider_unreachable sm.transition(StateMachine.Trigger.ARM, "s1"); sm.transition(StateMachine.Trigger.OPEN, "s1"); @@ -236,8 +334,12 @@ void toolsCall_registeredTool_withoutProviderManager_returnsProviderUnreachable( JsonNode r = handler.handle("tools/call", params); // Without providerManager, dispatch returns provider_unreachable failure response assertTrue(r.path("isError").asBoolean(), "isError must be true"); - JsonNode err = parseErrorJson(r); - assertEquals("provider_unreachable", err.path("error_code").asText()); + String text = r.path("content").get(0).path("text").asText(); + assertTrue(text.contains("provider_unreachable"), "Error text must contain error code"); + JsonNode gap = r.path("capability_gap"); + assertEquals("provider_not_running", gap.path("gap_type").asText()); + assertFalse(gap.path("explanation").asText().isBlank()); + assertEquals("provider_lifecycle_governance", gap.path("premium_feature_id").asText()); } @Test @@ -246,8 +348,9 @@ void toolsCall_disambiguate_returnsRecommendation() throws Exception { sm.transition(StateMachine.Trigger.OPEN, "s1"); confirmGroup("web", "webfetch", "websearch"); confirmGroup("edit", "apply_patch"); - confirmGroup("project", "todowrite", "list", "codesearch", "lsp", "task_create", "task_list", "task_update", "task_delete"); - confirmGroup("session", "plan_enter", "plan_exit", "skill", "batch"); + confirmGroup("project", "todowrite", "list", "codesearch", "lsp", + "task_create", "task_list", "task_update", "task_delete"); + confirmGroup("session", "plan_enter", "plan_exit", "skill", "batch", "mcphub_checkpoint"); ObjectNode params = mapper.createObjectNode(); params.put("name", "mcphub_disambiguate"); @@ -292,18 +395,18 @@ void adapterRegistration_confirmsEntries() throws Exception { sm.transition(StateMachine.Trigger.OPEN, "s1"); JsonNode before = handler.handle("tools/list", null); - assertEquals(1, before.path("tools").size(), - "Before adapter registration, only disambiguation is listed"); + assertEquals(2, before.path("tools").size(), + "Before adapter registration, only hub tools (disambiguate + task_context) are listed"); confirmGroup("web", "webfetch", "websearch"); JsonNode after = handler.handle("tools/list", null); - assertEquals(3, after.path("tools").size(), - "After web adapter confirms, 2 tools + disambiguation"); + assertEquals(4, after.path("tools").size(), + "After web adapter confirms, 2 tools + 2 hub tools"); } @Test - void toolsCall_pendingTool_returnsProviderUnreachable() throws Exception { + void toolsCall_pendingTool_returnsProviderUnavailable() throws Exception { sm.transition(StateMachine.Trigger.ARM, "s1"); sm.transition(StateMachine.Trigger.OPEN, "s1"); @@ -313,8 +416,8 @@ void toolsCall_pendingTool_returnsProviderUnreachable() throws Exception { JsonNode r = handler.handle("tools/call", params); assertTrue(r.path("isError").asBoolean(), "isError must be true"); - JsonNode err = parseErrorJson(r); - assertEquals("provider_unreachable", err.path("error_code").asText()); + String text = r.path("content").get(0).path("text").asText(); + assertTrue(text.contains("provider_unreachable"), "Error text must contain error code"); } // ------------------------------------------------------------------------- @@ -382,159 +485,21 @@ private void confirmGroup(String groupId, String... tools) throws Exception { handler.handle("mcphub.internal.adapter_registration", params); } - // ------------------------------------------------------------------------- - // AC-2: structured failure response with fallback_tools from contracts - // ------------------------------------------------------------------------- - - @Test - void toolsCall_deniedTool_fallbackToolsFromContract() throws Exception { - PolicyRule deny = new PolicyRule(); - deny.ruleId = "deny-webfetch"; - deny.toolPattern = "webfetch"; - deny.action = "deny"; - deny.priority = 999; - deny.scope = "global"; - policy.loadGlobalRules(java.util.List.of(deny)); - - sm.transition(StateMachine.Trigger.ARM, "s1"); - sm.transition(StateMachine.Trigger.OPEN, "s1"); - confirmGroup("web", "webfetch", "websearch"); - + private void setTaskContext(String context) throws Exception { ObjectNode params = mapper.createObjectNode(); - params.put("name", "webfetch"); - params.set("arguments", mapper.createObjectNode()); - - JsonNode r = handler.handle("tools/call", params); - JsonNode err = parseErrorJson(r); - assertEquals("tool_denied", err.path("error_code").asText()); - // webfetch contract disambiguates_from includes websearch - assertTrue(err.has("fallback_tools"), "fallback_tools must be populated from contract"); - assertTrue(err.path("fallback_tools").isArray()); - boolean hasWebsearch = false; - for (JsonNode ft : err.path("fallback_tools")) { - if ("websearch".equals(ft.asText())) hasWebsearch = true; - } - assertTrue(hasWebsearch, "fallback_tools should contain websearch from contract"); - // REQ-5.8.1: use_alternative when fallbacks exist - assertEquals("use_alternative", err.path("next_action").asText()); - } - - @Test - void toolsCall_deniedTool_noFallbacks_suggestsDisambiguate() throws Exception { - // Deny a tool that has no disambiguates_from entries in contract - PolicyRule deny = new PolicyRule(); - deny.ruleId = "deny-list"; - deny.toolPattern = "list"; - deny.action = "deny"; - deny.priority = 999; - deny.scope = "global"; - policy.loadGlobalRules(java.util.List.of(deny)); - - sm.transition(StateMachine.Trigger.ARM, "s1"); - sm.transition(StateMachine.Trigger.OPEN, "s1"); - confirmGroup("project", "list"); - - ObjectNode params = mapper.createObjectNode(); - params.put("name", "list"); - params.set("arguments", mapper.createObjectNode()); - - JsonNode r = handler.handle("tools/call", params); - JsonNode err = parseErrorJson(r); - assertEquals("tool_denied", err.path("error_code").asText()); - assertFalse(err.has("fallback_tools"), "No contract fallbacks for list"); - assertEquals("disambiguate", err.path("next_action").asText()); - } - - // ------------------------------------------------------------------------- - // AC-3: disambiguation with multi-candidate contract resolution - // ------------------------------------------------------------------------- - - @Test - void disambiguate_contractResolution_deterministic() throws Exception { - sm.transition(StateMachine.Trigger.ARM, "s1"); - sm.transition(StateMachine.Trigger.OPEN, "s1"); - confirmGroup("web", "webfetch", "websearch"); - confirmGroup("project", "codesearch"); - - ObjectNode params = mapper.createObjectNode(); - params.put("name", "mcphub_disambiguate"); + params.put("name", "mcphub_set_task_context"); ObjectNode args = mapper.createObjectNode(); - args.put("task_description", "find information online"); - ArrayNode candidates = mapper.createArrayNode(); - candidates.add("websearch"); - candidates.add("webfetch"); - candidates.add("codesearch"); - args.set("candidate_tools", candidates); + args.put("context", context); params.set("arguments", args); - - JsonNode r = handler.handle("tools/call", params); - assertTrue(r.has("content"), "Disambiguation must return 'content' field"); - String text = r.path("content").get(0).path("text").asText(); - JsonNode result = mapper.readTree(text); - - // websearch disambiguates_from covers webfetch AND codesearch - assertEquals("websearch", result.path("recommended_tool").asText()); - assertEquals("deterministic", result.path("confidence").asText()); - // Alternatives should be empty when deterministic - assertEquals(0, result.path("alternatives").size(), - "Alternatives must be empty when confidence is deterministic"); - } - - @Test - void disambiguate_ambiguous_returnsNone() throws Exception { - sm.transition(StateMachine.Trigger.ARM, "s1"); - sm.transition(StateMachine.Trigger.OPEN, "s1"); - confirmGroup("web", "webfetch", "websearch"); - - ObjectNode params = mapper.createObjectNode(); - params.put("name", "mcphub_disambiguate"); - ObjectNode args = mapper.createObjectNode(); - args.put("task_description", "fetch or search"); - ArrayNode candidates = mapper.createArrayNode(); - candidates.add("webfetch"); - candidates.add("websearch"); - args.set("candidate_tools", candidates); - params.set("arguments", args); - - JsonNode r = handler.handle("tools/call", params); - String text = r.path("content").get(0).path("text").asText(); - JsonNode result = mapper.readTree(text); - - // Both cover each other → ambiguous - assertTrue(result.path("recommended_tool").isNull()); - assertEquals("none", result.path("confidence").asText()); - // Alternatives should be enriched with contract fields - JsonNode alts = result.path("alternatives"); - assertTrue(alts.size() > 0, "Alternatives must be populated when ambiguous"); - boolean hasSideEffectClass = false; - for (JsonNode alt : alts) { - if (alt.has("side_effect_class")) hasSideEffectClass = true; - } - assertTrue(hasSideEffectClass, "Alternatives must include side_effect_class"); + handler.handle("tools/call", params); } - // ------------------------------------------------------------------------- - // AC-4: intent annotation in tool schemas - // ------------------------------------------------------------------------- - - @Test - void toolsList_includesIntentProperty() throws Exception { - sm.transition(StateMachine.Trigger.ARM, "s1"); - sm.transition(StateMachine.Trigger.OPEN, "s1"); - confirmGroup("web", "webfetch", "websearch"); - - JsonNode r = handler.handle("tools/list", null); - JsonNode tools = r.path("tools"); - assertTrue(tools.size() > 0, "tools/list must return tools"); - - for (JsonNode t : tools) { - JsonNode props = t.path("inputSchema").path("properties"); - assertTrue(props.has("_intent"), - "Tool '" + t.path("name").asText() + "' must have _intent in inputSchema"); - JsonNode intent = props.path("_intent"); - assertEquals("string", intent.path("type").asText()); - assertTrue(intent.path("description").asText().contains("route logs")); + private java.util.Set toolNames(JsonNode toolsListResponse) { + java.util.Set names = new java.util.HashSet<>(); + for (JsonNode tool : toolsListResponse.path("tools")) { + names.add(tool.path("name").asText()); } + return names; } // ------------------------------------------------------------------------- @@ -589,69 +554,4 @@ void toolsCall_withoutSessionManager_doesNotCrash() throws Exception { JsonNode r = handler.handle("tools/call", params); assertNotNull(r); } - - // ------------------------------------------------------------------------- - // AC-4 follow-up: _intent persistence to route_log (F-4) - // ------------------------------------------------------------------------- - - @Test - void toolsCall_intentAnnotation_withSecret_scrubbedInRouteLog() throws Exception { - sm.transition(StateMachine.Trigger.ARM, "s1"); - sm.transition(StateMachine.Trigger.OPEN, "s1"); - confirmGroup("web", "webfetch", "websearch"); - - ObjectNode params = mapper.createObjectNode(); - params.put("name", "webfetch"); - ObjectNode args = mapper.createObjectNode(); - args.put("url", "https://example.com"); - params.set("arguments", args); - params.put("_intent", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"); - - handler.handle("tools/call", params); - - // Wait for async route log write - Thread.sleep(100); - - var conn = db.getConnection(); - try (var st = conn.createStatement(); - var rs = st.executeQuery( - "SELECT intent_annotation FROM route_log WHERE tool_name='webfetch'")) { - assertTrue(rs.next(), "route_log must contain an entry for webfetch"); - assertEquals("[scrubbed: secret pattern detected]", rs.getString("intent_annotation"), - "JWT token in _intent must be scrubbed before DB write"); - } - } - - @Test - void toolsCall_intentAnnotation_persistedToRouteLog() throws Exception { - sm.transition(StateMachine.Trigger.ARM, "s1"); - sm.transition(StateMachine.Trigger.OPEN, "s1"); - confirmGroup("web", "webfetch", "websearch"); - - ObjectNode params = mapper.createObjectNode(); - params.put("name", "webfetch"); - ObjectNode args = mapper.createObjectNode(); - args.put("url", "https://example.com"); - params.set("arguments", args); - params.put("_intent", "testing intent persistence"); - - handler.handle("tools/call", params); - - // Wait for async route log write - Thread.sleep(100); - - var conn = db.getConnection(); - try (var st = conn.createStatement(); - var rs = st.executeQuery( - "SELECT intent_annotation FROM route_log WHERE tool_name='webfetch'")) { - assertTrue(rs.next(), "route_log must contain an entry for webfetch"); - assertEquals("testing intent persistence", rs.getString("intent_annotation")); - } - } - - /** Parse the JSON-encoded structured error inside the MCP text content. */ - private JsonNode parseErrorJson(JsonNode response) throws Exception { - String text = response.path("content").get(0).path("text").asText(); - return mapper.readTree(text); - } } diff --git a/java/src/test/java/dev/sorted/mcphub/PolicyEngineTest.java b/java/src/test/java/dev/sorted/mcphub/PolicyEngineTest.java index a7f1e50..67d414b 100644 --- a/java/src/test/java/dev/sorted/mcphub/PolicyEngineTest.java +++ b/java/src/test/java/dev/sorted/mcphub/PolicyEngineTest.java @@ -180,26 +180,6 @@ void clearSessionRules_restoresGlobalBehavior() { assertEquals(PolicyEngine.Decision.ALLOW, engine.evaluate("webfetch").decision()); } - @Test - void sessionRule_withExplicitHighPriority_beatsGlobalRule() { - // REQ-7.5.2: session rule priority 200 → boosted to 10200, beating global 1000 - engine.loadGlobalRules(List.of(rule("global-deny", "webfetch", "deny", 1000))); - PolicyRule sessionRule = rule("session-allow", "webfetch", "allow", 200); - sessionRule.scope = "session"; - engine.addSessionRule(sessionRule); - assertEquals(PolicyEngine.Decision.ALLOW, engine.evaluate("webfetch").decision()); - } - - @Test - void sessionRule_withNegativePriority_doesNotOverrideGlobalRule() { - // REQ-7.5.2: negative priority = explicit opt-out of boost - engine.loadGlobalRules(List.of(rule("global-deny", "webfetch", "deny", 100))); - PolicyRule sessionRule = rule("session-allow", "webfetch", "allow", -1); - sessionRule.scope = "session"; - engine.addSessionRule(sessionRule); - assertEquals(PolicyEngine.Decision.DENY, engine.evaluate("webfetch").decision()); - } - // --- Helpers --- private PolicyRule rule(String id, String pattern, String action, int priority) { diff --git a/java/src/test/java/dev/sorted/mcphub/ProviderHealthTrackerTest.java b/java/src/test/java/dev/sorted/mcphub/ProviderHealthTrackerTest.java index e384e75..f9ccf9c 100644 --- a/java/src/test/java/dev/sorted/mcphub/ProviderHealthTrackerTest.java +++ b/java/src/test/java/dev/sorted/mcphub/ProviderHealthTrackerTest.java @@ -102,6 +102,14 @@ void healthForTool_unmapped_returnsUnavailable() { assertEquals("unavailable", tracker.healthForTool("nonexistent_tool")); } + @Test + void healthForTool_mapsRuntimeRelayTools() { + tracker.updateGroup("verdict", "running"); + tracker.mapTools("verdict", java.util.List.of("test_provider_health", "test_browser_operate")); + assertEquals("running", tracker.healthForTool("test_provider_health")); + assertEquals("running", tracker.healthForTool("test_browser_operate")); + } + @Test void healthForTool_nullToolName_returnsUnavailable() { // Must not crash diff --git a/java/src/test/java/dev/sorted/mcphub/ProviderManagerTest.java b/java/src/test/java/dev/sorted/mcphub/ProviderManagerTest.java index 4fc1629..924045c 100644 --- a/java/src/test/java/dev/sorted/mcphub/ProviderManagerTest.java +++ b/java/src/test/java/dev/sorted/mcphub/ProviderManagerTest.java @@ -20,14 +20,15 @@ class ProviderManagerTest { @Test - void defaultGroups_contains5Groups() { + void defaultGroups_contains6Groups() { List groups = ProviderManager.defaultGroups(); - assertEquals(5, groups.size()); + assertEquals(6, groups.size()); assertEquals("web", groups.get(0).id()); assertEquals("edit", groups.get(1).id()); assertEquals("project", groups.get(2).id()); - assertEquals("session", groups.get(3).id()); - assertEquals("synthetic", groups.get(4).id()); + assertEquals("nexus", groups.get(3).id()); + assertEquals("session", groups.get(4).id()); + assertEquals("synthetic", groups.get(5).id()); } @Test @@ -42,10 +43,19 @@ void resolveGroupId_staticMappings() { assertEquals("project", pm.resolveGroupId("list")); assertEquals("project", pm.resolveGroupId("codesearch")); assertEquals("project", pm.resolveGroupId("lsp")); + assertEquals("project", pm.resolveGroupId("task_create")); + assertEquals("project", pm.resolveGroupId("task_list")); + assertEquals("project", pm.resolveGroupId("task_update")); + assertEquals("project", pm.resolveGroupId("task_delete")); + assertEquals("nexus", pm.resolveGroupId("nexus_issue_create")); + assertEquals("nexus", pm.resolveGroupId("nexus_issue_list")); + assertEquals("nexus", pm.resolveGroupId("nexus_issue_close")); + assertEquals("nexus", pm.resolveGroupId("nexus_issue_update")); assertEquals("session", pm.resolveGroupId("plan_enter")); assertEquals("session", pm.resolveGroupId("plan_exit")); assertEquals("session", pm.resolveGroupId("skill")); assertEquals("session", pm.resolveGroupId("batch")); + assertEquals("session", pm.resolveGroupId("mcphub_checkpoint")); assertEquals("unknown", pm.resolveGroupId("nonexistent_tool")); } diff --git a/java/src/test/java/dev/sorted/mcphub/SecretScannerTest.java b/java/src/test/java/dev/sorted/mcphub/SecretScannerTest.java index ad9a69b..31643f2 100644 --- a/java/src/test/java/dev/sorted/mcphub/SecretScannerTest.java +++ b/java/src/test/java/dev/sorted/mcphub/SecretScannerTest.java @@ -7,17 +7,9 @@ class SecretScannerTest { - // Build test secret prefixes via concatenation to avoid GitHub Push Protection - // blocking test data as real secrets. These are NOT real keys. - private static final String STRIPE_LIVE_PREFIX = "sk_" + "live_"; - private static final String GITHUB_PAT_PREFIX = "gh" + "p_"; - private static final String AWS_PREFIX = "AK" + "IA"; - private static final String DUMMY_SUFFIX = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - @Test void scan_jwt() { - // JWT is not flagged by Push Protection — safe as literal - String t = "token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"; + String t = "token: eyJhbGciOi" + "JIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyIn0.signatureDataHereForJwtExample"; var f = SecretScanner.scan(t); assertEquals(1, f.size()); assertEquals("jwt", f.get(0).patternName()); @@ -25,19 +17,19 @@ void scan_jwt() { @Test void scan_awsKey() { - String t = "AWS access key: " + AWS_PREFIX + "IOSFODNN7EXAMPLE"; + String t = "AWS access key: AK" + "IAIOSFODNN7EXAMPLE"; assertTrue(SecretScanner.containsSecret(t)); assertEquals("aws_access", SecretScanner.scan(t).get(0).patternName()); } @Test void scan_stripeLive() { - assertTrue(SecretScanner.containsSecret(STRIPE_LIVE_PREFIX + DUMMY_SUFFIX)); + assertTrue(SecretScanner.containsSecret("sk_" + "live_abcdefghijklmnop123456789012345")); } @Test void scan_githubPat() { - assertTrue(SecretScanner.containsSecret(GITHUB_PAT_PREFIX + DUMMY_SUFFIX)); + assertTrue(SecretScanner.containsSecret("gh" + "p_abcdefghijklmnopqrstuvwxyz0123456789")); } @Test diff --git a/java/src/test/java/dev/sorted/mcphub/StateMachineTest.java b/java/src/test/java/dev/sorted/mcphub/StateMachineTest.java index cb8f42d..cec0a42 100644 --- a/java/src/test/java/dev/sorted/mcphub/StateMachineTest.java +++ b/java/src/test/java/dev/sorted/mcphub/StateMachineTest.java @@ -143,7 +143,8 @@ void idleTimeoutTriggersTransition() throws Exception { @Test void bridgeDetachTriggersTransition() throws Exception { - // REQ-3.7.2: OPEN + BRIDGE_DETACH -> COOLING_DOWN + // Low-level session.detach path. Production bridge lifecycle detach is + // handled by ControlHandler so OPEN sessions can resume idle timeout. sm.transition(StateMachine.Trigger.ARM, "s1"); sm.transition(StateMachine.Trigger.OPEN, "s1"); sm.transition(StateMachine.Trigger.BRIDGE_DETACH, "s1"); diff --git a/java/src/test/java/dev/sorted/mcphub/StdioBridgeTest.java b/java/src/test/java/dev/sorted/mcphub/StdioBridgeTest.java index dd86525..c09f5d1 100644 --- a/java/src/test/java/dev/sorted/mcphub/StdioBridgeTest.java +++ b/java/src/test/java/dev/sorted/mcphub/StdioBridgeTest.java @@ -2,6 +2,8 @@ import org.junit.jupiter.api.Test; +import java.util.Map; + import static org.junit.jupiter.api.Assertions.*; /** @@ -18,4 +20,24 @@ void bridgeCanBeInstantiated() { StdioBridge bridge = new StdioBridge(); assertNotNull(bridge); } + + @Test + void daemonUnavailableMessageIncludesDefaultStartAndOpenRecovery() { + String command = StdioBridge.cliBaseCommand(Map.of()); + String message = StdioBridge.daemonUnavailableMessage(command); + + assertEquals("mcphub", command); + assertTrue(message.contains("cli_recovery_start_command: mcphub start")); + assertTrue(message.contains("cli_recovery_open_command: mcphub open")); + } + + @Test + void daemonUnavailableMessageUsesProvidedCliCommand() { + String command = StdioBridge.cliBaseCommand(Map.of("MCPHUB_CLI_COMMAND", "/tmp/mcphub-bin")); + String message = StdioBridge.daemonUnavailableMessage(command); + + assertEquals("/tmp/mcphub-bin", command); + assertTrue(message.contains("cli_recovery_start_command: /tmp/mcphub-bin start")); + assertTrue(message.contains("cli_recovery_open_command: /tmp/mcphub-bin open")); + } } diff --git a/mcphub-bridge.sh b/mcphub-bridge.sh new file mode 100755 index 0000000..29226f4 --- /dev/null +++ b/mcphub-bridge.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd "$(dirname "$0")" +exec ./mcphub bridge "$@" diff --git a/packaging/launchd/dev.sorted.mcphub.plist b/packaging/launchd/dev.sorted.mcphub.plist index d338f8b..fe49ae8 100644 --- a/packaging/launchd/dev.sorted.mcphub.plist +++ b/packaging/launchd/dev.sorted.mcphub.plist @@ -20,13 +20,19 @@ EnvironmentVariables MCPHUB_DATA_DIR - ${HOME}/.local/share/mcphub + {{MCPHUB_PREFIX}} + MCPHUB_ADAPTER_DIR + {{MCPHUB_PREFIX}}/adapters + MCPHUB_HOME + {{MCPHUB_PREFIX}} + MCPHUB_DASHBOARD_ENABLED + 1 StandardOutPath - ${HOME}/.local/share/mcphub/logs/mcphub.out.log + {{MCPHUB_PREFIX}}/logs/mcphub.out.log StandardErrorPath - ${HOME}/.local/share/mcphub/logs/mcphub.err.log + {{MCPHUB_PREFIX}}/logs/mcphub.err.log diff --git a/packaging/systemd/mcphub.service b/packaging/systemd/mcphub.service index 01b6732..0550908 100644 --- a/packaging/systemd/mcphub.service +++ b/packaging/systemd/mcphub.service @@ -3,12 +3,15 @@ Description=MCPHUB Daemon After=network.target [Service] -Type=notify +Type=simple ExecStart={{MCPHUB_INSTALL_PATH}} _daemon -ExecStop={{MCPHUB_INSTALL_PATH}} stop +ExecStop={{MCPHUB_INSTALL_PATH}} close Restart=on-failure RestartSec=5 Environment=MCPHUB_DATA_DIR=%h/.local/share/mcphub +Environment=MCPHUB_ADAPTER_DIR={{MCPHUB_PREFIX}}/adapters +Environment=MCPHUB_HOME={{MCPHUB_PREFIX}} +Environment=MCPHUB_DASHBOARD_ENABLED=1 [Install] WantedBy=default.target diff --git a/scripts/release-build.sh b/scripts/release-build.sh new file mode 100755 index 0000000..5e39742 --- /dev/null +++ b/scripts/release-build.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +# MCPHUB Release Build Script — BL-06 / P1-pre T-P1-P2 +# Builds distribution archives for release automation. +# Usage: PRODUCT_NAME=mcphub ./scripts/release-build.sh [version] +# Output: dist/release/---.tar.gz + +PRODUCT_NAME="${PRODUCT_NAME:-mcphub}" +VERSION="${1:-$(grep 'const version' cmd/mcphub/main.go | grep -oP '"[^"]+"' | tr -d '"')}" +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DIST_DIR="$REPO_ROOT/dist/release" +PLATFORMS=( + "linux/amd64" + "darwin/amd64" + "darwin/arm64" +) + +echo "=== ${PRODUCT_NAME} Release Build ===" +echo "Product: $PRODUCT_NAME" +echo "Version: $VERSION" +echo "Output: $DIST_DIR" +echo "" + +# Clean +rm -rf "$DIST_DIR" +mkdir -p "$DIST_DIR" + +# Step 1: Build Java fat-JAR (platform-independent) +echo "==> Building Java fat-JAR..." +cd "$REPO_ROOT/java" +./gradlew jar --quiet +JAR_FILE=$(find build/libs -name "mcphub-core-*.jar" ! -name "*plain*" | head -1) +if [[ -z "$JAR_FILE" ]]; then + echo "ERROR: JAR not found after build" >&2 + exit 1 +fi +echo " $JAR_FILE" + +# Step 2: Build TypeScript adapters (platform-independent) +echo "==> Building TypeScript adapters..." +cd "$REPO_ROOT/adapters" +if [[ ! -d node_modules ]]; then + npm install --silent +fi +npx tsc --build +echo " adapters/dist/" + +# Step 3: Build Go binaries for each platform +cd "$REPO_ROOT" +for PLATFORM in "${PLATFORMS[@]}"; do + OS="${PLATFORM%%/*}" + ARCH="${PLATFORM##*/}" + BINARY_NAME="mcphub" + + echo "==> Building Go binary: ${OS}/${ARCH}..." + GOOS="$OS" GOARCH="$ARCH" go build -o "$DIST_DIR/${PRODUCT_NAME}-${OS}-${ARCH}" ./cmd/mcphub + + # Step 4: Create distribution archive + ARCHIVE_NAME="${PRODUCT_NAME}-${VERSION}-${OS}-${ARCH}" + STAGE_DIR="$DIST_DIR/stage/${ARCHIVE_NAME}/${PRODUCT_NAME}" + mkdir -p "$STAGE_DIR/bin" "$STAGE_DIR/lib" "$STAGE_DIR/adapters" + + # Go binary + cp "$DIST_DIR/${PRODUCT_NAME}-${OS}-${ARCH}" "$STAGE_DIR/bin/${PRODUCT_NAME}" + chmod +x "$STAGE_DIR/bin/${PRODUCT_NAME}" + + # Java JAR + cp "$REPO_ROOT/java/$JAR_FILE" "$STAGE_DIR/lib/mcphub-core.jar" + + # Adapters + cp -r "$REPO_ROOT/adapters/dist/"* "$STAGE_DIR/adapters/" + + # Support files + cp "$REPO_ROOT/install.sh" "$STAGE_DIR/" + cp "$REPO_ROOT/install-remote.sh" "$STAGE_DIR/" + cp "$REPO_ROOT/README.md" "$STAGE_DIR/" + cp "$REPO_ROOT/LICENSE" "$STAGE_DIR/" + + # Create archive + echo " Packaging ${ARCHIVE_NAME}.tar.gz..." + tar -czf "$DIST_DIR/${ARCHIVE_NAME}.tar.gz" \ + -C "$DIST_DIR/stage/${ARCHIVE_NAME}" \ + "${PRODUCT_NAME}" + + echo " $DIST_DIR/${ARCHIVE_NAME}.tar.gz" +done + +# Step 5: Generate checksums +echo "==> Generating checksums..." +cd "$DIST_DIR" +sha256sum "${PRODUCT_NAME}-${VERSION}"-*.tar.gz > sha256sums.txt +echo " sha256sums.txt" + +# Cleanup staging and intermediate binaries +rm -rf "$DIST_DIR/stage" "$DIST_DIR"/"${PRODUCT_NAME}"-linux-* "$DIST_DIR"/"${PRODUCT_NAME}"-darwin-* + +echo "" +echo "=== Release Build Complete ===" +echo "Archives:" +ls -lh "$DIST_DIR"/"${PRODUCT_NAME}-${VERSION}"-*.tar.gz +echo "" +echo "Checksums:" +cat "$DIST_DIR/sha256sums.txt"