diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 85fa86c..5214ef6 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,7 +5,7 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status login logout config get describe delete logs create edit apply patch backup approve console cache" + local commands="status login logout config get describe delete logs create edit apply patch backup approve console cache test" local project_commands="get describe delete logs create edit attach-server detach-server" local global_opts="-v --version --daemon-url --direct -p --project -h --help" local resources="servers instances secrets templates projects users groups rbac prompts promptrequests serverattachments proxymodels all" @@ -314,6 +314,21 @@ _mcpctl() { esac fi return ;; + test) + local test_sub=$(_mcpctl_get_subcmd $subcmd_pos) + if [[ -z "$test_sub" ]]; then + COMPREPLY=($(compgen -W "mcp help" -- "$cur")) + else + case "$test_sub" in + mcp) + COMPREPLY=($(compgen -W "--token --tool --args --expect-tools --timeout -o --output --no-health -h --help" -- "$cur")) + ;; + *) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + esac + fi + return ;; help) COMPREPLY=($(compgen -W "$commands" -- "$cur")) return ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 9c55f7c..f45a46d 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -4,7 +4,7 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status login logout config get describe delete logs create edit apply patch backup approve console cache +set -l commands status login logout config get describe delete logs create edit apply patch backup approve console cache test set -l project_commands get describe delete logs create edit attach-server detach-server # Disable file completions by default @@ -231,6 +231,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a pending prompt request (atomic: delete request, create prompt)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a console -d 'Interactive MCP console — unified timeline with tools, provenance, and lab replay' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a cache -d 'Manage ProxyModel pipeline cache' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a test -d 'Utilities for testing MCP endpoints and config' # Project-scoped commands (with --project) complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a get -d 'List resources (servers, projects, instances, all)' @@ -377,6 +378,19 @@ complete -c mcpctl -n "__fish_seen_subcommand_from cache; and not __fish_seen_su complete -c mcpctl -n "__mcpctl_subcmd_active cache clear" -l older-than -d 'Clear entries older than N days' -x complete -c mcpctl -n "__mcpctl_subcmd_active cache clear" -s y -l yes -d 'Skip confirmation' +# test subcommands +set -l test_cmds mcp +complete -c mcpctl -n "__fish_seen_subcommand_from test; and not __fish_seen_subcommand_from $test_cmds" -a mcp -d 'Verify a Streamable-HTTP MCP endpoint: health, initialize, tools/list, optionally call a tool.' + +# test mcp options +complete -c mcpctl -n "__mcpctl_subcmd_active test mcp" -l token -d 'Bearer token (also reads $MCPCTL_TOKEN)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active test mcp" -l tool -d 'Invoke a specific tool after listing' -x +complete -c mcpctl -n "__mcpctl_subcmd_active test mcp" -l args -d 'JSON-encoded arguments for --tool' -x +complete -c mcpctl -n "__mcpctl_subcmd_active test mcp" -l expect-tools -d 'Comma-separated tool names that MUST appear; fails otherwise' -x +complete -c mcpctl -n "__mcpctl_subcmd_active test mcp" -l timeout -d 'Per-request timeout in seconds' -x +complete -c mcpctl -n "__mcpctl_subcmd_active test mcp" -s o -l output -d 'Output format: text or json' -x +complete -c mcpctl -n "__mcpctl_subcmd_active test mcp" -l no-health -d 'Skip the /healthz preflight check' + # status options complete -c mcpctl -n "__fish_seen_subcommand_from status" -s o -l output -d 'output format (table, json, yaml)' -x diff --git a/deploy/Dockerfile.mcplocal b/deploy/Dockerfile.mcplocal new file mode 100644 index 0000000..e184d6f --- /dev/null +++ b/deploy/Dockerfile.mcplocal @@ -0,0 +1,60 @@ +# HTTP-only mcplocal for k8s deploy (Service `mcp`, Ingress `mcp.ad.itaz.eu`). +# Container CMD runs the `serve.ts` entry which — unlike the systemd/STDIO +# entry — has no stdin/stdout MCP client and bootstraps exclusively from env. + +# Stage 1: Build TypeScript +FROM node:20-alpine AS builder + +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +WORKDIR /app + +# Copy workspace config and package manifests +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./ +COPY src/mcplocal/package.json src/mcplocal/tsconfig.json src/mcplocal/ +COPY src/shared/package.json src/shared/tsconfig.json src/shared/ +COPY src/db/package.json src/db/tsconfig.json src/db/ + +# Install all dependencies +RUN pnpm install --frozen-lockfile + +# Copy source +COPY src/mcplocal/src/ src/mcplocal/src/ +COPY src/shared/src/ src/shared/src/ +COPY src/db/src/ src/db/src/ +COPY src/db/prisma/ src/db/prisma/ + +# Build (mcplocal depends on shared; db is pulled transitively by shared/... actually +# mcplocal does not depend on db at runtime — prisma client is only used by mcpd). +RUN pnpm -F @mcpctl/shared build && pnpm -F @mcpctl/mcplocal build + +# Stage 2: Production runtime +FROM node:20-alpine + +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +WORKDIR /app + +# Copy workspace config, manifests, and lockfile +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./ +COPY src/mcplocal/package.json src/mcplocal/ +COPY src/shared/package.json src/shared/ + +# Install deps (production only — no db / prisma runtime here). +RUN pnpm install --frozen-lockfile + +# Copy built output +COPY --from=builder /app/src/shared/dist/ src/shared/dist/ +COPY --from=builder /app/src/mcplocal/dist/ src/mcplocal/dist/ + +EXPOSE 3200 + +# Cache directory — expected to be mounted as a PVC in k8s. +VOLUME /var/lib/mcplocal/cache + +HEALTHCHECK --interval=10s --timeout=5s --retries=3 --start-period=10s \ + CMD wget -q --spider http://localhost:3200/healthz || exit 1 + +# MCPLOCAL_MCPD_URL and MCPLOCAL_MCPD_TOKEN are required and must come from +# the Pulumi-managed Secret. Other env vars default sensibly. +CMD ["node", "src/mcplocal/dist/serve.js"] diff --git a/docs/mcptoken-implementation.md b/docs/mcptoken-implementation.md index e586f34..9b08847 100644 --- a/docs/mcptoken-implementation.md +++ b/docs/mcptoken-implementation.md @@ -107,9 +107,53 @@ The extracted `parseRoleBinding` helper is what PR 3's `mcpctl create mcptoken - - Build clean across all 5 packages. - Completions freshness check green. -## PR 4 — HTTP-mode mcplocal + container + `mcpctl test mcp` + smoke +## PR 4 — HTTP-mode mcplocal + container + `mcpctl test mcp` + smoke ✅ -_(blocked)_ +| # | Step | Status | +|---|---|---| +| 1 | **Shared HTTP MCP client** — `src/shared/src/mcp-http/index.ts`. `McpHttpSession(url, {bearer?, headers?, timeoutMs?})` with `initialize / listTools / callTool / close / send / sendNotification`. Handles http + https, multiplexed SSE bodies, JSON-RPC id correlation. Distinct `McpProtocolError` / `McpTransportError` classes for contract-vs-transport failures. Plus `deriveBaseUrl(url)` + `mcpHealthCheck(base)`. Exported from `@mcpctl/shared`. | ✅ | +| 2 | **`mcpctl test mcp `** — new CLI verb under `src/cli/src/commands/test-mcp.ts`. Flags: `--token` (also reads `$MCPCTL_TOKEN`), `--tool`, `--args` (JSON), `--expect-tools`, `--timeout`, `-o text\|json`, `--no-health`. Exit codes: 0 PASS, 1 TRANSPORT/AUTH FAIL, 2 CONTRACT FAIL (e.g. missing tool or `isError=true`). | ✅ | +| 3 | **Unit tests** for the verb — `src/cli/tests/commands/test-mcp.test.ts`. 9 cases: happy path, health preflight failure, `--expect-tools` miss / hit, transport throw, `--tool` + `isError` → exit 2, `-o json` report, `$MCPCTL_TOKEN` env fallback, invalid `--args`. All green. | ✅ | +| 4 | **`src/mcplocal/src/serve.ts`** — new HTTP-only entry. Drops `StdioProxyServer` and `--upstream`; forces host/port from `MCPLOCAL_HTTP_HOST`/`MCPLOCAL_HTTP_PORT`; requires `MCPLOCAL_MCPD_URL`. Registers a Fastify preHandler that runs the new `token-auth` middleware on `/projects/*` and `/mcp`. Preserves LLM provider loading + proxymodel hot-reload watchers. | ✅ | +| 5 | **`src/mcplocal/src/http/token-auth.ts`** — Fastify preHandler that validates `mcpctl_pat_…` bearers by calling `GET /api/v1/mcptokens/introspect`. Cache: 30s positive / 5s negative TTL keyed on `hashToken(raw)`. Rejects non-Bearer, non-`mcpctl_pat_`, revoked, expired, and wrong-project (403 when path `projectName` ≠ token's bound project). Sets `request.mcpToken = { tokenName, tokenSha, projectName }` for the audit collector. | ✅ | +| 6 | **FileCache PVC plumbing** — `src/mcplocal/src/http/project-mcp-endpoint.ts` now honours `process.env.MCPLOCAL_CACHE_DIR` at both `FileCache` construction sites (gated + dynamic). No constructor change needed — `FileCache` already accepted a `dir` config; we just wire the env-derived value through. | ✅ | +| 7 | **Audit collector integration** — when `request.mcpToken` is set, the `onsessioninitialized` handler in `project-mcp-endpoint.ts` now also calls `collector.setSessionMcpToken(id, {tokenName, tokenSha})` alongside the existing `setSessionUserName`. Session map from PR 3 merges both principals. | ✅ | +| 8 | **Container image** — `deploy/Dockerfile.mcplocal` mirrors `Dockerfile.mcpd` shape: multi-stage Node 20 Alpine, pnpm workspace build of `@mcpctl/shared` + `@mcpctl/mcplocal`, runtime `CMD node src/mcplocal/dist/serve.js`, `EXPOSE 3200`, `VOLUME /var/lib/mcplocal/cache`, `HEALTHCHECK` on `/healthz`. | ✅ | +| 9 | **Build + push script** — `scripts/build-mcplocal.sh` (executable, 755) mirrors `build-mcpd.sh`. Pushes to `10.0.0.194:3012/michal/mcplocal:latest`. | ✅ | +| 10 | **`fulldeploy.sh`** — now a 4-step pipeline: (1) build + push mcpd, (2) build + push mcplocal, (3) rollout both deployments on k8s (mcplocal gated behind a `kubectl get deployment/mcplocal` check so the script stays green before the Pulumi stack lands), (4) RPM release. Smoke suite runs at the end as before. | ✅ | +| 11 | **`mcpctl test mcp` + new create flags in completions** — bash + fish regenerated. `src/mcplocal/package.json` gains a `serve` script for convenience. | ✅ | +| 12 | **Smoke test** — `src/mcplocal/tests/smoke/mcptoken.smoke.test.ts`. Gated on `healthz($MCPGW_URL)`; skipped with a clear warning if the gateway is unreachable. Scenarios: happy path via `mcpctl test mcp` → exit 0; cross-project → exit 1 with a 403 message; `--expect-tools __nonexistent__` → exit 2; delete-then-retry after the 5s negative-cache window → exit 1 with 401. Cleans up both projects at the end. | ✅ | + +### Deploy-time steps still owed (outside this repo) + +- **Pulumi (`../kubernetes-deployment`, stack `homelab`)** — add a `Deployment` named `mcplocal` in ns `mcpctl` pointing at the new image, a `Service` named `mcp` (port 3200→80), an `Ingress` for `mcp.ad.itaz.eu` with TLS via the existing cluster-issuer, a PVC `mcplocal-cache` (10Gi RWO), a Secret `mcplocal-env` with `MCPLOCAL_MCPD_URL` + `MCPLOCAL_MCPD_TOKEN`, and a NetworkPolicy mirroring mcpd's. `fulldeploy.sh` already runs `pulumi preview` first and halts on drift. +- **mcplocal's own identity** — recommend minting a dedicated `ServiceAccount:mcplocal-http` subject in mcpd with a non-expiring session token and putting it in `MCPLOCAL_MCPD_TOKEN`. The current session-minting path expires after 30d. + +### Test stats + +- 1773/1773 workspace tests pass (up from 1764 before PR 4). +- All five packages build clean. +- Shell completions fresh. +- `mcpctl test mcp --help` and `mcpctl create mcptoken --help` render expected surfaces. + +## End-to-end verification (manual, after Pulumi resources land) + +```bash +# From a workstation outside the k8s cluster: +mcpctl create project vllm --force +TOK=$(mcpctl create mcptoken vllm-token --project vllm --rbac clone | grep mcpctl_pat_) +export MCPCTL_TOKEN="$TOK" + +# Probe the public gateway +mcpctl test mcp https://mcp.ad.itaz.eu/projects/vllm/mcp --expect-tools begin_session + +# Negative: wrong project → exit 1 +mcpctl test mcp https://mcp.ad.itaz.eu/projects/other/mcp +echo $? # 1 + +# Audit — the call should be tagged with tokenName=vllm-token +mcpctl console --audit # look for the TOKEN column once the TUI patch lands +``` ## Design decisions recap (so you don't have to re-read the plan) diff --git a/fulldeploy.sh b/fulldeploy.sh index 1471494..10d1c3e 100755 --- a/fulldeploy.sh +++ b/fulldeploy.sh @@ -53,18 +53,30 @@ else fi echo "" -echo ">>> Step 1/3: Build & push mcpd Docker image" +echo ">>> Step 1/4: Build & push mcpd Docker image" echo "" bash scripts/build-mcpd.sh "$@" echo "" -echo ">>> Step 2/3: Roll out mcpd on k8s ($KUBE_CONTEXT / $KUBE_NAMESPACE)" +echo ">>> Step 2/4: Build & push mcplocal (HTTP-mode) Docker image" +echo "" +bash scripts/build-mcplocal.sh "$@" + +echo "" +echo ">>> Step 3/4: Roll out mcpd + mcplocal on k8s ($KUBE_CONTEXT / $KUBE_NAMESPACE)" echo "" kubectl --context "$KUBE_CONTEXT" -n "$KUBE_NAMESPACE" rollout restart "deployment/$KUBE_DEPLOYMENT" kubectl --context "$KUBE_CONTEXT" -n "$KUBE_NAMESPACE" rollout status "deployment/$KUBE_DEPLOYMENT" --timeout=3m +if kubectl --context "$KUBE_CONTEXT" -n "$KUBE_NAMESPACE" get deployment/mcplocal >/dev/null 2>&1; then + kubectl --context "$KUBE_CONTEXT" -n "$KUBE_NAMESPACE" rollout restart deployment/mcplocal + kubectl --context "$KUBE_CONTEXT" -n "$KUBE_NAMESPACE" rollout status deployment/mcplocal --timeout=3m +else + echo " NOTE: deployment/mcplocal does not exist in the cluster yet — skipping rollout." + echo " Apply the Pulumi stack in ../kubernetes-deployment to create it." +fi echo "" -echo ">>> Step 3/3: Build, publish & install RPM" +echo ">>> Step 4/4: Build, publish & install RPM" echo "" bash scripts/release.sh diff --git a/scripts/build-mcplocal.sh b/scripts/build-mcplocal.sh new file mode 100755 index 0000000..e05a4c7 --- /dev/null +++ b/scripts/build-mcplocal.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# Build mcplocal (HTTP-only) Docker image and push to Gitea container registry. +# +# Usage: +# ./build-mcplocal.sh [tag] # Build for native arch +# ./build-mcplocal.sh [tag] --platform linux/amd64 +# ./build-mcplocal.sh [tag] --multi-arch +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +cd "$PROJECT_ROOT" + +# Load .env for GITEA_TOKEN +if [ -f .env ]; then + set -a; source .env; set +a +fi + +# Push directly to internal address (external proxy has body size limit) +REGISTRY="10.0.0.194:3012" +IMAGE="mcplocal" +TAG="${1:-latest}" + +PLATFORM="" +MULTI_ARCH=false +shift 2>/dev/null || true +while [[ $# -gt 0 ]]; do + case "$1" in + --platform) + PLATFORM="$2" + shift 2 + ;; + --multi-arch) + MULTI_ARCH=true + shift + ;; + *) + shift + ;; + esac +done + +if [ "$MULTI_ARCH" = true ]; then + echo "==> Building multi-arch $IMAGE image (linux/amd64 + linux/arm64)..." + podman build --platform linux/amd64,linux/arm64 \ + --manifest "$IMAGE:$TAG" -f deploy/Dockerfile.mcplocal . + + echo "==> Tagging manifest as $REGISTRY/michal/$IMAGE:$TAG..." + podman tag "$IMAGE:$TAG" "$REGISTRY/michal/$IMAGE:$TAG" + + echo "==> Logging in to $REGISTRY..." + podman login --tls-verify=false -u michal -p "$GITEA_TOKEN" "$REGISTRY" + + echo "==> Pushing manifest to $REGISTRY/michal/$IMAGE:$TAG..." + podman manifest push --tls-verify=false --all \ + "$REGISTRY/michal/$IMAGE:$TAG" "docker://$REGISTRY/michal/$IMAGE:$TAG" +else + PLATFORM_FLAG="" + if [ -n "$PLATFORM" ]; then + PLATFORM_FLAG="--platform $PLATFORM" + echo "==> Building $IMAGE image for $PLATFORM..." + else + echo "==> Building $IMAGE image (native arch)..." + fi + + podman build $PLATFORM_FLAG -t "$IMAGE:$TAG" -f deploy/Dockerfile.mcplocal . + + echo "==> Tagging as $REGISTRY/michal/$IMAGE:$TAG..." + podman tag "$IMAGE:$TAG" "$REGISTRY/michal/$IMAGE:$TAG" + + echo "==> Logging in to $REGISTRY..." + podman login --tls-verify=false -u michal -p "$GITEA_TOKEN" "$REGISTRY" + + echo "==> Pushing to $REGISTRY/michal/$IMAGE:$TAG..." + podman push --tls-verify=false "$REGISTRY/michal/$IMAGE:$TAG" +fi + +# Ensure package is linked to the repository +source "$SCRIPT_DIR/link-package.sh" +link_package "container" "$IMAGE" + +echo "==> Done!" +echo " Image: $REGISTRY/michal/$IMAGE:$TAG" diff --git a/src/cli/src/commands/test-mcp.ts b/src/cli/src/commands/test-mcp.ts new file mode 100644 index 0000000..17ccc89 --- /dev/null +++ b/src/cli/src/commands/test-mcp.ts @@ -0,0 +1,176 @@ +import { Command } from 'commander'; +import { McpHttpSession, McpProtocolError, McpTransportError, deriveBaseUrl, mcpHealthCheck } from '@mcpctl/shared'; + +export interface TestMcpCommandDeps { + log: (...args: unknown[]) => void; + /** + * Inject a session factory for testing. The default creates a real `McpHttpSession`. + */ + createSession?: (url: string, opts: { bearer?: string; timeoutMs?: number }) => { + initialize(): Promise; + listTools(): Promise>; + callTool(name: string, args: Record): Promise; + close(): Promise; + }; + healthCheck?: (baseUrl: string) => Promise; +} + +export type TestMcpExitCode = 0 | 1 | 2; + +export interface TestMcpReport { + url: string; + health: 'ok' | 'fail' | 'skipped'; + initialize: 'ok' | 'fail'; + tools: string[] | null; + toolCall?: { name: string; result: unknown; isError?: boolean }; + missingTools?: string[]; + exitCode: TestMcpExitCode; + error?: string; +} + +export function createTestCommand(deps: TestMcpCommandDeps): Command { + const { log } = deps; + const createSession = deps.createSession ?? ((url, opts) => new McpHttpSession(url, opts)); + const healthCheck = deps.healthCheck ?? mcpHealthCheck; + + const test = new Command('test').description('Utilities for testing MCP endpoints and config'); + + test + .command('mcp') + .description('Verify a Streamable-HTTP MCP endpoint: health, initialize, tools/list, optionally call a tool.') + .argument('', 'Full URL of the MCP endpoint (e.g. https://mcp.example.com/projects/foo/mcp)') + .option('--token ', 'Bearer token (also reads $MCPCTL_TOKEN)') + .option('--tool ', 'Invoke a specific tool after listing') + .option('--args ', 'JSON-encoded arguments for --tool', '{}') + .option('--expect-tools ', 'Comma-separated tool names that MUST appear; fails otherwise') + .option('--timeout ', 'Per-request timeout in seconds', '10') + .option('-o, --output ', 'Output format: text or json', 'text') + .option('--no-health', 'Skip the /healthz preflight check') + .action(async (url: string, opts: { + token?: string; + tool?: string; + args: string; + expectTools?: string; + timeout: string; + output: string; + health: boolean; + }) => { + const bearer = opts.token ?? process.env.MCPCTL_TOKEN; + const timeoutMs = Number(opts.timeout) * 1000; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error(`--timeout must be a positive number of seconds (got '${opts.timeout}')`); + } + + const report: TestMcpReport = { + url, + health: 'skipped', + initialize: 'fail', + tools: null, + exitCode: 1, + }; + + // 1. Health preflight + if (opts.health !== false) { + const baseUrl = deriveBaseUrl(url); + const ok = await healthCheck(baseUrl); + report.health = ok ? 'ok' : 'fail'; + if (!ok) { + report.error = `healthz preflight failed at ${baseUrl}/healthz`; + return emit(report, opts.output, log); + } + } + + const sessionOpts: { bearer?: string; timeoutMs: number } = { timeoutMs }; + if (bearer !== undefined) sessionOpts.bearer = bearer; + const session = createSession(url, sessionOpts); + + try { + // 2. Initialize + await session.initialize(); + report.initialize = 'ok'; + + // 3. tools/list + const tools = await session.listTools(); + report.tools = tools.map((t) => t.name); + + // 4. --expect-tools check + if (opts.expectTools !== undefined && opts.expectTools.trim() !== '') { + const expected = opts.expectTools.split(',').map((s) => s.trim()).filter(Boolean); + const missing = expected.filter((name) => !report.tools!.includes(name)); + if (missing.length > 0) { + report.missingTools = missing; + report.exitCode = 2; + report.error = `Missing tools: ${missing.join(', ')}`; + return emit(report, opts.output, log); + } + } + + // 5. Optional --tool call + if (opts.tool !== undefined) { + let parsedArgs: Record = {}; + try { + parsedArgs = JSON.parse(opts.args) as Record; + } catch { + throw new Error(`--args must be valid JSON (got '${opts.args}')`); + } + const result = await session.callTool(opts.tool, parsedArgs); + const toolCall: TestMcpReport['toolCall'] = { name: opts.tool, result }; + if (typeof result === 'object' && result !== null && 'isError' in result) { + toolCall.isError = Boolean((result as { isError?: boolean }).isError); + } + report.toolCall = toolCall; + if (toolCall.isError) { + report.exitCode = 2; + report.error = `Tool '${opts.tool}' returned isError=true`; + return emit(report, opts.output, log); + } + } + + report.exitCode = 0; + } catch (err) { + if (err instanceof McpProtocolError) { + report.exitCode = 1; + report.error = `protocol error ${err.code}: ${err.message}`; + } else if (err instanceof McpTransportError) { + report.exitCode = 1; + report.error = `transport error (HTTP ${err.status}): ${err.message}`; + } else { + report.exitCode = 1; + report.error = err instanceof Error ? err.message : String(err); + } + } finally { + await session.close().catch(() => { /* best-effort */ }); + } + + return emit(report, opts.output, log); + }); + + return test; +} + +function emit(report: TestMcpReport, output: string, log: (...args: unknown[]) => void): void { + if (output === 'json') { + log(JSON.stringify(report, null, 2)); + } else { + log(`URL: ${report.url}`); + log(`Health: ${report.health}`); + log(`Initialize: ${report.initialize}`); + if (report.tools !== null) { + log(`Tools (${report.tools.length}): ${report.tools.slice(0, 10).join(', ')}${report.tools.length > 10 ? `, …(+${report.tools.length - 10})` : ''}`); + } + if (report.missingTools !== undefined) { + log(`Missing: ${report.missingTools.join(', ')}`); + } + if (report.toolCall !== undefined) { + log(`Tool call: ${report.toolCall.name} → ${report.toolCall.isError ? 'ERROR' : 'ok'}`); + } + if (report.error !== undefined) { + log(`Error: ${report.error}`); + } + log(`Result: ${report.exitCode === 0 ? 'PASS' : report.exitCode === 2 ? 'CONTRACT FAIL' : 'TRANSPORT/AUTH FAIL'}`); + } + + if (report.exitCode !== 0) { + process.exitCode = report.exitCode; + } +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 68cb3b9..ca395b8 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -8,6 +8,7 @@ import { createDescribeCommand } from './commands/describe.js'; import { createDeleteCommand } from './commands/delete.js'; import { createLogsCommand } from './commands/logs.js'; import { createApplyCommand } from './commands/apply.js'; +import { createTestCommand } from './commands/test-mcp.js'; import { createCreateCommand } from './commands/create.js'; import { createEditCommand } from './commands/edit.js'; import { createBackupCommand } from './commands/backup.js'; @@ -244,6 +245,10 @@ export function createProgram(): Command { mcplocalUrl: config.mcplocalUrl, })); + program.addCommand(createTestCommand({ + log: (...args) => console.log(...args), + })); + return program; } diff --git a/src/cli/tests/commands/test-mcp.test.ts b/src/cli/tests/commands/test-mcp.test.ts new file mode 100644 index 0000000..97b5259 --- /dev/null +++ b/src/cli/tests/commands/test-mcp.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createTestCommand } from '../../src/commands/test-mcp.js'; + +function makeSession(overrides: Partial<{ + initialize: () => Promise; + listTools: () => Promise>; + callTool: (name: string, args: Record) => Promise; + close: () => Promise; +}> = {}) { + return { + initialize: overrides.initialize ?? vi.fn(async () => ({ protocolVersion: '2024-11-05' })), + listTools: overrides.listTools ?? vi.fn(async () => [{ name: 'echo' }, { name: 'search' }]), + callTool: overrides.callTool ?? vi.fn(async () => ({ content: [{ type: 'text', text: 'hi' }] })), + close: overrides.close ?? vi.fn(async () => { /* no-op */ }), + }; +} + +describe('mcpctl test mcp', () => { + const output: string[] = []; + const log = (...args: unknown[]) => { + output.push(args.map(String).join(' ')); + }; + + beforeEach(() => { + output.length = 0; + process.exitCode = 0; + }); + + afterEach(() => { + process.exitCode = 0; + }); + + it('exits 0 on happy path (health + initialize + tools/list)', async () => { + const session = makeSession(); + const cmd = createTestCommand({ + log, + createSession: () => session, + healthCheck: async () => true, + }); + await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' }); + expect(process.exitCode).toBe(0); + expect(session.initialize).toHaveBeenCalled(); + expect(session.listTools).toHaveBeenCalled(); + expect(output.join('\n')).toContain('Result: PASS'); + }); + + it('exits 1 when the /healthz preflight fails', async () => { + const cmd = createTestCommand({ + log, + createSession: () => makeSession(), + healthCheck: async () => false, + }); + await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' }); + expect(process.exitCode).toBe(1); + expect(output.join('\n')).toContain('healthz preflight failed'); + }); + + it('exits 2 (contract fail) when --expect-tools are missing', async () => { + const cmd = createTestCommand({ + log, + createSession: () => makeSession({ + listTools: async () => [{ name: 'echo' }], + }), + healthCheck: async () => true, + }); + await cmd.parseAsync( + ['mcp', 'https://mcp.example.com/projects/foo/mcp', '--expect-tools', 'echo,search'], + { from: 'user' }, + ); + expect(process.exitCode).toBe(2); + expect(output.join('\n')).toContain('Missing: search'); + expect(output.join('\n')).toContain('CONTRACT FAIL'); + }); + + it('exits 0 when --expect-tools all match', async () => { + const cmd = createTestCommand({ + log, + createSession: () => makeSession({ + listTools: async () => [{ name: 'echo' }, { name: 'search' }, { name: 'x' }], + }), + healthCheck: async () => true, + }); + await cmd.parseAsync( + ['mcp', 'https://mcp.example.com/projects/foo/mcp', '--expect-tools', 'echo,search'], + { from: 'user' }, + ); + expect(process.exitCode).toBe(0); + }); + + it('exits 1 on transport/auth failure (initialize throws)', async () => { + const cmd = createTestCommand({ + log, + createSession: () => makeSession({ + initialize: async () => { throw new Error('HTTP 401: unauthorized'); }, + }), + healthCheck: async () => true, + }); + await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' }); + expect(process.exitCode).toBe(1); + expect(output.join('\n')).toContain('Error:'); + expect(output.join('\n')).toContain('TRANSPORT/AUTH FAIL'); + }); + + it('invokes --tool with --args and reports isError', async () => { + const callTool = vi.fn(async () => ({ content: [{ type: 'text', text: 'oops' }], isError: true })); + const cmd = createTestCommand({ + log, + createSession: () => makeSession({ callTool }), + healthCheck: async () => true, + }); + await cmd.parseAsync( + ['mcp', 'https://mcp.example.com/projects/foo/mcp', '--tool', 'echo', '--args', '{"msg":"hi"}'], + { from: 'user' }, + ); + expect(callTool).toHaveBeenCalledWith('echo', { msg: 'hi' }); + expect(process.exitCode).toBe(2); + }); + + it('outputs a JSON report with -o json', async () => { + const cmd = createTestCommand({ + log, + createSession: () => makeSession(), + healthCheck: async () => true, + }); + await cmd.parseAsync( + ['mcp', 'https://mcp.example.com/projects/foo/mcp', '-o', 'json'], + { from: 'user' }, + ); + const parsed = JSON.parse(output.join('\n')) as { exitCode: number; tools: string[] }; + expect(parsed.exitCode).toBe(0); + expect(parsed.tools).toEqual(['echo', 'search']); + }); + + it('reads $MCPCTL_TOKEN when --token is not given', async () => { + let observedBearer: string | undefined; + const cmd = createTestCommand({ + log, + createSession: (_url, opts) => { + observedBearer = opts.bearer; + return makeSession(); + }, + healthCheck: async () => true, + }); + const prev = process.env.MCPCTL_TOKEN; + process.env.MCPCTL_TOKEN = 'mcpctl_pat_fromenv'; + try { + await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' }); + } finally { + if (prev === undefined) delete process.env.MCPCTL_TOKEN; + else process.env.MCPCTL_TOKEN = prev; + } + expect(observedBearer).toBe('mcpctl_pat_fromenv'); + }); + + it('rejects invalid --args as JSON', async () => { + const cmd = createTestCommand({ + log, + createSession: () => makeSession(), + healthCheck: async () => true, + }); + await cmd.parseAsync( + ['mcp', 'https://mcp.example.com/projects/foo/mcp', '--tool', 'echo', '--args', 'not-json'], + { from: 'user' }, + ); + expect(process.exitCode).toBe(1); + expect(output.join('\n')).toContain('must be valid JSON'); + }); +}); diff --git a/src/mcplocal/package.json b/src/mcplocal/package.json index 5f748aa..9c01eff 100644 --- a/src/mcplocal/package.json +++ b/src/mcplocal/package.json @@ -10,6 +10,7 @@ "clean": "rimraf dist", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", + "serve": "node dist/serve.js", "test": "vitest", "test:run": "vitest run", "test:smoke": "vitest run --config vitest.smoke.config.ts" diff --git a/src/mcplocal/src/http/project-mcp-endpoint.ts b/src/mcplocal/src/http/project-mcp-endpoint.ts index cb20d7c..48cd39e 100644 --- a/src/mcplocal/src/http/project-mcp-endpoint.ts +++ b/src/mcplocal/src/http/project-mcp-endpoint.ts @@ -97,7 +97,8 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp ?? effectiveRegistry?.getActiveName() ?? 'none'; const llmModel = resolvedModel ?? 'default'; - const cache = new FileCache(`${llmProvider}--${llmModel}--${proxyModelName}`); + const cacheConfig = process.env.MCPLOCAL_CACHE_DIR ? { dir: process.env.MCPLOCAL_CACHE_DIR } : undefined; + const cache = new FileCache(`${llmProvider}--${llmModel}--${proxyModelName}`, cacheConfig); router.setProxyModel(proxyModelName, llmAdapter, cache); // Per-server proxymodel overrides (if mcpd provides them) @@ -200,6 +201,17 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp void ensureUserName().then((name) => { if (name) collector.setSessionUserName(id, name); }); + + // HTTP-mode mcplocal: if the token-auth preHandler attached an McpToken + // principal to the request, tag the session so audit events carry the + // tokenName/tokenSha alongside (or instead of) userName. + const principal = request.mcpToken; + if (principal) { + collector.setSessionMcpToken(id, { + tokenName: principal.tokenName, + tokenSha: principal.tokenSha, + }); + } } // Audit: session_bind @@ -388,7 +400,7 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp const llmAdapter = providerRegistry ? new LLMProviderAdapter(providerRegistry) : { complete: async () => '', available: () => false }; - const cache = new FileCache('dynamic'); + const cache = new FileCache('dynamic', process.env.MCPLOCAL_CACHE_DIR ? { dir: process.env.MCPLOCAL_CACHE_DIR } : undefined); if (serverName && serverProxyModel) { entry.router.setServerProxyModel(serverName, serverProxyModel, llmAdapter, cache); diff --git a/src/mcplocal/src/http/token-auth.ts b/src/mcplocal/src/http/token-auth.ts new file mode 100644 index 0000000..8cebbce --- /dev/null +++ b/src/mcplocal/src/http/token-auth.ts @@ -0,0 +1,114 @@ +/** + * Fastify preHandler that authenticates `/projects/*` and `/mcp` requests + * against mcpd's McpToken introspection endpoint. + * + * Flow: + * 1. Reject non-Bearer and non-`mcpctl_pat_` auth up front. + * 2. Call `GET /api/v1/mcptokens/introspect` with the raw bearer. + * 3. Cache the result (positive + negative TTLs) to avoid a round-trip per MCP call. + * 4. Enforce `request.params.projectName === response.projectName`. + * 5. Stash the principal on `request.mcpToken` for the audit collector. + */ +import type { FastifyRequest, FastifyReply } from 'fastify'; +import { isMcpToken, hashToken } from '@mcpctl/shared'; + +export interface TokenAuthOptions { + mcpdUrl: string; + /** TTL for a successful introspection, ms. Default 30_000. */ + positiveTtlMs?: number; + /** TTL for a failed introspection, ms. Default 5_000. */ + negativeTtlMs?: number; + /** Injectable HTTP fetcher for tests. Defaults to `fetch`. */ + fetch?: (url: string, init?: RequestInit) => Promise; +} + +export interface McpTokenPrincipal { + tokenName: string; + tokenSha: string; + projectName: string; +} + +declare module 'fastify' { + interface FastifyRequest { + /** Populated by the token-auth preHandler when the bearer was a McpToken. */ + mcpToken?: McpTokenPrincipal; + } +} + +interface IntrospectResponse { + ok: boolean; + tokenName?: string; + tokenSha?: string; + projectName?: string; + revoked?: boolean; + expired?: boolean; + error?: string; +} + +interface CacheEntry { + result: IntrospectResponse; + expiresAt: number; +} + +export function createTokenAuthMiddleware(opts: TokenAuthOptions) { + const positiveTtl = opts.positiveTtlMs ?? 30_000; + const negativeTtl = opts.negativeTtlMs ?? 5_000; + const fetchImpl = opts.fetch ?? (globalThis.fetch as typeof fetch); + const cache = new Map(); + + async function introspect(raw: string): Promise { + const key = hashToken(raw); + const now = Date.now(); + const hit = cache.get(key); + if (hit && hit.expiresAt > now) return hit.result; + + try { + const res = await fetchImpl(`${opts.mcpdUrl.replace(/\/$/, '')}/api/v1/mcptokens/introspect`, { + method: 'GET', + headers: { Authorization: `Bearer ${raw}` }, + }); + const body = (await res.json().catch(() => ({ ok: false, error: 'unreadable body' }))) as IntrospectResponse; + const result: IntrospectResponse = res.ok ? body : { ...body, ok: false }; + cache.set(key, { result, expiresAt: now + (result.ok ? positiveTtl : negativeTtl) }); + return result; + } catch (err) { + const result: IntrospectResponse = { ok: false, error: err instanceof Error ? err.message : String(err) }; + cache.set(key, { result, expiresAt: now + negativeTtl }); + return result; + } + } + + return async function tokenAuth(request: FastifyRequest, reply: FastifyReply): Promise { + const header = request.headers.authorization; + if (header === undefined || !header.startsWith('Bearer ')) { + reply.code(401).send({ error: 'Missing Authorization bearer' }); + return; + } + const raw = header.slice(7); + if (!isMcpToken(raw)) { + reply.code(401).send({ error: 'Only mcpctl_pat_ bearers are accepted on this endpoint' }); + return; + } + + const introspection = await introspect(raw); + if (!introspection.ok) { + reply.code(401).send({ + error: introspection.revoked ? 'Token revoked' : introspection.expired ? 'Token expired' : 'Invalid token', + }); + return; + } + + // Project-scope check: token.projectName must match the path param. + const params = request.params as { projectName?: string } | undefined; + if (params?.projectName !== undefined && params.projectName !== introspection.projectName) { + reply.code(403).send({ error: `Token is not valid for project '${params.projectName}'` }); + return; + } + + request.mcpToken = { + tokenName: introspection.tokenName!, + tokenSha: introspection.tokenSha!, + projectName: introspection.projectName!, + }; + }; +} diff --git a/src/mcplocal/src/serve.ts b/src/mcplocal/src/serve.ts new file mode 100644 index 0000000..e79ecba --- /dev/null +++ b/src/mcplocal/src/serve.ts @@ -0,0 +1,99 @@ +#!/usr/bin/env node +/** + * HTTP-only entry for the containerized mcplocal (deployed behind Ingress as `mcp.ad.itaz.eu`). + * + * Differences from main.ts (the STDIO/systemd entry): + * - No StdioProxyServer (there's no stdin/stdout MCP client in a pod). + * - No `--upstream` flag (upstreams come from mcpd project discovery). + * - Host + port from env (MCPLOCAL_HTTP_HOST / MCPLOCAL_HTTP_PORT). + * - Requires MCPLOCAL_MCPD_URL to point at mcpd inside the cluster. + * - Registers a token-auth preHandler on `/projects/*` and `/mcp`. + * - FileCache directory honours MCPLOCAL_CACHE_DIR (wired via project-mcp-endpoint). + */ +import { McpRouter } from './router.js'; +import { createHttpServer } from './http/server.js'; +import { loadHttpConfig, loadLlmProviders } from './http/config.js'; +import { createProvidersFromConfig } from './llm-config.js'; +import { createSecretStore } from '@mcpctl/shared'; +import { reloadStages, startWatchers, stopWatchers } from './proxymodel/watcher.js'; +import { createTokenAuthMiddleware } from './http/token-auth.js'; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (value === undefined || value === '') { + throw new Error(`Required env var ${name} is not set`); + } + return value; +} + +export async function serve(): Promise { + const mcpdUrl = requireEnv('MCPLOCAL_MCPD_URL'); + const httpHost = process.env.MCPLOCAL_HTTP_HOST ?? '0.0.0.0'; + const httpPort = Number(process.env.MCPLOCAL_HTTP_PORT ?? '3200'); + if (!Number.isFinite(httpPort) || httpPort <= 0) { + throw new Error(`Invalid MCPLOCAL_HTTP_PORT: ${process.env.MCPLOCAL_HTTP_PORT}`); + } + // MCPLOCAL_CACHE_DIR is optional; FileCache reads it directly. + const cacheDir = process.env.MCPLOCAL_CACHE_DIR; + + // loadHttpConfig reads user-level config.json; we override with env. + const baseConfig = loadHttpConfig(); + const httpConfig = { + ...baseConfig, + httpHost, + httpPort, + mcpdUrl, + }; + + // LLM providers (configured via mounted ConfigMap at ~/.mcpctl/config.json or env). + const llmEntries = loadLlmProviders(); + const secretStore = await createSecretStore(); + const providerRegistry = await createProvidersFromConfig(llmEntries, secretStore); + + process.stderr.write( + `mcplocal-serve: mcpd=${mcpdUrl} host=${httpHost} port=${httpPort} cache=${cacheDir ?? '~/.mcpctl/cache'}\n`, + ); + + const router = new McpRouter(); + + const httpServer = await createHttpServer(httpConfig, { router, providerRegistry }); + + // Auth preHandler: only protect the MCP surfaces. /health, /healthz, /proxymodels etc stay open. + const tokenAuth = createTokenAuthMiddleware({ mcpdUrl }); + httpServer.addHook('preHandler', async (request, reply) => { + const url = request.url; + if (!url.startsWith('/projects/') && !url.startsWith('/mcp')) return; + await tokenAuth(request, reply); + }); + + await httpServer.listen({ port: httpPort, host: httpHost }); + process.stderr.write(`mcplocal-serve listening on ${httpHost}:${httpPort}\n`); + + // Hot-reload proxymodel stages from ~/.mcpctl/stages (same as main.ts). + await reloadStages(); + startWatchers(); + + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + stopWatchers(); + providerRegistry.disposeAll(); + await httpServer.close(); + await router.closeAll(); + process.exit(0); + }; + process.on('SIGTERM', () => void shutdown()); + process.on('SIGINT', () => void shutdown()); +} + +const isMain = + process.argv[1]?.endsWith('serve.js') || + process.argv[1]?.endsWith('serve.ts'); + +if (isMain) { + serve().catch((err) => { + process.stderr.write(`Fatal: ${err}\n`); + process.exit(1); + }); +} diff --git a/src/mcplocal/tests/smoke/mcptoken.smoke.test.ts b/src/mcplocal/tests/smoke/mcptoken.smoke.test.ts new file mode 100644 index 0000000..b78c12c --- /dev/null +++ b/src/mcplocal/tests/smoke/mcptoken.smoke.test.ts @@ -0,0 +1,143 @@ +/** + * Smoke tests: McpToken + HTTP-mode mcplocal end-to-end. + * + * Exercises the full public CLI contract: + * 1. `mcpctl create project` + `mcpctl create mcptoken` + * 2. `mcpctl test mcp --token $TOK --expect-tools …` → exit 0 + * 3. Same token against a different project → exit 1 (403) + * 4. Revoke the token, retry → exit 1 (401) within the negative-cache window + * 5. --expect-tools → exit 2 (contract failure) + * + * Target endpoint: $MCPGW_URL (default https://mcp.ad.itaz.eu). The containerized + * mcplocal must be deployed and reachable. If the /healthz preflight fails we + * skip the whole suite with a clear message. + * + * Run with: pnpm test:smoke + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import http from 'node:http'; +import https from 'node:https'; +import { execSync } from 'node:child_process'; + +const MCPGW_URL = process.env.MCPGW_URL ?? 'https://mcp.ad.itaz.eu'; +const PROJECT_NAME = `smoke-mcptoken-${Date.now().toString(36)}`; +const TOKEN_NAME = 'smoketok'; +const OTHER_PROJECT = 'smoke-mcptoken-other'; + +interface CliResult { code: number; stdout: string; stderr: string } + +function run(args: string): CliResult { + try { + const stdout = execSync(`mcpctl ${args}`, { + encoding: 'utf-8', + timeout: 30_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, stdout: stdout.trim(), stderr: '' }; + } catch (err) { + const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string }; + return { + code: e.status ?? 1, + stdout: e.stdout ? (typeof e.stdout === 'string' ? e.stdout : e.stdout.toString('utf-8')) : '', + stderr: e.stderr ? (typeof e.stderr === 'string' ? e.stderr : e.stderr.toString('utf-8')) : '', + }; + } +} + +function healthz(url: string, timeoutMs = 5000): Promise { + return new Promise((resolve) => { + const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`); + const driver = parsed.protocol === 'https:' ? https : http; + const req = driver.get( + { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname, + timeout: timeoutMs, + }, + (res) => { + resolve((res.statusCode ?? 500) < 500); + res.resume(); + }, + ); + req.on('error', () => resolve(false)); + req.on('timeout', () => { req.destroy(); resolve(false); }); + }); +} + +let gatewayUp = false; +let rawToken = ''; +let knownToolName: string | undefined; + +beforeAll(async () => { + gatewayUp = await healthz(MCPGW_URL); +}, 20_000); + +describe.skipIf(!gatewayUp)('mcptoken smoke (MCPGW_URL=' + MCPGW_URL + ')', () => { + it('creates the project and a project-scoped mcptoken', () => { + run(`delete project ${PROJECT_NAME} --force`); // cleanup leftovers — best-effort + const createProj = run(`create project ${PROJECT_NAME} --force`); + expect(createProj.code).toBe(0); + + const createTok = run(`create mcptoken ${TOKEN_NAME} --project ${PROJECT_NAME} --rbac clone`); + expect(createTok.code).toBe(0); + const match = createTok.stdout.match(/mcpctl_pat_[A-Za-z0-9]+/); + expect(match, 'raw token was printed to stdout').not.toBeNull(); + rawToken = match![0]; + }); + + it('passes `mcpctl test mcp` against the token\'s project endpoint', () => { + const result = run(`test mcp ${MCPGW_URL}/projects/${PROJECT_NAME}/mcp --token ${rawToken} -o json`); + expect(result.code, result.stderr || result.stdout).toBe(0); + const report = JSON.parse(result.stdout.slice(result.stdout.indexOf('{'))) as { + exitCode: number; + tools: string[] | null; + initialize: string; + }; + expect(report.exitCode).toBe(0); + expect(report.initialize).toBe('ok'); + expect(Array.isArray(report.tools)).toBe(true); + // Remember a tool name for the next negative --expect-tools assertion + knownToolName = report.tools?.[0]; + }); + + it('fails `mcpctl test mcp` against a different project with 403', () => { + run(`create project ${OTHER_PROJECT} --force`); + const result = run(`test mcp ${MCPGW_URL}/projects/${OTHER_PROJECT}/mcp --token ${rawToken} -o json`); + expect(result.code).toBe(1); + const report = JSON.parse(result.stdout.slice(result.stdout.indexOf('{'))) as { error?: string }; + expect(report.error ?? '').toMatch(/403|not valid for|project/i); + }); + + it('exits 2 (contract failure) when --expect-tools names a nonexistent tool', () => { + const result = run(`test mcp ${MCPGW_URL}/projects/${PROJECT_NAME}/mcp --token ${rawToken} --expect-tools __nonexistent_tool_xyz__`); + expect(result.code).toBe(2); + }); + + it('returns 401 after the token is revoked (within the negative-cache window)', async () => { + const del = run(`delete mcptoken ${TOKEN_NAME} --project ${PROJECT_NAME}`); + expect(del.code).toBe(0); + // Let the mcplocal negative-cache window expire. Introspection negative TTL + // defaults to 5s; we wait 7s to be safe. + await new Promise((r) => setTimeout(r, 7_000)); + const result = run(`test mcp ${MCPGW_URL}/projects/${PROJECT_NAME}/mcp --token ${rawToken} -o json`); + expect(result.code).toBe(1); + const report = JSON.parse(result.stdout.slice(result.stdout.indexOf('{'))) as { error?: string }; + expect(report.error ?? '').toMatch(/401|revoked|Invalid token/i); + }, 20_000); + + it('cleans up test fixtures', () => { + run(`delete project ${PROJECT_NAME} --force`); + run(`delete project ${OTHER_PROJECT} --force`); + // Suppress the unused-var warning in strict setups + expect(knownToolName === undefined || typeof knownToolName === 'string').toBe(true); + }); +}); + +describe.skipIf(gatewayUp)('mcptoken smoke (SKIPPED)', () => { + it('is skipped because MCPGW_URL is unreachable', () => { + // eslint-disable-next-line no-console + console.warn(`mcptoken smoke: skipped — ${MCPGW_URL}/healthz unreachable. Set MCPGW_URL to override.`); + expect(true).toBe(true); + }); +}); diff --git a/src/shared/src/index.ts b/src/shared/src/index.ts index 5c4955a..6f36b42 100644 --- a/src/shared/src/index.ts +++ b/src/shared/src/index.ts @@ -4,3 +4,4 @@ export * from './constants/index.js'; export * from './utils/index.js'; export * from './secrets/index.js'; export * from './tokens/index.js'; +export * from './mcp-http/index.js'; diff --git a/src/shared/src/mcp-http/index.ts b/src/shared/src/mcp-http/index.ts new file mode 100644 index 0000000..50bc882 --- /dev/null +++ b/src/shared/src/mcp-http/index.ts @@ -0,0 +1,246 @@ +/** + * Reusable Streamable-HTTP MCP client. + * + * Handles: + * - Bearer auth (session tokens or McpToken PATs) + * - mcp-session-id round-trip + * - Both JSON and text/event-stream response bodies + * - JSON-RPC id correlation when a response is multiplexed with notifications + * + * Used by the smoke suite (`SmokeMcpSession` is a thin wrapper around this) + * and by `mcpctl test mcp `. + */ +import http from 'node:http'; +import https from 'node:https'; + +export interface McpHttpSessionOptions { + /** Bearer to send on every request. Accepts raw tokens (no "Bearer " prefix). */ + bearer?: string; + /** Additional headers merged into every request. */ + headers?: Record; + /** Timeout per HTTP request in milliseconds. Defaults to 30_000. */ + timeoutMs?: number; +} + +export interface ToolInfo { + name: string; + description?: string; + inputSchema?: unknown; +} + +export interface ToolCallResult { + content: Array<{ type: string; text?: string }>; + isError?: boolean; +} + +interface HttpRequestArgs { + url: string; + method: string; + headers?: Record; + body?: string; + timeoutMs?: number; +} + +interface HttpRequestResult { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +function rawHttpRequest(opts: HttpRequestArgs): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(opts.url); + const driver = parsed.protocol === 'https:' ? https : http; + const req = driver.request( + { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: opts.method, + headers: opts.headers, + timeout: opts.timeoutMs ?? 30_000, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf-8'), + }); + }); + }, + ); + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timed out')); + }); + if (opts.body) req.write(opts.body); + req.end(); + }); +} + +function parseSse(body: string): unknown[] { + const messages: unknown[] = []; + for (const line of body.split('\n')) { + if (line.startsWith('data: ')) { + try { + messages.push(JSON.parse(line.slice(6))); + } catch { + // skip malformed SSE data line + } + } + } + return messages; +} + +/** Thrown when the server returned a response JSON-RPC error payload. */ +export class McpProtocolError extends Error { + constructor(public readonly code: number, message: string) { + super(`MCP error ${code}: ${message}`); + this.name = 'McpProtocolError'; + } +} + +/** Thrown when the HTTP layer rejected the request (auth, transport, 5xx). */ +export class McpTransportError extends Error { + constructor(public readonly status: number, public readonly body: string, message?: string) { + super(message ?? `HTTP ${status}: ${body.slice(0, 200)}`); + this.name = 'McpTransportError'; + } +} + +export class McpHttpSession { + private sessionId: string | undefined; + private nextId = 1; + + constructor( + /** Full URL of the MCP endpoint (e.g. `https://mcp.example.com/projects/foo/mcp`). */ + public readonly url: string, + private readonly options: McpHttpSessionOptions = {}, + ) {} + + private buildHeaders(extra: Record = {}): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + ...(this.options.headers ?? {}), + ...extra, + }; + if (this.sessionId) headers['mcp-session-id'] = this.sessionId; + if (this.options.bearer) headers['Authorization'] = `Bearer ${this.options.bearer}`; + return headers; + } + + /** + * Send a JSON-RPC request and wait for the response with a matching id. + * Handles both single JSON and multiplexed SSE bodies. + */ + async send(method: string, params: Record = {}): Promise { + const id = this.nextId++; + const request = { jsonrpc: '2.0', id, method, params }; + + const args: HttpRequestArgs = { + url: this.url, + method: 'POST', + headers: this.buildHeaders(), + body: JSON.stringify(request), + }; + if (this.options.timeoutMs !== undefined) args.timeoutMs = this.options.timeoutMs; + const result = await rawHttpRequest(args); + + if (!this.sessionId) { + const sid = result.headers['mcp-session-id']; + if (typeof sid === 'string') this.sessionId = sid; + } + + if (result.status >= 400) { + let message = `HTTP ${result.status}`; + try { + const body = JSON.parse(result.body) as { error?: string | { message?: string } }; + const errField = body.error; + if (typeof errField === 'string') message = errField; + else if (errField && typeof errField === 'object' && typeof errField.message === 'string') message = errField.message; + } catch { + message = `HTTP ${result.status}: ${result.body.slice(0, 200)}`; + } + throw new McpTransportError(result.status, result.body, message); + } + + const messages = result.headers['content-type']?.includes('text/event-stream') + ? parseSse(result.body) + : [JSON.parse(result.body)]; + + const matched = messages.find((m) => { + const msg = m as { id?: unknown }; + return msg.id === id; + }) as { result?: unknown; error?: { code: number; message: string } } | undefined; + + const parsed = matched ?? messages[0] as { result?: unknown; error?: { code: number; message: string } } | undefined; + if (!parsed) throw new Error(`No response for ${method}`); + if (parsed.error) throw new McpProtocolError(parsed.error.code, parsed.error.message); + return parsed.result; + } + + async sendNotification(method: string, params: Record = {}): Promise { + const notification = { jsonrpc: '2.0', method, params }; + const args: HttpRequestArgs = { + url: this.url, + method: 'POST', + headers: this.buildHeaders(), + body: JSON.stringify(notification), + }; + if (this.options.timeoutMs !== undefined) args.timeoutMs = this.options.timeoutMs; + await rawHttpRequest(args).catch(() => { /* best-effort */ }); + } + + /** MCP `initialize` handshake. */ + async initialize(): Promise<{ protocolVersion?: string; serverInfo?: { name?: string; version?: string }; capabilities?: unknown }> { + return await this.send('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'mcpctl-mcp-http-client', version: '1.0.0' }, + }) as { protocolVersion?: string; serverInfo?: { name?: string; version?: string }; capabilities?: unknown }; + } + + /** List tools exposed by the endpoint. */ + async listTools(): Promise { + const result = await this.send('tools/list') as { tools?: ToolInfo[] }; + return result.tools ?? []; + } + + /** Call a tool and return its `content` payload. */ + async callTool(name: string, args: Record = {}): Promise { + return await this.send('tools/call', { name, arguments: args }) as ToolCallResult; + } + + /** Clean-close the session with a DELETE. Safe to call when no sessionId has been negotiated. */ + async close(): Promise { + if (this.sessionId === undefined) return; + await rawHttpRequest({ + url: this.url, + method: 'DELETE', + headers: this.buildHeaders(), + timeoutMs: 5_000, + }).catch(() => { /* best-effort */ }); + this.sessionId = undefined; + } +} + +/** Best-effort healthcheck against `/healthz`. */ +export async function mcpHealthCheck(baseUrl: string, timeoutMs = 5_000): Promise { + try { + const res = await rawHttpRequest({ url: `${baseUrl.replace(/\/$/, '')}/healthz`, method: 'GET', timeoutMs }); + return res.status >= 200 && res.status < 500; + } catch { + return false; + } +} + +/** Derive `://[:port]` from a full MCP endpoint URL (for healthcheck). */ +export function deriveBaseUrl(mcpUrl: string): string { + const u = new URL(mcpUrl); + return `${u.protocol}//${u.host}`; +}