From 3f603a6c119ba445ff5e386443d854988201151b Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Fri, 3 Jul 2026 19:08:23 +0200 Subject: [PATCH 01/36] docs: add mcp-auth-proxy design spec Design for codemie mcp-auth-proxy (run 20260703-1845-mcp-auth-proxy), binding docs/SPEC-mcp-auth-proxy.md to repo patterns; resolves streaming mechanism to node:http + pipeline per spec.clarification gate. Generated with AI Co-Authored-By: codemie-ai --- .../specs/2026-07-03-mcp-auth-proxy-design.md | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-03-mcp-auth-proxy-design.md diff --git a/docs/superpowers/specs/2026-07-03-mcp-auth-proxy-design.md b/docs/superpowers/specs/2026-07-03-mcp-auth-proxy-design.md new file mode 100644 index 00000000..232f2a2f --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-mcp-auth-proxy-design.md @@ -0,0 +1,132 @@ +# Design: `codemie mcp-auth-proxy` — MCP OAuth Rewriting Proxy + +Date: 2026-07-03 +Run: 20260703-1845-mcp-auth-proxy +Status: Proposed (pending `spec.approved` gate) + +`docs/SPEC-mcp-auth-proxy.md` is the **authoritative functional spec** (route map, rewrite +rules R1–R6, config semantics, CLI surface, security rules, acceptance criteria). This +design does not restate it; it binds the spec to this repository: concrete modules, +interfaces, in-repo templates to mirror, and the resolved open decisions. Where this doc +and the spec diverge, the divergence is listed in "Resolved decisions" below — everything +else defers to the spec. + +## Resolved decisions + +| # | Decision | Resolution | Rationale | +|---|---|---|---| +| D1 | Streaming pass-through mechanism | **Raw `node:http`/`node:https` client + `stream/promises` `pipeline`** — not undici / fetch `duplex:'half'` | Gate `spec.clarification` verdict (decisions.jsonl 2026-07-03): the spec's R1 MUSTs (unbuffered bidirectional streaming, abort propagation, no body-parsing middleware) are mechanism-agnostic; the undici mention is a parenthetical hint; undici is not a dependency and the repo's proven SSE-proxy template is `src/providers/plugins/sso/proxy/proxy-http-client.ts`. **Deliberate deviation from the spec's hint — do not flag as drift in review.** | +| D2 | Error classes | Config validation → `ConfigurationError` (with offending key path); daemon lifecycle → `ToolExecutionError`; per-request forwarding failures → HTTP JSON error responses (`502 upstream_unreachable`, `404 unknown_route`), no thrown cross-layer errors | Spec mandates `src/utils/errors.ts` classes; importing `src/providers/plugins/sso/proxy/proxy-errors.ts` into `src/mcp/` would cross a plugin boundary (technical-analysis §6). | +| D3 | Corporate proxy env | Upstream requests honor `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` via the existing `https-proxy-agent`/`http-proxy-agent` dependencies, mirroring the SSO proxy outbound client | Enterprise upstreams (`mcp.epam.com`) sit behind corporate proxies; repo convention already does this; zero new dependencies. | +| D4 | Daemon spawn target | Git-tracked ESM wrapper `bin/mcp-auth-proxy-daemon.js` → `import('../dist/bin/mcp-auth-proxy-daemon.js')`; spawn by file path via `spawnDetached` | Repo ADR: daemons spawn by file path to `bin/*.js` wrappers (`daemon-manager.ts`); tsc compiles all of `src/`, no build config change. | +| D5 | Shared daemon runtime | New `src/mcp/auth-proxy/runtime.ts` with `runAuthProxyDaemon(opts)` used by both the bin entry and CLI `--foreground` | `--foreground` has no repo precedent; a shared runtime avoids duplicating config-load/start/state-write/signal-handling between the bin entry and the CLI. | +| D6 | Reserved route ids | `as`, `.well-known` (spec) **plus `healthz`** | The proxy serves `GET /healthz`; a route named `healthz` would shadow it. Spec gap, fixed here. | +| D7 | Port conflict | Pinned port: `EADDRINUSE` → fail start with `ConfigurationError`-style message (no auto-increment) | Client MCP URLs embed the port; silently moving it would break every registered `claude mcp add` URL. (SSO proxy auto-retries only for unpinned ports.) | +| D8 | State-file helpers | New `src/mcp/auth-proxy/state.ts` (read/write/clear + `isProcessAlive`), consumed by CLI and runtime | Existing `daemon-manager.ts` is typed to the SSO proxy's `DaemonState` (profile, gatewayKey); the new state schema `{pid, port, routes, startedAt}` differs. Mirror the pattern (atomic tmp+rename, defensive parse → null), not the code. | + +## Architecture + +Fits the 5-layer architecture: CLI (`src/cli/commands/mcp-auth-proxy.ts`) → core module +(`src/mcp/auth-proxy/`) → utils (`paths`, `errors`, `logger`, `security`, `processes`). +No registry/plugin surface. Existing `codemie mcp-proxy` stdio bridge is untouched. + +### Files + +| File | Kind | Responsibility | +|---|---|---| +| `src/mcp/auth-proxy/types.ts` | new | `AuthProxyConfig`, `RouteConfig`, `AuthProxyDaemonState`, `UpstreamMetadata`, `RouteRuntime` (status ok/degraded), rewrite-option types. Explicit exported types, no `any`. | +| `src/mcp/auth-proxy/config.ts` | new | `loadAuthProxyConfig(path?): AuthProxyConfig` — read JSON (default `getCodemiePath('mcp-auth-proxy.json')`), validate with manual guards, throw `ConfigurationError` with offending key path (`servers.radar.upstreamUrl`, …). Rules: `port` optional int 1–65535 (default 42800); route id `^[a-z0-9][a-z0-9-]*$`, not in {`as`, `.well-known`, `healthz`}; `upstreamUrl` required `https://` URL; `clientName` optional non-empty string; `scopes` optional non-empty `string[]`. | +| `src/mcp/auth-proxy/rewrites.ts` | new | **Pure functions, no I/O** (unit-test core): `rewriteChallengeHeader`, `rewritePrm`, `rewriteAsMetadata`, `rewriteRegistrationBody`, `rewriteAuthorizeQuery`, `rewriteTokenBody` — exactly the six rules R1–R6 of the spec, each taking explicit inputs (upstream document/header/query + `{proxyOrigin, routeId, scopes?, clientName?, upstreamResource?}`) and returning the rewritten value plus the list of rewritten field names (for safe logging). | +| `src/mcp/auth-proxy/metadata-cache.ts` | new | `MetadataCache` — per-route upstream discovery + TTL cache. Discovery: probe upstream PRM (`/.well-known/oauth-protected-resource/`, then root variant; additionally accept a `resource_metadata` URL captured from a live upstream 401 via `notePrmUrl(routeId, url)` called by the pass-through handler, per R2), then AS metadata from PRM's first `authorization_servers` issuer using the spec's well-known priority order (RFC 8414 path-insertion → OIDC path-insertion → OIDC path-appending). Extracts upstream canonical `resource`, `registration_endpoint` (missing ⇒ route startup failure per R3), `authorization_endpoint`, `token_endpoint`, `revocation_endpoint?`. Positive TTL 300 s, negative cache 10 s, per-route isolation (failure of one route never touches another). Multiple `authorization_servers` ⇒ first + `logger.warn`. | +| `src/mcp/auth-proxy/server.ts` | new | `McpAuthProxy` class: `start(): Promise<{port, url}>`, `stop(): Promise`. `node:http` server bound to literal `'127.0.0.1'` (repo ADR). Route dispatch per the spec's route map (rows 1–11) incl. single-route root-PRM alias and `/healthz`. MCP pass-through per R1 via `node:http`/`node:https` client + `pipeline` (D1): hop-by-hop header strip, `Host` set to upstream, `Authorization`/`Mcp-Session-Id` passed through, client-abort propagation (`req.on('close')` → upstream `.destroy()`), timeout 0 + keep-alive agents for SSE, `WWW-Authenticate` rewrite/injection on 401 and `insufficient_scope` 403. OAuth endpoints: 64 KB body limit on register/token, forward + relay verbatim, `302` for authorize. Degraded routes → `502 {"error":"upstream_unreachable","route":""}` and `/healthz` marks `degraded`. Uniform log rule: method, route id, path, status, duration, rewritten-field *names* only; never bodies/queries/headers on `/as/*`. | +| `src/mcp/auth-proxy/state.ts` | new | `AuthProxyDaemonState {pid, port, routes: string[], startedAt}`; `readAuthProxyState`/`writeAuthProxyState` (atomic tmp+rename)/`clearAuthProxyState`/`isProcessAlive` — pattern-mirror of `daemon-manager.ts`. State file `getCodemiePath('mcp-auth-proxy.state.json')`. | +| `src/mcp/auth-proxy/runtime.ts` | new | `runAuthProxyDaemon({configPath?, port?, stateFile?}): Promise<{proxy, stop}>` — load config, apply port override, start `McpAuthProxy`, write state, install SIGTERM/SIGINT cleanup (stop server, unlink state). Used by the bin entry and CLI `--foreground` (D5). **No watcher** (spec non-goal). | +| `src/bin/mcp-auth-proxy-daemon.ts` | new | Thin detached entry: `parseArgs` (`--config`, `--port`, `--state-file`) → `runAuthProxyDaemon`. Start failure → stderr + `exit(1)`. Mirrors `src/bin/proxy-daemon.ts` minus watcher. | +| `bin/mcp-auth-proxy-daemon.js` | new | Git-tracked ESM wrapper importing `../dist/bin/mcp-auth-proxy-daemon.js` (D4). | +| `src/cli/commands/mcp-auth-proxy.ts` | new | `createMcpAuthProxyCommand()` — `new Command('mcp-auth-proxy')` with `start [--config ] [--port ] [--foreground]`, `stop`, `status`, mirroring `src/cli/commands/proxy/index.ts` (port parse helper → `ConfigurationError`; `chalk` output). `start`: validate config first (fail fast with key path), then detached `spawnDetached(process.execPath, [wrapper, ...args])` + 5 s state/pid poll (`ToolExecutionError` on timeout), or `--foreground` → `runAuthProxyDaemon` in-process; on success print per-route `claude mcp add --scope local --transport http http://127.0.0.1:/` lines. `status`: read state, pid liveness, `GET /healthz`, print per-route upstream + add-command; clear stale state. `stop`: SIGTERM + poll, SIGKILL escalation, clear state. | +| `src/cli/index.ts` | edit | Import + `program.addCommand(createMcpAuthProxyCommand())` next to `createMcpProxyCommand()` (line ~98). | +| `package.json` | edit | Optional `bin` entry `"codemie-mcp-auth-proxy-daemon": "bin/mcp-auth-proxy-daemon.js"` for consistency; `files` already ships `bin/` + `dist/`. No build changes (tsc compiles `src/**/*`). | +| `docs/COMMANDS.md` | edit | Document the new command surface (repo review checklist: public docs updated when behavior changes). | + +Every new `src/**/*.ts` file carries the Apache-2.0 license header (license gate). + +### Rewrite function signatures (the unit-test core) + +```ts +interface RewriteContext { proxyOrigin: string; routeId: string; scopes?: string[] } + +rewriteChallengeHeader(header: string | undefined, ctx: RewriteContext): { value: string; rewrote: string[] } +rewritePrm(prm: JsonObject, ctx: RewriteContext): { value: JsonObject; rewrote: string[] } +rewriteAsMetadata(as: JsonObject, ctx: RewriteContext & { upstreamHasRevocation: boolean }): { value: JsonObject; rewrote: string[] } +rewriteRegistrationBody(body: JsonObject, ctx: { clientName?: string; scopes?: string[] }): { value: JsonObject; rewrote: string[] } +rewriteAuthorizeQuery(query: URLSearchParams, ctx: { scopes?: string[]; upstreamResource: string }): { value: URLSearchParams; rewrote: string[] } +rewriteTokenBody(form: URLSearchParams, ctx: { scopes?: string[]; upstreamResource: string }): { value: URLSearchParams; rewrote: string[] } +``` + +Behavior is exactly R1–R6 (including: challenge injection when upstream sent none; +`client_id_metadata_document_supported` deletion; `code_challenge_methods_supported` +preservation; `scope` rewritten in token body only when the field is present; unknown +params/fields pass through). `rewrote` feeds the names-only debug log line. + +## Data flow + +1. **MCP call**: Claude Code → `/:id/...` → stream pass-through → upstream MCP URL. + Upstream 401/403(`insufficient_scope`) → challenge header rewritten to point at the + proxy's PRM (+ configured scope) → Claude Code starts its native discovery. +2. **Discovery**: client fetches proxy PRM (row 2/3) and AS metadata (rows 4–6) — both are + the cached upstream documents rewritten by `rewritePrm`/`rewriteAsMetadata` (issuer = + `http://127.0.0.1:/as/`; CIMD flag stripped ⇒ client takes the DCR path). +3. **DCR**: `POST /as//register` → body rewritten (`client_name`, `scope`) → upstream + `registration_endpoint` → response relayed verbatim (dynamic `client_id` to client). +4. **Authorize**: `GET /as//authorize` → query rewritten (`scope`, `resource` → upstream + canonical) → `302` to upstream `authorization_endpoint`. IdP consent → redirect straight + to Claude Code's localhost callback (never via proxy). +5. **Token/refresh**: `POST /as//token` → form rewritten (`resource`, conditional + `scope`) → upstream `token_endpoint` → relayed verbatim. Proxy stores nothing. + +## Error handling + +- Config invalid → `ConfigurationError` with key path at startup (CLI prints and exits 1). +- Route discovery failure → route marked `degraded` (negative-cached 10 s), proxy stays up, + `502` on that route's endpoints, retry on next request. Other routes unaffected. +- Upstream unreachable/TLS failure mid-request → `502 {"error":"upstream_unreachable","route":""}`; + `logger.warn` with upstream host only. +- Unknown route/path → `404 {"error":"unknown_route"}`. +- Daemon spawn/stop timeouts → `ToolExecutionError('mcp-auth-proxy-daemon', …)`. +- Body over 64 KB on register/token → `413 {"error":"payload_too_large"}`. + +## Testing strategy + +Tests are explicitly requested as plan tasks (repo policy satisfied): Vitest, co-located +`src/mcp/auth-proxy/__tests__/`, TDD per task. + +- `rewrites.test.ts` — table-driven cases per rule: scope configured/absent, challenge + present/absent/insufficient_scope, CIMD flag deletion, PKCE field preservation, resource + restoration, pass-through of unknown fields. Pure functions — no mocks. +- `config.test.ts` — valid example config; each validation failure asserts the offending + key path in the error message. +- `state.test.ts` — atomic write/read/clear + stale-pid handling (mirror + `daemon-manager.test.ts`, mocking `getCodemieHome` to a tmpdir). +- `metadata-cache.test.ts` — discovery priority order, TTL + negative cache, first-AS + warning, missing `registration_endpoint` failure (local `node:http` fixture server). +- `server.test.ts` — route dispatch against a local fake upstream (`node:http`): MCP + pass-through incl. SSE streaming + abort, 401 challenge rewrite/injection, per-route + isolation with one degraded upstream (acceptance criterion 7 locally), 64 KB limit, + `/healthz`, single-route root-PRM alias, loopback-only bind. + +## Acceptance mapping + +- Criteria 1, 8, 10 — verified in this run (CLI start/status/stop against local config; + quality gates in Phase 8). +- Criteria 3–7, 9 — logic verified in this run at unit/integration level against fake + upstream + fixture AS documents (rewrite correctness, isolation, degraded-route + independence, no-secret logging); full end-to-end against the live EPAM IdP/Claude Code + session is a manual post-merge step (spec Verification Aids). +- Criterion 2 — structural: the proxy never initiates auth (no browser-open code exists in + the module; nothing runs at Claude Code startup). + +## Non-goals + +Unchanged from the spec (CIMD, multi-AS, TLS/non-loopback, token caching, JSON-RPC +rewriting, watcher, Windows service). Additionally out of scope here: changes to the SSO +proxy, `mcp-proxy` stdio bridge, or `daemon-manager.ts`. From 294b9e575558a0441ec13c467c5880ff0deeaae6 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Fri, 3 Jul 2026 19:33:36 +0200 Subject: [PATCH 02/36] docs: add mcp-auth-proxy implementation plan Nine TDD tasks binding the approved design to concrete code; design doc errata: license-check gates dependencies only, no package.json change, upstream-client.ts file split. Generated with AI Co-Authored-By: codemie-ai --- .../plans/2026-07-03-mcp-auth-proxy.md | 2927 +++++++++++++++++ .../specs/2026-07-03-mcp-auth-proxy-design.md | 8 +- 2 files changed, 2933 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-03-mcp-auth-proxy.md diff --git a/docs/superpowers/plans/2026-07-03-mcp-auth-proxy.md b/docs/superpowers/plans/2026-07-03-mcp-auth-proxy.md new file mode 100644 index 00000000..40e6a737 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-mcp-auth-proxy.md @@ -0,0 +1,2927 @@ +# MCP Auth Proxy (`codemie mcp-auth-proxy`) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the `codemie mcp-auth-proxy` daemon — a loopback-only transparent HTTP proxy that rewrites OAuth `client_name`/`scope`/`resource` between Claude Code and multiple remote MCP servers, per `docs/SPEC-mcp-auth-proxy.md` (authoritative) and `docs/superpowers/specs/2026-07-03-mcp-auth-proxy-design.md` (repo binding, decisions D1–D8). + +**Architecture:** New core module `src/mcp/auth-proxy/` (types, config, rewrites, state, upstream-client, metadata-cache, server, runtime) + daemon bin entry + CLI command, following the 5-layer rule (CLI → core → utils). Streaming pass-through uses raw `node:http(s)` + `stream/promises` `pipeline` (design D1 — deliberate, gate-approved deviation from the spec's undici hint). The proxy holds no tokens; per-route OAuth artifacts are fully isolated. + +**Tech Stack:** Node 20 ES modules (`.js` import extensions), Commander, chalk, `http-proxy-agent`/`https-proxy-agent` (existing deps), Vitest 4 (co-located `__tests__/`), project utils (`getCodemiePath`, `logger`, `sanitizeLogArgs`, `spawnDetached`, `ConfigurationError`/`ToolExecutionError`). + +**Conventions that apply to every task:** +- No `console.log` outside `src/cli/**` (CLI user output uses `console`/chalk like existing commands); module code logs via `logger.debug` + `sanitizeLogArgs`. +- Never log tokens, codes, verifiers, `Authorization` headers, bodies, or `/as/*` query strings — log rewritten field *names* only. +- Explicit return types on exports, no `any`, single quotes. +- Commit scope: `proxy` for module/daemon work, `cli` for CLI/registration, `docs` for docs (allowed scopes are enforced by commitlint). +- No new dependencies; no `package.json` changes. +- Run single test files with `npx vitest run `. + +--- + +### Task 1: Types + config validation + +**Test-first: yes — `validateAuthProxyConfig` rejects each invalid shape with the offending key path; fails until `config.ts` exists.** + +**Files:** +- Create: `src/mcp/auth-proxy/types.ts` +- Create: `src/mcp/auth-proxy/config.ts` +- Test: `src/mcp/auth-proxy/__tests__/config.test.ts` + +- [ ] **Step 1: Write `types.ts`** (types only — needed by the test's imports; contains no logic to test) + +```ts +/** + * MCP Auth Proxy — shared types. + * + * See docs/SPEC-mcp-auth-proxy.md (authoritative spec) and + * docs/superpowers/specs/2026-07-03-mcp-auth-proxy-design.md (design decisions D1–D8). + */ + +export type JsonObject = Record; + +export interface RouteConfig { + /** Canonical upstream MCP endpoint (https:// only; stored without trailing slash). */ + upstreamUrl: string; + /** DCR client_name override; absent = pass through (R4). */ + clientName?: string; + /** Scope override applied at every rewrite point (R1–R6); absent = pass through. */ + scopes?: string[]; +} + +export interface AuthProxyConfig { + port: number; + servers: Record; +} + +export interface AuthProxyDaemonState { + pid: number; + port: number; + routes: string[]; + startedAt: string; +} + +export type RouteStatus = 'ok' | 'degraded' | 'unknown'; + +export interface UpstreamMetadata { + /** Upstream Protected Resource Metadata document (RFC 9728), verbatim. */ + prm: JsonObject; + /** Upstream Authorization Server metadata document (RFC 8414 / OIDC), verbatim. */ + asMetadata: JsonObject; + /** Upstream canonical resource URI for RFC 8707 rewrites (no trailing slash). */ + upstreamResource: string; + registrationEndpoint: string; + authorizationEndpoint: string; + tokenEndpoint: string; + revocationEndpoint?: string; +} + +export interface RewriteResult { + value: T; + /** Names of rewritten fields — safe to log (names only, never values). */ + rewrote: string[]; +} + +export interface RewriteContext { + /** e.g. `http://127.0.0.1:42800` (no trailing slash). */ + proxyOrigin: string; + routeId: string; + scopes?: string[]; +} +``` + +- [ ] **Step 2: Write the failing test `src/mcp/auth-proxy/__tests__/config.test.ts`** + +```ts +/** + * mcp-auth-proxy config validation tests + * @group unit + */ +import { describe, it, expect } from 'vitest'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + DEFAULT_AUTH_PROXY_PORT, + loadAuthProxyConfig, + validateAuthProxyConfig, +} from '../config.js'; +import { ConfigurationError } from '../../../utils/errors.js'; + +const validServers = { radar: { upstreamUrl: 'https://mcp.example.com/mcp/radar' } }; + +describe('validateAuthProxyConfig', () => { + it('accepts a minimal config and applies the default port', () => { + const config = validateAuthProxyConfig({ servers: validServers }); + expect(config.port).toBe(DEFAULT_AUTH_PROXY_PORT); + expect(config.servers.radar.upstreamUrl).toBe('https://mcp.example.com/mcp/radar'); + }); + + it('accepts a full route config and strips trailing slashes from upstreamUrl', () => { + const config = validateAuthProxyConfig({ + port: 42801, + servers: { + radar: { + upstreamUrl: 'https://mcp.example.com/mcp/radar/', + clientName: 'EPAM Approved MCP Client', + scopes: ['openid', 'mcp:access'], + }, + }, + }); + expect(config.port).toBe(42801); + expect(config.servers.radar.upstreamUrl).toBe('https://mcp.example.com/mcp/radar'); + expect(config.servers.radar.clientName).toBe('EPAM Approved MCP Client'); + expect(config.servers.radar.scopes).toEqual(['openid', 'mcp:access']); + }); + + it.each([null, [], 'x', 42])('rejects non-object root: %j', (root) => { + expect(() => validateAuthProxyConfig(root)).toThrow(ConfigurationError); + }); + + it.each([0, -1, 1.5, 70000, '42800'])('rejects invalid port %j naming "port"', (port) => { + expect(() => validateAuthProxyConfig({ port, servers: validServers })).toThrow(/"port"/); + }); + + it('rejects missing or empty servers naming "servers"', () => { + expect(() => validateAuthProxyConfig({})).toThrow(/"servers"/); + expect(() => validateAuthProxyConfig({ servers: {} })).toThrow(/at least one route/); + }); + + it.each(['Radar', '-radar', 'ra_dar', 'ra.dar'])('rejects invalid route id %j with key path', (id) => { + expect(() => + validateAuthProxyConfig({ servers: { [id]: { upstreamUrl: 'https://x.example' } } }) + ).toThrow(/servers\./); + }); + + it.each(['as', 'healthz'])('rejects reserved route id %j', (id) => { + expect(() => + validateAuthProxyConfig({ servers: { [id]: { upstreamUrl: 'https://x.example' } } }) + ).toThrow(/reserved/); + }); + + it('rejects missing, malformed, and non-https upstreamUrl with key path', () => { + expect(() => validateAuthProxyConfig({ servers: { radar: {} } })).toThrow( + /servers\.radar\.upstreamUrl/ + ); + expect(() => + validateAuthProxyConfig({ servers: { radar: { upstreamUrl: 'not a url' } } }) + ).toThrow(/servers\.radar\.upstreamUrl/); + expect(() => + validateAuthProxyConfig({ servers: { radar: { upstreamUrl: 'http://mcp.example.com' } } }) + ).toThrow(/servers\.radar\.upstreamUrl.*https/); + }); + + it('rejects empty clientName and invalid scopes with key paths', () => { + const upstreamUrl = 'https://x.example'; + expect(() => + validateAuthProxyConfig({ servers: { radar: { upstreamUrl, clientName: '' } } }) + ).toThrow(/servers\.radar\.clientName/); + expect(() => + validateAuthProxyConfig({ servers: { radar: { upstreamUrl, scopes: [] } } }) + ).toThrow(/servers\.radar\.scopes/); + expect(() => + validateAuthProxyConfig({ servers: { radar: { upstreamUrl, scopes: [''] } } }) + ).toThrow(/servers\.radar\.scopes/); + expect(() => + validateAuthProxyConfig({ servers: { radar: { upstreamUrl, scopes: 'openid' } } }) + ).toThrow(/servers\.radar\.scopes/); + }); +}); + +describe('loadAuthProxyConfig', () => { + it('throws ConfigurationError for a missing config file', async () => { + const missing = join(tmpdir(), `mcp-auth-proxy-missing-${process.pid}.json`); + await expect(loadAuthProxyConfig(missing)).rejects.toThrow(ConfigurationError); + await expect(loadAuthProxyConfig(missing)).rejects.toThrow(/not found/); + }); +}); +``` + +- [ ] **Step 3: Run the test — verify it fails** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/config.test.ts` +Expected: FAIL — `Cannot find module '../config.js'` (or equivalent resolve error). + +- [ ] **Step 4: Write `src/mcp/auth-proxy/config.ts`** + +```ts +/** + * MCP Auth Proxy — config loading + validation. + * + * Config file: /mcp-auth-proxy.json (resolved via getCodemiePath — never + * hardcode ~/.codemie). Validation errors name the offending key path (spec requirement). + */ +import { readFile } from 'node:fs/promises'; +import { getCodemiePath } from '../../utils/paths.js'; +import { ConfigurationError } from '../../utils/errors.js'; +import type { AuthProxyConfig, RouteConfig } from './types.js'; + +export const DEFAULT_AUTH_PROXY_PORT = 42800; +export const AUTH_PROXY_CONFIG_FILE = 'mcp-auth-proxy.json'; +export const AUTH_PROXY_STATE_FILE = 'mcp-auth-proxy.state.json'; + +const ROUTE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +// `as` + `.well-known` are reserved by the route map; `healthz` by the health endpoint +// (design D6 — a route named "healthz" would shadow GET /healthz). +const RESERVED_ROUTE_IDS = new Set(['as', '.well-known', 'healthz']); + +export function getDefaultConfigPath(): string { + return getCodemiePath(AUTH_PROXY_CONFIG_FILE); +} + +export function getDefaultStatePath(): string { + return getCodemiePath(AUTH_PROXY_STATE_FILE); +} + +export async function loadAuthProxyConfig(configPath?: string): Promise { + const path = configPath ?? getDefaultConfigPath(); + + let raw: string; + try { + raw = await readFile(path, 'utf-8'); + } catch { + throw new ConfigurationError( + `MCP auth proxy config not found: ${path}\n` + + `Create it with a "servers" map — see docs/SPEC-mcp-auth-proxy.md § Configuration.` + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new ConfigurationError(`${path}: invalid JSON — ${(error as Error).message}`); + } + + return validateAuthProxyConfig(parsed); +} + +export function validateAuthProxyConfig(parsed: unknown): AuthProxyConfig { + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new ConfigurationError('mcp-auth-proxy config: root must be a JSON object'); + } + const root = parsed as Record; + + let port = DEFAULT_AUTH_PROXY_PORT; + if (root.port !== undefined) { + if ( + typeof root.port !== 'number' || + !Number.isInteger(root.port) || + root.port < 1 || + root.port > 65535 + ) { + throw new ConfigurationError( + 'mcp-auth-proxy config: "port" must be an integer between 1 and 65535' + ); + } + port = root.port; + } + + if (typeof root.servers !== 'object' || root.servers === null || Array.isArray(root.servers)) { + throw new ConfigurationError( + 'mcp-auth-proxy config: "servers" must be an object mapping route ids to server configs' + ); + } + + const entries = Object.entries(root.servers as Record); + if (entries.length === 0) { + throw new ConfigurationError('mcp-auth-proxy config: "servers" must contain at least one route'); + } + + const servers: Record = {}; + for (const [id, value] of entries) { + if (!ROUTE_ID_PATTERN.test(id)) { + throw new ConfigurationError( + `mcp-auth-proxy config: servers.${id}: route id must match ^[a-z0-9][a-z0-9-]*$` + ); + } + if (RESERVED_ROUTE_IDS.has(id)) { + throw new ConfigurationError(`mcp-auth-proxy config: servers.${id}: route id is reserved`); + } + servers[id] = validateRoute(value, `servers.${id}`); + } + + return { port, servers }; +} + +function validateRoute(value: unknown, keyPath: string): RouteConfig { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new ConfigurationError(`mcp-auth-proxy config: ${keyPath}: must be an object`); + } + const route = value as Record; + + if (typeof route.upstreamUrl !== 'string' || route.upstreamUrl.length === 0) { + throw new ConfigurationError(`mcp-auth-proxy config: ${keyPath}.upstreamUrl: required string`); + } + let parsedUrl: URL; + try { + parsedUrl = new URL(route.upstreamUrl); + } catch { + throw new ConfigurationError(`mcp-auth-proxy config: ${keyPath}.upstreamUrl: not a valid URL`); + } + if (parsedUrl.protocol !== 'https:') { + throw new ConfigurationError(`mcp-auth-proxy config: ${keyPath}.upstreamUrl: must use https://`); + } + + if ( + route.clientName !== undefined && + (typeof route.clientName !== 'string' || route.clientName.length === 0) + ) { + throw new ConfigurationError( + `mcp-auth-proxy config: ${keyPath}.clientName: must be a non-empty string` + ); + } + + if (route.scopes !== undefined) { + if ( + !Array.isArray(route.scopes) || + route.scopes.length === 0 || + route.scopes.some((scope) => typeof scope !== 'string' || scope.length === 0) + ) { + throw new ConfigurationError( + `mcp-auth-proxy config: ${keyPath}.scopes: must be a non-empty array of non-empty strings` + ); + } + } + + return { + upstreamUrl: route.upstreamUrl.replace(/\/+$/, ''), + ...(route.clientName !== undefined ? { clientName: route.clientName as string } : {}), + ...(route.scopes !== undefined ? { scopes: [...(route.scopes as string[])] } : {}), + }; +} +``` + +- [ ] **Step 5: Run the test — verify it passes** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/config.test.ts` +Expected: PASS (all cases). + +- [ ] **Step 6: Commit** + +```bash +git add src/mcp/auth-proxy/types.ts src/mcp/auth-proxy/config.ts src/mcp/auth-proxy/__tests__/config.test.ts +git commit -m "feat(proxy): add mcp-auth-proxy types and config validation" +``` + +--- + +### Task 2: Pure rewrite rules R1–R6 + +**Test-first: yes — table-driven tests for all six rewrite functions (challenge rewrite/injection, CIMD deletion, PKCE preservation, resource restoration, conditional scope); fails until `rewrites.ts` exists.** + +**Files:** +- Create: `src/mcp/auth-proxy/rewrites.ts` +- Test: `src/mcp/auth-proxy/__tests__/rewrites.test.ts` + +- [ ] **Step 1: Write the failing test `src/mcp/auth-proxy/__tests__/rewrites.test.ts`** + +```ts +/** + * mcp-auth-proxy rewrite rules (R1–R6) tests — pure functions, no mocks. + * @group unit + */ +import { describe, it, expect } from 'vitest'; +import { + rewriteAsMetadata, + rewriteAuthorizeQuery, + rewriteChallengeHeader, + rewritePrm, + rewriteRegistrationBody, + rewriteTokenBody, +} from '../rewrites.js'; +import type { JsonObject } from '../types.js'; + +const ctx = { proxyOrigin: 'http://127.0.0.1:42800', routeId: 'radar' }; +const ctxScoped = { ...ctx, scopes: ['openid', 'mcp:access'] }; +const PRM_URL = 'http://127.0.0.1:42800/.well-known/oauth-protected-resource/radar'; + +describe('rewriteChallengeHeader (R1)', () => { + it('rewrites resource_metadata and sets scope, preserving other params', () => { + const header = + 'Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp/radar", error="invalid_token"'; + const { value, rewrote } = rewriteChallengeHeader(header, ctxScoped); + expect(value).toContain(`resource_metadata="${PRM_URL}"`); + expect(value).toContain('scope="openid mcp:access"'); + expect(value).toContain('error="invalid_token"'); + expect(rewrote).toEqual(['resource_metadata', 'scope']); + }); + + it('replaces an existing scope param instead of duplicating it', () => { + const header = 'Bearer resource_metadata="https://x.example/prm", scope="claudeai"'; + const { value } = rewriteChallengeHeader(header, ctxScoped); + expect(value).toContain('scope="openid mcp:access"'); + expect(value).not.toContain('claudeai'); + }); + + it('leaves upstream scope untouched when no scopes are configured', () => { + const header = 'Bearer resource_metadata="https://x.example/prm", scope="upstream-scope"'; + const { value, rewrote } = rewriteChallengeHeader(header, ctx); + expect(value).toContain('scope="upstream-scope"'); + expect(rewrote).toEqual(['resource_metadata']); + }); + + it('prepends resource_metadata when the Bearer challenge lacks it', () => { + const { value } = rewriteChallengeHeader('Bearer error="invalid_token"', ctx); + expect(value).toBe(`Bearer resource_metadata="${PRM_URL}", error="invalid_token"`); + }); + + it('injects a full challenge when the upstream sent no header at all', () => { + const { value } = rewriteChallengeHeader(undefined, ctxScoped); + expect(value).toBe(`Bearer resource_metadata="${PRM_URL}", scope="openid mcp:access"`); + }); + + it('handles a bare "Bearer" header', () => { + const { value } = rewriteChallengeHeader('Bearer', ctx); + expect(value).toBe(`Bearer resource_metadata="${PRM_URL}"`); + }); +}); + +describe('rewritePrm (R2)', () => { + const prm: JsonObject = { + resource: 'https://mcp.example.com/mcp/radar', + authorization_servers: ['https://idp.example.com/realms/x'], + scopes_supported: ['upstream-scope'], + bearer_methods_supported: ['header'], + }; + + it('rewrites resource + authorization_servers, passes other fields through', () => { + const { value, rewrote } = rewritePrm(prm, ctx); + expect(value.resource).toBe('http://127.0.0.1:42800/radar'); + expect(value.authorization_servers).toEqual(['http://127.0.0.1:42800/as/radar']); + expect(value.bearer_methods_supported).toEqual(['header']); + expect(value.scopes_supported).toEqual(['upstream-scope']); + expect(rewrote).toEqual(['resource', 'authorization_servers']); + }); + + it('overrides scopes_supported only when scopes are configured', () => { + const { value, rewrote } = rewritePrm(prm, ctxScoped); + expect(value.scopes_supported).toEqual(['openid', 'mcp:access']); + expect(rewrote).toContain('scopes_supported'); + }); +}); + +describe('rewriteAsMetadata (R3)', () => { + const asMeta: JsonObject = { + issuer: 'https://idp.example.com/realms/x', + authorization_endpoint: 'https://idp.example.com/realms/x/authorize', + token_endpoint: 'https://idp.example.com/realms/x/token', + registration_endpoint: 'https://idp.example.com/realms/x/register', + revocation_endpoint: 'https://idp.example.com/realms/x/revoke', + jwks_uri: 'https://idp.example.com/realms/x/jwks', + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true, + }; + + it('maps issuer + endpoints to the proxy, keeps jwks_uri upstream, preserves PKCE, deletes CIMD flag', () => { + const { value, rewrote } = rewriteAsMetadata(asMeta, { ...ctx, upstreamHasRevocation: true }); + expect(value.issuer).toBe('http://127.0.0.1:42800/as/radar'); + expect(value.authorization_endpoint).toBe('http://127.0.0.1:42800/as/radar/authorize'); + expect(value.token_endpoint).toBe('http://127.0.0.1:42800/as/radar/token'); + expect(value.registration_endpoint).toBe('http://127.0.0.1:42800/as/radar/register'); + expect(value.revocation_endpoint).toBe('http://127.0.0.1:42800/as/radar/revoke'); + expect(value.jwks_uri).toBe('https://idp.example.com/realms/x/jwks'); + expect(value.code_challenge_methods_supported).toEqual(['S256']); + expect(value).not.toHaveProperty('client_id_metadata_document_supported'); + expect(rewrote).toContain('client_id_metadata_document_supported'); + }); + + it('drops revocation_endpoint when the upstream advertises none', () => { + const { value } = rewriteAsMetadata(asMeta, { ...ctx, upstreamHasRevocation: false }); + expect(value).not.toHaveProperty('revocation_endpoint'); + }); + + it('overrides scopes_supported only when configured', () => { + const { value } = rewriteAsMetadata( + { ...asMeta, scopes_supported: ['upstream-scope'] }, + { ...ctxScoped, upstreamHasRevocation: false } + ); + expect(value.scopes_supported).toEqual(['openid', 'mcp:access']); + }); +}); + +describe('rewriteRegistrationBody (R4)', () => { + it('overrides client_name and injects scope, preserving redirect_uris and grant_types', () => { + const body: JsonObject = { + client_name: 'Claude Code', + redirect_uris: ['http://localhost:12345/callback'], + grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'none', + }; + const { value, rewrote } = rewriteRegistrationBody(body, { + clientName: 'EPAM Approved MCP Client', + scopes: ['openid'], + }); + expect(value.client_name).toBe('EPAM Approved MCP Client'); + expect(value.scope).toBe('openid'); + expect(value.redirect_uris).toEqual(['http://localhost:12345/callback']); + expect(value.grant_types).toEqual(['authorization_code', 'refresh_token']); + expect(value.token_endpoint_auth_method).toBe('none'); + expect(rewrote).toEqual(['client_name', 'scope']); + }); + + it('passes everything through untouched when nothing is configured', () => { + const body: JsonObject = { client_name: 'Claude Code', scope: 'claudeai' }; + const { value, rewrote } = rewriteRegistrationBody(body, {}); + expect(value).toEqual(body); + expect(rewrote).toEqual([]); + }); +}); + +describe('rewriteAuthorizeQuery (R5)', () => { + it('sets resource to the upstream canonical URI and overrides scope, keeping PKCE/state', () => { + const query = new URLSearchParams({ + response_type: 'code', + client_id: 'abc', + redirect_uri: 'http://localhost:1/cb', + state: 's1', + code_challenge: 'cc', + code_challenge_method: 'S256', + scope: 'claudeai', + resource: 'http://127.0.0.1:42800/radar', + }); + const { value, rewrote } = rewriteAuthorizeQuery(query, { + scopes: ['openid'], + upstreamResource: 'https://mcp.example.com/mcp/radar', + }); + expect(value.get('resource')).toBe('https://mcp.example.com/mcp/radar'); + expect(value.get('scope')).toBe('openid'); + expect(value.get('code_challenge')).toBe('cc'); + expect(value.get('code_challenge_method')).toBe('S256'); + expect(value.get('state')).toBe('s1'); + expect(value.get('client_id')).toBe('abc'); + expect(rewrote).toEqual(['resource', 'scope']); + }); + + it('leaves scope alone when not configured and injects resource when absent', () => { + const query = new URLSearchParams({ scope: 'claudeai' }); + const { value, rewrote } = rewriteAuthorizeQuery(query, { + upstreamResource: 'https://mcp.example.com/mcp/radar', + }); + expect(value.get('scope')).toBe('claudeai'); + expect(value.get('resource')).toBe('https://mcp.example.com/mcp/radar'); + expect(rewrote).toEqual(['resource']); + }); +}); + +describe('rewriteTokenBody (R6)', () => { + it('rewrites resource and existing scope for authorization_code grants', () => { + const form = new URLSearchParams({ + grant_type: 'authorization_code', + code: 'c', + code_verifier: 'v', + client_id: 'abc', + redirect_uri: 'http://localhost:1/cb', + resource: 'http://127.0.0.1:42800/radar', + scope: 'claudeai', + }); + const { value, rewrote } = rewriteTokenBody(form, { + scopes: ['openid'], + upstreamResource: 'https://mcp.example.com/mcp/radar', + }); + expect(value.get('resource')).toBe('https://mcp.example.com/mcp/radar'); + expect(value.get('scope')).toBe('openid'); + expect(value.get('code')).toBe('c'); + expect(value.get('code_verifier')).toBe('v'); + expect(rewrote).toEqual(['resource', 'scope']); + }); + + it('does not inject scope into a refresh_token grant that has none', () => { + const form = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: 'r' }); + const { value, rewrote } = rewriteTokenBody(form, { + scopes: ['openid'], + upstreamResource: 'https://mcp.example.com/mcp/radar', + }); + expect(value.has('scope')).toBe(false); + expect(value.get('refresh_token')).toBe('r'); + expect(rewrote).toEqual(['resource']); + }); +}); +``` + +- [ ] **Step 2: Run the test — verify it fails** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/rewrites.test.ts` +Expected: FAIL — `Cannot find module '../rewrites.js'`. + +- [ ] **Step 3: Write `src/mcp/auth-proxy/rewrites.ts`** + +```ts +/** + * MCP Auth Proxy — pure rewrite rules R1–R6 (docs/SPEC-mcp-auth-proxy.md § Rewrite Rules). + * + * No I/O: every function maps (upstream artifact, context) → rewritten artifact plus the + * NAMES of rewritten fields (names only — values are never logged). + */ +import type { JsonObject, RewriteContext, RewriteResult } from './types.js'; + +function proxyPrmUrl(ctx: RewriteContext): string { + return `${ctx.proxyOrigin}/.well-known/oauth-protected-resource/${ctx.routeId}`; +} + +function proxyIssuer(ctx: RewriteContext): string { + return `${ctx.proxyOrigin}/as/${ctx.routeId}`; +} + +/** + * R1 — WWW-Authenticate challenge on 401 / 403(insufficient_scope): point + * resource_metadata at the proxy PRM and (if configured) set the authoritative scope. + * Injects a full challenge when the upstream sent none, so discovery always lands on + * the proxy. All other challenge params pass through. + */ +export function rewriteChallengeHeader( + header: string | undefined, + ctx: RewriteContext +): RewriteResult { + const rewrote: string[] = ['resource_metadata']; + const scopeValue = ctx.scopes?.join(' '); + const prmParam = `resource_metadata="${proxyPrmUrl(ctx)}"`; + + const bearerMatch = header === undefined ? null : /^\s*Bearer\b\s*(.*)$/i.exec(header); + if (!bearerMatch) { + const params = [prmParam]; + if (scopeValue !== undefined) { + params.push(`scope="${scopeValue}"`); + rewrote.push('scope'); + } + return { value: `Bearer ${params.join(', ')}`, rewrote }; + } + + let params = bearerMatch[1].trim(); + if (/resource_metadata\s*=\s*"[^"]*"/i.test(params)) { + params = params.replace(/resource_metadata\s*=\s*"[^"]*"/i, prmParam); + } else { + params = params.length > 0 ? `${prmParam}, ${params}` : prmParam; + } + + if (scopeValue !== undefined) { + if (/scope\s*=\s*"[^"]*"/i.test(params)) { + params = params.replace(/scope\s*=\s*"[^"]*"/i, `scope="${scopeValue}"`); + } else { + params = `${params}, scope="${scopeValue}"`; + } + rewrote.push('scope'); + } + + return { value: `Bearer ${params}`, rewrote }; +} + +/** + * R2 — Protected Resource Metadata: resource → proxy MCP URL (no trailing slash), + * authorization_servers → proxy AS issuer, scopes_supported → configured scopes (only + * when configured). All other fields pass through. + */ +export function rewritePrm(prm: JsonObject, ctx: RewriteContext): RewriteResult { + const rewrote = ['resource', 'authorization_servers']; + const value: JsonObject = { + ...prm, + resource: `${ctx.proxyOrigin}/${ctx.routeId}`, + authorization_servers: [proxyIssuer(ctx)], + }; + if (ctx.scopes !== undefined) { + value.scopes_supported = [...ctx.scopes]; + rewrote.push('scopes_supported'); + } + return { value, rewrote }; +} + +/** + * R3 — AS metadata: issuer/authorize/token/register (and revoke, when the upstream has + * one) → proxy endpoints; client_id_metadata_document_supported deleted (forces the DCR + * path — spec fact 3); code_challenge_methods_supported and every other field (jwks_uri, + * grant types, auth methods, …) pass through with their real upstream values. + */ +export function rewriteAsMetadata( + asMetadata: JsonObject, + ctx: RewriteContext & { upstreamHasRevocation: boolean } +): RewriteResult { + const issuer = proxyIssuer(ctx); + const rewrote = ['issuer', 'authorization_endpoint', 'token_endpoint', 'registration_endpoint']; + const value: JsonObject = { + ...asMetadata, + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + registration_endpoint: `${issuer}/register`, + }; + if (ctx.upstreamHasRevocation) { + value.revocation_endpoint = `${issuer}/revoke`; + rewrote.push('revocation_endpoint'); + } else { + delete value.revocation_endpoint; + } + if ('client_id_metadata_document_supported' in value) { + delete value.client_id_metadata_document_supported; + rewrote.push('client_id_metadata_document_supported'); + } + if (ctx.scopes !== undefined) { + value.scopes_supported = [...ctx.scopes]; + rewrote.push('scopes_supported'); + } + return { value, rewrote }; +} + +/** + * R4 — Dynamic Client Registration body: client_name / scope overrides (scope injected + * when absent). Everything else — redirect_uris, grant_types, response_types, + * token_endpoint_auth_method, … — passes through untouched. + */ +export function rewriteRegistrationBody( + body: JsonObject, + ctx: { clientName?: string; scopes?: string[] } +): RewriteResult { + const rewrote: string[] = []; + const value: JsonObject = { ...body }; + if (ctx.clientName !== undefined) { + value.client_name = ctx.clientName; + rewrote.push('client_name'); + } + if (ctx.scopes !== undefined) { + value.scope = ctx.scopes.join(' '); + rewrote.push('scope'); + } + return { value, rewrote }; +} + +/** + * R5 — authorization redirect query: resource → upstream canonical URI (RFC 8707; + * set even when absent so issued tokens are audience-bound upstream), scope override + * only when configured. client_id, redirect_uri, state, PKCE params, and unknown + * params pass through untouched. + */ +export function rewriteAuthorizeQuery( + query: URLSearchParams, + ctx: { scopes?: string[]; upstreamResource: string } +): RewriteResult { + const rewrote = ['resource']; + const value = new URLSearchParams(query); + value.set('resource', ctx.upstreamResource); + if (ctx.scopes !== undefined) { + value.set('scope', ctx.scopes.join(' ')); + rewrote.push('scope'); + } + return { value, rewrote }; +} + +/** + * R6 — token exchange form body (all grant types): resource → upstream canonical URI; + * scope rewritten only when the field is present AND scopes are configured. code, + * code_verifier, client_id, redirect_uri, refresh_token pass through untouched. + */ +export function rewriteTokenBody( + form: URLSearchParams, + ctx: { scopes?: string[]; upstreamResource: string } +): RewriteResult { + const rewrote = ['resource']; + const value = new URLSearchParams(form); + value.set('resource', ctx.upstreamResource); + if (ctx.scopes !== undefined && value.has('scope')) { + value.set('scope', ctx.scopes.join(' ')); + rewrote.push('scope'); + } + return { value, rewrote }; +} +``` + +- [ ] **Step 4: Run the test — verify it passes** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/rewrites.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/auth-proxy/rewrites.ts src/mcp/auth-proxy/__tests__/rewrites.test.ts +git commit -m "feat(proxy): add pure OAuth rewrite rules R1-R6 for mcp-auth-proxy" +``` + +--- + +### Task 3: Daemon state file helpers + +**Test-first: yes — atomic round-trip, malformed-file null, clear idempotency, pid liveness; fails until `state.ts` exists.** + +**Files:** +- Create: `src/mcp/auth-proxy/state.ts` +- Test: `src/mcp/auth-proxy/__tests__/state.test.ts` + +- [ ] **Step 1: Write the failing test `src/mcp/auth-proxy/__tests__/state.test.ts`** + +Note: all helpers take an explicit `stateFile` path (defaulting to the codemie-home path at +call time), so tests pass tmp paths directly — no `vi.mock` of paths needed. + +```ts +/** + * mcp-auth-proxy daemon state file tests + * @group unit + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { unlink, writeFile } from 'node:fs/promises'; +import { + clearAuthProxyState, + isProcessAlive, + readAuthProxyState, + writeAuthProxyState, +} from '../state.js'; +import type { AuthProxyDaemonState } from '../types.js'; + +const stateFile = join(tmpdir(), `mcp-auth-proxy-state-test-${process.pid}-${Date.now()}.json`); +const state: AuthProxyDaemonState = { + pid: process.pid, + port: 42800, + routes: ['radar', 'other'], + startedAt: '2026-07-03T00:00:00Z', +}; + +afterEach(async () => { + try { + await unlink(stateFile); + } catch { + // already gone + } +}); + +describe('auth proxy state file', () => { + it('round-trips state atomically (no .tmp file left behind)', async () => { + await writeAuthProxyState(state, stateFile); + expect(existsSync(`${stateFile}.tmp`)).toBe(false); + expect(await readAuthProxyState(stateFile)).toEqual(state); + }); + + it('returns null for a missing, malformed, or wrong-shaped state file', async () => { + expect(await readAuthProxyState(stateFile)).toBeNull(); + await writeFile(stateFile, 'not-json', 'utf-8'); + expect(await readAuthProxyState(stateFile)).toBeNull(); + await writeFile(stateFile, '{"pid":"x"}', 'utf-8'); + expect(await readAuthProxyState(stateFile)).toBeNull(); + }); + + it('clearAuthProxyState removes the file and tolerates absence', async () => { + await writeAuthProxyState(state, stateFile); + await clearAuthProxyState(stateFile); + expect(existsSync(stateFile)).toBe(false); + await expect(clearAuthProxyState(stateFile)).resolves.toBeUndefined(); + }); + + it('isProcessAlive: true for this process, false for a dead pid', () => { + expect(isProcessAlive(process.pid)).toBe(true); + expect(isProcessAlive(2 ** 30)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run the test — verify it fails** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/state.test.ts` +Expected: FAIL — `Cannot find module '../state.js'`. + +- [ ] **Step 3: Write `src/mcp/auth-proxy/state.ts`** + +Pattern-mirror of `src/cli/commands/proxy/daemon-manager.ts` (atomic tmp+rename writes, +defensive reads, signal-0 liveness) for the auth-proxy state schema. Default paths are +resolved per call so `CODEMIE_HOME` is honored at call time. + +```ts +/** + * MCP Auth Proxy — daemon state file helpers. + * + * Atomic tmp+rename writes and defensive reads, mirroring the SSO proxy's + * daemon-manager pattern for the auth-proxy state schema {pid, port, routes, startedAt}. + */ +import { readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { getDefaultStatePath } from './config.js'; +import type { AuthProxyDaemonState } from './types.js'; + +export async function readAuthProxyState( + stateFile: string = getDefaultStatePath() +): Promise { + try { + const raw = await readFile(stateFile, 'utf-8'); + const parsed = JSON.parse(raw) as AuthProxyDaemonState; + if (typeof parsed.pid !== 'number' || typeof parsed.port !== 'number') { + return null; + } + return parsed; + } catch { + return null; + } +} + +export async function writeAuthProxyState( + state: AuthProxyDaemonState, + stateFile: string = getDefaultStatePath() +): Promise { + const tmp = `${stateFile}.tmp`; + await writeFile(tmp, JSON.stringify(state, null, 2), 'utf-8'); + await rename(tmp, stateFile); +} + +export async function clearAuthProxyState( + stateFile: string = getDefaultStatePath() +): Promise { + try { + await unlink(stateFile); + } catch { + // Already gone — no-op + } +} + +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} +``` + +- [ ] **Step 4: Run the test — verify it passes** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/state.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/auth-proxy/state.ts src/mcp/auth-proxy/__tests__/state.test.ts +git commit -m "feat(proxy): add mcp-auth-proxy daemon state file helpers" +``` + +--- + +### Task 4: Upstream HTTP client + +**Test-first: no — thin I/O wrapper mirroring the proven `proxy-http-client.ts` template; its forwarding, streaming, and fetchJson behavior is exercised end-to-end by Task 6's server tests against a real local upstream.** + +**Files:** +- Create: `src/mcp/auth-proxy/upstream-client.ts` + +- [ ] **Step 1: Write `src/mcp/auth-proxy/upstream-client.ts`** + +Differences from the SSO template, on purpose: TLS verification stays ON +(`rejectUnauthorized` defaults to `true` — this client relays OAuth traffic; corporate +CAs go through `NODE_EXTRA_CA_CERTS`), and `begin()` exposes the `ClientRequest` so the +server can propagate client aborts (spec R1). + +```ts +/** + * MCP Auth Proxy — outbound HTTP client. + * + * Streaming forward for the MCP pass-through (no buffering, abort propagation) plus a + * small buffered fetchJson for OAuth metadata discovery. Honors HTTP(S)_PROXY env like + * the SSO proxy's outbound client. TLS verification is intentionally ON: this client + * relays OAuth traffic to the enterprise IdP. + */ +import http from 'node:http'; +import https from 'node:https'; +import { pipeline } from 'node:stream/promises'; +import type { Readable } from 'node:stream'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import { logger } from '../../utils/logger.js'; +import type { JsonObject } from './types.js'; + +const FETCH_JSON_TIMEOUT_MS = 5000; +const FETCH_JSON_MAX_BYTES = 256 * 1024; + +function getProxyEnvUrl(protocol: string): string | undefined { + if (protocol === 'https:') { + return ( + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy + ); + } + return process.env.HTTP_PROXY || process.env.http_proxy; +} + +export interface BeginOptions { + method: string; + headers: http.OutgoingHttpHeaders; + /** Streaming request body (MCP pass-through). Mutually exclusive with `body`. */ + bodyStream?: Readable; + /** Buffered request body (rewritten OAuth payloads). */ + body?: Buffer; +} + +export interface UpstreamExchange { + request: http.ClientRequest; + response: Promise; +} + +export class UpstreamClient { + private readonly httpsAgent: https.Agent; + private readonly httpAgent: http.Agent; + + constructor() { + const agentOptions = { keepAlive: true, maxSockets: 50 }; + const httpsProxyUrl = getProxyEnvUrl('https:'); + const httpProxyUrl = getProxyEnvUrl('http:'); + this.httpsAgent = httpsProxyUrl + ? new HttpsProxyAgent(httpsProxyUrl, agentOptions) + : new https.Agent(agentOptions); + this.httpAgent = httpProxyUrl + ? new HttpProxyAgent(httpProxyUrl, agentOptions) + : new http.Agent(agentOptions); + if (httpsProxyUrl || httpProxyUrl) { + logger.debug('[mcp-auth-proxy] Using corporate proxy from environment for upstream calls'); + } + } + + /** + * Open an upstream request. `response` rejects on network errors; callers destroy + * `request` to propagate client aborts. Timeout 0 — MCP SSE streams are long-lived. + */ + begin(url: URL, options: BeginOptions): UpstreamExchange { + const isHttps = url.protocol === 'https:'; + const protocol = isHttps ? https : http; + const agent = isHttps ? this.httpsAgent : this.httpAgent; + + let request!: http.ClientRequest; + const response = new Promise((resolve, reject) => { + request = protocol.request( + { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname + url.search, + method: options.method, + headers: options.headers, + agent, + timeout: 0, + }, + resolve + ); + request.on('error', reject); + }); + + if (options.bodyStream) { + pipeline(options.bodyStream, request).catch((error: unknown) => { + request.destroy(error instanceof Error ? error : new Error(String(error))); + }); + } else if (options.body !== undefined) { + request.end(options.body); + } else { + request.end(); + } + + return { request, response }; + } + + /** Buffered GET returning parsed JSON. Non-2xx, oversized, or non-object → throws. */ + async fetchJson(url: string): Promise { + const target = new URL(url); + const { request, response } = this.begin(target, { + method: 'GET', + headers: { accept: 'application/json' }, + }); + const timer = setTimeout( + () => request.destroy(new Error(`Timed out fetching metadata from ${target.host}`)), + FETCH_JSON_TIMEOUT_MS + ); + try { + const res = await response; + const status = res.statusCode ?? 0; + if (status < 200 || status >= 300) { + res.resume(); + throw new Error(`GET ${target.host}${target.pathname} returned ${status}`); + } + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of res) { + const buf = Buffer.from(chunk as Buffer); + size += buf.length; + if (size > FETCH_JSON_MAX_BYTES) { + res.destroy(); + throw new Error(`Metadata document from ${target.host} exceeds ${FETCH_JSON_MAX_BYTES} bytes`); + } + chunks.push(buf); + } + const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf-8')); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`Metadata document from ${target.host} is not a JSON object`); + } + return parsed as JsonObject; + } finally { + clearTimeout(timer); + } + } + + close(): void { + this.httpsAgent.destroy(); + this.httpAgent.destroy(); + } +} +``` + +- [ ] **Step 2: Verify it typechecks** + +Run: `npm run typecheck` +Expected: PASS (no errors). + +- [ ] **Step 3: Commit** + +```bash +git add src/mcp/auth-proxy/upstream-client.ts +git commit -m "feat(proxy): add streaming upstream HTTP client for mcp-auth-proxy" +``` + +--- + +### Task 5: Metadata discovery + TTL cache + +**Test-first: yes — discovery priority order, 401-hint preference, positive TTL, 10 s negative cache with per-route isolation, multi-AS warning, missing registration_endpoint failure; fails until `metadata-cache.ts` exists.** + +**Files:** +- Create: `src/mcp/auth-proxy/metadata-cache.ts` +- Test: `src/mcp/auth-proxy/__tests__/metadata-cache.test.ts` + +- [ ] **Step 1: Write the failing test `src/mcp/auth-proxy/__tests__/metadata-cache.test.ts`** + +```ts +/** + * mcp-auth-proxy metadata discovery + cache tests (fetchJson injected — no network). + * @group unit + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + METADATA_TTL_MS, + NEGATIVE_TTL_MS, + MetadataCache, + MetadataDiscoveryError, +} from '../metadata-cache.js'; +import { logger } from '../../../utils/logger.js'; +import type { JsonObject, RouteConfig } from '../types.js'; + +const route: RouteConfig = { upstreamUrl: 'https://mcp.example.com/mcp/radar' }; + +const PRM: JsonObject = { + resource: 'https://mcp.example.com/mcp/radar/', + authorization_servers: ['https://idp.example.com/realms/x'], +}; +const AS: JsonObject = { + issuer: 'https://idp.example.com/realms/x', + authorization_endpoint: 'https://idp.example.com/a', + token_endpoint: 'https://idp.example.com/t', + registration_endpoint: 'https://idp.example.com/r', +}; + +const PRM_PATH_URL = 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp/radar'; +const AS_8414_URL = 'https://idp.example.com/.well-known/oauth-authorization-server/realms/x'; + +function fakeFetcher(docs: Record): { + calls: string[]; + fetchJson: (url: string) => Promise; +} { + const calls: string[] = []; + return { + calls, + fetchJson: (url: string): Promise => { + calls.push(url); + const doc = docs[url]; + return doc !== undefined ? Promise.resolve(doc) : Promise.reject(new Error(`404 ${url}`)); + }, + }; +} + +const HAPPY_DOCS: Record = { + [PRM_PATH_URL]: PRM, + [AS_8414_URL]: AS, +}; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('MetadataCache', () => { + it('discovers PRM path-variant first and AS metadata in the spec priority order', async () => { + const { calls, fetchJson } = fakeFetcher(HAPPY_DOCS); + const cache = new MetadataCache(fetchJson); + const meta = await cache.getMetadata('radar', route); + expect(calls[0]).toBe(PRM_PATH_URL); + expect(calls[1]).toBe(AS_8414_URL); + expect(meta.upstreamResource).toBe('https://mcp.example.com/mcp/radar'); + expect(meta.registrationEndpoint).toBe('https://idp.example.com/r'); + expect(meta.authorizationEndpoint).toBe('https://idp.example.com/a'); + expect(meta.tokenEndpoint).toBe('https://idp.example.com/t'); + expect(meta.revocationEndpoint).toBeUndefined(); + expect(cache.getStatus('radar')).toBe('ok'); + }); + + it('falls back to the root PRM variant and the OIDC path-appending AS variant', async () => { + const docs: Record = { + 'https://mcp.example.com/.well-known/oauth-protected-resource': PRM, + 'https://idp.example.com/realms/x/.well-known/openid-configuration': AS, + }; + const { calls, fetchJson } = fakeFetcher(docs); + const cache = new MetadataCache(fetchJson); + await cache.getMetadata('radar', route); + expect(calls).toContain('https://mcp.example.com/.well-known/oauth-protected-resource'); + const insertion = calls.indexOf(AS_8414_URL); + const oidcInsertion = calls.indexOf( + 'https://idp.example.com/.well-known/openid-configuration/realms/x' + ); + const appending = calls.indexOf( + 'https://idp.example.com/realms/x/.well-known/openid-configuration' + ); + expect(insertion).toBeGreaterThanOrEqual(0); + expect(insertion).toBeLessThan(oidcInsertion); + expect(oidcInsertion).toBeLessThan(appending); + }); + + it('prefers a PRM URL captured from a live 401 challenge', async () => { + const hinted = 'https://mcp.example.com/custom/prm-location'; + const { calls, fetchJson } = fakeFetcher({ ...HAPPY_DOCS, [hinted]: PRM }); + const cache = new MetadataCache(fetchJson); + cache.notePrmUrl('radar', hinted); + await cache.getMetadata('radar', route); + expect(calls[0]).toBe(hinted); + }); + + it('caches positive results for the TTL and refreshes after expiry', async () => { + const { calls, fetchJson } = fakeFetcher(HAPPY_DOCS); + const cache = new MetadataCache(fetchJson); + await cache.getMetadata('radar', route); + await cache.getMetadata('radar', route); + expect(calls.length).toBe(2); + vi.advanceTimersByTime(METADATA_TTL_MS + 1); + await cache.getMetadata('radar', route); + expect(calls.length).toBe(4); + }); + + it('negative-caches failures for 10 s, retries after, and keeps failures per-route', async () => { + const { calls, fetchJson } = fakeFetcher({}); + const cache = new MetadataCache(fetchJson); + await expect(cache.getMetadata('radar', route)).rejects.toThrow(MetadataDiscoveryError); + expect(cache.getStatus('radar')).toBe('degraded'); + const callsAfterFirst = calls.length; + await expect(cache.getMetadata('radar', route)).rejects.toThrow(MetadataDiscoveryError); + expect(calls.length).toBe(callsAfterFirst); + expect(cache.getStatus('other')).toBe('unknown'); + vi.advanceTimersByTime(NEGATIVE_TTL_MS + 1); + await expect(cache.getMetadata('radar', route)).rejects.toThrow(MetadataDiscoveryError); + expect(calls.length).toBeGreaterThan(callsAfterFirst); + }); + + it('warns and uses the first AS when the upstream PRM lists several', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const docs: Record = { + [PRM_PATH_URL]: { + ...PRM, + authorization_servers: ['https://idp.example.com/realms/x', 'https://other.example'], + }, + [AS_8414_URL]: AS, + }; + const cache = new MetadataCache(fakeFetcher(docs).fetchJson); + const meta = await cache.getMetadata('radar', route); + expect(meta.tokenEndpoint).toBe('https://idp.example.com/t'); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('fails when the AS advertises no registration_endpoint (DCR is mandatory)', async () => { + const asWithoutRegistration: JsonObject = { ...AS }; + delete asWithoutRegistration.registration_endpoint; + const docs: Record = { [PRM_PATH_URL]: PRM, [AS_8414_URL]: asWithoutRegistration }; + const cache = new MetadataCache(fakeFetcher(docs).fetchJson); + await expect(cache.getMetadata('radar', route)).rejects.toThrow(/registration_endpoint/); + }); + + it('fails when the PRM has no authorization_servers', async () => { + const docs: Record = { [PRM_PATH_URL]: { resource: 'x' } }; + const cache = new MetadataCache(fakeFetcher(docs).fetchJson); + await expect(cache.getMetadata('radar', route)).rejects.toThrow(/authorization_servers/); + }); +}); +``` + +- [ ] **Step 2: Run the test — verify it fails** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/metadata-cache.test.ts` +Expected: FAIL — `Cannot find module '../metadata-cache.js'`. + +- [ ] **Step 3: Write `src/mcp/auth-proxy/metadata-cache.ts`** + +```ts +/** + * MCP Auth Proxy — per-route upstream OAuth metadata discovery + TTL cache. + * + * Discovery chain per the MCP Authorization spec (rev 2025-11-25): upstream PRM + * (path-aware well-known, then root, plus any resource_metadata URL captured from a live + * upstream 401), then AS metadata from the PRM's first authorization server, trying the + * spec's well-known variants in priority order (RFC 8414 path-insertion → OIDC + * path-insertion → OIDC path-appending). Positive TTL 300 s; failures negative-cached + * 10 s so a degraded route retries quickly without hammering the upstream. Route + * failures are fully isolated — one degraded route never affects another. + */ +import { CodeMieError } from '../../utils/errors.js'; +import { logger } from '../../utils/logger.js'; +import type { JsonObject, RouteConfig, RouteStatus, UpstreamMetadata } from './types.js'; + +export const METADATA_TTL_MS = 300_000; +export const NEGATIVE_TTL_MS = 10_000; + +export class MetadataDiscoveryError extends CodeMieError { + constructor(routeId: string, reason: string) { + super(`Metadata discovery failed for route "${routeId}": ${reason}`); + this.name = 'MetadataDiscoveryError'; + } +} + +export type FetchJson = (url: string) => Promise; + +interface CacheEntry { + metadata: UpstreamMetadata; + expiresAt: number; +} + +interface FailureEntry { + reason: string; + expiresAt: number; +} + +export class MetadataCache { + private readonly entries = new Map(); + private readonly failures = new Map(); + private readonly prmUrlHints = new Map(); + + constructor(private readonly fetchJson: FetchJson) {} + + /** R2: remember a resource_metadata URL observed on a live upstream 401. */ + notePrmUrl(routeId: string, url: string): void { + try { + new URL(url); + this.prmUrlHints.set(routeId, url); + } catch { + // Malformed hint — ignore. + } + } + + getStatus(routeId: string): RouteStatus { + if (this.entries.has(routeId)) { + return 'ok'; + } + const failure = this.failures.get(routeId); + if (failure !== undefined && failure.expiresAt > Date.now()) { + return 'degraded'; + } + return 'unknown'; + } + + async getMetadata(routeId: string, route: RouteConfig): Promise { + const cached = this.entries.get(routeId); + if (cached !== undefined && cached.expiresAt > Date.now()) { + return cached.metadata; + } + if (cached !== undefined) { + this.entries.delete(routeId); + } + + const failure = this.failures.get(routeId); + if (failure !== undefined && failure.expiresAt > Date.now()) { + throw new MetadataDiscoveryError(routeId, failure.reason); + } + + try { + const metadata = await this.discover(routeId, route); + this.entries.set(routeId, { metadata, expiresAt: Date.now() + METADATA_TTL_MS }); + this.failures.delete(routeId); + return metadata; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.failures.set(routeId, { reason, expiresAt: Date.now() + NEGATIVE_TTL_MS }); + throw error instanceof MetadataDiscoveryError + ? error + : new MetadataDiscoveryError(routeId, reason); + } + } + + private async discover(routeId: string, route: RouteConfig): Promise { + const upstream = new URL(route.upstreamUrl); + const upstreamPath = upstream.pathname.replace(/\/+$/, ''); + + const prmCandidates: string[] = []; + const hint = this.prmUrlHints.get(routeId); + if (hint !== undefined) { + prmCandidates.push(hint); + } + if (upstreamPath !== '' && upstreamPath !== '/') { + prmCandidates.push(`${upstream.origin}/.well-known/oauth-protected-resource${upstreamPath}`); + } + prmCandidates.push(`${upstream.origin}/.well-known/oauth-protected-resource`); + + const prm = await this.firstJson(prmCandidates, routeId, 'protected resource metadata'); + + const authServers = prm.authorization_servers; + if (!Array.isArray(authServers) || authServers.length === 0 || typeof authServers[0] !== 'string') { + throw new MetadataDiscoveryError(routeId, 'upstream PRM has no authorization_servers'); + } + if (authServers.length > 1) { + logger.warn( + `[mcp-auth-proxy] Route "${routeId}": upstream lists ${authServers.length} authorization servers; using the first (v1 limitation)` + ); + } + + const issuer = new URL(authServers[0]); + const issuerPath = issuer.pathname.replace(/\/+$/, ''); + const asCandidates = + issuerPath !== '' && issuerPath !== '/' + ? [ + `${issuer.origin}/.well-known/oauth-authorization-server${issuerPath}`, + `${issuer.origin}/.well-known/openid-configuration${issuerPath}`, + `${issuer.origin}${issuerPath}/.well-known/openid-configuration`, + ] + : [ + `${issuer.origin}/.well-known/oauth-authorization-server`, + `${issuer.origin}/.well-known/openid-configuration`, + ]; + + const asMetadata = await this.firstJson(asCandidates, routeId, 'authorization server metadata'); + + const registrationEndpoint = asMetadata.registration_endpoint; + if (typeof registrationEndpoint !== 'string' || registrationEndpoint.length === 0) { + throw new MetadataDiscoveryError( + routeId, + 'upstream AS advertises no registration_endpoint — DCR is mandatory for this proxy' + ); + } + const authorizationEndpoint = asMetadata.authorization_endpoint; + const tokenEndpoint = asMetadata.token_endpoint; + if (typeof authorizationEndpoint !== 'string' || typeof tokenEndpoint !== 'string') { + throw new MetadataDiscoveryError( + routeId, + 'upstream AS metadata lacks authorization_endpoint/token_endpoint' + ); + } + + const upstreamResource = + typeof prm.resource === 'string' && prm.resource.length > 0 + ? prm.resource.replace(/\/+$/, '') + : route.upstreamUrl.replace(/\/+$/, ''); + + return { + prm, + asMetadata, + upstreamResource, + registrationEndpoint, + authorizationEndpoint, + tokenEndpoint, + ...(typeof asMetadata.revocation_endpoint === 'string' + ? { revocationEndpoint: asMetadata.revocation_endpoint } + : {}), + }; + } + + private async firstJson(candidates: string[], routeId: string, what: string): Promise { + let lastError = 'no candidate URLs'; + for (const candidate of candidates) { + try { + return await this.fetchJson(candidate); + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + throw new MetadataDiscoveryError(routeId, `could not fetch ${what}: ${lastError}`); + } +} +``` + +- [ ] **Step 4: Run the test — verify it passes** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/metadata-cache.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/auth-proxy/metadata-cache.ts src/mcp/auth-proxy/__tests__/metadata-cache.test.ts +git commit -m "feat(proxy): add upstream OAuth metadata discovery and TTL cache" +``` + +--- + +### Task 6: Proxy server (route dispatch + streaming pass-through + OAuth endpoints) + +**Test-first: yes — integration-style tests against a real local fake upstream (`node:http`): pass-through with auth, 401 challenge rewrite + injection, PRM/AS metadata rewrites on all three well-known variants, DCR/authorize/token rewrites observed at the upstream, SSE streaming, multi-route isolation with one dead upstream, 64 KB limit, healthz, root-PRM alias, unknown route; fails until `server.ts` exists.** + +**Files:** +- Create: `src/mcp/auth-proxy/server.ts` +- Test: `src/mcp/auth-proxy/__tests__/server.test.ts` + +Notes for this task: +- Tests build `AuthProxyConfig` object literals directly (bypassing `validateAuthProxyConfig`) so the fake upstream can use `http://127.0.0.1` and port `0` (ephemeral). Production configs are always validated before reaching `McpAuthProxy`. +- Requests to the proxy use global `fetch` — it ignores `HTTP_PROXY` env, which is exactly right for loopback tests. + +- [ ] **Step 1: Write the failing test `src/mcp/auth-proxy/__tests__/server.test.ts`** + +```ts +/** + * mcp-auth-proxy server tests — real HTTP against a local fake upstream (MCP RS + AS). + * @group unit + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { McpAuthProxy } from '../server.js'; +import type { AuthProxyConfig, JsonObject } from '../types.js'; + +interface FakeUpstream { + server: http.Server; + origin: string; + state: { lastRegisterBody?: JsonObject; lastTokenBody?: string; lastMcpAuth?: string }; +} + +function createFakeUpstream(): Promise { + const state: FakeUpstream['state'] = {}; + const server = http.createServer((req, res) => { + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const url = new URL(req.url ?? '/', origin); + const sendJson = (status: number, body: unknown): void => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + const readBody = async (): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.from(chunk as Buffer)); + } + return Buffer.concat(chunks).toString('utf-8'); + }; + + void (async (): Promise => { + if (url.pathname === '/mcp/radar' && req.method === 'POST') { + state.lastMcpAuth = req.headers.authorization; + if (req.headers.authorization) { + sendJson(200, { jsonrpc: '2.0', id: 1, result: 'ok' }); + } else { + res.writeHead(401, { + 'www-authenticate': `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp/radar", scope="upstream-scope"`, + }); + res.end(); + } + return; + } + if (url.pathname === '/mcp/radar/sse' && req.method === 'GET') { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.write('data: one\n\n'); + setTimeout(() => { + res.write('data: two\n\n'); + res.end(); + }, 20); + return; + } + if (url.pathname === '/mcp/naked') { + res.writeHead(401); + res.end(); + return; + } + if ( + url.pathname === '/.well-known/oauth-protected-resource/mcp/radar' || + url.pathname === '/.well-known/oauth-protected-resource/mcp/naked' + ) { + const resource = url.pathname.endsWith('naked') ? `${origin}/mcp/naked` : `${origin}/mcp/radar`; + sendJson(200, { + resource, + authorization_servers: [`${origin}/idp`], + scopes_supported: ['upstream-scope'], + }); + return; + } + if (url.pathname === '/.well-known/oauth-authorization-server/idp') { + sendJson(200, { + issuer: `${origin}/idp`, + authorization_endpoint: `${origin}/idp/authorize`, + token_endpoint: `${origin}/idp/token`, + registration_endpoint: `${origin}/idp/register`, + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true, + }); + return; + } + if (url.pathname === '/idp/register' && req.method === 'POST') { + state.lastRegisterBody = JSON.parse(await readBody()) as JsonObject; + sendJson(201, { client_id: `dyn-${Date.now()}`, client_name: state.lastRegisterBody.client_name }); + return; + } + if (url.pathname === '/idp/token' && req.method === 'POST') { + state.lastTokenBody = await readBody(); + sendJson(200, { access_token: 'tok-123', token_type: 'Bearer' }); + return; + } + sendJson(404, { error: 'not_found' }); + })(); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve({ + server, + origin: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + state, + }); + }); + }); +} + +describe('McpAuthProxy', () => { + let upstream: FakeUpstream; + let proxy: McpAuthProxy; + let proxyOrigin: string; + + beforeAll(async () => { + upstream = await createFakeUpstream(); + const config: AuthProxyConfig = { + port: 0, + servers: { + radar: { + upstreamUrl: `${upstream.origin}/mcp/radar`, + clientName: 'EPAM Approved MCP Client', + scopes: ['openid', 'mcp:access'], + }, + naked: { upstreamUrl: `${upstream.origin}/mcp/naked` }, + down: { upstreamUrl: 'http://127.0.0.1:1/mcp/down' }, + }, + }; + proxy = new McpAuthProxy(config); + const { url } = await proxy.start(); + proxyOrigin = url; + }); + + afterAll(async () => { + await proxy.stop(); + await new Promise((resolve) => upstream.server.close(() => resolve())); + }); + + it('streams MCP POSTs through untouched, passing Authorization along', async () => { + const res = await fetch(`${proxyOrigin}/radar`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Bearer tok-abc' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ jsonrpc: '2.0', id: 1, result: 'ok' }); + expect(upstream.state.lastMcpAuth).toBe('Bearer tok-abc'); + }); + + it('rewrites the 401 challenge to point at the proxy PRM with configured scope', async () => { + const res = await fetch(`${proxyOrigin}/radar`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + expect(res.status).toBe(401); + const challenge = res.headers.get('www-authenticate') ?? ''; + expect(challenge).toContain(`resource_metadata="${proxyOrigin}/.well-known/oauth-protected-resource/radar"`); + expect(challenge).toContain('scope="openid mcp:access"'); + expect(challenge).not.toContain('upstream-scope'); + }); + + it('injects a challenge when the upstream 401 carries none', async () => { + const res = await fetch(`${proxyOrigin}/naked`, { method: 'GET' }); + expect(res.status).toBe(401); + const challenge = res.headers.get('www-authenticate') ?? ''; + expect(challenge).toContain(`resource_metadata="${proxyOrigin}/.well-known/oauth-protected-resource/naked"`); + expect(challenge).not.toContain('scope='); + }); + + it('streams SSE responses through with the event-stream content type', async () => { + const res = await fetch(`${proxyOrigin}/radar/sse`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('text/event-stream'); + const body = await res.text(); + expect(body).toContain('data: one'); + expect(body).toContain('data: two'); + }); + + it('serves the rewritten PRM for a route', async () => { + const res = await fetch(`${proxyOrigin}/.well-known/oauth-protected-resource/radar`); + expect(res.status).toBe(200); + const prm = (await res.json()) as JsonObject; + expect(prm.resource).toBe(`${proxyOrigin}/radar`); + expect(prm.authorization_servers).toEqual([`${proxyOrigin}/as/radar`]); + expect(prm.scopes_supported).toEqual(['openid', 'mcp:access']); + }); + + it('serves identical rewritten AS metadata on all three well-known variants', async () => { + const variants = [ + `${proxyOrigin}/.well-known/oauth-authorization-server/as/radar`, + `${proxyOrigin}/.well-known/openid-configuration/as/radar`, + `${proxyOrigin}/as/radar/.well-known/openid-configuration`, + ]; + const bodies: JsonObject[] = []; + for (const variant of variants) { + const res = await fetch(variant); + expect(res.status).toBe(200); + bodies.push((await res.json()) as JsonObject); + } + for (const body of bodies) { + expect(body.issuer).toBe(`${proxyOrigin}/as/radar`); + expect(body.authorization_endpoint).toBe(`${proxyOrigin}/as/radar/authorize`); + expect(body.token_endpoint).toBe(`${proxyOrigin}/as/radar/token`); + expect(body.registration_endpoint).toBe(`${proxyOrigin}/as/radar/register`); + expect(body.code_challenge_methods_supported).toEqual(['S256']); + expect(body).not.toHaveProperty('client_id_metadata_document_supported'); + expect(body).not.toHaveProperty('revocation_endpoint'); + } + }); + + it('rewrites DCR bodies (client_name + injected scope) and relays the dynamic client_id', async () => { + const res = await fetch(`${proxyOrigin}/as/radar/register`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Claude Code', + redirect_uris: ['http://localhost:33418/callback'], + }), + }); + expect(res.status).toBe(201); + const registered = (await res.json()) as JsonObject; + expect(String(registered.client_id)).toMatch(/^dyn-/); + expect(upstream.state.lastRegisterBody?.client_name).toBe('EPAM Approved MCP Client'); + expect(upstream.state.lastRegisterBody?.scope).toBe('openid mcp:access'); + expect(upstream.state.lastRegisterBody?.redirect_uris).toEqual(['http://localhost:33418/callback']); + }); + + it('302-redirects authorize with rewritten scope + resource, preserving PKCE params', async () => { + const query = new URLSearchParams({ + response_type: 'code', + client_id: 'dyn-1', + redirect_uri: 'http://localhost:33418/callback', + state: 's1', + code_challenge: 'cc', + code_challenge_method: 'S256', + scope: 'claudeai', + resource: `${proxyOrigin}/radar`, + }); + const res = await fetch(`${proxyOrigin}/as/radar/authorize?${query.toString()}`, { + redirect: 'manual', + }); + expect(res.status).toBe(302); + const location = new URL(res.headers.get('location') ?? ''); + expect(`${location.origin}${location.pathname}`).toBe(`${upstream.origin}/idp/authorize`); + expect(location.searchParams.get('scope')).toBe('openid mcp:access'); + expect(location.searchParams.get('resource')).toBe(`${upstream.origin}/mcp/radar`); + expect(location.searchParams.get('code_challenge')).toBe('cc'); + expect(location.searchParams.get('state')).toBe('s1'); + }); + + it('rewrites token bodies (resource) and relays the token response verbatim', async () => { + const form = new URLSearchParams({ + grant_type: 'authorization_code', + code: 'auth-code-1', + code_verifier: 'verifier-1', + client_id: 'dyn-1', + redirect_uri: 'http://localhost:33418/callback', + resource: `${proxyOrigin}/radar`, + }); + const res = await fetch(`${proxyOrigin}/as/radar/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: form.toString(), + }); + expect(res.status).toBe(200); + expect(((await res.json()) as JsonObject).access_token).toBe('tok-123'); + const sent = new URLSearchParams(upstream.state.lastTokenBody ?? ''); + expect(sent.get('resource')).toBe(`${upstream.origin}/mcp/radar`); + expect(sent.get('code')).toBe('auth-code-1'); + expect(sent.get('code_verifier')).toBe('verifier-1'); + }); + + it('keeps routes isolated: a dead upstream 502s while others keep working', async () => { + const downRes = await fetch(`${proxyOrigin}/down`, { method: 'POST', body: '{}' }); + expect(downRes.status).toBe(502); + expect(await downRes.json()).toEqual({ error: 'upstream_unreachable', route: 'down' }); + + const okRes = await fetch(`${proxyOrigin}/radar`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Bearer tok-abc' }, + body: '{}', + }); + expect(okRes.status).toBe(200); + }); + + it('marks a route degraded in /healthz after failed discovery, without touching others', async () => { + const prmRes = await fetch(`${proxyOrigin}/.well-known/oauth-protected-resource/down`); + expect(prmRes.status).toBe(502); + + const health = await fetch(`${proxyOrigin}/healthz`); + expect(health.status).toBe(200); + const body = (await health.json()) as { status: string; routes: Array<{ id: string; status: string }> }; + expect(body.status).toBe('ok'); + expect(body.routes.find((r) => r.id === 'down')?.status).toBe('degraded'); + expect(body.routes.find((r) => r.id === 'radar')?.status).toBe('ok'); + }); + + it('rejects oversized register bodies with 413', async () => { + const res = await fetch(`${proxyOrigin}/as/radar/register`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ client_name: 'x'.repeat(65 * 1024) }), + }); + expect(res.status).toBe(413); + expect(await res.json()).toEqual({ error: 'payload_too_large' }); + }); + + it('404s unknown routes and the root PRM alias when multiple routes exist', async () => { + const unknown = await fetch(`${proxyOrigin}/nope`); + expect(unknown.status).toBe(404); + expect(await unknown.json()).toEqual({ error: 'unknown_route' }); + + const rootPrm = await fetch(`${proxyOrigin}/.well-known/oauth-protected-resource`); + expect(rootPrm.status).toBe(404); + }); +}); + +describe('McpAuthProxy single-route alias', () => { + it('serves the root PRM alias when exactly one route is configured', async () => { + const upstream = await createFakeUpstream(); + const config: AuthProxyConfig = { + port: 0, + servers: { radar: { upstreamUrl: `${upstream.origin}/mcp/radar` } }, + }; + const solo = new McpAuthProxy(config); + const { url } = await solo.start(); + try { + const res = await fetch(`${url}/.well-known/oauth-protected-resource`); + expect(res.status).toBe(200); + expect(((await res.json()) as JsonObject).resource).toBe(`${url}/radar`); + } finally { + await solo.stop(); + await new Promise((resolve) => upstream.server.close(() => resolve())); + } + }); +}); +``` + +- [ ] **Step 2: Run the test — verify it fails** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/server.test.ts` +Expected: FAIL — `Cannot find module '../server.js'`. + +- [ ] **Step 3: Write `src/mcp/auth-proxy/server.ts`** + +```ts +/** + * MCP Auth Proxy — HTTP server (docs/SPEC-mcp-auth-proxy.md § Route Map, rows 1–11). + * + * Loopback-only transparent proxy: streams MCP traffic to upstreams untouched (design + * D1: raw node:http + pipeline — no buffering, no body-parsing middleware) and serves + * per-route OAuth endpoints whose only job is the surgical rewrites in rewrites.ts. + * Stateless w.r.t. auth: no tokens, codes, or client records are ever held. + * + * Uniform log rule: method, route id, endpoint kind, status, duration, and rewritten + * field NAMES only — never headers, bodies, or query strings on OAuth routes. + */ +import http from 'node:http'; +import type { Socket } from 'node:net'; +import { pipeline } from 'node:stream/promises'; +import { ConfigurationError } from '../../utils/errors.js'; +import { logger } from '../../utils/logger.js'; +import { sanitizeLogArgs } from '../../utils/security.js'; +import { MetadataCache, MetadataDiscoveryError } from './metadata-cache.js'; +import { + rewriteAsMetadata, + rewriteAuthorizeQuery, + rewriteChallengeHeader, + rewritePrm, + rewriteRegistrationBody, + rewriteTokenBody, +} from './rewrites.js'; +import { UpstreamClient } from './upstream-client.js'; +import type { AuthProxyConfig, JsonObject, RewriteContext, RouteConfig } from './types.js'; + +// Bind to the literal IPv4 loopback, never 'localhost' (repo ADR: macOS resolves +// 'localhost' to ::1 only) and never a configurable host — the proxy relays bearer +// tokens and must not be network-exposed. +export const BIND_HOST = '127.0.0.1'; + +const OAUTH_BODY_LIMIT_BYTES = 64 * 1024; + +/** Hop-by-hop headers (RFC 9110 §7.6.1) — never forwarded in either direction. */ +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'transfer-encoding', + 'te', + 'upgrade', + 'trailer', + 'proxy-authenticate', + 'proxy-authorization', + 'proxy-connection', +]); + +class BodyTooLargeError extends Error {} + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + const payload = Buffer.from(JSON.stringify(body), 'utf-8'); + res.writeHead(status, { + 'content-type': 'application/json', + 'content-length': String(payload.length), + }); + res.end(payload); +} + +async function readBodyLimited(req: http.IncomingMessage, limit: number): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + const buf = Buffer.from(chunk as Buffer); + size += buf.length; + if (size > limit) { + throw new BodyTooLargeError(); + } + chunks.push(buf); + } + return Buffer.concat(chunks); +} + +function forwardHeaders(incoming: http.IncomingHttpHeaders, target: URL): http.OutgoingHttpHeaders { + const headers: http.OutgoingHttpHeaders = {}; + for (const [key, value] of Object.entries(incoming)) { + const lower = key.toLowerCase(); + if (HOP_BY_HOP_HEADERS.has(lower) || lower === 'host') { + continue; + } + if (value !== undefined) { + headers[lower] = value; + } + } + headers.host = target.host; + return headers; +} + +function copyResponseHeaders(upstream: http.IncomingMessage): http.OutgoingHttpHeaders { + const headers: http.OutgoingHttpHeaders = {}; + for (const [key, value] of Object.entries(upstream.headers)) { + if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase()) && value !== undefined) { + headers[key] = value; + } + } + return headers; +} + +function isUpstreamNetworkError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const code = (error as NodeJS.ErrnoException).code; + return code !== undefined || error.message.includes('Timed out') || error.message.includes('socket hang up'); +} + +export class McpAuthProxy { + private server?: http.Server; + private port: number; + private readonly sockets = new Set(); + private readonly client: UpstreamClient; + private readonly metadata: MetadataCache; + + constructor(private readonly config: AuthProxyConfig) { + this.port = config.port; + this.client = new UpstreamClient(); + this.metadata = new MetadataCache((url) => this.client.fetchJson(url)); + } + + get origin(): string { + return `http://${BIND_HOST}:${this.port}`; + } + + async start(): Promise<{ port: number; url: string }> { + if (this.server) { + throw new ConfigurationError('mcp-auth-proxy server is already running'); + } + const server = http.createServer((req, res) => { + void this.handleRequest(req, res); + }); + server.on('connection', (socket) => { + this.sockets.add(socket); + socket.on('close', () => this.sockets.delete(socket)); + }); + + await new Promise((resolve, reject) => { + server.once('error', (error: NodeJS.ErrnoException) => { + reject( + error.code === 'EADDRINUSE' + ? new ConfigurationError( + `Port ${this.port} is already in use — stop the other process or set a different "port" in mcp-auth-proxy.json` + ) + : error + ); + }); + server.listen(this.port, BIND_HOST, () => resolve()); + }); + + const address = server.address(); + if (address !== null && typeof address === 'object') { + this.port = address.port; + } + this.server = server; + logger.debug( + `[mcp-auth-proxy] Listening on ${this.origin} (routes: ${Object.keys(this.config.servers).join(', ')})` + ); + return { port: this.port, url: this.origin }; + } + + async stop(): Promise { + const server = this.server; + if (!server) { + return; + } + this.server = undefined; + await new Promise((resolve) => { + server.close(() => resolve()); + // Long-lived SSE sockets would otherwise keep close() pending forever. + for (const socket of this.sockets) { + socket.destroy(); + } + }); + this.client.close(); + } + + private ctx(routeId: string): RewriteContext { + return { + proxyOrigin: this.origin, + routeId, + scopes: this.config.servers[routeId]?.scopes, + }; + } + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const startedAt = Date.now(); + const url = new URL(req.url ?? '/', this.origin); + const segments = url.pathname.split('/').filter((segment) => segment.length > 0); + let kind = 'unknown'; + let routeId = ''; + + try { + if (req.method === 'GET' && url.pathname === '/healthz') { + kind = 'healthz'; + this.serveHealth(res); + } else if (segments[0] === '.well-known') { + [kind, routeId] = await this.handleWellKnown(req, res, segments); + } else if (segments[0] === 'as' && segments.length >= 2) { + routeId = segments[1]; + kind = await this.handleOAuth(req, res, url, segments); + } else if (segments.length >= 1 && this.config.servers[segments[0]] !== undefined) { + routeId = segments[0]; + kind = 'mcp'; + await this.passThrough(req, res, url, routeId); + } else { + sendJson(res, 404, { error: 'unknown_route' }); + } + } catch (error) { + this.handleError(res, routeId, error); + } + + logger.debug( + '[mcp-auth-proxy] request', + ...sanitizeLogArgs({ + method: req.method, + route: routeId || undefined, + kind, + status: res.statusCode, + durationMs: Date.now() - startedAt, + }) + ); + } + + // ── Route map rows 2–6: well-known documents ───────────────────────────── + + private async handleWellKnown( + req: http.IncomingMessage, + res: http.ServerResponse, + segments: string[] + ): Promise<[string, string]> { + if (req.method !== 'GET') { + sendJson(res, 404, { error: 'unknown_route' }); + return ['unknown', '']; + } + const doc = segments[1]; + + if (doc === 'oauth-protected-resource') { + if (segments.length === 3) { + await this.servePrm(res, segments[2]); + return ['prm', segments[2]]; + } + // Row 3: root alias only when exactly one route is configured. + const routeIds = Object.keys(this.config.servers); + if (segments.length === 2 && routeIds.length === 1) { + await this.servePrm(res, routeIds[0]); + return ['prm', routeIds[0]]; + } + sendJson(res, 404, { error: 'unknown_route' }); + return ['unknown', '']; + } + + if ( + (doc === 'oauth-authorization-server' || doc === 'openid-configuration') && + segments[2] === 'as' && + segments.length === 4 + ) { + await this.serveAsMetadata(res, segments[3]); + return ['as-metadata', segments[3]]; + } + + sendJson(res, 404, { error: 'unknown_route' }); + return ['unknown', '']; + } + + private async servePrm(res: http.ServerResponse, routeId: string): Promise { + const route = this.config.servers[routeId]; + if (!route) { + sendJson(res, 404, { error: 'unknown_route' }); + return; + } + const meta = await this.metadata.getMetadata(routeId, route); + const { value, rewrote } = rewritePrm(meta.prm, this.ctx(routeId)); + sendJson(res, 200, value); + this.logRewrites('prm', routeId, rewrote); + } + + private async serveAsMetadata(res: http.ServerResponse, routeId: string): Promise { + const route = this.config.servers[routeId]; + if (!route) { + sendJson(res, 404, { error: 'unknown_route' }); + return; + } + const meta = await this.metadata.getMetadata(routeId, route); + const { value, rewrote } = rewriteAsMetadata(meta.asMetadata, { + ...this.ctx(routeId), + upstreamHasRevocation: meta.revocationEndpoint !== undefined, + }); + sendJson(res, 200, value); + this.logRewrites('as-metadata', routeId, rewrote); + } + + // ── Route map rows 7–10: OAuth endpoints under /as//* ──────────────── + + private async handleOAuth( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, + segments: string[] + ): Promise { + const routeId = segments[1]; + const route = this.config.servers[routeId]; + if (!route) { + sendJson(res, 404, { error: 'unknown_route' }); + return 'unknown'; + } + const action = segments.slice(2).join('/'); + + if (req.method === 'GET' && action === '.well-known/openid-configuration') { + await this.serveAsMetadata(res, routeId); + return 'as-metadata'; + } + if (req.method === 'POST' && action === 'register') { + await this.handleRegister(req, res, routeId, route); + return 'register'; + } + if (req.method === 'GET' && action === 'authorize') { + await this.handleAuthorize(res, url, routeId, route); + return 'authorize'; + } + if (req.method === 'POST' && action === 'token') { + await this.handleToken(req, res, routeId, route); + return 'token'; + } + if (req.method === 'POST' && action === 'revoke') { + await this.handleRevoke(req, res, routeId, route); + return 'revoke'; + } + sendJson(res, 404, { error: 'unknown_route' }); + return 'unknown'; + } + + private async handleRegister( + req: http.IncomingMessage, + res: http.ServerResponse, + routeId: string, + route: RouteConfig + ): Promise { + const meta = await this.metadata.getMetadata(routeId, route); + const body = await this.readOAuthBody(req, res); + if (body === null) { + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(body.toString('utf-8')); + } catch { + sendJson(res, 400, { error: 'invalid_request' }); + return; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + sendJson(res, 400, { error: 'invalid_request' }); + return; + } + const { value, rewrote } = rewriteRegistrationBody(parsed as JsonObject, { + clientName: route.clientName, + scopes: route.scopes, + }); + await this.relay(req, res, new URL(meta.registrationEndpoint), Buffer.from(JSON.stringify(value), 'utf-8'), 'application/json'); + this.logRewrites('register', routeId, rewrote); + } + + private async handleAuthorize( + res: http.ServerResponse, + url: URL, + routeId: string, + route: RouteConfig + ): Promise { + const meta = await this.metadata.getMetadata(routeId, route); + const { value, rewrote } = rewriteAuthorizeQuery(url.searchParams, { + scopes: route.scopes, + upstreamResource: meta.upstreamResource, + }); + const location = new URL(meta.authorizationEndpoint); + for (const [key, param] of value) { + location.searchParams.set(key, param); + } + res.writeHead(302, { location: location.toString() }); + res.end(); + this.logRewrites('authorize', routeId, rewrote); + } + + private async handleToken( + req: http.IncomingMessage, + res: http.ServerResponse, + routeId: string, + route: RouteConfig + ): Promise { + const meta = await this.metadata.getMetadata(routeId, route); + const body = await this.readOAuthBody(req, res); + if (body === null) { + return; + } + const { value, rewrote } = rewriteTokenBody(new URLSearchParams(body.toString('utf-8')), { + scopes: route.scopes, + upstreamResource: meta.upstreamResource, + }); + // NEVER log request or response bodies on this route. + await this.relay( + req, + res, + new URL(meta.tokenEndpoint), + Buffer.from(value.toString(), 'utf-8'), + 'application/x-www-form-urlencoded' + ); + this.logRewrites('token', routeId, rewrote); + } + + private async handleRevoke( + req: http.IncomingMessage, + res: http.ServerResponse, + routeId: string, + route: RouteConfig + ): Promise { + const meta = await this.metadata.getMetadata(routeId, route); + if (meta.revocationEndpoint === undefined) { + sendJson(res, 404, { error: 'unknown_route' }); + return; + } + const body = await this.readOAuthBody(req, res); + if (body === null) { + return; + } + const contentType = + typeof req.headers['content-type'] === 'string' + ? req.headers['content-type'] + : 'application/x-www-form-urlencoded'; + await this.relay(req, res, new URL(meta.revocationEndpoint), body, contentType); + } + + /** Reads a 64 KB-limited OAuth body; answers 413 and returns null when oversized. */ + private async readOAuthBody( + req: http.IncomingMessage, + res: http.ServerResponse + ): Promise { + try { + return await readBodyLimited(req, OAUTH_BODY_LIMIT_BYTES); + } catch (error) { + if (error instanceof BodyTooLargeError) { + sendJson(res, 413, { error: 'payload_too_large' }); + return null; + } + throw error; + } + } + + /** Forwards a buffered OAuth payload upstream and relays status + body verbatim. */ + private async relay( + req: http.IncomingMessage, + res: http.ServerResponse, + target: URL, + payload: Buffer, + contentType: string + ): Promise { + const headers = forwardHeaders(req.headers, target); + headers['content-type'] = contentType; + headers['content-length'] = String(payload.length); + const { request, response } = this.client.begin(target, { method: 'POST', headers, body: payload }); + res.on('close', () => { + if (!res.writableEnded) { + request.destroy(); + } + }); + const upstream = await response; + res.writeHead(upstream.statusCode ?? 502, copyResponseHeaders(upstream)); + await pipeline(upstream, res); + } + + // ── Route map row 1: MCP streaming pass-through ─────────────────────────── + + private async passThrough( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, + routeId: string + ): Promise { + const route = this.config.servers[routeId]; + const target = new URL(route.upstreamUrl); + target.pathname = target.pathname.replace(/\/+$/, '') + url.pathname.slice(`/${routeId}`.length); + target.search = url.search; + + const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; + const { request, response } = this.client.begin(target, { + method: req.method ?? 'GET', + headers: forwardHeaders(req.headers, target), + ...(hasBody ? { bodyStream: req } : {}), + }); + res.on('close', () => { + if (!res.writableEnded) { + request.destroy(); + } + }); + + const upstream = await response; + const status = upstream.statusCode ?? 502; + const outHeaders = copyResponseHeaders(upstream); + + const challenge = upstream.headers['www-authenticate']; + const challengeText = typeof challenge === 'string' ? challenge : undefined; + const needsChallengeRewrite = + status === 401 || + (status === 403 && + challengeText !== undefined && + /error\s*=\s*"?insufficient_scope"?/i.test(challengeText)); + + if (needsChallengeRewrite) { + if (challengeText !== undefined) { + const match = /resource_metadata\s*=\s*"([^"]+)"/i.exec(challengeText); + if (match) { + this.metadata.notePrmUrl(routeId, match[1]); + } + } + const { value, rewrote } = rewriteChallengeHeader(challengeText, this.ctx(routeId)); + outHeaders['www-authenticate'] = value; + this.logRewrites('mcp', routeId, rewrote); + } + + res.writeHead(status, outHeaders); + try { + await pipeline(upstream, res); + } catch { + // Client disconnected mid-stream (normal for aborted SSE) — tear both sides down. + upstream.destroy(); + res.destroy(); + } + } + + // ── Health + errors ─────────────────────────────────────────────────────── + + private serveHealth(res: http.ServerResponse): void { + const routes = Object.entries(this.config.servers).map(([id, route]) => ({ + id, + upstreamUrl: route.upstreamUrl, + status: this.metadata.getStatus(id), + })); + sendJson(res, 200, { status: 'ok', routes }); + } + + private handleError(res: http.ServerResponse, routeId: string, error: unknown): void { + if (res.headersSent) { + res.destroy(); + return; + } + if (error instanceof MetadataDiscoveryError || isUpstreamNetworkError(error)) { + // Spec log rule: upstream host only — we log the route id, never full URLs. + logger.warn( + '[mcp-auth-proxy] Upstream unreachable', + ...sanitizeLogArgs({ route: routeId || 'unknown' }) + ); + sendJson( + res, + 502, + routeId ? { error: 'upstream_unreachable', route: routeId } : { error: 'upstream_unreachable' } + ); + return; + } + logger.error('[mcp-auth-proxy] Request handling failed', error); + sendJson(res, 500, { error: 'internal_error' }); + } + + private logRewrites(kind: string, routeId: string, rewrote: string[]): void { + if (rewrote.length === 0) { + return; + } + logger.debug( + '[mcp-auth-proxy] rewrote', + ...sanitizeLogArgs({ route: routeId, kind, fields: rewrote.join(', ') }) + ); + } +} +``` + +- [ ] **Step 4: Run the test — verify it passes** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/server.test.ts` +Expected: PASS (all cases, including isolation and single-route alias suites). + +- [ ] **Step 5: Run the whole module's tests together** + +Run: `npx vitest run src/mcp/auth-proxy` +Expected: PASS — config, rewrites, state, metadata-cache, server. + +- [ ] **Step 6: Commit** + +```bash +git add src/mcp/auth-proxy/server.ts src/mcp/auth-proxy/__tests__/server.test.ts +git commit -m "feat(proxy): add McpAuthProxy server with streaming pass-through and OAuth rewrites" +``` + +--- + +### Task 7: Daemon runtime + bin entry + wrapper + +**Test-first: no — thin glue mirroring `src/bin/proxy-daemon.ts` (deliberately WITHOUT the ProxyWatcher: spec non-goal). Config, state, and server behavior are already unit-tested; end-to-end daemon lifecycle is smoke-verified in Task 8 Step 5 via the real CLI.** + +**Files:** +- Create: `src/mcp/auth-proxy/runtime.ts` +- Create: `src/bin/mcp-auth-proxy-daemon.ts` +- Create: `bin/mcp-auth-proxy-daemon.js` + +- [ ] **Step 1: Write `src/mcp/auth-proxy/runtime.ts`** (design D5 — shared by the bin entry and CLI `--foreground`) + +```ts +/** + * MCP Auth Proxy — shared daemon runtime. + * + * Single implementation behind both the detached bin entry and CLI --foreground: + * load + validate config, start the server, persist the state file, clean up on + * SIGTERM/SIGINT. No self-healing watcher by design (spec § Non-Goals): the proxy + * holds no session state, so a crash only needs a manual restart. + */ +import { logger } from '../../utils/logger.js'; +import { getDefaultStatePath, loadAuthProxyConfig } from './config.js'; +import { McpAuthProxy } from './server.js'; +import { clearAuthProxyState, writeAuthProxyState } from './state.js'; + +export interface RunDaemonOptions { + configPath?: string; + port?: number; + stateFile?: string; +} + +export interface RunningDaemon { + proxy: McpAuthProxy; + port: number; + url: string; + routes: string[]; + stop: () => Promise; +} + +export async function runAuthProxyDaemon(options: RunDaemonOptions = {}): Promise { + const config = await loadAuthProxyConfig(options.configPath); + if (options.port !== undefined) { + config.port = options.port; + } + const stateFile = options.stateFile ?? getDefaultStatePath(); + + const proxy = new McpAuthProxy(config); + const { port, url } = await proxy.start(); + const routes = Object.keys(config.servers); + + await writeAuthProxyState( + { pid: process.pid, port, routes, startedAt: new Date().toISOString() }, + stateFile + ); + + const stop = async (): Promise => { + try { + await proxy.stop(); + } catch { + // Best-effort shutdown + } + try { + await clearAuthProxyState(stateFile); + } catch { + // Best-effort cleanup + } + }; + const onSignal = (): void => { + void stop().then(() => process.exit(0)); + }; + process.on('SIGTERM', onSignal); + process.on('SIGINT', onSignal); + + logger.debug(`[mcp-auth-proxy] Daemon running at ${url} (routes: ${routes.join(', ')})`); + return { proxy, port, url, routes, stop }; +} +``` + +- [ ] **Step 2: Write `src/bin/mcp-auth-proxy-daemon.ts`** + +```ts +/** + * MCP Auth Proxy Daemon Entry Point + * + * Spawned as a detached process by `codemie mcp-auth-proxy start`. + * Loads the config, starts McpAuthProxy, writes the state file, handles SIGTERM. + */ +import { parseArgs } from 'node:util'; +import { runAuthProxyDaemon } from '../mcp/auth-proxy/runtime.js'; + +const { values } = parseArgs({ + options: { + config: { type: 'string' }, + port: { type: 'string' }, + 'state-file': { type: 'string' }, + }, + strict: false, +}); + +const portArg = values.port as string | undefined; +const port = portArg ? Number.parseInt(portArg, 10) : undefined; +if (port !== undefined && (!Number.isFinite(port) || port <= 0)) { + process.stderr.write(`[mcp-auth-proxy-daemon] Invalid --port value: ${portArg}\n`); + process.exit(1); +} + +try { + await runAuthProxyDaemon({ + configPath: values.config as string | undefined, + port, + stateFile: values['state-file'] as string | undefined, + }); +} catch (error) { + process.stderr.write(`[mcp-auth-proxy-daemon] Failed to start: ${(error as Error).message}\n`); + process.exit(1); +} +``` + +- [ ] **Step 3: Write the git-tracked wrapper `bin/mcp-auth-proxy-daemon.js`** (design D4 — spawn is by file path; tsc emits the compiled entry under `dist/bin/`) + +```js +#!/usr/bin/env node + +/** + * CodeMie MCP Auth Proxy Daemon entry point + * Imports compiled daemon from dist/ + */ +import('../dist/bin/mcp-auth-proxy-daemon.js').catch((error) => { + process.stderr.write(`[mcp-auth-proxy-daemon] Fatal: ${error.message}\n`); + process.exit(1); +}); +``` + +- [ ] **Step 4: Typecheck and build** + +Run: `npm run typecheck && npm run build` +Expected: PASS; `dist/bin/mcp-auth-proxy-daemon.js` exists after build (`ls dist/bin/ | grep mcp-auth`). + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/auth-proxy/runtime.ts src/bin/mcp-auth-proxy-daemon.ts bin/mcp-auth-proxy-daemon.js +git commit -m "feat(proxy): add mcp-auth-proxy daemon runtime and detached entry point" +``` + +--- + +### Task 8: CLI command + registration + smoke test + +**Test-first: no — mirrors the existing (untested) Commander start/stop/status precedent in `src/cli/commands/proxy/index.ts`; underlying config/state/server logic is unit-tested. Verified here by a real daemon lifecycle smoke run (Step 5).** + +**Files:** +- Create: `src/cli/commands/mcp-auth-proxy.ts` +- Modify: `src/cli/index.ts` (imports around line 37; registration around line 98) + +- [ ] **Step 1: Write `src/cli/commands/mcp-auth-proxy.ts`** + +```ts +/** + * `codemie mcp-auth-proxy` — manage the MCP OAuth rewriting proxy daemon. + * + * Distinct from `codemie mcp-proxy` (the stdio↔HTTP bridge): this command manages a + * background loopback HTTP proxy that rewrites OAuth client_name/scope/resource for + * remote MCP servers — see docs/SPEC-mcp-auth-proxy.md. + */ +import { Command } from 'commander'; +import chalk from 'chalk'; +import http from 'node:http'; +import { join, resolve } from 'node:path'; +import { + ConfigurationError, + ToolExecutionError, + createErrorContext, + formatErrorForUser, +} from '../../utils/errors.js'; +import { getDirname } from '../../utils/paths.js'; +import { logger } from '../../utils/logger.js'; +import { spawnDetached } from '../../utils/processes.js'; +import { + getDefaultConfigPath, + getDefaultStatePath, + loadAuthProxyConfig, +} from '../../mcp/auth-proxy/config.js'; +import { runAuthProxyDaemon } from '../../mcp/auth-proxy/runtime.js'; +import { + clearAuthProxyState, + isProcessAlive, + readAuthProxyState, +} from '../../mcp/auth-proxy/state.js'; +import type { RouteStatus } from '../../mcp/auth-proxy/types.js'; + +function parsePortOption(value: string | undefined): number | undefined { + if (!value) { + return undefined; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65535) { + throw new ConfigurationError(`Invalid port value: ${value}`); + } + return parsed; +} + +function printError(error: unknown, label: string): never { + logger.error(label, error); + if (error instanceof ConfigurationError || error instanceof ToolExecutionError) { + console.error(chalk.red(`✗ ${error.message}`)); + } else { + console.error(formatErrorForUser(createErrorContext(error), { showSystem: false })); + } + process.exit(1); +} + +function printAddCommands(port: number, routes: string[]): void { + console.log(chalk.bold('\nAdd to Claude Code:')); + for (const id of routes) { + console.log(` claude mcp add --scope local --transport http ${id} http://127.0.0.1:${port}/${id}`); + } +} + +interface HealthzRoute { + id: string; + upstreamUrl: string; + status: RouteStatus; +} + +function fetchHealth(port: number): Promise<{ status: string; routes: HealthzRoute[] }> { + return new Promise((resolveHealth, rejectHealth) => { + const request = http.get( + { host: '127.0.0.1', port, path: '/healthz', timeout: 2000 }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + try { + resolveHealth(JSON.parse(Buffer.concat(chunks).toString('utf-8'))); + } catch (error) { + rejectHealth(error as Error); + } + }); + } + ); + request.on('error', rejectHealth); + request.on('timeout', () => request.destroy(new Error('healthz timed out'))); + }); +} + +export function createMcpAuthProxyCommand(): Command { + const command = new Command('mcp-auth-proxy'); + command.description( + 'Manage the MCP OAuth rewriting proxy (client_name/scope/resource rewrites for remote MCP servers; not the stdio mcp-proxy bridge)' + ); + + command + .command('start') + .description('Start the proxy daemon (detached by default)') + .option('--config ', 'Config file path (default: /mcp-auth-proxy.json)') + .option('--port ', 'Override the configured listen port') + .option('--foreground', 'Run in the foreground (debugging; CODEMIE_DEBUG=true for verbose logs)') + .action(async (opts: { config?: string; port?: string; foreground?: boolean }) => { + try { + const existing = await readAuthProxyState(); + if (existing && isProcessAlive(existing.pid)) { + console.log( + chalk.green( + `✓ mcp-auth-proxy already running on http://127.0.0.1:${existing.port} (pid ${existing.pid})` + ) + ); + printAddCommands(existing.port, existing.routes); + return; + } + await clearAuthProxyState(); + + const configPath = opts.config ? resolve(opts.config) : getDefaultConfigPath(); + const config = await loadAuthProxyConfig(configPath); // fail fast with the offending key path + const port = parsePortOption(opts.port) ?? config.port; + const routes = Object.keys(config.servers); + + if (opts.foreground) { + await runAuthProxyDaemon({ configPath, port }); + console.log(chalk.green(`✓ mcp-auth-proxy running (foreground) on http://127.0.0.1:${port}`)); + printAddCommands(port, routes); + console.log(chalk.gray('Press Ctrl+C to stop.')); + return; + } + + // dist/cli/commands/mcp-auth-proxy.js → ../../../bin/mcp-auth-proxy-daemon.js + const daemonBin = join(getDirname(import.meta.url), '../../../bin/mcp-auth-proxy-daemon.js'); + spawnDetached(process.execPath, [ + daemonBin, + '--config', configPath, + '--port', String(port), + '--state-file', getDefaultStatePath(), + ]); + + for (let i = 0; i < 50; i++) { + await new Promise((r) => setTimeout(r, 100)); + const state = await readAuthProxyState(); + if (state && isProcessAlive(state.pid)) { + console.log( + chalk.green(`✓ mcp-auth-proxy started on http://127.0.0.1:${state.port} (pid ${state.pid})`) + ); + printAddCommands(state.port, state.routes); + return; + } + } + throw new ToolExecutionError( + 'mcp-auth-proxy-daemon', + 'Daemon failed to start within 5 seconds. Try --foreground with CODEMIE_DEBUG=true.' + ); + } catch (error) { + printError(error, '[mcp-auth-proxy] start failed'); + } + }); + + command + .command('status') + .description('Show daemon status and per-route health') + .action(async () => { + const state = await readAuthProxyState(); + if (!state || !isProcessAlive(state.pid)) { + if (state) { + await clearAuthProxyState(); + } + console.log(chalk.yellow('mcp-auth-proxy is not running')); + return; + } + console.log( + chalk.green( + `✓ mcp-auth-proxy running on http://127.0.0.1:${state.port} (pid ${state.pid}, started ${state.startedAt})` + ) + ); + try { + const health = await fetchHealth(state.port); + for (const route of health.routes) { + const marker = + route.status === 'degraded' ? chalk.red('✗ degraded') : chalk.green(`✓ ${route.status}`); + console.log(` ${route.id}: ${marker} → ${route.upstreamUrl}`); + console.log( + ` claude mcp add --scope local --transport http ${route.id} http://127.0.0.1:${state.port}/${route.id}` + ); + } + } catch { + console.log(chalk.red(' ✗ Daemon process is alive but /healthz did not answer')); + } + }); + + command + .command('stop') + .description('Stop the proxy daemon and remove its state file') + .action(async () => { + const state = await readAuthProxyState(); + if (!state || !isProcessAlive(state.pid)) { + await clearAuthProxyState(); + console.log(chalk.yellow('mcp-auth-proxy is not running')); + return; + } + process.kill(state.pid, 'SIGTERM'); + for (let i = 0; i < 50; i++) { + await new Promise((r) => setTimeout(r, 100)); + if (!isProcessAlive(state.pid)) { + break; + } + } + if (isProcessAlive(state.pid)) { + logger.warn('[mcp-auth-proxy] Daemon ignored SIGTERM; escalating to SIGKILL'); + try { + process.kill(state.pid, 'SIGKILL'); + } catch { + // Already gone between the check and the signal — fine. + } + } + await clearAuthProxyState(); + console.log(chalk.green('✓ mcp-auth-proxy stopped')); + }); + + return command; +} +``` + +- [ ] **Step 2: Register the command in `src/cli/index.ts`** + +Next to the existing import at line ~37: + +```ts +import { createMcpAuthProxyCommand } from './commands/mcp-auth-proxy.js'; +``` + +Next to the existing registration at line ~98 (`program.addCommand(createMcpProxyCommand());`): + +```ts +program.addCommand(createMcpAuthProxyCommand()); +``` + +- [ ] **Step 3: Typecheck, lint, build** + +Run: `npm run typecheck && npm run lint && npm run build` +Expected: PASS with zero warnings. + +- [ ] **Step 4: Help smoke** + +Run: `node bin/codemie.js mcp-auth-proxy --help && node bin/codemie.js mcp-auth-proxy start --help` +Expected: shows `start`/`stop`/`status` subcommands and the `--config/--port/--foreground` options. + +- [ ] **Step 5: Daemon lifecycle smoke (isolated CODEMIE_HOME; discovery is lazy so a dummy upstream is fine)** + +```bash +SMOKE_HOME=$(mktemp -d) +cat > "$SMOKE_HOME/mcp-auth-proxy.json" <<'JSON' +{ + "port": 42890, + "servers": { + "radar": { "upstreamUrl": "https://mcp.example.invalid/mcp/radar", "clientName": "Smoke Client", "scopes": ["openid"] } + } +} +JSON +CODEMIE_HOME="$SMOKE_HOME" node bin/codemie.js mcp-auth-proxy start +CODEMIE_HOME="$SMOKE_HOME" node bin/codemie.js mcp-auth-proxy status +curl -s http://127.0.0.1:42890/healthz +CODEMIE_HOME="$SMOKE_HOME" node bin/codemie.js mcp-auth-proxy stop +test ! -f "$SMOKE_HOME/mcp-auth-proxy.state.json" && echo "state file removed" +rm -rf "$SMOKE_HOME" +``` + +Expected: start prints the `claude mcp add … http://127.0.0.1:42890/radar` line; status shows the route (status `unknown` — no discovery yet); healthz returns `{"status":"ok","routes":[…]}`; stop terminates the daemon and the state file is gone. + +- [ ] **Step 6: Commit** + +```bash +git add src/cli/commands/mcp-auth-proxy.ts src/cli/index.ts +git commit -m "feat(cli): register codemie mcp-auth-proxy start/stop/status command" +``` + +--- + +### Task 9: Docs + full quality gates + +**Test-first: no — documentation and gate verification only.** + +**Files:** +- Modify: `docs/COMMANDS.md` (Core Commands block, next to the `codemie mcp-proxy` line at ~line 24) + +- [ ] **Step 1: Add the command to `docs/COMMANDS.md`** + +Directly below the line `codemie mcp-proxy # Stdio-to-HTTP MCP proxy with OAuth support` add: + +```text +codemie mcp-auth-proxy # OAuth-rewriting proxy daemon for remote MCP servers (client_name/scope/resource overrides; config: ~/.codemie/mcp-auth-proxy.json) +``` + +- [ ] **Step 2: Run the full gates (acceptance criterion 10)** + +Run: `npm run lint && npm run typecheck && npm run build && npm run test:unit` +Expected: all PASS, zero warnings; the five `src/mcp/auth-proxy/__tests__/*` suites are included in the unit run. + +- [ ] **Step 3: Commit** + +```bash +git add docs/COMMANDS.md +git commit -m "docs: document codemie mcp-auth-proxy command" +``` + +--- + +## Plan Self-Review Notes (already applied) + +- **Spec coverage:** route map rows 1–11 → Task 6 (`handleRequest`/`handleWellKnown`/`handleOAuth`/`passThrough`, incl. row-3 single-route alias and row-10 revoke gating); R1–R6 → Task 2 (pure) + Task 6 (wiring); config semantics → Task 1; CLI/daemon/state → Tasks 3, 7, 8; healthz/status/add-command output → Tasks 6, 8; error handling (`502 upstream_unreachable`+route, `404 unknown_route`, degraded routes, 64 KB → 413) → Task 6; security (loopback literal bind, TLS verify on, no token/query logging, no watcher) → Tasks 4, 6, 7; acceptance criterion 10 → Task 9. E2E criteria 1–9 are covered at unit/integration level per the design's acceptance mapping; live-IdP verification is a manual post-merge step. +- **Deliberate deviations (do not "fix" during implementation):** node:http+pipeline instead of undici (design D1, gate-approved); no package.json change; no license headers (repo has none; license-check gates dependencies only). +- **Type consistency:** `RewriteResult`/`RewriteContext` defined once in Task 1 and used verbatim in Tasks 2/6; `getDefaultStatePath` lives in `config.ts` and is imported by `state.ts`/CLI; `isProcessAlive` is exported from `state.ts` (module-local, not imported from daemon-manager — layering). + diff --git a/docs/superpowers/specs/2026-07-03-mcp-auth-proxy-design.md b/docs/superpowers/specs/2026-07-03-mcp-auth-proxy-design.md index 232f2a2f..337a6ab3 100644 --- a/docs/superpowers/specs/2026-07-03-mcp-auth-proxy-design.md +++ b/docs/superpowers/specs/2026-07-03-mcp-auth-proxy-design.md @@ -44,11 +44,15 @@ No registry/plugin surface. Existing `codemie mcp-proxy` stdio bridge is untouch | `src/bin/mcp-auth-proxy-daemon.ts` | new | Thin detached entry: `parseArgs` (`--config`, `--port`, `--state-file`) → `runAuthProxyDaemon`. Start failure → stderr + `exit(1)`. Mirrors `src/bin/proxy-daemon.ts` minus watcher. | | `bin/mcp-auth-proxy-daemon.js` | new | Git-tracked ESM wrapper importing `../dist/bin/mcp-auth-proxy-daemon.js` (D4). | | `src/cli/commands/mcp-auth-proxy.ts` | new | `createMcpAuthProxyCommand()` — `new Command('mcp-auth-proxy')` with `start [--config ] [--port ] [--foreground]`, `stop`, `status`, mirroring `src/cli/commands/proxy/index.ts` (port parse helper → `ConfigurationError`; `chalk` output). `start`: validate config first (fail fast with key path), then detached `spawnDetached(process.execPath, [wrapper, ...args])` + 5 s state/pid poll (`ToolExecutionError` on timeout), or `--foreground` → `runAuthProxyDaemon` in-process; on success print per-route `claude mcp add --scope local --transport http http://127.0.0.1:/` lines. `status`: read state, pid liveness, `GET /healthz`, print per-route upstream + add-command; clear stale state. `stop`: SIGTERM + poll, SIGKILL escalation, clear state. | +| `src/mcp/auth-proxy/upstream-client.ts` | new | `UpstreamClient` — outbound HTTP for the module: keep-alive agents honoring `HTTP(S)_PROXY` env (D3), `begin(url, opts)` returning `{request, response}` for streaming pass-through with abort propagation, buffered `fetchJson(url)` (5 s timeout, 256 KB cap) for metadata discovery. TLS verification ON (`rejectUnauthorized: true`) — this client relays OAuth traffic. Keeps `server.ts` focused (file split per design-for-isolation). | | `src/cli/index.ts` | edit | Import + `program.addCommand(createMcpAuthProxyCommand())` next to `createMcpProxyCommand()` (line ~98). | -| `package.json` | edit | Optional `bin` entry `"codemie-mcp-auth-proxy-daemon": "bin/mcp-auth-proxy-daemon.js"` for consistency; `files` already ships `bin/` + `dist/`. No build changes (tsc compiles `src/**/*`). | | `docs/COMMANDS.md` | edit | Document the new command surface (repo review checklist: public docs updated when behavior changes). | -Every new `src/**/*.ts` file carries the Apache-2.0 license header (license gate). +`package.json` needs **no change**: `files` already ships `bin/` + `dist/`, tsc compiles all of +`src/**/*`, and the existing `proxy-daemon` exposes no npm `bin` entry — the new daemon mirrors +that (spawned by file path only). The `license-check` gate validates **dependency** licenses +(`npx license-checker`); no new dependencies are added and repo source files carry no license +headers, so no header/license work is needed. ### Rewrite function signatures (the unit-test core) From 6202146785142b534080518acea6e89d421f444b Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Fri, 3 Jul 2026 19:39:50 +0200 Subject: [PATCH 03/36] feat(proxy): add mcp-auth-proxy types and config validation Generated with AI Co-Authored-By: codemie-ai --- src/mcp/auth-proxy/__tests__/config.test.ts | 101 ++++++++++++++ src/mcp/auth-proxy/config.ts | 145 ++++++++++++++++++++ src/mcp/auth-proxy/types.ts | 57 ++++++++ 3 files changed, 303 insertions(+) create mode 100644 src/mcp/auth-proxy/__tests__/config.test.ts create mode 100644 src/mcp/auth-proxy/config.ts create mode 100644 src/mcp/auth-proxy/types.ts diff --git a/src/mcp/auth-proxy/__tests__/config.test.ts b/src/mcp/auth-proxy/__tests__/config.test.ts new file mode 100644 index 00000000..a2cc40ed --- /dev/null +++ b/src/mcp/auth-proxy/__tests__/config.test.ts @@ -0,0 +1,101 @@ +/** + * mcp-auth-proxy config validation tests + * @group unit + */ +import { describe, it, expect } from 'vitest'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + DEFAULT_AUTH_PROXY_PORT, + loadAuthProxyConfig, + validateAuthProxyConfig, +} from '../config.js'; +import { ConfigurationError } from '../../../utils/errors.js'; + +const validServers = { radar: { upstreamUrl: 'https://mcp.example.com/mcp/radar' } }; + +describe('validateAuthProxyConfig', () => { + it('accepts a minimal config and applies the default port', () => { + const config = validateAuthProxyConfig({ servers: validServers }); + expect(config.port).toBe(DEFAULT_AUTH_PROXY_PORT); + expect(config.servers.radar.upstreamUrl).toBe('https://mcp.example.com/mcp/radar'); + }); + + it('accepts a full route config and strips trailing slashes from upstreamUrl', () => { + const config = validateAuthProxyConfig({ + port: 42801, + servers: { + radar: { + upstreamUrl: 'https://mcp.example.com/mcp/radar/', + clientName: 'EPAM Approved MCP Client', + scopes: ['openid', 'mcp:access'], + }, + }, + }); + expect(config.port).toBe(42801); + expect(config.servers.radar.upstreamUrl).toBe('https://mcp.example.com/mcp/radar'); + expect(config.servers.radar.clientName).toBe('EPAM Approved MCP Client'); + expect(config.servers.radar.scopes).toEqual(['openid', 'mcp:access']); + }); + + it.each([null, [], 'x', 42])('rejects non-object root: %j', (root) => { + expect(() => validateAuthProxyConfig(root)).toThrow(ConfigurationError); + }); + + it.each([0, -1, 1.5, 70000, '42800'])('rejects invalid port %j naming "port"', (port) => { + expect(() => validateAuthProxyConfig({ port, servers: validServers })).toThrow(/"port"/); + }); + + it('rejects missing or empty servers naming "servers"', () => { + expect(() => validateAuthProxyConfig({})).toThrow(/"servers"/); + expect(() => validateAuthProxyConfig({ servers: {} })).toThrow(/at least one route/); + }); + + it.each(['Radar', '-radar', 'ra_dar', 'ra.dar'])('rejects invalid route id %j with key path', (id) => { + expect(() => + validateAuthProxyConfig({ servers: { [id]: { upstreamUrl: 'https://x.example' } } }) + ).toThrow(/servers\./); + }); + + it.each(['as', 'healthz'])('rejects reserved route id %j', (id) => { + expect(() => + validateAuthProxyConfig({ servers: { [id]: { upstreamUrl: 'https://x.example' } } }) + ).toThrow(/reserved/); + }); + + it('rejects missing, malformed, and non-https upstreamUrl with key path', () => { + expect(() => validateAuthProxyConfig({ servers: { radar: {} } })).toThrow( + /servers\.radar\.upstreamUrl/ + ); + expect(() => + validateAuthProxyConfig({ servers: { radar: { upstreamUrl: 'not a url' } } }) + ).toThrow(/servers\.radar\.upstreamUrl/); + expect(() => + validateAuthProxyConfig({ servers: { radar: { upstreamUrl: 'http://mcp.example.com' } } }) + ).toThrow(/servers\.radar\.upstreamUrl.*https/); + }); + + it('rejects empty clientName and invalid scopes with key paths', () => { + const upstreamUrl = 'https://x.example'; + expect(() => + validateAuthProxyConfig({ servers: { radar: { upstreamUrl, clientName: '' } } }) + ).toThrow(/servers\.radar\.clientName/); + expect(() => + validateAuthProxyConfig({ servers: { radar: { upstreamUrl, scopes: [] } } }) + ).toThrow(/servers\.radar\.scopes/); + expect(() => + validateAuthProxyConfig({ servers: { radar: { upstreamUrl, scopes: [''] } } }) + ).toThrow(/servers\.radar\.scopes/); + expect(() => + validateAuthProxyConfig({ servers: { radar: { upstreamUrl, scopes: 'openid' } } }) + ).toThrow(/servers\.radar\.scopes/); + }); +}); + +describe('loadAuthProxyConfig', () => { + it('throws ConfigurationError for a missing config file', async () => { + const missing = join(tmpdir(), `mcp-auth-proxy-missing-${process.pid}.json`); + await expect(loadAuthProxyConfig(missing)).rejects.toThrow(ConfigurationError); + await expect(loadAuthProxyConfig(missing)).rejects.toThrow(/not found/); + }); +}); diff --git a/src/mcp/auth-proxy/config.ts b/src/mcp/auth-proxy/config.ts new file mode 100644 index 00000000..2d9f0ade --- /dev/null +++ b/src/mcp/auth-proxy/config.ts @@ -0,0 +1,145 @@ +/** + * MCP Auth Proxy — config loading + validation. + * + * Config file: /mcp-auth-proxy.json (resolved via getCodemiePath — never + * hardcode ~/.codemie). Validation errors name the offending key path (spec requirement). + */ +import { readFile } from 'node:fs/promises'; +import { getCodemiePath } from '../../utils/paths.js'; +import { ConfigurationError } from '../../utils/errors.js'; +import type { AuthProxyConfig, RouteConfig } from './types.js'; + +export const DEFAULT_AUTH_PROXY_PORT = 42800; +export const AUTH_PROXY_CONFIG_FILE = 'mcp-auth-proxy.json'; +export const AUTH_PROXY_STATE_FILE = 'mcp-auth-proxy.state.json'; + +const ROUTE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +// `as` + `.well-known` are reserved by the route map; `healthz` by the health endpoint +// (design D6 — a route named "healthz" would shadow GET /healthz). +const RESERVED_ROUTE_IDS = new Set(['as', '.well-known', 'healthz']); + +export function getDefaultConfigPath(): string { + return getCodemiePath(AUTH_PROXY_CONFIG_FILE); +} + +export function getDefaultStatePath(): string { + return getCodemiePath(AUTH_PROXY_STATE_FILE); +} + +export async function loadAuthProxyConfig(configPath?: string): Promise { + const path = configPath ?? getDefaultConfigPath(); + + let raw: string; + try { + raw = await readFile(path, 'utf-8'); + } catch { + throw new ConfigurationError( + `MCP auth proxy config not found: ${path}\n` + + `Create it with a "servers" map — see docs/SPEC-mcp-auth-proxy.md § Configuration.` + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new ConfigurationError(`${path}: invalid JSON — ${(error as Error).message}`); + } + + return validateAuthProxyConfig(parsed); +} + +export function validateAuthProxyConfig(parsed: unknown): AuthProxyConfig { + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new ConfigurationError('mcp-auth-proxy config: root must be a JSON object'); + } + const root = parsed as Record; + + let port = DEFAULT_AUTH_PROXY_PORT; + if (root.port !== undefined) { + if ( + typeof root.port !== 'number' || + !Number.isInteger(root.port) || + root.port < 1 || + root.port > 65535 + ) { + throw new ConfigurationError( + 'mcp-auth-proxy config: "port" must be an integer between 1 and 65535' + ); + } + port = root.port; + } + + if (typeof root.servers !== 'object' || root.servers === null || Array.isArray(root.servers)) { + throw new ConfigurationError( + 'mcp-auth-proxy config: "servers" must be an object mapping route ids to server configs' + ); + } + + const entries = Object.entries(root.servers as Record); + if (entries.length === 0) { + throw new ConfigurationError('mcp-auth-proxy config: "servers" must contain at least one route'); + } + + const servers: Record = {}; + for (const [id, value] of entries) { + if (!ROUTE_ID_PATTERN.test(id)) { + throw new ConfigurationError( + `mcp-auth-proxy config: servers.${id}: route id must match ^[a-z0-9][a-z0-9-]*$` + ); + } + if (RESERVED_ROUTE_IDS.has(id)) { + throw new ConfigurationError(`mcp-auth-proxy config: servers.${id}: route id is reserved`); + } + servers[id] = validateRoute(value, `servers.${id}`); + } + + return { port, servers }; +} + +function validateRoute(value: unknown, keyPath: string): RouteConfig { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new ConfigurationError(`mcp-auth-proxy config: ${keyPath}: must be an object`); + } + const route = value as Record; + + if (typeof route.upstreamUrl !== 'string' || route.upstreamUrl.length === 0) { + throw new ConfigurationError(`mcp-auth-proxy config: ${keyPath}.upstreamUrl: required string`); + } + let parsedUrl: URL; + try { + parsedUrl = new URL(route.upstreamUrl); + } catch { + throw new ConfigurationError(`mcp-auth-proxy config: ${keyPath}.upstreamUrl: not a valid URL`); + } + if (parsedUrl.protocol !== 'https:') { + throw new ConfigurationError(`mcp-auth-proxy config: ${keyPath}.upstreamUrl: must use https://`); + } + + if ( + route.clientName !== undefined && + (typeof route.clientName !== 'string' || route.clientName.length === 0) + ) { + throw new ConfigurationError( + `mcp-auth-proxy config: ${keyPath}.clientName: must be a non-empty string` + ); + } + + if (route.scopes !== undefined) { + if ( + !Array.isArray(route.scopes) || + route.scopes.length === 0 || + route.scopes.some((scope) => typeof scope !== 'string' || scope.length === 0) + ) { + throw new ConfigurationError( + `mcp-auth-proxy config: ${keyPath}.scopes: must be a non-empty array of non-empty strings` + ); + } + } + + return { + upstreamUrl: route.upstreamUrl.replace(/\/+$/, ''), + ...(route.clientName !== undefined ? { clientName: route.clientName as string } : {}), + ...(route.scopes !== undefined ? { scopes: [...(route.scopes as string[])] } : {}), + }; +} diff --git a/src/mcp/auth-proxy/types.ts b/src/mcp/auth-proxy/types.ts new file mode 100644 index 00000000..db1d7c38 --- /dev/null +++ b/src/mcp/auth-proxy/types.ts @@ -0,0 +1,57 @@ +/** + * MCP Auth Proxy — shared types. + * + * See docs/SPEC-mcp-auth-proxy.md (authoritative spec) and + * docs/superpowers/specs/2026-07-03-mcp-auth-proxy-design.md (design decisions D1–D8). + */ + +export type JsonObject = Record; + +export interface RouteConfig { + /** Canonical upstream MCP endpoint (https:// only; stored without trailing slash). */ + upstreamUrl: string; + /** DCR client_name override; absent = pass through (R4). */ + clientName?: string; + /** Scope override applied at every rewrite point (R1–R6); absent = pass through. */ + scopes?: string[]; +} + +export interface AuthProxyConfig { + port: number; + servers: Record; +} + +export interface AuthProxyDaemonState { + pid: number; + port: number; + routes: string[]; + startedAt: string; +} + +export type RouteStatus = 'ok' | 'degraded' | 'unknown'; + +export interface UpstreamMetadata { + /** Upstream Protected Resource Metadata document (RFC 9728), verbatim. */ + prm: JsonObject; + /** Upstream Authorization Server metadata document (RFC 8414 / OIDC), verbatim. */ + asMetadata: JsonObject; + /** Upstream canonical resource URI for RFC 8707 rewrites (no trailing slash). */ + upstreamResource: string; + registrationEndpoint: string; + authorizationEndpoint: string; + tokenEndpoint: string; + revocationEndpoint?: string; +} + +export interface RewriteResult { + value: T; + /** Names of rewritten fields — safe to log (names only, never values). */ + rewrote: string[]; +} + +export interface RewriteContext { + /** e.g. `http://127.0.0.1:42800` (no trailing slash). */ + proxyOrigin: string; + routeId: string; + scopes?: string[]; +} From a7dac1d540e0fc32872588f0354869fb12169f15 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Fri, 3 Jul 2026 19:43:18 +0200 Subject: [PATCH 04/36] feat(proxy): add pure OAuth rewrite rules R1-R6 for mcp-auth-proxy Generated with AI Co-Authored-By: codemie-ai --- src/mcp/auth-proxy/__tests__/rewrites.test.ts | 220 ++++++++++++++++++ src/mcp/auth-proxy/rewrites.ts | 174 ++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 src/mcp/auth-proxy/__tests__/rewrites.test.ts create mode 100644 src/mcp/auth-proxy/rewrites.ts diff --git a/src/mcp/auth-proxy/__tests__/rewrites.test.ts b/src/mcp/auth-proxy/__tests__/rewrites.test.ts new file mode 100644 index 00000000..a6734eff --- /dev/null +++ b/src/mcp/auth-proxy/__tests__/rewrites.test.ts @@ -0,0 +1,220 @@ +/** + * mcp-auth-proxy rewrite rules (R1–R6) tests — pure functions, no mocks. + * @group unit + */ +import { describe, it, expect } from 'vitest'; +import { + rewriteAsMetadata, + rewriteAuthorizeQuery, + rewriteChallengeHeader, + rewritePrm, + rewriteRegistrationBody, + rewriteTokenBody, +} from '../rewrites.js'; +import type { JsonObject } from '../types.js'; + +const ctx = { proxyOrigin: 'http://127.0.0.1:42800', routeId: 'radar' }; +const ctxScoped = { ...ctx, scopes: ['openid', 'mcp:access'] }; +const PRM_URL = 'http://127.0.0.1:42800/.well-known/oauth-protected-resource/radar'; + +describe('rewriteChallengeHeader (R1)', () => { + it('rewrites resource_metadata and sets scope, preserving other params', () => { + const header = + 'Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp/radar", error="invalid_token"'; + const { value, rewrote } = rewriteChallengeHeader(header, ctxScoped); + expect(value).toContain(`resource_metadata="${PRM_URL}"`); + expect(value).toContain('scope="openid mcp:access"'); + expect(value).toContain('error="invalid_token"'); + expect(rewrote).toEqual(['resource_metadata', 'scope']); + }); + + it('replaces an existing scope param instead of duplicating it', () => { + const header = 'Bearer resource_metadata="https://x.example/prm", scope="claudeai"'; + const { value } = rewriteChallengeHeader(header, ctxScoped); + expect(value).toContain('scope="openid mcp:access"'); + expect(value).not.toContain('claudeai'); + }); + + it('leaves upstream scope untouched when no scopes are configured', () => { + const header = 'Bearer resource_metadata="https://x.example/prm", scope="upstream-scope"'; + const { value, rewrote } = rewriteChallengeHeader(header, ctx); + expect(value).toContain('scope="upstream-scope"'); + expect(rewrote).toEqual(['resource_metadata']); + }); + + it('prepends resource_metadata when the Bearer challenge lacks it', () => { + const { value } = rewriteChallengeHeader('Bearer error="invalid_token"', ctx); + expect(value).toBe(`Bearer resource_metadata="${PRM_URL}", error="invalid_token"`); + }); + + it('injects a full challenge when the upstream sent no header at all', () => { + const { value } = rewriteChallengeHeader(undefined, ctxScoped); + expect(value).toBe(`Bearer resource_metadata="${PRM_URL}", scope="openid mcp:access"`); + }); + + it('handles a bare "Bearer" header', () => { + const { value } = rewriteChallengeHeader('Bearer', ctx); + expect(value).toBe(`Bearer resource_metadata="${PRM_URL}"`); + }); +}); + +describe('rewritePrm (R2)', () => { + const prm: JsonObject = { + resource: 'https://mcp.example.com/mcp/radar', + authorization_servers: ['https://idp.example.com/realms/x'], + scopes_supported: ['upstream-scope'], + bearer_methods_supported: ['header'], + }; + + it('rewrites resource + authorization_servers, passes other fields through', () => { + const { value, rewrote } = rewritePrm(prm, ctx); + expect(value.resource).toBe('http://127.0.0.1:42800/radar'); + expect(value.authorization_servers).toEqual(['http://127.0.0.1:42800/as/radar']); + expect(value.bearer_methods_supported).toEqual(['header']); + expect(value.scopes_supported).toEqual(['upstream-scope']); + expect(rewrote).toEqual(['resource', 'authorization_servers']); + }); + + it('overrides scopes_supported only when scopes are configured', () => { + const { value, rewrote } = rewritePrm(prm, ctxScoped); + expect(value.scopes_supported).toEqual(['openid', 'mcp:access']); + expect(rewrote).toContain('scopes_supported'); + }); +}); + +describe('rewriteAsMetadata (R3)', () => { + const asMeta: JsonObject = { + issuer: 'https://idp.example.com/realms/x', + authorization_endpoint: 'https://idp.example.com/realms/x/authorize', + token_endpoint: 'https://idp.example.com/realms/x/token', + registration_endpoint: 'https://idp.example.com/realms/x/register', + revocation_endpoint: 'https://idp.example.com/realms/x/revoke', + jwks_uri: 'https://idp.example.com/realms/x/jwks', + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true, + }; + + it('maps issuer + endpoints to the proxy, keeps jwks_uri upstream, preserves PKCE, deletes CIMD flag', () => { + const { value, rewrote } = rewriteAsMetadata(asMeta, { ...ctx, upstreamHasRevocation: true }); + expect(value.issuer).toBe('http://127.0.0.1:42800/as/radar'); + expect(value.authorization_endpoint).toBe('http://127.0.0.1:42800/as/radar/authorize'); + expect(value.token_endpoint).toBe('http://127.0.0.1:42800/as/radar/token'); + expect(value.registration_endpoint).toBe('http://127.0.0.1:42800/as/radar/register'); + expect(value.revocation_endpoint).toBe('http://127.0.0.1:42800/as/radar/revoke'); + expect(value.jwks_uri).toBe('https://idp.example.com/realms/x/jwks'); + expect(value.code_challenge_methods_supported).toEqual(['S256']); + expect(value).not.toHaveProperty('client_id_metadata_document_supported'); + expect(rewrote).toContain('client_id_metadata_document_supported'); + }); + + it('drops revocation_endpoint when the upstream advertises none', () => { + const { value } = rewriteAsMetadata(asMeta, { ...ctx, upstreamHasRevocation: false }); + expect(value).not.toHaveProperty('revocation_endpoint'); + }); + + it('overrides scopes_supported only when configured', () => { + const { value } = rewriteAsMetadata( + { ...asMeta, scopes_supported: ['upstream-scope'] }, + { ...ctxScoped, upstreamHasRevocation: false } + ); + expect(value.scopes_supported).toEqual(['openid', 'mcp:access']); + }); +}); + +describe('rewriteRegistrationBody (R4)', () => { + it('overrides client_name and injects scope, preserving redirect_uris and grant_types', () => { + const body: JsonObject = { + client_name: 'Claude Code', + redirect_uris: ['http://localhost:12345/callback'], + grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'none', + }; + const { value, rewrote } = rewriteRegistrationBody(body, { + clientName: 'EPAM Approved MCP Client', + scopes: ['openid'], + }); + expect(value.client_name).toBe('EPAM Approved MCP Client'); + expect(value.scope).toBe('openid'); + expect(value.redirect_uris).toEqual(['http://localhost:12345/callback']); + expect(value.grant_types).toEqual(['authorization_code', 'refresh_token']); + expect(value.token_endpoint_auth_method).toBe('none'); + expect(rewrote).toEqual(['client_name', 'scope']); + }); + + it('passes everything through untouched when nothing is configured', () => { + const body: JsonObject = { client_name: 'Claude Code', scope: 'claudeai' }; + const { value, rewrote } = rewriteRegistrationBody(body, {}); + expect(value).toEqual(body); + expect(rewrote).toEqual([]); + }); +}); + +describe('rewriteAuthorizeQuery (R5)', () => { + it('sets resource to the upstream canonical URI and overrides scope, keeping PKCE/state', () => { + const query = new URLSearchParams({ + response_type: 'code', + client_id: 'abc', + redirect_uri: 'http://localhost:1/cb', + state: 's1', + code_challenge: 'cc', + code_challenge_method: 'S256', + scope: 'claudeai', + resource: 'http://127.0.0.1:42800/radar', + }); + const { value, rewrote } = rewriteAuthorizeQuery(query, { + scopes: ['openid'], + upstreamResource: 'https://mcp.example.com/mcp/radar', + }); + expect(value.get('resource')).toBe('https://mcp.example.com/mcp/radar'); + expect(value.get('scope')).toBe('openid'); + expect(value.get('code_challenge')).toBe('cc'); + expect(value.get('code_challenge_method')).toBe('S256'); + expect(value.get('state')).toBe('s1'); + expect(value.get('client_id')).toBe('abc'); + expect(rewrote).toEqual(['resource', 'scope']); + }); + + it('leaves scope alone when not configured and injects resource when absent', () => { + const query = new URLSearchParams({ scope: 'claudeai' }); + const { value, rewrote } = rewriteAuthorizeQuery(query, { + upstreamResource: 'https://mcp.example.com/mcp/radar', + }); + expect(value.get('scope')).toBe('claudeai'); + expect(value.get('resource')).toBe('https://mcp.example.com/mcp/radar'); + expect(rewrote).toEqual(['resource']); + }); +}); + +describe('rewriteTokenBody (R6)', () => { + it('rewrites resource and existing scope for authorization_code grants', () => { + const form = new URLSearchParams({ + grant_type: 'authorization_code', + code: 'c', + code_verifier: 'v', + client_id: 'abc', + redirect_uri: 'http://localhost:1/cb', + resource: 'http://127.0.0.1:42800/radar', + scope: 'claudeai', + }); + const { value, rewrote } = rewriteTokenBody(form, { + scopes: ['openid'], + upstreamResource: 'https://mcp.example.com/mcp/radar', + }); + expect(value.get('resource')).toBe('https://mcp.example.com/mcp/radar'); + expect(value.get('scope')).toBe('openid'); + expect(value.get('code')).toBe('c'); + expect(value.get('code_verifier')).toBe('v'); + expect(rewrote).toEqual(['resource', 'scope']); + }); + + it('does not inject scope into a refresh_token grant that has none', () => { + const form = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: 'r' }); + const { value, rewrote } = rewriteTokenBody(form, { + scopes: ['openid'], + upstreamResource: 'https://mcp.example.com/mcp/radar', + }); + expect(value.has('scope')).toBe(false); + expect(value.get('refresh_token')).toBe('r'); + expect(rewrote).toEqual(['resource']); + }); +}); diff --git a/src/mcp/auth-proxy/rewrites.ts b/src/mcp/auth-proxy/rewrites.ts new file mode 100644 index 00000000..5d62e1c2 --- /dev/null +++ b/src/mcp/auth-proxy/rewrites.ts @@ -0,0 +1,174 @@ +/** + * MCP Auth Proxy — pure rewrite rules R1–R6 (docs/SPEC-mcp-auth-proxy.md § Rewrite Rules). + * + * No I/O: every function maps (upstream artifact, context) → rewritten artifact plus the + * NAMES of rewritten fields (names only — values are never logged). + */ +import type { JsonObject, RewriteContext, RewriteResult } from './types.js'; + +function proxyPrmUrl(ctx: RewriteContext): string { + return `${ctx.proxyOrigin}/.well-known/oauth-protected-resource/${ctx.routeId}`; +} + +function proxyIssuer(ctx: RewriteContext): string { + return `${ctx.proxyOrigin}/as/${ctx.routeId}`; +} + +/** + * R1 — WWW-Authenticate challenge on 401 / 403(insufficient_scope): point + * resource_metadata at the proxy PRM and (if configured) set the authoritative scope. + * Injects a full challenge when the upstream sent none, so discovery always lands on + * the proxy. All other challenge params pass through. + */ +export function rewriteChallengeHeader( + header: string | undefined, + ctx: RewriteContext +): RewriteResult { + const rewrote: string[] = ['resource_metadata']; + const scopeValue = ctx.scopes?.join(' '); + const prmParam = `resource_metadata="${proxyPrmUrl(ctx)}"`; + + const bearerMatch = header === undefined ? null : /^\s*Bearer\b\s*(.*)$/i.exec(header); + if (!bearerMatch) { + const params = [prmParam]; + if (scopeValue !== undefined) { + params.push(`scope="${scopeValue}"`); + rewrote.push('scope'); + } + return { value: `Bearer ${params.join(', ')}`, rewrote }; + } + + let params = bearerMatch[1].trim(); + if (/resource_metadata\s*=\s*"[^"]*"/i.test(params)) { + params = params.replace(/resource_metadata\s*=\s*"[^"]*"/i, prmParam); + } else { + params = params.length > 0 ? `${prmParam}, ${params}` : prmParam; + } + + if (scopeValue !== undefined) { + if (/scope\s*=\s*"[^"]*"/i.test(params)) { + params = params.replace(/scope\s*=\s*"[^"]*"/i, `scope="${scopeValue}"`); + } else { + params = `${params}, scope="${scopeValue}"`; + } + rewrote.push('scope'); + } + + return { value: `Bearer ${params}`, rewrote }; +} + +/** + * R2 — Protected Resource Metadata: resource → proxy MCP URL (no trailing slash), + * authorization_servers → proxy AS issuer, scopes_supported → configured scopes (only + * when configured). All other fields pass through. + */ +export function rewritePrm(prm: JsonObject, ctx: RewriteContext): RewriteResult { + const rewrote = ['resource', 'authorization_servers']; + const value: JsonObject = { + ...prm, + resource: `${ctx.proxyOrigin}/${ctx.routeId}`, + authorization_servers: [proxyIssuer(ctx)], + }; + if (ctx.scopes !== undefined) { + value.scopes_supported = [...ctx.scopes]; + rewrote.push('scopes_supported'); + } + return { value, rewrote }; +} + +/** + * R3 — AS metadata: issuer/authorize/token/register (and revoke, when the upstream has + * one) → proxy endpoints; client_id_metadata_document_supported deleted (forces the DCR + * path — spec fact 3); code_challenge_methods_supported and every other field (jwks_uri, + * grant types, auth methods, …) pass through with their real upstream values. + */ +export function rewriteAsMetadata( + asMetadata: JsonObject, + ctx: RewriteContext & { upstreamHasRevocation: boolean } +): RewriteResult { + const issuer = proxyIssuer(ctx); + const rewrote = ['issuer', 'authorization_endpoint', 'token_endpoint', 'registration_endpoint']; + const value: JsonObject = { + ...asMetadata, + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + registration_endpoint: `${issuer}/register`, + }; + if (ctx.upstreamHasRevocation) { + value.revocation_endpoint = `${issuer}/revoke`; + rewrote.push('revocation_endpoint'); + } else { + delete value.revocation_endpoint; + } + if ('client_id_metadata_document_supported' in value) { + delete value.client_id_metadata_document_supported; + rewrote.push('client_id_metadata_document_supported'); + } + if (ctx.scopes !== undefined) { + value.scopes_supported = [...ctx.scopes]; + rewrote.push('scopes_supported'); + } + return { value, rewrote }; +} + +/** + * R4 — Dynamic Client Registration body: client_name / scope overrides (scope injected + * when absent). Everything else — redirect_uris, grant_types, response_types, + * token_endpoint_auth_method, … — passes through untouched. + */ +export function rewriteRegistrationBody( + body: JsonObject, + ctx: { clientName?: string; scopes?: string[] } +): RewriteResult { + const rewrote: string[] = []; + const value: JsonObject = { ...body }; + if (ctx.clientName !== undefined) { + value.client_name = ctx.clientName; + rewrote.push('client_name'); + } + if (ctx.scopes !== undefined) { + value.scope = ctx.scopes.join(' '); + rewrote.push('scope'); + } + return { value, rewrote }; +} + +/** + * R5 — authorization redirect query: resource → upstream canonical URI (RFC 8707; + * set even when absent so issued tokens are audience-bound upstream), scope override + * only when configured. client_id, redirect_uri, state, PKCE params, and unknown + * params pass through untouched. + */ +export function rewriteAuthorizeQuery( + query: URLSearchParams, + ctx: { scopes?: string[]; upstreamResource: string } +): RewriteResult { + const rewrote = ['resource']; + const value = new URLSearchParams(query); + value.set('resource', ctx.upstreamResource); + if (ctx.scopes !== undefined) { + value.set('scope', ctx.scopes.join(' ')); + rewrote.push('scope'); + } + return { value, rewrote }; +} + +/** + * R6 — token exchange form body (all grant types): resource → upstream canonical URI; + * scope rewritten only when the field is present AND scopes are configured. code, + * code_verifier, client_id, redirect_uri, refresh_token pass through untouched. + */ +export function rewriteTokenBody( + form: URLSearchParams, + ctx: { scopes?: string[]; upstreamResource: string } +): RewriteResult { + const rewrote = ['resource']; + const value = new URLSearchParams(form); + value.set('resource', ctx.upstreamResource); + if (ctx.scopes !== undefined && value.has('scope')) { + value.set('scope', ctx.scopes.join(' ')); + rewrote.push('scope'); + } + return { value, rewrote }; +} From 259d3da3db20f5a75577969c2e5121bbdc537600 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Fri, 3 Jul 2026 19:46:01 +0200 Subject: [PATCH 05/36] feat(proxy): add mcp-auth-proxy daemon state file helpers Generated with AI Co-Authored-By: codemie-ai --- src/mcp/auth-proxy/__tests__/state.test.ts | 60 ++++++++++++++++++++++ src/mcp/auth-proxy/state.ts | 52 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/mcp/auth-proxy/__tests__/state.test.ts create mode 100644 src/mcp/auth-proxy/state.ts diff --git a/src/mcp/auth-proxy/__tests__/state.test.ts b/src/mcp/auth-proxy/__tests__/state.test.ts new file mode 100644 index 00000000..039cff60 --- /dev/null +++ b/src/mcp/auth-proxy/__tests__/state.test.ts @@ -0,0 +1,60 @@ +/** + * mcp-auth-proxy daemon state file tests + * @group unit + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { unlink, writeFile } from 'node:fs/promises'; +import { + clearAuthProxyState, + isProcessAlive, + readAuthProxyState, + writeAuthProxyState, +} from '../state.js'; +import type { AuthProxyDaemonState } from '../types.js'; + +const stateFile = join(tmpdir(), `mcp-auth-proxy-state-test-${process.pid}-${Date.now()}.json`); +const state: AuthProxyDaemonState = { + pid: process.pid, + port: 42800, + routes: ['radar', 'other'], + startedAt: '2026-07-03T00:00:00Z', +}; + +afterEach(async () => { + try { + await unlink(stateFile); + } catch { + // already gone + } +}); + +describe('auth proxy state file', () => { + it('round-trips state atomically (no .tmp file left behind)', async () => { + await writeAuthProxyState(state, stateFile); + expect(existsSync(`${stateFile}.tmp`)).toBe(false); + expect(await readAuthProxyState(stateFile)).toEqual(state); + }); + + it('returns null for a missing, malformed, or wrong-shaped state file', async () => { + expect(await readAuthProxyState(stateFile)).toBeNull(); + await writeFile(stateFile, 'not-json', 'utf-8'); + expect(await readAuthProxyState(stateFile)).toBeNull(); + await writeFile(stateFile, '{"pid":"x"}', 'utf-8'); + expect(await readAuthProxyState(stateFile)).toBeNull(); + }); + + it('clearAuthProxyState removes the file and tolerates absence', async () => { + await writeAuthProxyState(state, stateFile); + await clearAuthProxyState(stateFile); + expect(existsSync(stateFile)).toBe(false); + await expect(clearAuthProxyState(stateFile)).resolves.toBeUndefined(); + }); + + it('isProcessAlive: true for this process, false for a dead pid', () => { + expect(isProcessAlive(process.pid)).toBe(true); + expect(isProcessAlive(2 ** 30)).toBe(false); + }); +}); diff --git a/src/mcp/auth-proxy/state.ts b/src/mcp/auth-proxy/state.ts new file mode 100644 index 00000000..efcc43bd --- /dev/null +++ b/src/mcp/auth-proxy/state.ts @@ -0,0 +1,52 @@ +/** + * MCP Auth Proxy — daemon state file helpers. + * + * Atomic tmp+rename writes and defensive reads, mirroring the SSO proxy's + * daemon-manager pattern for the auth-proxy state schema {pid, port, routes, startedAt}. + */ +import { readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { getDefaultStatePath } from './config.js'; +import type { AuthProxyDaemonState } from './types.js'; + +export async function readAuthProxyState( + stateFile: string = getDefaultStatePath() +): Promise { + try { + const raw = await readFile(stateFile, 'utf-8'); + const parsed = JSON.parse(raw) as AuthProxyDaemonState; + if (typeof parsed.pid !== 'number' || typeof parsed.port !== 'number') { + return null; + } + return parsed; + } catch { + return null; + } +} + +export async function writeAuthProxyState( + state: AuthProxyDaemonState, + stateFile: string = getDefaultStatePath() +): Promise { + const tmp = `${stateFile}.tmp`; + await writeFile(tmp, JSON.stringify(state, null, 2), 'utf-8'); + await rename(tmp, stateFile); +} + +export async function clearAuthProxyState( + stateFile: string = getDefaultStatePath() +): Promise { + try { + await unlink(stateFile); + } catch { + // Already gone — no-op + } +} + +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} From f440ed526ce231e179b12defddd69f2a59668cff Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Fri, 3 Jul 2026 19:48:47 +0200 Subject: [PATCH 06/36] feat(proxy): add streaming upstream HTTP client for mcp-auth-proxy Generated with AI Co-Authored-By: codemie-ai --- src/mcp/auth-proxy/upstream-client.ts | 148 ++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 src/mcp/auth-proxy/upstream-client.ts diff --git a/src/mcp/auth-proxy/upstream-client.ts b/src/mcp/auth-proxy/upstream-client.ts new file mode 100644 index 00000000..a27d0a12 --- /dev/null +++ b/src/mcp/auth-proxy/upstream-client.ts @@ -0,0 +1,148 @@ +/** + * MCP Auth Proxy — outbound HTTP client. + * + * Streaming forward for the MCP pass-through (no buffering, abort propagation) plus a + * small buffered fetchJson for OAuth metadata discovery. Honors HTTP(S)_PROXY env like + * the SSO proxy's outbound client. TLS verification is intentionally ON: this client + * relays OAuth traffic to the enterprise IdP. + */ +import http from 'node:http'; +import https from 'node:https'; +import { pipeline } from 'node:stream/promises'; +import type { Readable } from 'node:stream'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import { logger } from '../../utils/logger.js'; +import type { JsonObject } from './types.js'; + +const FETCH_JSON_TIMEOUT_MS = 5000; +const FETCH_JSON_MAX_BYTES = 256 * 1024; + +function getProxyEnvUrl(protocol: string): string | undefined { + if (protocol === 'https:') { + return ( + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy + ); + } + return process.env.HTTP_PROXY || process.env.http_proxy; +} + +export interface BeginOptions { + method: string; + headers: http.OutgoingHttpHeaders; + /** Streaming request body (MCP pass-through). Mutually exclusive with `body`. */ + bodyStream?: Readable; + /** Buffered request body (rewritten OAuth payloads). */ + body?: Buffer; +} + +export interface UpstreamExchange { + request: http.ClientRequest; + response: Promise; +} + +export class UpstreamClient { + private readonly httpsAgent: https.Agent; + private readonly httpAgent: http.Agent; + + constructor() { + const agentOptions = { keepAlive: true, maxSockets: 50 }; + const httpsProxyUrl = getProxyEnvUrl('https:'); + const httpProxyUrl = getProxyEnvUrl('http:'); + this.httpsAgent = httpsProxyUrl + ? new HttpsProxyAgent(httpsProxyUrl, agentOptions) + : new https.Agent(agentOptions); + this.httpAgent = httpProxyUrl + ? new HttpProxyAgent(httpProxyUrl, agentOptions) + : new http.Agent(agentOptions); + if (httpsProxyUrl || httpProxyUrl) { + logger.debug('[mcp-auth-proxy] Using corporate proxy from environment for upstream calls'); + } + } + + /** + * Open an upstream request. `response` rejects on network errors; callers destroy + * `request` to propagate client aborts. Timeout 0 — MCP SSE streams are long-lived. + */ + begin(url: URL, options: BeginOptions): UpstreamExchange { + const isHttps = url.protocol === 'https:'; + const protocol = isHttps ? https : http; + const agent = isHttps ? this.httpsAgent : this.httpAgent; + + let request!: http.ClientRequest; + const response = new Promise((resolve, reject) => { + request = protocol.request( + { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname + url.search, + method: options.method, + headers: options.headers, + agent, + timeout: 0, + }, + resolve + ); + request.on('error', reject); + }); + + if (options.bodyStream) { + pipeline(options.bodyStream, request).catch((error: unknown) => { + request.destroy(error instanceof Error ? error : new Error(String(error))); + }); + } else if (options.body !== undefined) { + request.end(options.body); + } else { + request.end(); + } + + return { request, response }; + } + + /** Buffered GET returning parsed JSON. Non-2xx, oversized, or non-object → throws. */ + async fetchJson(url: string): Promise { + const target = new URL(url); + const { request, response } = this.begin(target, { + method: 'GET', + headers: { accept: 'application/json' }, + }); + const timer = setTimeout( + () => request.destroy(new Error(`Timed out fetching metadata from ${target.host}`)), + FETCH_JSON_TIMEOUT_MS + ); + try { + const res = await response; + const status = res.statusCode ?? 0; + if (status < 200 || status >= 300) { + res.resume(); + throw new Error(`GET ${target.host}${target.pathname} returned ${status}`); + } + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of res) { + const buf = Buffer.from(chunk as Buffer); + size += buf.length; + if (size > FETCH_JSON_MAX_BYTES) { + res.destroy(); + throw new Error(`Metadata document from ${target.host} exceeds ${FETCH_JSON_MAX_BYTES} bytes`); + } + chunks.push(buf); + } + const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf-8')); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`Metadata document from ${target.host} is not a JSON object`); + } + return parsed as JsonObject; + } finally { + clearTimeout(timer); + } + } + + close(): void { + this.httpsAgent.destroy(); + this.httpAgent.destroy(); + } +} From ca9c590dd178a0f1fa9ce8afb4325ebafc8b0b0c Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Fri, 3 Jul 2026 19:52:30 +0200 Subject: [PATCH 07/36] feat(proxy): add upstream OAuth metadata discovery and TTL cache Generated with AI Co-Authored-By: codemie-ai --- .../__tests__/metadata-cache.test.ts | 158 +++++++++++++++ src/mcp/auth-proxy/metadata-cache.ts | 181 ++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 src/mcp/auth-proxy/__tests__/metadata-cache.test.ts create mode 100644 src/mcp/auth-proxy/metadata-cache.ts diff --git a/src/mcp/auth-proxy/__tests__/metadata-cache.test.ts b/src/mcp/auth-proxy/__tests__/metadata-cache.test.ts new file mode 100644 index 00000000..43250d23 --- /dev/null +++ b/src/mcp/auth-proxy/__tests__/metadata-cache.test.ts @@ -0,0 +1,158 @@ +/** + * mcp-auth-proxy metadata discovery + cache tests (fetchJson injected — no network). + * @group unit + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + METADATA_TTL_MS, + NEGATIVE_TTL_MS, + MetadataCache, + MetadataDiscoveryError, +} from '../metadata-cache.js'; +import { logger } from '../../../utils/logger.js'; +import type { JsonObject, RouteConfig } from '../types.js'; + +const route: RouteConfig = { upstreamUrl: 'https://mcp.example.com/mcp/radar' }; + +const PRM: JsonObject = { + resource: 'https://mcp.example.com/mcp/radar/', + authorization_servers: ['https://idp.example.com/realms/x'], +}; +const AS: JsonObject = { + issuer: 'https://idp.example.com/realms/x', + authorization_endpoint: 'https://idp.example.com/a', + token_endpoint: 'https://idp.example.com/t', + registration_endpoint: 'https://idp.example.com/r', +}; + +const PRM_PATH_URL = 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp/radar'; +const AS_8414_URL = 'https://idp.example.com/.well-known/oauth-authorization-server/realms/x'; + +function fakeFetcher(docs: Record): { + calls: string[]; + fetchJson: (url: string) => Promise; +} { + const calls: string[] = []; + return { + calls, + fetchJson: (url: string): Promise => { + calls.push(url); + const doc = docs[url]; + return doc !== undefined ? Promise.resolve(doc) : Promise.reject(new Error(`404 ${url}`)); + }, + }; +} + +const HAPPY_DOCS: Record = { + [PRM_PATH_URL]: PRM, + [AS_8414_URL]: AS, +}; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('MetadataCache', () => { + it('discovers PRM path-variant first and AS metadata in the spec priority order', async () => { + const { calls, fetchJson } = fakeFetcher(HAPPY_DOCS); + const cache = new MetadataCache(fetchJson); + const meta = await cache.getMetadata('radar', route); + expect(calls[0]).toBe(PRM_PATH_URL); + expect(calls[1]).toBe(AS_8414_URL); + expect(meta.upstreamResource).toBe('https://mcp.example.com/mcp/radar'); + expect(meta.registrationEndpoint).toBe('https://idp.example.com/r'); + expect(meta.authorizationEndpoint).toBe('https://idp.example.com/a'); + expect(meta.tokenEndpoint).toBe('https://idp.example.com/t'); + expect(meta.revocationEndpoint).toBeUndefined(); + expect(cache.getStatus('radar')).toBe('ok'); + }); + + it('falls back to the root PRM variant and the OIDC path-appending AS variant', async () => { + const docs: Record = { + 'https://mcp.example.com/.well-known/oauth-protected-resource': PRM, + 'https://idp.example.com/realms/x/.well-known/openid-configuration': AS, + }; + const { calls, fetchJson } = fakeFetcher(docs); + const cache = new MetadataCache(fetchJson); + await cache.getMetadata('radar', route); + expect(calls).toContain('https://mcp.example.com/.well-known/oauth-protected-resource'); + const insertion = calls.indexOf(AS_8414_URL); + const oidcInsertion = calls.indexOf( + 'https://idp.example.com/.well-known/openid-configuration/realms/x' + ); + const appending = calls.indexOf( + 'https://idp.example.com/realms/x/.well-known/openid-configuration' + ); + expect(insertion).toBeGreaterThanOrEqual(0); + expect(insertion).toBeLessThan(oidcInsertion); + expect(oidcInsertion).toBeLessThan(appending); + }); + + it('prefers a PRM URL captured from a live 401 challenge', async () => { + const hinted = 'https://mcp.example.com/custom/prm-location'; + const { calls, fetchJson } = fakeFetcher({ ...HAPPY_DOCS, [hinted]: PRM }); + const cache = new MetadataCache(fetchJson); + cache.notePrmUrl('radar', hinted); + await cache.getMetadata('radar', route); + expect(calls[0]).toBe(hinted); + }); + + it('caches positive results for the TTL and refreshes after expiry', async () => { + const { calls, fetchJson } = fakeFetcher(HAPPY_DOCS); + const cache = new MetadataCache(fetchJson); + await cache.getMetadata('radar', route); + await cache.getMetadata('radar', route); + expect(calls.length).toBe(2); + vi.advanceTimersByTime(METADATA_TTL_MS + 1); + await cache.getMetadata('radar', route); + expect(calls.length).toBe(4); + }); + + it('negative-caches failures for 10 s, retries after, and keeps failures per-route', async () => { + const { calls, fetchJson } = fakeFetcher({}); + const cache = new MetadataCache(fetchJson); + await expect(cache.getMetadata('radar', route)).rejects.toThrow(MetadataDiscoveryError); + expect(cache.getStatus('radar')).toBe('degraded'); + const callsAfterFirst = calls.length; + await expect(cache.getMetadata('radar', route)).rejects.toThrow(MetadataDiscoveryError); + expect(calls.length).toBe(callsAfterFirst); + expect(cache.getStatus('other')).toBe('unknown'); + vi.advanceTimersByTime(NEGATIVE_TTL_MS + 1); + await expect(cache.getMetadata('radar', route)).rejects.toThrow(MetadataDiscoveryError); + expect(calls.length).toBeGreaterThan(callsAfterFirst); + }); + + it('warns and uses the first AS when the upstream PRM lists several', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const docs: Record = { + [PRM_PATH_URL]: { + ...PRM, + authorization_servers: ['https://idp.example.com/realms/x', 'https://other.example'], + }, + [AS_8414_URL]: AS, + }; + const cache = new MetadataCache(fakeFetcher(docs).fetchJson); + const meta = await cache.getMetadata('radar', route); + expect(meta.tokenEndpoint).toBe('https://idp.example.com/t'); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('fails when the AS advertises no registration_endpoint (DCR is mandatory)', async () => { + const asWithoutRegistration: JsonObject = { ...AS }; + delete asWithoutRegistration.registration_endpoint; + const docs: Record = { [PRM_PATH_URL]: PRM, [AS_8414_URL]: asWithoutRegistration }; + const cache = new MetadataCache(fakeFetcher(docs).fetchJson); + await expect(cache.getMetadata('radar', route)).rejects.toThrow(/registration_endpoint/); + }); + + it('fails when the PRM has no authorization_servers', async () => { + const docs: Record = { [PRM_PATH_URL]: { resource: 'x' } }; + const cache = new MetadataCache(fakeFetcher(docs).fetchJson); + await expect(cache.getMetadata('radar', route)).rejects.toThrow(/authorization_servers/); + }); +}); diff --git a/src/mcp/auth-proxy/metadata-cache.ts b/src/mcp/auth-proxy/metadata-cache.ts new file mode 100644 index 00000000..025fe840 --- /dev/null +++ b/src/mcp/auth-proxy/metadata-cache.ts @@ -0,0 +1,181 @@ +/** + * MCP Auth Proxy — per-route upstream OAuth metadata discovery + TTL cache. + * + * Discovery chain per the MCP Authorization spec (rev 2025-11-25): upstream PRM + * (path-aware well-known, then root, plus any resource_metadata URL captured from a live + * upstream 401), then AS metadata from the PRM's first authorization server, trying the + * spec's well-known variants in priority order (RFC 8414 path-insertion → OIDC + * path-insertion → OIDC path-appending). Positive TTL 300 s; failures negative-cached + * 10 s so a degraded route retries quickly without hammering the upstream. Route + * failures are fully isolated — one degraded route never affects another. + */ +import { CodeMieError } from '../../utils/errors.js'; +import { logger } from '../../utils/logger.js'; +import type { JsonObject, RouteConfig, RouteStatus, UpstreamMetadata } from './types.js'; + +export const METADATA_TTL_MS = 300_000; +export const NEGATIVE_TTL_MS = 10_000; + +export class MetadataDiscoveryError extends CodeMieError { + constructor(routeId: string, reason: string) { + super(`Metadata discovery failed for route "${routeId}": ${reason}`); + this.name = 'MetadataDiscoveryError'; + } +} + +export type FetchJson = (url: string) => Promise; + +interface CacheEntry { + metadata: UpstreamMetadata; + expiresAt: number; +} + +interface FailureEntry { + reason: string; + expiresAt: number; +} + +export class MetadataCache { + private readonly entries = new Map(); + private readonly failures = new Map(); + private readonly prmUrlHints = new Map(); + + constructor(private readonly fetchJson: FetchJson) {} + + /** R2: remember a resource_metadata URL observed on a live upstream 401. */ + notePrmUrl(routeId: string, url: string): void { + try { + new URL(url); + this.prmUrlHints.set(routeId, url); + } catch { + // Malformed hint — ignore. + } + } + + getStatus(routeId: string): RouteStatus { + if (this.entries.has(routeId)) { + return 'ok'; + } + const failure = this.failures.get(routeId); + if (failure !== undefined && failure.expiresAt > Date.now()) { + return 'degraded'; + } + return 'unknown'; + } + + async getMetadata(routeId: string, route: RouteConfig): Promise { + const cached = this.entries.get(routeId); + if (cached !== undefined && cached.expiresAt > Date.now()) { + return cached.metadata; + } + if (cached !== undefined) { + this.entries.delete(routeId); + } + + const failure = this.failures.get(routeId); + if (failure !== undefined && failure.expiresAt > Date.now()) { + throw new MetadataDiscoveryError(routeId, failure.reason); + } + + try { + const metadata = await this.discover(routeId, route); + this.entries.set(routeId, { metadata, expiresAt: Date.now() + METADATA_TTL_MS }); + this.failures.delete(routeId); + return metadata; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.failures.set(routeId, { reason, expiresAt: Date.now() + NEGATIVE_TTL_MS }); + throw error instanceof MetadataDiscoveryError + ? error + : new MetadataDiscoveryError(routeId, reason); + } + } + + private async discover(routeId: string, route: RouteConfig): Promise { + const upstream = new URL(route.upstreamUrl); + const upstreamPath = upstream.pathname.replace(/\/+$/, ''); + + const prmCandidates: string[] = []; + const hint = this.prmUrlHints.get(routeId); + if (hint !== undefined) { + prmCandidates.push(hint); + } + if (upstreamPath !== '' && upstreamPath !== '/') { + prmCandidates.push(`${upstream.origin}/.well-known/oauth-protected-resource${upstreamPath}`); + } + prmCandidates.push(`${upstream.origin}/.well-known/oauth-protected-resource`); + + const prm = await this.firstJson(prmCandidates, routeId, 'protected resource metadata'); + + const authServers = prm.authorization_servers; + if (!Array.isArray(authServers) || authServers.length === 0 || typeof authServers[0] !== 'string') { + throw new MetadataDiscoveryError(routeId, 'upstream PRM has no authorization_servers'); + } + if (authServers.length > 1) { + logger.warn( + `[mcp-auth-proxy] Route "${routeId}": upstream lists ${authServers.length} authorization servers; using the first (v1 limitation)` + ); + } + + const issuer = new URL(authServers[0]); + const issuerPath = issuer.pathname.replace(/\/+$/, ''); + const asCandidates = + issuerPath !== '' && issuerPath !== '/' + ? [ + `${issuer.origin}/.well-known/oauth-authorization-server${issuerPath}`, + `${issuer.origin}/.well-known/openid-configuration${issuerPath}`, + `${issuer.origin}${issuerPath}/.well-known/openid-configuration`, + ] + : [ + `${issuer.origin}/.well-known/oauth-authorization-server`, + `${issuer.origin}/.well-known/openid-configuration`, + ]; + + const asMetadata = await this.firstJson(asCandidates, routeId, 'authorization server metadata'); + + const registrationEndpoint = asMetadata.registration_endpoint; + if (typeof registrationEndpoint !== 'string' || registrationEndpoint.length === 0) { + throw new MetadataDiscoveryError( + routeId, + 'upstream AS advertises no registration_endpoint — DCR is mandatory for this proxy' + ); + } + const authorizationEndpoint = asMetadata.authorization_endpoint; + const tokenEndpoint = asMetadata.token_endpoint; + if (typeof authorizationEndpoint !== 'string' || typeof tokenEndpoint !== 'string') { + throw new MetadataDiscoveryError( + routeId, + 'upstream AS metadata lacks authorization_endpoint/token_endpoint' + ); + } + + const upstreamResource = + typeof prm.resource === 'string' && prm.resource.length > 0 + ? prm.resource.replace(/\/+$/, '') + : route.upstreamUrl.replace(/\/+$/, ''); + + return { + prm, + asMetadata, + upstreamResource, + registrationEndpoint, + authorizationEndpoint, + tokenEndpoint, + ...(typeof asMetadata.revocation_endpoint === 'string' + ? { revocationEndpoint: asMetadata.revocation_endpoint } + : {}), + }; + } + + private async firstJson(candidates: string[], routeId: string, what: string): Promise { + let lastError = 'no candidate URLs'; + for (const candidate of candidates) { + try { + return await this.fetchJson(candidate); + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + throw new MetadataDiscoveryError(routeId, `could not fetch ${what}: ${lastError}`); + } +} From 76a65dbf59b71c626de75b123c63361660d62d76 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Fri, 3 Jul 2026 19:57:30 +0200 Subject: [PATCH 08/36] feat(proxy): add McpAuthProxy server with streaming pass-through and OAuth rewrites Generated with AI Co-Authored-By: codemie-ai --- src/mcp/auth-proxy/__tests__/server.test.ts | 335 ++++++++++++ src/mcp/auth-proxy/server.ts | 569 ++++++++++++++++++++ 2 files changed, 904 insertions(+) create mode 100644 src/mcp/auth-proxy/__tests__/server.test.ts create mode 100644 src/mcp/auth-proxy/server.ts diff --git a/src/mcp/auth-proxy/__tests__/server.test.ts b/src/mcp/auth-proxy/__tests__/server.test.ts new file mode 100644 index 00000000..30562e8e --- /dev/null +++ b/src/mcp/auth-proxy/__tests__/server.test.ts @@ -0,0 +1,335 @@ +/** + * mcp-auth-proxy server tests — real HTTP against a local fake upstream (MCP RS + AS). + * @group unit + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { McpAuthProxy } from '../server.js'; +import type { AuthProxyConfig, JsonObject } from '../types.js'; + +interface FakeUpstream { + server: http.Server; + origin: string; + state: { lastRegisterBody?: JsonObject; lastTokenBody?: string; lastMcpAuth?: string }; +} + +function createFakeUpstream(): Promise { + const state: FakeUpstream['state'] = {}; + const server = http.createServer((req, res) => { + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const url = new URL(req.url ?? '/', origin); + const sendJson = (status: number, body: unknown): void => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + const readBody = async (): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.from(chunk as Buffer)); + } + return Buffer.concat(chunks).toString('utf-8'); + }; + + void (async (): Promise => { + if (url.pathname === '/mcp/radar' && req.method === 'POST') { + state.lastMcpAuth = req.headers.authorization; + if (req.headers.authorization) { + sendJson(200, { jsonrpc: '2.0', id: 1, result: 'ok' }); + } else { + res.writeHead(401, { + 'www-authenticate': `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp/radar", scope="upstream-scope"`, + }); + res.end(); + } + return; + } + if (url.pathname === '/mcp/radar/sse' && req.method === 'GET') { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.write('data: one\n\n'); + setTimeout(() => { + res.write('data: two\n\n'); + res.end(); + }, 20); + return; + } + if (url.pathname === '/mcp/naked') { + res.writeHead(401); + res.end(); + return; + } + if ( + url.pathname === '/.well-known/oauth-protected-resource/mcp/radar' || + url.pathname === '/.well-known/oauth-protected-resource/mcp/naked' + ) { + const resource = url.pathname.endsWith('naked') ? `${origin}/mcp/naked` : `${origin}/mcp/radar`; + sendJson(200, { + resource, + authorization_servers: [`${origin}/idp`], + scopes_supported: ['upstream-scope'], + }); + return; + } + if (url.pathname === '/.well-known/oauth-authorization-server/idp') { + sendJson(200, { + issuer: `${origin}/idp`, + authorization_endpoint: `${origin}/idp/authorize`, + token_endpoint: `${origin}/idp/token`, + registration_endpoint: `${origin}/idp/register`, + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true, + }); + return; + } + if (url.pathname === '/idp/register' && req.method === 'POST') { + state.lastRegisterBody = JSON.parse(await readBody()) as JsonObject; + sendJson(201, { client_id: `dyn-${Date.now()}`, client_name: state.lastRegisterBody.client_name }); + return; + } + if (url.pathname === '/idp/token' && req.method === 'POST') { + state.lastTokenBody = await readBody(); + sendJson(200, { access_token: 'tok-123', token_type: 'Bearer' }); + return; + } + sendJson(404, { error: 'not_found' }); + })(); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve({ + server, + origin: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + state, + }); + }); + }); +} + +describe('McpAuthProxy', () => { + let upstream: FakeUpstream; + let proxy: McpAuthProxy; + let proxyOrigin: string; + + beforeAll(async () => { + upstream = await createFakeUpstream(); + const config: AuthProxyConfig = { + port: 0, + servers: { + radar: { + upstreamUrl: `${upstream.origin}/mcp/radar`, + clientName: 'EPAM Approved MCP Client', + scopes: ['openid', 'mcp:access'], + }, + naked: { upstreamUrl: `${upstream.origin}/mcp/naked` }, + down: { upstreamUrl: 'http://127.0.0.1:1/mcp/down' }, + }, + }; + proxy = new McpAuthProxy(config); + const { url } = await proxy.start(); + proxyOrigin = url; + }); + + afterAll(async () => { + await proxy.stop(); + await new Promise((resolve) => upstream.server.close(() => resolve())); + }); + + it('streams MCP POSTs through untouched, passing Authorization along', async () => { + const res = await fetch(`${proxyOrigin}/radar`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Bearer tok-abc' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ jsonrpc: '2.0', id: 1, result: 'ok' }); + expect(upstream.state.lastMcpAuth).toBe('Bearer tok-abc'); + }); + + it('rewrites the 401 challenge to point at the proxy PRM with configured scope', async () => { + const res = await fetch(`${proxyOrigin}/radar`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + expect(res.status).toBe(401); + const challenge = res.headers.get('www-authenticate') ?? ''; + expect(challenge).toContain(`resource_metadata="${proxyOrigin}/.well-known/oauth-protected-resource/radar"`); + expect(challenge).toContain('scope="openid mcp:access"'); + expect(challenge).not.toContain('upstream-scope'); + }); + + it('injects a challenge when the upstream 401 carries none', async () => { + const res = await fetch(`${proxyOrigin}/naked`, { method: 'GET' }); + expect(res.status).toBe(401); + const challenge = res.headers.get('www-authenticate') ?? ''; + expect(challenge).toContain(`resource_metadata="${proxyOrigin}/.well-known/oauth-protected-resource/naked"`); + expect(challenge).not.toContain('scope='); + }); + + it('streams SSE responses through with the event-stream content type', async () => { + const res = await fetch(`${proxyOrigin}/radar/sse`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('text/event-stream'); + const body = await res.text(); + expect(body).toContain('data: one'); + expect(body).toContain('data: two'); + }); + + it('serves the rewritten PRM for a route', async () => { + const res = await fetch(`${proxyOrigin}/.well-known/oauth-protected-resource/radar`); + expect(res.status).toBe(200); + const prm = (await res.json()) as JsonObject; + expect(prm.resource).toBe(`${proxyOrigin}/radar`); + expect(prm.authorization_servers).toEqual([`${proxyOrigin}/as/radar`]); + expect(prm.scopes_supported).toEqual(['openid', 'mcp:access']); + }); + + it('serves identical rewritten AS metadata on all three well-known variants', async () => { + const variants = [ + `${proxyOrigin}/.well-known/oauth-authorization-server/as/radar`, + `${proxyOrigin}/.well-known/openid-configuration/as/radar`, + `${proxyOrigin}/as/radar/.well-known/openid-configuration`, + ]; + const bodies: JsonObject[] = []; + for (const variant of variants) { + const res = await fetch(variant); + expect(res.status).toBe(200); + bodies.push((await res.json()) as JsonObject); + } + for (const body of bodies) { + expect(body.issuer).toBe(`${proxyOrigin}/as/radar`); + expect(body.authorization_endpoint).toBe(`${proxyOrigin}/as/radar/authorize`); + expect(body.token_endpoint).toBe(`${proxyOrigin}/as/radar/token`); + expect(body.registration_endpoint).toBe(`${proxyOrigin}/as/radar/register`); + expect(body.code_challenge_methods_supported).toEqual(['S256']); + expect(body).not.toHaveProperty('client_id_metadata_document_supported'); + expect(body).not.toHaveProperty('revocation_endpoint'); + } + }); + + it('rewrites DCR bodies (client_name + injected scope) and relays the dynamic client_id', async () => { + const res = await fetch(`${proxyOrigin}/as/radar/register`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Claude Code', + redirect_uris: ['http://localhost:33418/callback'], + }), + }); + expect(res.status).toBe(201); + const registered = (await res.json()) as JsonObject; + expect(String(registered.client_id)).toMatch(/^dyn-/); + expect(upstream.state.lastRegisterBody?.client_name).toBe('EPAM Approved MCP Client'); + expect(upstream.state.lastRegisterBody?.scope).toBe('openid mcp:access'); + expect(upstream.state.lastRegisterBody?.redirect_uris).toEqual(['http://localhost:33418/callback']); + }); + + it('302-redirects authorize with rewritten scope + resource, preserving PKCE params', async () => { + const query = new URLSearchParams({ + response_type: 'code', + client_id: 'dyn-1', + redirect_uri: 'http://localhost:33418/callback', + state: 's1', + code_challenge: 'cc', + code_challenge_method: 'S256', + scope: 'claudeai', + resource: `${proxyOrigin}/radar`, + }); + const res = await fetch(`${proxyOrigin}/as/radar/authorize?${query.toString()}`, { + redirect: 'manual', + }); + expect(res.status).toBe(302); + const location = new URL(res.headers.get('location') ?? ''); + expect(`${location.origin}${location.pathname}`).toBe(`${upstream.origin}/idp/authorize`); + expect(location.searchParams.get('scope')).toBe('openid mcp:access'); + expect(location.searchParams.get('resource')).toBe(`${upstream.origin}/mcp/radar`); + expect(location.searchParams.get('code_challenge')).toBe('cc'); + expect(location.searchParams.get('state')).toBe('s1'); + }); + + it('rewrites token bodies (resource) and relays the token response verbatim', async () => { + const form = new URLSearchParams({ + grant_type: 'authorization_code', + code: 'auth-code-1', + code_verifier: 'verifier-1', + client_id: 'dyn-1', + redirect_uri: 'http://localhost:33418/callback', + resource: `${proxyOrigin}/radar`, + }); + const res = await fetch(`${proxyOrigin}/as/radar/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: form.toString(), + }); + expect(res.status).toBe(200); + expect(((await res.json()) as JsonObject).access_token).toBe('tok-123'); + const sent = new URLSearchParams(upstream.state.lastTokenBody ?? ''); + expect(sent.get('resource')).toBe(`${upstream.origin}/mcp/radar`); + expect(sent.get('code')).toBe('auth-code-1'); + expect(sent.get('code_verifier')).toBe('verifier-1'); + }); + + it('keeps routes isolated: a dead upstream 502s while others keep working', async () => { + const downRes = await fetch(`${proxyOrigin}/down`, { method: 'POST', body: '{}' }); + expect(downRes.status).toBe(502); + expect(await downRes.json()).toEqual({ error: 'upstream_unreachable', route: 'down' }); + + const okRes = await fetch(`${proxyOrigin}/radar`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Bearer tok-abc' }, + body: '{}', + }); + expect(okRes.status).toBe(200); + }); + + it('marks a route degraded in /healthz after failed discovery, without touching others', async () => { + const prmRes = await fetch(`${proxyOrigin}/.well-known/oauth-protected-resource/down`); + expect(prmRes.status).toBe(502); + + const health = await fetch(`${proxyOrigin}/healthz`); + expect(health.status).toBe(200); + const body = (await health.json()) as { status: string; routes: Array<{ id: string; status: string }> }; + expect(body.status).toBe('ok'); + expect(body.routes.find((r) => r.id === 'down')?.status).toBe('degraded'); + expect(body.routes.find((r) => r.id === 'radar')?.status).toBe('ok'); + }); + + it('rejects oversized register bodies with 413', async () => { + const res = await fetch(`${proxyOrigin}/as/radar/register`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ client_name: 'x'.repeat(65 * 1024) }), + }); + expect(res.status).toBe(413); + expect(await res.json()).toEqual({ error: 'payload_too_large' }); + }); + + it('404s unknown routes and the root PRM alias when multiple routes exist', async () => { + const unknown = await fetch(`${proxyOrigin}/nope`); + expect(unknown.status).toBe(404); + expect(await unknown.json()).toEqual({ error: 'unknown_route' }); + + const rootPrm = await fetch(`${proxyOrigin}/.well-known/oauth-protected-resource`); + expect(rootPrm.status).toBe(404); + }); +}); + +describe('McpAuthProxy single-route alias', () => { + it('serves the root PRM alias when exactly one route is configured', async () => { + const upstream = await createFakeUpstream(); + const config: AuthProxyConfig = { + port: 0, + servers: { radar: { upstreamUrl: `${upstream.origin}/mcp/radar` } }, + }; + const solo = new McpAuthProxy(config); + const { url } = await solo.start(); + try { + const res = await fetch(`${url}/.well-known/oauth-protected-resource`); + expect(res.status).toBe(200); + expect(((await res.json()) as JsonObject).resource).toBe(`${url}/radar`); + } finally { + await solo.stop(); + await new Promise((resolve) => upstream.server.close(() => resolve())); + } + }); +}); diff --git a/src/mcp/auth-proxy/server.ts b/src/mcp/auth-proxy/server.ts new file mode 100644 index 00000000..a0e112be --- /dev/null +++ b/src/mcp/auth-proxy/server.ts @@ -0,0 +1,569 @@ +/** + * MCP Auth Proxy — HTTP server (docs/SPEC-mcp-auth-proxy.md § Route Map, rows 1–11). + * + * Loopback-only transparent proxy: streams MCP traffic to upstreams untouched (design + * D1: raw node:http + pipeline — no buffering, no body-parsing middleware) and serves + * per-route OAuth endpoints whose only job is the surgical rewrites in rewrites.ts. + * Stateless w.r.t. auth: no tokens, codes, or client records are ever held. + * + * Uniform log rule: method, route id, endpoint kind, status, duration, and rewritten + * field NAMES only — never headers, bodies, or query strings on OAuth routes. + */ +import http from 'node:http'; +import type { Socket } from 'node:net'; +import { pipeline } from 'node:stream/promises'; +import { ConfigurationError } from '../../utils/errors.js'; +import { logger } from '../../utils/logger.js'; +import { sanitizeLogArgs } from '../../utils/security.js'; +import { MetadataCache, MetadataDiscoveryError } from './metadata-cache.js'; +import { + rewriteAsMetadata, + rewriteAuthorizeQuery, + rewriteChallengeHeader, + rewritePrm, + rewriteRegistrationBody, + rewriteTokenBody, +} from './rewrites.js'; +import { UpstreamClient } from './upstream-client.js'; +import type { AuthProxyConfig, JsonObject, RewriteContext, RouteConfig } from './types.js'; + +// Bind to the literal IPv4 loopback, never 'localhost' (repo ADR: macOS resolves +// 'localhost' to ::1 only) and never a configurable host — the proxy relays bearer +// tokens and must not be network-exposed. +export const BIND_HOST = '127.0.0.1'; + +const OAUTH_BODY_LIMIT_BYTES = 64 * 1024; + +/** Hop-by-hop headers (RFC 9110 §7.6.1) — never forwarded in either direction. */ +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'transfer-encoding', + 'te', + 'upgrade', + 'trailer', + 'proxy-authenticate', + 'proxy-authorization', + 'proxy-connection', +]); + +class BodyTooLargeError extends Error {} + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + const payload = Buffer.from(JSON.stringify(body), 'utf-8'); + res.writeHead(status, { + 'content-type': 'application/json', + 'content-length': String(payload.length), + }); + res.end(payload); +} + +async function readBodyLimited(req: http.IncomingMessage, limit: number): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + const buf = Buffer.from(chunk as Buffer); + size += buf.length; + if (size > limit) { + throw new BodyTooLargeError(); + } + chunks.push(buf); + } + return Buffer.concat(chunks); +} + +function forwardHeaders(incoming: http.IncomingHttpHeaders, target: URL): http.OutgoingHttpHeaders { + const headers: http.OutgoingHttpHeaders = {}; + for (const [key, value] of Object.entries(incoming)) { + const lower = key.toLowerCase(); + if (HOP_BY_HOP_HEADERS.has(lower) || lower === 'host') { + continue; + } + if (value !== undefined) { + headers[lower] = value; + } + } + headers.host = target.host; + return headers; +} + +function copyResponseHeaders(upstream: http.IncomingMessage): http.OutgoingHttpHeaders { + const headers: http.OutgoingHttpHeaders = {}; + for (const [key, value] of Object.entries(upstream.headers)) { + if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase()) && value !== undefined) { + headers[key] = value; + } + } + return headers; +} + +function isUpstreamNetworkError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const code = (error as NodeJS.ErrnoException).code; + return code !== undefined || error.message.includes('Timed out') || error.message.includes('socket hang up'); +} + +export class McpAuthProxy { + private server?: http.Server; + private port: number; + private readonly sockets = new Set(); + private readonly client: UpstreamClient; + private readonly metadata: MetadataCache; + + constructor(private readonly config: AuthProxyConfig) { + this.port = config.port; + this.client = new UpstreamClient(); + this.metadata = new MetadataCache((url) => this.client.fetchJson(url)); + } + + get origin(): string { + return `http://${BIND_HOST}:${this.port}`; + } + + async start(): Promise<{ port: number; url: string }> { + if (this.server) { + throw new ConfigurationError('mcp-auth-proxy server is already running'); + } + const server = http.createServer((req, res) => { + void this.handleRequest(req, res); + }); + server.on('connection', (socket) => { + this.sockets.add(socket); + socket.on('close', () => this.sockets.delete(socket)); + }); + + await new Promise((resolve, reject) => { + server.once('error', (error: NodeJS.ErrnoException) => { + reject( + error.code === 'EADDRINUSE' + ? new ConfigurationError( + `Port ${this.port} is already in use — stop the other process or set a different "port" in mcp-auth-proxy.json` + ) + : error + ); + }); + server.listen(this.port, BIND_HOST, () => resolve()); + }); + + const address = server.address(); + if (address !== null && typeof address === 'object') { + this.port = address.port; + } + this.server = server; + logger.debug( + `[mcp-auth-proxy] Listening on ${this.origin} (routes: ${Object.keys(this.config.servers).join(', ')})` + ); + return { port: this.port, url: this.origin }; + } + + async stop(): Promise { + const server = this.server; + if (!server) { + return; + } + this.server = undefined; + await new Promise((resolve) => { + server.close(() => resolve()); + // Long-lived SSE sockets would otherwise keep close() pending forever. + for (const socket of this.sockets) { + socket.destroy(); + } + }); + this.client.close(); + } + + private ctx(routeId: string): RewriteContext { + return { + proxyOrigin: this.origin, + routeId, + scopes: this.config.servers[routeId]?.scopes, + }; + } + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const startedAt = Date.now(); + const url = new URL(req.url ?? '/', this.origin); + const segments = url.pathname.split('/').filter((segment) => segment.length > 0); + let kind = 'unknown'; + let routeId = ''; + + try { + if (req.method === 'GET' && url.pathname === '/healthz') { + kind = 'healthz'; + this.serveHealth(res); + } else if (segments[0] === '.well-known') { + [kind, routeId] = await this.handleWellKnown(req, res, segments); + } else if (segments[0] === 'as' && segments.length >= 2) { + routeId = segments[1]; + kind = await this.handleOAuth(req, res, url, segments); + } else if (segments.length >= 1 && this.config.servers[segments[0]] !== undefined) { + routeId = segments[0]; + kind = 'mcp'; + await this.passThrough(req, res, url, routeId); + } else { + sendJson(res, 404, { error: 'unknown_route' }); + } + } catch (error) { + this.handleError(res, routeId, error); + } + + logger.debug( + '[mcp-auth-proxy] request', + ...sanitizeLogArgs({ + method: req.method, + route: routeId || undefined, + kind, + status: res.statusCode, + durationMs: Date.now() - startedAt, + }) + ); + } + + // ── Route map rows 2–6: well-known documents ───────────────────────────── + + private async handleWellKnown( + req: http.IncomingMessage, + res: http.ServerResponse, + segments: string[] + ): Promise<[string, string]> { + if (req.method !== 'GET') { + sendJson(res, 404, { error: 'unknown_route' }); + return ['unknown', '']; + } + const doc = segments[1]; + + if (doc === 'oauth-protected-resource') { + if (segments.length === 3) { + await this.servePrm(res, segments[2]); + return ['prm', segments[2]]; + } + // Row 3: root alias only when exactly one route is configured. + const routeIds = Object.keys(this.config.servers); + if (segments.length === 2 && routeIds.length === 1) { + await this.servePrm(res, routeIds[0]); + return ['prm', routeIds[0]]; + } + sendJson(res, 404, { error: 'unknown_route' }); + return ['unknown', '']; + } + + if ( + (doc === 'oauth-authorization-server' || doc === 'openid-configuration') && + segments[2] === 'as' && + segments.length === 4 + ) { + await this.serveAsMetadata(res, segments[3]); + return ['as-metadata', segments[3]]; + } + + sendJson(res, 404, { error: 'unknown_route' }); + return ['unknown', '']; + } + + private async servePrm(res: http.ServerResponse, routeId: string): Promise { + const route = this.config.servers[routeId]; + if (!route) { + sendJson(res, 404, { error: 'unknown_route' }); + return; + } + const meta = await this.metadata.getMetadata(routeId, route); + const { value, rewrote } = rewritePrm(meta.prm, this.ctx(routeId)); + sendJson(res, 200, value); + this.logRewrites('prm', routeId, rewrote); + } + + private async serveAsMetadata(res: http.ServerResponse, routeId: string): Promise { + const route = this.config.servers[routeId]; + if (!route) { + sendJson(res, 404, { error: 'unknown_route' }); + return; + } + const meta = await this.metadata.getMetadata(routeId, route); + const { value, rewrote } = rewriteAsMetadata(meta.asMetadata, { + ...this.ctx(routeId), + upstreamHasRevocation: meta.revocationEndpoint !== undefined, + }); + sendJson(res, 200, value); + this.logRewrites('as-metadata', routeId, rewrote); + } + + // ── Route map rows 7–10: OAuth endpoints under /as//* ──────────────── + + private async handleOAuth( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, + segments: string[] + ): Promise { + const routeId = segments[1]; + const route = this.config.servers[routeId]; + if (!route) { + sendJson(res, 404, { error: 'unknown_route' }); + return 'unknown'; + } + const action = segments.slice(2).join('/'); + + if (req.method === 'GET' && action === '.well-known/openid-configuration') { + await this.serveAsMetadata(res, routeId); + return 'as-metadata'; + } + if (req.method === 'POST' && action === 'register') { + await this.handleRegister(req, res, routeId, route); + return 'register'; + } + if (req.method === 'GET' && action === 'authorize') { + await this.handleAuthorize(res, url, routeId, route); + return 'authorize'; + } + if (req.method === 'POST' && action === 'token') { + await this.handleToken(req, res, routeId, route); + return 'token'; + } + if (req.method === 'POST' && action === 'revoke') { + await this.handleRevoke(req, res, routeId, route); + return 'revoke'; + } + sendJson(res, 404, { error: 'unknown_route' }); + return 'unknown'; + } + + private async handleRegister( + req: http.IncomingMessage, + res: http.ServerResponse, + routeId: string, + route: RouteConfig + ): Promise { + const meta = await this.metadata.getMetadata(routeId, route); + const body = await this.readOAuthBody(req, res); + if (body === null) { + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(body.toString('utf-8')); + } catch { + sendJson(res, 400, { error: 'invalid_request' }); + return; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + sendJson(res, 400, { error: 'invalid_request' }); + return; + } + const { value, rewrote } = rewriteRegistrationBody(parsed as JsonObject, { + clientName: route.clientName, + scopes: route.scopes, + }); + await this.relay(req, res, new URL(meta.registrationEndpoint), Buffer.from(JSON.stringify(value), 'utf-8'), 'application/json'); + this.logRewrites('register', routeId, rewrote); + } + + private async handleAuthorize( + res: http.ServerResponse, + url: URL, + routeId: string, + route: RouteConfig + ): Promise { + const meta = await this.metadata.getMetadata(routeId, route); + const { value, rewrote } = rewriteAuthorizeQuery(url.searchParams, { + scopes: route.scopes, + upstreamResource: meta.upstreamResource, + }); + const location = new URL(meta.authorizationEndpoint); + for (const [key, param] of value) { + location.searchParams.set(key, param); + } + res.writeHead(302, { location: location.toString() }); + res.end(); + this.logRewrites('authorize', routeId, rewrote); + } + + private async handleToken( + req: http.IncomingMessage, + res: http.ServerResponse, + routeId: string, + route: RouteConfig + ): Promise { + const meta = await this.metadata.getMetadata(routeId, route); + const body = await this.readOAuthBody(req, res); + if (body === null) { + return; + } + const { value, rewrote } = rewriteTokenBody(new URLSearchParams(body.toString('utf-8')), { + scopes: route.scopes, + upstreamResource: meta.upstreamResource, + }); + // NEVER log request or response bodies on this route. + await this.relay( + req, + res, + new URL(meta.tokenEndpoint), + Buffer.from(value.toString(), 'utf-8'), + 'application/x-www-form-urlencoded' + ); + this.logRewrites('token', routeId, rewrote); + } + + private async handleRevoke( + req: http.IncomingMessage, + res: http.ServerResponse, + routeId: string, + route: RouteConfig + ): Promise { + const meta = await this.metadata.getMetadata(routeId, route); + if (meta.revocationEndpoint === undefined) { + sendJson(res, 404, { error: 'unknown_route' }); + return; + } + const body = await this.readOAuthBody(req, res); + if (body === null) { + return; + } + const contentType = + typeof req.headers['content-type'] === 'string' + ? req.headers['content-type'] + : 'application/x-www-form-urlencoded'; + await this.relay(req, res, new URL(meta.revocationEndpoint), body, contentType); + } + + /** Reads a 64 KB-limited OAuth body; answers 413 and returns null when oversized. */ + private async readOAuthBody( + req: http.IncomingMessage, + res: http.ServerResponse + ): Promise { + try { + return await readBodyLimited(req, OAUTH_BODY_LIMIT_BYTES); + } catch (error) { + if (error instanceof BodyTooLargeError) { + sendJson(res, 413, { error: 'payload_too_large' }); + return null; + } + throw error; + } + } + + /** Forwards a buffered OAuth payload upstream and relays status + body verbatim. */ + private async relay( + req: http.IncomingMessage, + res: http.ServerResponse, + target: URL, + payload: Buffer, + contentType: string + ): Promise { + const headers = forwardHeaders(req.headers, target); + headers['content-type'] = contentType; + headers['content-length'] = String(payload.length); + const { request, response } = this.client.begin(target, { method: 'POST', headers, body: payload }); + res.on('close', () => { + if (!res.writableEnded) { + request.destroy(); + } + }); + const upstream = await response; + res.writeHead(upstream.statusCode ?? 502, copyResponseHeaders(upstream)); + await pipeline(upstream, res); + } + + // ── Route map row 1: MCP streaming pass-through ─────────────────────────── + + private async passThrough( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, + routeId: string + ): Promise { + const route = this.config.servers[routeId]; + const target = new URL(route.upstreamUrl); + target.pathname = target.pathname.replace(/\/+$/, '') + url.pathname.slice(`/${routeId}`.length); + target.search = url.search; + + const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; + const { request, response } = this.client.begin(target, { + method: req.method ?? 'GET', + headers: forwardHeaders(req.headers, target), + ...(hasBody ? { bodyStream: req } : {}), + }); + res.on('close', () => { + if (!res.writableEnded) { + request.destroy(); + } + }); + + const upstream = await response; + const status = upstream.statusCode ?? 502; + const outHeaders = copyResponseHeaders(upstream); + + const challenge = upstream.headers['www-authenticate']; + const challengeText = typeof challenge === 'string' ? challenge : undefined; + const needsChallengeRewrite = + status === 401 || + (status === 403 && + challengeText !== undefined && + /error\s*=\s*"?insufficient_scope"?/i.test(challengeText)); + + if (needsChallengeRewrite) { + if (challengeText !== undefined) { + const match = /resource_metadata\s*=\s*"([^"]+)"/i.exec(challengeText); + if (match) { + this.metadata.notePrmUrl(routeId, match[1]); + } + } + const { value, rewrote } = rewriteChallengeHeader(challengeText, this.ctx(routeId)); + outHeaders['www-authenticate'] = value; + this.logRewrites('mcp', routeId, rewrote); + } + + res.writeHead(status, outHeaders); + try { + await pipeline(upstream, res); + } catch { + // Client disconnected mid-stream (normal for aborted SSE) — tear both sides down. + upstream.destroy(); + res.destroy(); + } + } + + // ── Health + errors ─────────────────────────────────────────────────────── + + private serveHealth(res: http.ServerResponse): void { + const routes = Object.entries(this.config.servers).map(([id, route]) => ({ + id, + upstreamUrl: route.upstreamUrl, + status: this.metadata.getStatus(id), + })); + sendJson(res, 200, { status: 'ok', routes }); + } + + private handleError(res: http.ServerResponse, routeId: string, error: unknown): void { + if (res.headersSent) { + res.destroy(); + return; + } + if (error instanceof MetadataDiscoveryError || isUpstreamNetworkError(error)) { + // Spec log rule: upstream host only — we log the route id, never full URLs. + logger.warn( + '[mcp-auth-proxy] Upstream unreachable', + ...sanitizeLogArgs({ route: routeId || 'unknown' }) + ); + sendJson( + res, + 502, + routeId ? { error: 'upstream_unreachable', route: routeId } : { error: 'upstream_unreachable' } + ); + return; + } + logger.error('[mcp-auth-proxy] Request handling failed', error); + sendJson(res, 500, { error: 'internal_error' }); + } + + private logRewrites(kind: string, routeId: string, rewrote: string[]): void { + if (rewrote.length === 0) { + return; + } + logger.debug( + '[mcp-auth-proxy] rewrote', + ...sanitizeLogArgs({ route: routeId, kind, fields: rewrote.join(', ') }) + ); + } +} From 7635a99d0929c6cbce871bf8afcdcac51badc77c Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Fri, 3 Jul 2026 20:00:23 +0200 Subject: [PATCH 09/36] feat(proxy): add mcp-auth-proxy daemon runtime and detached entry point Generated with AI Co-Authored-By: codemie-ai --- bin/mcp-auth-proxy-daemon.js | 10 +++++ src/bin/mcp-auth-proxy-daemon.ts | 35 +++++++++++++++++ src/mcp/auth-proxy/runtime.ts | 64 ++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 bin/mcp-auth-proxy-daemon.js create mode 100644 src/bin/mcp-auth-proxy-daemon.ts create mode 100644 src/mcp/auth-proxy/runtime.ts diff --git a/bin/mcp-auth-proxy-daemon.js b/bin/mcp-auth-proxy-daemon.js new file mode 100644 index 00000000..794e0bf7 --- /dev/null +++ b/bin/mcp-auth-proxy-daemon.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +/** + * CodeMie MCP Auth Proxy Daemon entry point + * Imports compiled daemon from dist/ + */ +import('../dist/bin/mcp-auth-proxy-daemon.js').catch((error) => { + process.stderr.write(`[mcp-auth-proxy-daemon] Fatal: ${error.message}\n`); + process.exit(1); +}); diff --git a/src/bin/mcp-auth-proxy-daemon.ts b/src/bin/mcp-auth-proxy-daemon.ts new file mode 100644 index 00000000..273dc240 --- /dev/null +++ b/src/bin/mcp-auth-proxy-daemon.ts @@ -0,0 +1,35 @@ +/** + * MCP Auth Proxy Daemon Entry Point + * + * Spawned as a detached process by `codemie mcp-auth-proxy start`. + * Loads the config, starts McpAuthProxy, writes the state file, handles SIGTERM. + */ +import { parseArgs } from 'node:util'; +import { runAuthProxyDaemon } from '../mcp/auth-proxy/runtime.js'; + +const { values } = parseArgs({ + options: { + config: { type: 'string' }, + port: { type: 'string' }, + 'state-file': { type: 'string' }, + }, + strict: false, +}); + +const portArg = values.port as string | undefined; +const port = portArg ? Number.parseInt(portArg, 10) : undefined; +if (port !== undefined && (!Number.isFinite(port) || port <= 0)) { + process.stderr.write(`[mcp-auth-proxy-daemon] Invalid --port value: ${portArg}\n`); + process.exit(1); +} + +try { + await runAuthProxyDaemon({ + configPath: values.config as string | undefined, + port, + stateFile: values['state-file'] as string | undefined, + }); +} catch (error) { + process.stderr.write(`[mcp-auth-proxy-daemon] Failed to start: ${(error as Error).message}\n`); + process.exit(1); +} diff --git a/src/mcp/auth-proxy/runtime.ts b/src/mcp/auth-proxy/runtime.ts new file mode 100644 index 00000000..c24dbf34 --- /dev/null +++ b/src/mcp/auth-proxy/runtime.ts @@ -0,0 +1,64 @@ +/** + * MCP Auth Proxy — shared daemon runtime. + * + * Single implementation behind both the detached bin entry and CLI --foreground: + * load + validate config, start the server, persist the state file, clean up on + * SIGTERM/SIGINT. No self-healing watcher by design (spec § Non-Goals): the proxy + * holds no session state, so a crash only needs a manual restart. + */ +import { logger } from '../../utils/logger.js'; +import { getDefaultStatePath, loadAuthProxyConfig } from './config.js'; +import { McpAuthProxy } from './server.js'; +import { clearAuthProxyState, writeAuthProxyState } from './state.js'; + +export interface RunDaemonOptions { + configPath?: string; + port?: number; + stateFile?: string; +} + +export interface RunningDaemon { + proxy: McpAuthProxy; + port: number; + url: string; + routes: string[]; + stop: () => Promise; +} + +export async function runAuthProxyDaemon(options: RunDaemonOptions = {}): Promise { + const config = await loadAuthProxyConfig(options.configPath); + if (options.port !== undefined) { + config.port = options.port; + } + const stateFile = options.stateFile ?? getDefaultStatePath(); + + const proxy = new McpAuthProxy(config); + const { port, url } = await proxy.start(); + const routes = Object.keys(config.servers); + + await writeAuthProxyState( + { pid: process.pid, port, routes, startedAt: new Date().toISOString() }, + stateFile + ); + + const stop = async (): Promise => { + try { + await proxy.stop(); + } catch { + // Best-effort shutdown + } + try { + await clearAuthProxyState(stateFile); + } catch { + // Best-effort cleanup + } + }; + const onSignal = (): void => { + void stop().then(() => process.exit(0)); + }; + process.on('SIGTERM', onSignal); + process.on('SIGINT', onSignal); + + logger.debug(`[mcp-auth-proxy] Daemon running at ${url} (routes: ${routes.join(', ')})`); + return { proxy, port, url, routes, stop }; +} From 468160f019c652a6b25fed15af3b77b1da1dac81 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Fri, 3 Jul 2026 20:05:49 +0200 Subject: [PATCH 10/36] feat(cli): register codemie mcp-auth-proxy start/stop/status command Generated with AI Co-Authored-By: codemie-ai --- src/cli/commands/mcp-auth-proxy.ts | 219 +++++++++++++++++++++++++++++ src/cli/index.ts | 2 + 2 files changed, 221 insertions(+) create mode 100644 src/cli/commands/mcp-auth-proxy.ts diff --git a/src/cli/commands/mcp-auth-proxy.ts b/src/cli/commands/mcp-auth-proxy.ts new file mode 100644 index 00000000..ce6bd02a --- /dev/null +++ b/src/cli/commands/mcp-auth-proxy.ts @@ -0,0 +1,219 @@ +/** + * `codemie mcp-auth-proxy` — manage the MCP OAuth rewriting proxy daemon. + * + * Distinct from `codemie mcp-proxy` (the stdio↔HTTP bridge): this command manages a + * background loopback HTTP proxy that rewrites OAuth client_name/scope/resource for + * remote MCP servers — see docs/SPEC-mcp-auth-proxy.md. + */ +import { Command } from 'commander'; +import chalk from 'chalk'; +import http from 'node:http'; +import { join, resolve } from 'node:path'; +import { + ConfigurationError, + ToolExecutionError, + createErrorContext, + formatErrorForUser, +} from '../../utils/errors.js'; +import { getDirname } from '../../utils/paths.js'; +import { logger } from '../../utils/logger.js'; +import { spawnDetached } from '../../utils/processes.js'; +import { + getDefaultConfigPath, + getDefaultStatePath, + loadAuthProxyConfig, +} from '../../mcp/auth-proxy/config.js'; +import { runAuthProxyDaemon } from '../../mcp/auth-proxy/runtime.js'; +import { + clearAuthProxyState, + isProcessAlive, + readAuthProxyState, +} from '../../mcp/auth-proxy/state.js'; +import type { RouteStatus } from '../../mcp/auth-proxy/types.js'; + +function parsePortOption(value: string | undefined): number | undefined { + if (!value) { + return undefined; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65535) { + throw new ConfigurationError(`Invalid port value: ${value}`); + } + return parsed; +} + +function printError(error: unknown, label: string): never { + logger.error(label, error); + if (error instanceof ConfigurationError || error instanceof ToolExecutionError) { + console.error(chalk.red(`✗ ${error.message}`)); + } else { + console.error(formatErrorForUser(createErrorContext(error), { showSystem: false })); + } + process.exit(1); +} + +function printAddCommands(port: number, routes: string[]): void { + console.log(chalk.bold('\nAdd to Claude Code:')); + for (const id of routes) { + console.log(` claude mcp add --scope local --transport http ${id} http://127.0.0.1:${port}/${id}`); + } +} + +interface HealthzRoute { + id: string; + upstreamUrl: string; + status: RouteStatus; +} + +function fetchHealth(port: number): Promise<{ status: string; routes: HealthzRoute[] }> { + return new Promise((resolveHealth, rejectHealth) => { + const request = http.get( + { host: '127.0.0.1', port, path: '/healthz', timeout: 2000 }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + try { + resolveHealth(JSON.parse(Buffer.concat(chunks).toString('utf-8'))); + } catch (error) { + rejectHealth(error as Error); + } + }); + } + ); + request.on('error', rejectHealth); + request.on('timeout', () => request.destroy(new Error('healthz timed out'))); + }); +} + +export function createMcpAuthProxyCommand(): Command { + const command = new Command('mcp-auth-proxy'); + command.description( + 'Manage the MCP OAuth rewriting proxy (client_name/scope/resource rewrites for remote MCP servers; not the stdio mcp-proxy bridge)' + ); + + command + .command('start') + .description('Start the proxy daemon (detached by default)') + .option('--config ', 'Config file path (default: /mcp-auth-proxy.json)') + .option('--port ', 'Override the configured listen port') + .option('--foreground', 'Run in the foreground (debugging; CODEMIE_DEBUG=true for verbose logs)') + .action(async (opts: { config?: string; port?: string; foreground?: boolean }) => { + try { + const existing = await readAuthProxyState(); + if (existing && isProcessAlive(existing.pid)) { + console.log( + chalk.green( + `✓ mcp-auth-proxy already running on http://127.0.0.1:${existing.port} (pid ${existing.pid})` + ) + ); + printAddCommands(existing.port, existing.routes); + return; + } + await clearAuthProxyState(); + + const configPath = opts.config ? resolve(opts.config) : getDefaultConfigPath(); + const config = await loadAuthProxyConfig(configPath); // fail fast with the offending key path + const port = parsePortOption(opts.port) ?? config.port; + const routes = Object.keys(config.servers); + + if (opts.foreground) { + await runAuthProxyDaemon({ configPath, port }); + console.log(chalk.green(`✓ mcp-auth-proxy running (foreground) on http://127.0.0.1:${port}`)); + printAddCommands(port, routes); + console.log(chalk.gray('Press Ctrl+C to stop.')); + return; + } + + // dist/cli/commands/mcp-auth-proxy.js → ../../../bin/mcp-auth-proxy-daemon.js + const daemonBin = join(getDirname(import.meta.url), '../../../bin/mcp-auth-proxy-daemon.js'); + spawnDetached(process.execPath, [ + daemonBin, + '--config', configPath, + '--port', String(port), + '--state-file', getDefaultStatePath(), + ]); + + for (let i = 0; i < 50; i++) { + await new Promise((r) => setTimeout(r, 100)); + const state = await readAuthProxyState(); + if (state && isProcessAlive(state.pid)) { + console.log( + chalk.green(`✓ mcp-auth-proxy started on http://127.0.0.1:${state.port} (pid ${state.pid})`) + ); + printAddCommands(state.port, state.routes); + return; + } + } + throw new ToolExecutionError( + 'mcp-auth-proxy-daemon', + 'Daemon failed to start within 5 seconds. Try --foreground with CODEMIE_DEBUG=true.' + ); + } catch (error) { + printError(error, '[mcp-auth-proxy] start failed'); + } + }); + + command + .command('status') + .description('Show daemon status and per-route health') + .action(async () => { + const state = await readAuthProxyState(); + if (!state || !isProcessAlive(state.pid)) { + if (state) { + await clearAuthProxyState(); + } + console.log(chalk.yellow('mcp-auth-proxy is not running')); + return; + } + console.log( + chalk.green( + `✓ mcp-auth-proxy running on http://127.0.0.1:${state.port} (pid ${state.pid}, started ${state.startedAt})` + ) + ); + try { + const health = await fetchHealth(state.port); + for (const route of health.routes) { + const marker = + route.status === 'degraded' ? chalk.red('✗ degraded') : chalk.green(`✓ ${route.status}`); + console.log(` ${route.id}: ${marker} → ${route.upstreamUrl}`); + console.log( + ` claude mcp add --scope local --transport http ${route.id} http://127.0.0.1:${state.port}/${route.id}` + ); + } + } catch { + console.log(chalk.red(' ✗ Daemon process is alive but /healthz did not answer')); + } + }); + + command + .command('stop') + .description('Stop the proxy daemon and remove its state file') + .action(async () => { + const state = await readAuthProxyState(); + if (!state || !isProcessAlive(state.pid)) { + await clearAuthProxyState(); + console.log(chalk.yellow('mcp-auth-proxy is not running')); + return; + } + process.kill(state.pid, 'SIGTERM'); + for (let i = 0; i < 50; i++) { + await new Promise((r) => setTimeout(r, 100)); + if (!isProcessAlive(state.pid)) { + break; + } + } + if (isProcessAlive(state.pid)) { + logger.warn('[mcp-auth-proxy] Daemon ignored SIGTERM; escalating to SIGKILL'); + try { + process.kill(state.pid, 'SIGKILL'); + } catch { + // Already gone between the check and the signal — fine. + } + } + await clearAuthProxyState(); + console.log(chalk.green('✓ mcp-auth-proxy stopped')); + }); + + return command; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 9481882f..4d7ca3d7 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -35,6 +35,7 @@ import { createAssistantsCommand } from './commands/assistants/index.js'; import { createSdkCommand } from './commands/sdk/index.js'; import { createMcpCommand } from './commands/mcp/index.js'; import { createMcpProxyCommand } from './commands/mcp-proxy.js'; +import { createMcpAuthProxyCommand } from './commands/mcp-auth-proxy.js'; import { createProxyCommand } from './commands/proxy/index.js'; import { createCodebaseCommand } from './commands/codebase/index.js'; import { FirstTimeExperience } from './first-time.js'; @@ -96,6 +97,7 @@ program.addCommand(createModelsCommand()); program.addCommand(createSdkCommand()); program.addCommand(createMcpCommand()); program.addCommand(createMcpProxyCommand()); +program.addCommand(createMcpAuthProxyCommand()); program.addCommand(createProxyCommand()); program.addCommand(createCodebaseCommand()); From 2617124565ce519784ec8478e657bd31c260f555 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Fri, 3 Jul 2026 20:10:13 +0200 Subject: [PATCH 11/36] docs: document codemie mcp-auth-proxy command Generated with AI Co-Authored-By: codemie-ai --- docs/COMMANDS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index e3bdfdc6..a7dbc77c 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -22,6 +22,7 @@ codemie self-update # Update CodeMie CLI itself codemie doctor [options] # Health check and diagnostics codemie plugin # Manage native plugins codemie mcp-proxy # Stdio-to-HTTP MCP proxy with OAuth support +codemie mcp-auth-proxy # OAuth-rewriting proxy daemon for remote MCP servers (client_name/scope/resource overrides; config: ~/.codemie/mcp-auth-proxy.json) codemie codebase # Manage Codebase Memory graph UI codemie version # Show version information ``` From 62c4ac6b9f285b6295ff244cf3fa46578adde34d Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sat, 4 Jul 2026 00:11:37 +0200 Subject: [PATCH 12/36] fix(proxy): harden mcp-auth-proxy request dispatch and PRM-hint discovery Address code review CR-001 (crash on malformed request-target), CR-002 (prototype-inherited route keys return 404), CR-003 (restrict captured resource_metadata hint to the configured upstream host). Generated with AI Co-Authored-By: codemie-ai --- .../__tests__/metadata-cache.test.ts | 16 +++++++- src/mcp/auth-proxy/__tests__/server.test.ts | 38 ++++++++++++++++++ src/mcp/auth-proxy/metadata-cache.ts | 16 ++++++-- src/mcp/auth-proxy/server.ts | 40 ++++++++++++++----- 4 files changed, 97 insertions(+), 13 deletions(-) diff --git a/src/mcp/auth-proxy/__tests__/metadata-cache.test.ts b/src/mcp/auth-proxy/__tests__/metadata-cache.test.ts index 43250d23..72d4f7b7 100644 --- a/src/mcp/auth-proxy/__tests__/metadata-cache.test.ts +++ b/src/mcp/auth-proxy/__tests__/metadata-cache.test.ts @@ -97,11 +97,25 @@ describe('MetadataCache', () => { const hinted = 'https://mcp.example.com/custom/prm-location'; const { calls, fetchJson } = fakeFetcher({ ...HAPPY_DOCS, [hinted]: PRM }); const cache = new MetadataCache(fetchJson); - cache.notePrmUrl('radar', hinted); + cache.notePrmUrl('radar', hinted, route.upstreamUrl); await cache.getMetadata('radar', route); expect(calls[0]).toBe(hinted); }); + it('ignores 401 PRM hints that are not https on the configured upstream host', async () => { + const rejectedHints = [ + 'http://mcp.example.com/custom/prm-location', // right host, wrong scheme + 'https://evil.example/custom/prm-location', // https, wrong host + ]; + for (const hinted of rejectedHints) { + const { calls, fetchJson } = fakeFetcher({ ...HAPPY_DOCS, [hinted]: PRM }); + const cache = new MetadataCache(fetchJson); + cache.notePrmUrl('radar', hinted, route.upstreamUrl); + await cache.getMetadata('radar', route); + expect(calls[0], hinted).toBe(PRM_PATH_URL); + } + }); + it('caches positive results for the TTL and refreshes after expiry', async () => { const { calls, fetchJson } = fakeFetcher(HAPPY_DOCS); const cache = new MetadataCache(fetchJson); diff --git a/src/mcp/auth-proxy/__tests__/server.test.ts b/src/mcp/auth-proxy/__tests__/server.test.ts index 30562e8e..1da6e45f 100644 --- a/src/mcp/auth-proxy/__tests__/server.test.ts +++ b/src/mcp/auth-proxy/__tests__/server.test.ts @@ -4,6 +4,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import http from 'node:http'; +import net from 'node:net'; import type { AddressInfo } from 'node:net'; import { McpAuthProxy } from '../server.js'; import type { AuthProxyConfig, JsonObject } from '../types.js'; @@ -304,6 +305,43 @@ describe('McpAuthProxy', () => { expect(await res.json()).toEqual({ error: 'payload_too_large' }); }); + it('answers 400 to a malformed request-target and keeps serving afterwards', async () => { + const port = Number(new URL(proxyOrigin).port); + const statusLine = await new Promise((resolve, reject) => { + const socket = net.connect({ host: '127.0.0.1', port }, () => { + socket.write('GET http://[ HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n'); + }); + let data = ''; + socket.setTimeout(3000, () => { + socket.destroy(); + reject(new Error('proxy sent no response to the malformed request-target')); + }); + socket.on('data', (chunk) => { + data += chunk.toString('utf-8'); + }); + socket.on('end', () => resolve(data.split('\r\n')[0])); + socket.on('error', reject); + }); + expect(statusLine).toBe('HTTP/1.1 400 Bad Request'); + + // The daemon must survive the malformed request and keep serving other routes. + const res = await fetch(`${proxyOrigin}/.well-known/oauth-protected-resource/radar`); + expect(res.status).toBe(200); + }); + + it('404s prototype-inherited route ids instead of treating them as routes', async () => { + const cases = [ + { path: '/constructor', method: 'GET' }, + { path: '/as/constructor/token', method: 'POST' }, + { path: '/.well-known/oauth-protected-resource/toString', method: 'GET' }, + ]; + for (const { path, method } of cases) { + const res = await fetch(`${proxyOrigin}${path}`, { method }); + expect(res.status, `${method} ${path}`).toBe(404); + expect(await res.json()).toEqual({ error: 'unknown_route' }); + } + }); + it('404s unknown routes and the root PRM alias when multiple routes exist', async () => { const unknown = await fetch(`${proxyOrigin}/nope`); expect(unknown.status).toBe(404); diff --git a/src/mcp/auth-proxy/metadata-cache.ts b/src/mcp/auth-proxy/metadata-cache.ts index 025fe840..817fa9ee 100644 --- a/src/mcp/auth-proxy/metadata-cache.ts +++ b/src/mcp/auth-proxy/metadata-cache.ts @@ -42,10 +42,20 @@ export class MetadataCache { constructor(private readonly fetchJson: FetchJson) {} - /** R2: remember a resource_metadata URL observed on a live upstream 401. */ - notePrmUrl(routeId: string, url: string): void { + /** + * R2: remember a resource_metadata URL observed on a live upstream 401. + * SSRF guard: only https hints on the configured upstream's host are accepted — + * anything else is ignored and discovery falls back to the well-known probes. + */ + notePrmUrl(routeId: string, url: string, upstreamUrl: string): void { try { - new URL(url); + const hint = new URL(url); + if (hint.protocol !== 'https:' || hint.host !== new URL(upstreamUrl).host) { + logger.debug( + `[mcp-auth-proxy] Route "${routeId}": ignoring resource_metadata hint on host "${hint.host}" — not https on the upstream host` + ); + return; + } this.prmUrlHints.set(routeId, url); } catch { // Malformed hint — ignore. diff --git a/src/mcp/auth-proxy/server.ts b/src/mcp/auth-proxy/server.ts index a0e112be..5d2703fb 100644 --- a/src/mcp/auth-proxy/server.ts +++ b/src/mcp/auth-proxy/server.ts @@ -48,6 +48,7 @@ const HOP_BY_HOP_HEADERS = new Set([ ]); class BodyTooLargeError extends Error {} +class InvalidRequestTargetError extends Error {} function sendJson(res: http.ServerResponse, status: number, body: unknown): void { const payload = Buffer.from(JSON.stringify(body), 'utf-8'); @@ -174,22 +175,35 @@ export class McpAuthProxy { this.client.close(); } + /** Own-property route lookup — prototype-inherited keys ('constructor', …) are not routes. */ + private getRoute(id: string): RouteConfig | undefined { + return Object.hasOwn(this.config.servers, id) ? this.config.servers[id] : undefined; + } + private ctx(routeId: string): RewriteContext { return { proxyOrigin: this.origin, routeId, - scopes: this.config.servers[routeId]?.scopes, + scopes: this.getRoute(routeId)?.scopes, }; } private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { const startedAt = Date.now(); - const url = new URL(req.url ?? '/', this.origin); - const segments = url.pathname.split('/').filter((segment) => segment.length > 0); let kind = 'unknown'; let routeId = ''; try { + // Parse inside the try: a request-target WHATWG URL rejects (e.g. absolute-form + // "http://[") must yield a 400, not an unhandled rejection that kills the daemon. + let url: URL; + try { + url = new URL(req.url ?? '/', this.origin); + } catch { + throw new InvalidRequestTargetError(); + } + const segments = url.pathname.split('/').filter((segment) => segment.length > 0); + if (req.method === 'GET' && url.pathname === '/healthz') { kind = 'healthz'; this.serveHealth(res); @@ -198,7 +212,7 @@ export class McpAuthProxy { } else if (segments[0] === 'as' && segments.length >= 2) { routeId = segments[1]; kind = await this.handleOAuth(req, res, url, segments); - } else if (segments.length >= 1 && this.config.servers[segments[0]] !== undefined) { + } else if (segments.length >= 1 && this.getRoute(segments[0]) !== undefined) { routeId = segments[0]; kind = 'mcp'; await this.passThrough(req, res, url, routeId); @@ -263,7 +277,7 @@ export class McpAuthProxy { } private async servePrm(res: http.ServerResponse, routeId: string): Promise { - const route = this.config.servers[routeId]; + const route = this.getRoute(routeId); if (!route) { sendJson(res, 404, { error: 'unknown_route' }); return; @@ -275,7 +289,7 @@ export class McpAuthProxy { } private async serveAsMetadata(res: http.ServerResponse, routeId: string): Promise { - const route = this.config.servers[routeId]; + const route = this.getRoute(routeId); if (!route) { sendJson(res, 404, { error: 'unknown_route' }); return; @@ -298,7 +312,7 @@ export class McpAuthProxy { segments: string[] ): Promise { const routeId = segments[1]; - const route = this.config.servers[routeId]; + const route = this.getRoute(routeId); if (!route) { sendJson(res, 404, { error: 'unknown_route' }); return 'unknown'; @@ -473,7 +487,11 @@ export class McpAuthProxy { url: URL, routeId: string ): Promise { - const route = this.config.servers[routeId]; + const route = this.getRoute(routeId); + if (route === undefined) { + sendJson(res, 404, { error: 'unknown_route' }); + return; + } const target = new URL(route.upstreamUrl); target.pathname = target.pathname.replace(/\/+$/, '') + url.pathname.slice(`/${routeId}`.length); target.search = url.search; @@ -506,7 +524,7 @@ export class McpAuthProxy { if (challengeText !== undefined) { const match = /resource_metadata\s*=\s*"([^"]+)"/i.exec(challengeText); if (match) { - this.metadata.notePrmUrl(routeId, match[1]); + this.metadata.notePrmUrl(routeId, match[1], route.upstreamUrl); } } const { value, rewrote } = rewriteChallengeHeader(challengeText, this.ctx(routeId)); @@ -540,6 +558,10 @@ export class McpAuthProxy { res.destroy(); return; } + if (error instanceof InvalidRequestTargetError) { + sendJson(res, 400, { error: 'invalid_request' }); + return; + } if (error instanceof MetadataDiscoveryError || isUpstreamNetworkError(error)) { // Spec log rule: upstream host only — we log the route id, never full URLs. logger.warn( From 959da8be6134daab6dea1a25814b8946e6f52dd3 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sat, 4 Jul 2026 10:58:07 +0200 Subject: [PATCH 13/36] docs: add mcp-auth-proxy spec and SDLC work item Normative spec (docs/SPEC-mcp-auth-proxy.md) that the feature implements, plus the run 20260703-1845 work item record (status: Ready for review). Generated with AI Co-Authored-By: codemie-ai --- docs/SPEC-mcp-auth-proxy.md | 409 ++++++++++++++++++ docs/superpowers/work-items/mcp-auth-proxy.md | 45 ++ .../work-items/work-item-events.jsonl | 7 + 3 files changed, 461 insertions(+) create mode 100644 docs/SPEC-mcp-auth-proxy.md create mode 100644 docs/superpowers/work-items/mcp-auth-proxy.md create mode 100644 docs/superpowers/work-items/work-item-events.jsonl diff --git a/docs/SPEC-mcp-auth-proxy.md b/docs/SPEC-mcp-auth-proxy.md new file mode 100644 index 00000000..a870bdde --- /dev/null +++ b/docs/SPEC-mcp-auth-proxy.md @@ -0,0 +1,409 @@ +# Specification: MCP OAuth Rewriting Proxy (`codemie mcp-auth-proxy`) + +Status: **Proposed** +Target repo: `codemie-code` +New CLI command: `codemie mcp-auth-proxy` +New module: `src/mcp/auth-proxy/` +Normative reference: [MCP Authorization specification, revision 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) + +--- + +## Overview + +A local, transparent HTTP proxy that sits between an MCP client (Claude Code CLI) and one or +more remote MCP servers that implement MCP Authorization (OAuth 2.1). The proxy forwards all +MCP and OAuth traffic unchanged **except** for two surgical rewrites: + +1. **`client_name`** in the OAuth 2.0 Dynamic Client Registration (RFC 7591) request body. +2. **`scope`** everywhere the client's requested scopes appear (401 challenge, resource + metadata, DCR body, authorization request, token request). + +This lets Claude Code authenticate against MCP servers whose IdP rejects Claude Code's +default client identity (e.g. scope `claudeai`, client name `Claude Code`) — **without +modifying the MCP servers, without pre-registered clients, and while preserving Claude +Code's native lazy browser-based auth flow** (browser opens only on `/mcp` → authenticate, +never at CLI startup). + +## Problem Statement + +- We operate remote MCP servers (e.g. `https://mcp.epam.com/mcp/radar`) behind an enterprise + IdP. They support MCP Authorization over streamable HTTP. **They cannot be modified.** +- Claude Code CLI, added via `claude mcp add --scope local --transport http epam-radar + https://mcp.epam.com/mcp/radar`, fails OAuth with: + `Insufficient Scope. The following scopes are not allowed by IDP: claudeai.` +- The IdP additionally rejects the `client_name` Claude Code sends during Dynamic Client + Registration. +- **Pre-registered clients are not an option** — registration must remain fully dynamic + (DCR against the real authorization server, per user/session). + +### Rejected alternatives (context for future readers) + +| Alternative | Why rejected | +|---|---| +| `oauth.scopes` / `oauth.client_id` in Claude Code config | Tested; does not work reliably (known Claude Code bugs: anthropics/claude-code#68853, #26675), and there is no `client_name` override at all. Pre-registration is disallowed anyway. | +| `mcp-remote` npm bridge (`--static-oauth-client-metadata`) | Runs as a stdio server → spawned at Claude Code startup → opens the auth browser tab eagerly on start. Unacceptable UX. | +| Existing `codemie mcp-proxy ` stdio bridge (`src/mcp/stdio-http-bridge.ts`) | Same eager-auth problem (auth runs on the first stdio message, i.e. at startup `initialize`), and tokens are memory-only (re-auth every session). | +| Modifying MCP servers / IdP | Out of our control. | + +The transparent rewriting proxy is the only architecture that satisfies all constraints: +dynamic registration, untouched servers, rewritten `client_name` + `scope`, and Claude +Code's own on-demand OAuth UX (the proxy is passive; the browser opens only when Claude +Code itself initiates authorization). + +## Hard Requirements + +These are strict MUSTs; an implementation that violates any of them is incomplete: + +1. **Multiple MCP servers concurrently.** A single proxy instance MUST serve two or more + upstream MCP servers at the same time, each on its own route, usable simultaneously + from the same Claude Code session. Single-server operation is a degenerate case, not + the design target. Concretely: + - per-route `clientName`/`scopes` overrides applied independently — a rewrite for one + route MUST never leak into another; + - per-route OAuth discovery, metadata cache, and `/as//*` endpoints fully isolated + (route ids namespace every OAuth artifact); + - concurrent traffic — including simultaneous SSE streams and interleaved OAuth flows + on different routes — MUST NOT block or corrupt each other; + - a degraded route (upstream down, discovery failed) MUST NOT affect the other routes. +2. **No server/IdP modification** and **no pre-registered clients** — registration stays + fully dynamic (DCR) against the real authorization server. +3. **Lazy auth preserved** — the proxy never initiates authorization; a browser opens only + when Claude Code's own authenticate flow runs. +4. **No token custody** — the proxy stores no tokens, codes, or client credentials. + +## Normative Grounding — MCP Authorization spec (revision 2025-11-25) + +Source: . +Facts the design relies on: + +1. **Discovery chain.** Client gets `401` + `WWW-Authenticate: Bearer + resource_metadata="…"`, fetches the Protected Resource Metadata (PRM, RFC 9728), reads + `authorization_servers[]`, then fetches Authorization Server (AS) metadata trying + well-known URLs **in a mandated priority order** (RFC 8414 path-insertion, then OIDC + path-insertion, then OIDC path-appending). If the `WWW-Authenticate` header is absent, + the client probes `/.well-known/oauth-protected-resource[/]` itself. +2. **Scope selection strategy (client-side, normative).** Priority 1: the `scope` parameter + in the `WWW-Authenticate` challenge — *"Clients MUST treat the scopes provided in the + challenge as authoritative."* Priority 2: `scopes_supported` from the PRM. Otherwise: + omit `scope`. ⇒ **The proxy can steer a compliant client's scope request by rewriting the + challenge header and PRM** — the authorize/token rewrites below are a defensive backstop. +3. **Client registration priority.** Pre-registered → Client ID Metadata Documents (CIMD) → + DCR. A client uses CIMD only if AS metadata advertises + `client_id_metadata_document_supported: true`. With CIMD the AS fetches `client_name` + from a **client-hosted HTTPS document** (Anthropic's), which we cannot rewrite. ⇒ **The + proxy MUST strip this flag from the AS metadata it serves** to force the DCR path, where + `client_name` is a plain JSON field passing through us. +4. **Resource indicators (RFC 8707).** The client MUST send + `resource=` in **both** authorization and token requests, and + the MCP server MUST validate token audience against its own canonical URI. Since the + client only knows the *proxy* URL, ⇒ **the proxy MUST rewrite `resource` back to the + upstream canonical URI** on authorize and token requests, or every issued token is + rejected upstream. +5. **PKCE.** The client MUST verify `code_challenge_methods_supported` exists in AS + metadata and refuse to proceed otherwise. ⇒ the proxy MUST preserve this field. + `code_challenge`/`code_verifier` originate in the client and pass through untouched. +6. **PRM `resource` field.** The client validates that the PRM's `resource` matches the + server URL it is talking to. ⇒ the proxy's PRM must carry the **proxy** MCP URL, while + the upstream's PRM `resource` value is what we use for the `resource`-parameter rewrite. +7. **Step-up / insufficient-scope handling.** `403` + `WWW-Authenticate: + error="insufficient_scope", scope="…"` triggers client re-authorization with the + challenged scopes. ⇒ the proxy applies the same header rewrite on 403 as on 401. + +## Design + +### Topology + +``` +Claude Code mcp-auth-proxy (127.0.0.1:) Upstream +----------- ----------------------------------- -------- +http://127.0.0.1:42800/radar ──► route "radar" ── MCP pass-through ───────────► https://mcp.epam.com/mcp/radar + /.well-known/oauth-protected-resource/radar (RS + its real AS, untouched) + /.well-known/oauth-authorization-server/as/radar + /as/radar/register | /authorize | /token ────► real AS endpoints +Browser ───(302 via /as/radar/authorize)───────────────────────────────────────► real IdP consent page +IdP ───(redirect straight to Claude Code's localhost callback; NOT via proxy)──► Claude Code +``` + +- One proxy instance serves N upstream servers via first-path-segment routing + (`/radar`, `/xyz`, …). Each route has its own PRM, AS metadata, and `/as//*` OAuth + endpoints. +- The proxy is **stateless with respect to auth**: it stores no tokens, no codes, no client + records. It only caches upstream *metadata* documents (with TTL). Claude Code remains the + OAuth client of record; its token cache, refresh handling, and `/mcp` authenticate UX are + untouched. +- The per-route AS issuer is `http://127.0.0.1:/as/` (a path-bearing issuer, so + the client constructs the three well-known variants listed in Route Map rows 4–6). + +### Route Map + +| # | Proxy endpoint | Method | Action | +|---|---|---|---| +| 1 | `/` (and any subpath) | ALL | Stream pass-through to upstream MCP URL. Rewrite `WWW-Authenticate` on 401/403 responses (see R1). | +| 2 | `/.well-known/oauth-protected-resource/` | GET | Serve rewritten upstream PRM (see R2). | +| 3 | `/.well-known/oauth-protected-resource` | GET | Only when exactly one route is configured: alias of row 2. Otherwise 404. | +| 4 | `/.well-known/oauth-authorization-server/as/` | GET | Serve rewritten upstream AS metadata (see R3). | +| 5 | `/.well-known/openid-configuration/as/` | GET | Alias of row 4 (OIDC path-insertion variant). | +| 6 | `/as//.well-known/openid-configuration` | GET | Alias of row 4 (OIDC path-appending variant). | +| 7 | `/as//register` | POST | Rewrite DCR body (see R4), forward to upstream `registration_endpoint`, relay response verbatim. | +| 8 | `/as//authorize` | GET | Rewrite query (see R5), `302` to upstream `authorization_endpoint`. | +| 9 | `/as//token` | POST | Rewrite form body (see R6), forward to upstream `token_endpoint`, relay response verbatim. | +| 10 | `/as//revoke` | POST | Only if upstream advertises `revocation_endpoint`: forward verbatim. | +| 11 | anything else | ALL | `404` JSON error. | + +### Rewrite Rules + +**R1 — MCP pass-through (`/`).** +- Forward method, path remainder, query, headers, and body to ``; stream both + directions **without buffering** (MCP streamable HTTP uses SSE responses and long-lived + GET streams). Propagate client aborts upstream. Never apply body parsing or compression + middleware on this route. +- Strip hop-by-hop headers (`Connection`, `Keep-Alive`, `Transfer-Encoding`, `TE`, + `Upgrade`, `Proxy-*`); set `Host` to the upstream host. Pass `Authorization` and + `Mcp-Session-Id` through untouched in both directions. +- On upstream `401`, and on `403` whose `WWW-Authenticate` contains + `error="insufficient_scope"`: rewrite the `WWW-Authenticate` header — + `resource_metadata` → `http://127.0.0.1:/.well-known/oauth-protected-resource/`; + if `scopes` is configured for the route, set/inject `scope=""` + (this is the authoritative scope signal per spec fact 2). Preserve all other challenge + parameters. If upstream sent no `WWW-Authenticate` at all on a 401, inject one + (`Bearer resource_metadata="…"` + optional `scope`) so discovery always lands on the proxy. + +**R2 — Protected Resource Metadata.** +- Obtain the upstream PRM once per route (probe + `/.well-known/oauth-protected-resource/`, then the root + variant; also accept a `resource_metadata` URL captured from a live upstream 401). Cache + (default TTL 300 s) plus the parsed upstream canonical `resource` value for R5/R6. +- Serve it with: `resource` → `http://127.0.0.1:/` (no trailing slash); + `authorization_servers` → `["http://127.0.0.1:/as/"]`; + `scopes_supported` → configured `scopes` (only if configured; else pass through). + All other fields pass through. +- v1 limitation: if upstream PRM lists multiple authorization servers, use the first and + log a warning. + +**R3 — Authorization Server metadata.** +- Fetch upstream AS metadata from the upstream PRM's first `authorization_servers` issuer, + trying the spec's well-known variants in priority order. Cache with TTL. Serve for all + three proxy variants (route-map rows 4–6) with: + - `issuer` → `http://127.0.0.1:/as/` + - `authorization_endpoint` → `http://127.0.0.1:/as//authorize` + - `token_endpoint` → `http://127.0.0.1:/as//token` + - `registration_endpoint` → `http://127.0.0.1:/as//register` (if upstream lacks + one, fail route startup — DCR is mandatory for this proxy's purpose) + - `revocation_endpoint` → `http://127.0.0.1:/as//revoke` (only if present upstream) + - **delete `client_id_metadata_document_supported`** (spec fact 3) + - `scopes_supported` → configured `scopes` (only if configured) + - `code_challenge_methods_supported` → pass through unchanged (MUST stay present) + - everything else (grant types, auth methods, JWKS URI, etc.) passes through unchanged. + Endpoints intentionally NOT proxied (e.g. `jwks_uri`, `userinfo_endpoint`) keep their + real upstream URLs. + +**R4 — Dynamic Client Registration (`POST /as//register`).** +- Parse the JSON body (limit 64 KB). Apply overrides: + - `client_name` → configured `clientName` (if configured) + - `scope` → configured `scopes` space-joined (if configured; inject if absent) +- Everything else (`redirect_uris` — Claude Code's dynamic localhost callback, + `grant_types`, `response_types`, `token_endpoint_auth_method`, …) passes through + untouched. Forward to the upstream `registration_endpoint`; relay status + JSON response + verbatim — the upstream-issued `client_id` flows straight back to Claude Code + (registration stays fully dynamic). + +**R5 — Authorization redirect (`GET /as//authorize`).** +- Browser-facing. Take the incoming query string and rewrite: + - `scope` → configured `scopes` space-joined (if configured) + - `resource` → upstream canonical resource URI (from cached upstream PRM `resource`; + fallback: configured `upstreamUrl` normalized without trailing slash) +- `client_id`, `redirect_uri`, `state`, `code_challenge`, `code_challenge_method`, + `response_type`, and unknown params pass through untouched. Respond `302` with + `Location: ?`. Never follow the + redirect server-side; never log the full query (it is not secret, but codes/state later + in the flow are — keep one uniform rule: query values are never logged). +- The IdP's post-consent redirect goes directly to Claude Code's `redirect_uri` + (localhost) and never touches the proxy. + +**R6 — Token exchange (`POST /as//token`).** +- Parse `application/x-www-form-urlencoded` body (limit 64 KB). For all grant types + (`authorization_code`, `refresh_token`): + - `resource` → upstream canonical resource URI (same source as R5) + - `scope` → configured `scopes` space-joined (only if the field is present and `scopes` + is configured) +- `code`, `code_verifier`, `client_id`, `redirect_uri`, `refresh_token` pass through + untouched. Forward to upstream `token_endpoint`; relay status + body verbatim. **Never + log request or response bodies on this route.** + +### Why this preserves lazy auth + +The proxy never initiates anything. The full OAuth choreography (401 → discovery → DCR → +browser → callback → token) is still driven by Claude Code exactly as natively, triggered by +the user's `/mcp` → authenticate action. The proxy is a pure request/response transformer. + +## Configuration + +File: `/mcp-auth-proxy.json` (resolve via `getCodemiePath()` from +`src/utils/paths.ts` — never hardcode `~/.codemie`). + +```json +{ + "port": 42800, + "servers": { + "radar": { + "upstreamUrl": "https://mcp.epam.com/mcp/radar", + "clientName": "EPAM Approved MCP Client", + "scopes": ["openid", "profile", "mcp:access"] + }, + "other": { + "upstreamUrl": "https://mcp.epam.com/mcp/other", + "clientName": "EPAM Approved MCP Client" + } + } +} +``` + +Semantics: +- `port` (optional, default `42800`) — listen port; bind host is always `127.0.0.1` + (explicit IPv4 loopback, same rationale as `src/bin/proxy-daemon.ts`). +- Route id = object key; must match `^[a-z0-9][a-z0-9-]*$` and not collide with reserved + prefixes (`as`, `.well-known`). +- `upstreamUrl` (required) — canonical upstream MCP endpoint, `https://` only. +- `clientName` (optional) — DCR `client_name` override. Absent ⇒ pass through. +- `scopes` (optional, non-empty string array) — scope override applied at every point + listed in R1/R2/R3/R4/R5/R6. Absent ⇒ all scope values pass through untouched. + +Validation errors must be reported at startup with the offending key path +(use the project's error classes from `src/utils/errors.ts`, not generic `Error`). + +## CLI + +``` +codemie mcp-auth-proxy start [--config ] [--port ] [--foreground] +codemie mcp-auth-proxy stop +codemie mcp-auth-proxy status +``` + +- `start` — default detached daemon following the existing pattern + (`src/cli/commands/proxy/daemon-manager.ts` + `src/bin/proxy-daemon.ts`): spawn a + detached entry point `src/bin/mcp-auth-proxy-daemon.ts`, write a state file + (`/mcp-auth-proxy.state.json`: pid, port, routes, startedAt), handle + SIGTERM/SIGINT cleanup. `--foreground` runs in-process (useful for debugging; + `CODEMIE_DEBUG=true` for verbose logs). +- `status` — read the state file, verify the pid is alive and the port answers a + `GET /healthz` (proxy serves `{"status":"ok","routes":[…]}`), print per-route upstream + URL and the effective `claude mcp add` command for each route: + `claude mcp add --scope local --transport http http://127.0.0.1:/` +- `stop` — SIGTERM the daemon pid, remove the state file. +- On `start`, print the ready-to-copy `claude mcp add` line(s). + +No self-healing watcher in v1 (unlike the SSO proxy) — the proxy holds no session state, +so a crash only requires a manual restart; Claude Code re-auth is unaffected because +tokens live in Claude Code. + +## Architecture Placement & File Changes + +Follows the 5-layer architecture (`.ai-run/guides/architecture/architecture.md`): +`CLI → module core → utils`, no layer skipping, ES modules with `.js` import extensions, +`logger` (never `console.log`), `sanitizeLogArgs()` for anything header/body-adjacent. + +| File | Purpose | +|---|---| +| `src/mcp/auth-proxy/types.ts` | `AuthProxyConfig`, `RouteConfig`, cached-metadata types. Explicit exported types, no `any`. | +| `src/mcp/auth-proxy/config.ts` | Load + validate config file. | +| `src/mcp/auth-proxy/metadata-cache.ts` | Upstream PRM/AS-metadata discovery (well-known priority order), TTL cache, upstream canonical `resource` resolution. | +| `src/mcp/auth-proxy/rewrites.ts` | **Pure functions**: `rewriteChallengeHeader`, `rewritePrm`, `rewriteAsMetadata`, `rewriteRegistrationBody`, `rewriteAuthorizeQuery`, `rewriteTokenBody`. No I/O — this is the unit-testable core. | +| `src/mcp/auth-proxy/server.ts` | `McpAuthProxy` class: Node `http` server, route dispatch, streaming pass-through (use `undici`/native fetch with `duplex: 'half'`; no Express on the MCP route). `start(): Promise<{port,url}>`, `stop()`. | +| `src/bin/mcp-auth-proxy-daemon.ts` | Detached daemon entry (mirror `src/bin/proxy-daemon.ts`: parseArgs, state file, signal handling). | +| `src/cli/commands/mcp-auth-proxy.ts` | Commander command (`start`/`stop`/`status`), mirroring `src/cli/commands/mcp-proxy.ts` registration style. | +| `src/index.ts` / CLI registration point | Register the new command where `createMcpProxyCommand()` is registered. | +| `package.json` | Add the daemon bin to the build if entry points are enumerated. | + +Naming note: the existing `codemie mcp-proxy` (stdio bridge) is a different tool and must +remain untouched. The new command is `mcp-auth-proxy`; keep the distinction clear in +`--help` texts. + +## Error Handling & Logging + +- Upstream unreachable / TLS failure on any proxied call → `502` with + `{"error":"upstream_unreachable","route":""}`; log at `warn` with the upstream host + only (no full URLs with queries). +- Unknown route → `404 {"error":"unknown_route"}`. +- Metadata discovery failure for a route → keep the proxy up, mark the route degraded in + `/healthz`, return `502` on its endpoints; retry discovery on next request (respecting a + short negative-cache, e.g. 10 s). +- **Never log**: `Authorization` headers, token endpoint bodies (either direction), + authorization codes, `code_verifier`, query strings on `/as/*` routes. Debug logging must + go through `logger.debug` + `sanitizeLogArgs`. What MAY be logged: method, route id, + path, status, duration, and *names* of rewritten fields (e.g. `rewrote: client_name, scope`). + +## Security Considerations + +- Bind strictly to `127.0.0.1`. Refuse to start with a non-loopback bind (no config option + for it in v1) — the proxy relays bearer tokens and must never be network-exposed. +- Plain HTTP is acceptable only because of the loopback bind (OAuth 2.1 loopback + exemption). Claude Code accepts `http://127.0.0.1` MCP URLs. +- The proxy is not a token store: no tokens, codes, or client secrets are persisted or + cached. Restarting it leaks/loses nothing. +- Body size limits (64 KB) on `/as//register` and `/as//token`; no limit on the + streaming MCP route. +- The spec's confused-deputy warning targets proxies with a *static* downstream client_id; + this proxy forwards DCR dynamically per client and user consent still happens at the + real IdP, so it does not apply. +- Scope override is a *rewrite*, not an escalation: the IdP still enforces what it actually + grants; consent UI shows the overridden client name — this is the intended, authorized + behavior for our own servers/IdP (EPAM-internal use). + +## Non-Goals (v1) + +- CIMD support (deliberately disabled via metadata rewrite). +- Multiple `authorization_servers` per upstream (first one wins, warn). +- TLS termination / non-loopback exposure / multi-user or shared deployment. +- Token caching, refresh orchestration, or any OAuth client behavior in the proxy itself. +- Rewriting MCP JSON-RPC payloads (bodies on the MCP route are opaque). +- Self-healing watcher / auto-restart. +- Windows service installation (daemon uses the same detached-spawn approach as the + existing proxy; platform quirks inherit whatever `daemon-manager.ts` already handles). + +## Acceptance Criteria + +1. `codemie mcp-auth-proxy start` with the example config; then + `claude mcp add --scope local --transport http epam-radar http://127.0.0.1:42800/radar`. +2. Starting Claude Code does **not** open a browser. +3. `/mcp` → authenticate for `epam-radar` opens the IdP page: the consent screen shows the + configured `clientName`, and the authorization request carries exactly the configured + `scopes` and `resource=https://mcp.epam.com/mcp/radar` (verify via IdP logs or the + browser URL bar). +4. DCR observed upstream contains the overridden `client_name`; the issued `client_id` is + dynamic (different per registration). +5. After auth, MCP tools of the upstream server are listable and callable through the + proxy, including SSE-streamed responses. +6. Token refresh (long session) succeeds through `/as/radar/token` without re-opening a + browser; no 401 loops. +7. **Multi-server (hard requirement 1):** with at least two routes configured, both are + added to the same Claude Code session, each authenticates independently (distinct DCR + registrations, per-route `clientName`/`scopes` visible at the IdP), and tools on both + are callable concurrently — including while one route's upstream is deliberately + stopped, which must leave the other route fully functional. +8. `codemie mcp-auth-proxy status` reports healthy routes; `stop` terminates the daemon + and removes the state file. +9. `CODEMIE_DEBUG=true` logs show rewritten field names but no tokens, codes, verifiers, + or `/as/*` query strings. +10. `npm run lint`, `npm run typecheck`, `npm run build` pass (zero-warning policy). + +## Verification Aids + +- The pure functions in `rewrites.ts` are designed for direct unit testing (Vitest, + per `.ai-run/guides/testing/testing-patterns.md`). Per repo policy, write tests only when + explicitly requested in the implementation task. +- Manual OAuth-flow tracing: run the proxy in `--foreground` with `CODEMIE_DEBUG=true` and + use MCP Inspector or Claude Code `/mcp` against `http://127.0.0.1:42800/radar`. +- A canned upstream for local testing can be faked with any MCP server + any OIDC provider + supporting DCR (e.g. Keycloak dev realm) if the real EPAM upstream is unavailable. + +## References + +- MCP Authorization spec (2025-11-25): +- RFC 9728 (Protected Resource Metadata), RFC 8414 (AS Metadata), RFC 7591 (DCR), + RFC 8707 (Resource Indicators), OAuth 2.1 draft-13. +- Claude Code scope/client bugs motivating this work: anthropics/claude-code#7744, #12077, + #68853, #26675; modelcontextprotocol/modelcontextprotocol#653 (`claudeai` scope). +- In-repo prior art: `src/mcp/stdio-http-bridge.ts` + `src/mcp/auth/mcp-oauth-provider.ts` + (existing MCP OAuth client, eager-auth stdio bridge — the tool this proxy deliberately + differs from), `src/bin/proxy-daemon.ts` + `src/cli/commands/proxy/daemon-manager.ts` + (daemon/state-file pattern to mirror), `docs/ARCHITECTURE-PROXY.md` (SSO proxy design). diff --git a/docs/superpowers/work-items/mcp-auth-proxy.md b/docs/superpowers/work-items/mcp-auth-proxy.md new file mode 100644 index 00000000..64ad278d --- /dev/null +++ b/docs/superpowers/work-items/mcp-auth-proxy.md @@ -0,0 +1,45 @@ +# Work Item: MCP OAuth Rewriting Proxy (`codemie mcp-auth-proxy`) + +- **ID:** mcp-auth-proxy +- **Status:** Ready for review +- **External Ticket:** (none — external sync pending) +- **Source Story:** docs/SPEC-mcp-auth-proxy.md +- **Branch:** feat/mcp-auth-proxy + +## Summary + +Implement `codemie mcp-auth-proxy` — a local, transparent, loopback-only HTTP proxy between +MCP clients (Claude Code) and remote MCP servers implementing MCP Authorization (OAuth 2.1, +spec revision 2025-11-25). The proxy forwards all MCP and OAuth traffic unchanged except +surgical rewrites of `client_name` (RFC 7591 DCR) and `scope` (401/403 challenge, PRM, AS +metadata, DCR body, authorize query, token body) plus `resource` (RFC 8707) restoration to +the upstream canonical URI. Multiple upstream servers are served concurrently on isolated +routes. The proxy holds no tokens, preserves Claude Code's lazy browser auth, and keeps +registration fully dynamic. New module `src/mcp/auth-proxy/`, daemon entry +`src/bin/mcp-auth-proxy-daemon.ts`, CLI command `start`/`stop`/`status`. + +## Acceptance Criteria + +See `docs/SPEC-mcp-auth-proxy.md` § Acceptance Criteria (10 items, authoritative). + +## Linked Artifacts + +- docs/SPEC-mcp-auth-proxy.md +- docs/superpowers/specs/2026-07-03-mcp-auth-proxy-design.md +- docs/superpowers/plans/2026-07-03-mcp-auth-proxy.md +- docs/superpowers/runs/20260703-1845-mcp-auth-proxy/requirements.md +- docs/superpowers/runs/20260703-1845-mcp-auth-proxy/qa-report.md + +## History + +| When | Event | Notes | +|---|---|---| +| 2026-07-03T18:48 | created | Work item created by requirements-intake (run 20260703-1845-mcp-auth-proxy) from docs/SPEC-mcp-auth-proxy.md | +| 2026-07-03T18:48 | external-sync | pending — no ticket adapter invoked at intake; prepare_for_development to be emitted after branch guard | +| 2026-07-03T18:48 | linked-artifact | requirements.md written by requirements-intake | +| 2026-07-03T18:52 | assigned | Branch feat/mcp-auth-proxy created from up-to-date main; branch guard decision: continue (no tracked modifications; foreign untracked file codemie-analytics-2026-06-25.report.json excluded from staging) | +| 2026-07-03T18:52 | adapter-warning | prepare_for_development: no lifecycle adapter configured; external sync pending | +| 2026-07-04T00:30 | code-review | Round 1 request-changes (CR-001 daemon-crash, CR-002 proto-route-404, CR-003 SSRF-hint) → fix-up 62c4ac6 → Round 2 approve | +| 2026-07-04T00:50 | qa | All 7 guide gates PASS (license/lint/typecheck/build/unit 2272/integration 220/commitlint); feature-verification not required (no UI surface) | +| 2026-07-04T01:00 | complexity | Actual 26/36 (L), delta +2 vs initial heuristic 24 | +| 2026-07-04T01:05 | transitioned | Status → Ready for review; branch feat/mcp-auth-proxy ready for MR | diff --git a/docs/superpowers/work-items/work-item-events.jsonl b/docs/superpowers/work-items/work-item-events.jsonl new file mode 100644 index 00000000..13a5cc51 --- /dev/null +++ b/docs/superpowers/work-items/work-item-events.jsonl @@ -0,0 +1,7 @@ +{"schema":1,"ts":"2026-07-03T18:48:00+02:00","event":"work_item.created","run_id":"20260703-1845-mcp-auth-proxy","phase":1,"actor":"requirements-intake","summary":"Created local work item mcp-auth-proxy from docs/SPEC-mcp-auth-proxy.md","artifacts":["docs/superpowers/work-items/mcp-auth-proxy.md"],"data":{"source":"story:docs/SPEC-mcp-auth-proxy.md","external_sync":"pending"}} +{"schema":1,"ts":"2026-07-03T18:48:00+02:00","event":"work_item.linked_artifact","run_id":"20260703-1845-mcp-auth-proxy","phase":1,"actor":"requirements-intake","summary":"Linked requirements.md to work item mcp-auth-proxy","artifacts":["docs/superpowers/runs/20260703-1845-mcp-auth-proxy/requirements.md"],"data":{}} +{"schema":1,"ts":"2026-07-03T18:52:00+02:00","event":"work_item.assigned","run_id":"20260703-1845-mcp-auth-proxy","phase":2,"actor":"sdlc-pipeline","summary":"Assigned branch feat/mcp-auth-proxy after branch guard pass","artifacts":[],"data":{"branch":"feat/mcp-auth-proxy","branch_guard_decision":"continue"}} +{"schema":1,"ts":"2026-07-03T18:52:00+02:00","event":"work_item.adapter_warning","run_id":"20260703-1845-mcp-auth-proxy","phase":2,"actor":"sdlc-pipeline","summary":"prepare_for_development skipped: no lifecycle adapter configured; external sync pending","artifacts":[],"data":{"intent":"prepare_for_development"}} +{"schema":1,"ts":"2026-07-04T00:50:00+02:00","event":"work_item.adapter_warning","run_id":"20260703-1845-mcp-auth-proxy","phase":8,"actor":"sdlc-pipeline","summary":"record_delivery_audit: no lifecycle adapter; QA passed, external sync pending","artifacts":["docs/superpowers/runs/20260703-1845-mcp-auth-proxy/qa-report.md"],"data":{"intent":"record_delivery_audit"}} +{"schema":1,"ts":"2026-07-04T01:05:00+02:00","event":"work_item.transitioned","run_id":"20260703-1845-mcp-auth-proxy","phase":10,"actor":"sdlc-pipeline","summary":"Status -> Ready for review; branch feat/mcp-auth-proxy ready for MR/PR","artifacts":["docs/superpowers/runs/20260703-1845-mcp-auth-proxy/qa-report.md"],"data":{"status":"Ready for review","branch":"feat/mcp-auth-proxy"}} +{"schema":1,"ts":"2026-07-04T01:05:00+02:00","event":"work_item.adapter_warning","run_id":"20260703-1845-mcp-auth-proxy","phase":10,"actor":"sdlc-pipeline","summary":"complete_or_handoff: no lifecycle adapter configured; handoff recorded locally, external sync pending","artifacts":[],"data":{"intent":"complete_or_handoff"}} From bfb7ed6499b2ad67a63c2c87916c49c7c791d569 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko <41001394+TarasSpashchenko@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:10:34 +0200 Subject: [PATCH 14/36] docs: delete superseded mcp-auth-proxy spec document --- docs/SPEC-mcp-auth-proxy.md | 409 ------------------------------------ 1 file changed, 409 deletions(-) delete mode 100644 docs/SPEC-mcp-auth-proxy.md diff --git a/docs/SPEC-mcp-auth-proxy.md b/docs/SPEC-mcp-auth-proxy.md deleted file mode 100644 index a870bdde..00000000 --- a/docs/SPEC-mcp-auth-proxy.md +++ /dev/null @@ -1,409 +0,0 @@ -# Specification: MCP OAuth Rewriting Proxy (`codemie mcp-auth-proxy`) - -Status: **Proposed** -Target repo: `codemie-code` -New CLI command: `codemie mcp-auth-proxy` -New module: `src/mcp/auth-proxy/` -Normative reference: [MCP Authorization specification, revision 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) - ---- - -## Overview - -A local, transparent HTTP proxy that sits between an MCP client (Claude Code CLI) and one or -more remote MCP servers that implement MCP Authorization (OAuth 2.1). The proxy forwards all -MCP and OAuth traffic unchanged **except** for two surgical rewrites: - -1. **`client_name`** in the OAuth 2.0 Dynamic Client Registration (RFC 7591) request body. -2. **`scope`** everywhere the client's requested scopes appear (401 challenge, resource - metadata, DCR body, authorization request, token request). - -This lets Claude Code authenticate against MCP servers whose IdP rejects Claude Code's -default client identity (e.g. scope `claudeai`, client name `Claude Code`) — **without -modifying the MCP servers, without pre-registered clients, and while preserving Claude -Code's native lazy browser-based auth flow** (browser opens only on `/mcp` → authenticate, -never at CLI startup). - -## Problem Statement - -- We operate remote MCP servers (e.g. `https://mcp.epam.com/mcp/radar`) behind an enterprise - IdP. They support MCP Authorization over streamable HTTP. **They cannot be modified.** -- Claude Code CLI, added via `claude mcp add --scope local --transport http epam-radar - https://mcp.epam.com/mcp/radar`, fails OAuth with: - `Insufficient Scope. The following scopes are not allowed by IDP: claudeai.` -- The IdP additionally rejects the `client_name` Claude Code sends during Dynamic Client - Registration. -- **Pre-registered clients are not an option** — registration must remain fully dynamic - (DCR against the real authorization server, per user/session). - -### Rejected alternatives (context for future readers) - -| Alternative | Why rejected | -|---|---| -| `oauth.scopes` / `oauth.client_id` in Claude Code config | Tested; does not work reliably (known Claude Code bugs: anthropics/claude-code#68853, #26675), and there is no `client_name` override at all. Pre-registration is disallowed anyway. | -| `mcp-remote` npm bridge (`--static-oauth-client-metadata`) | Runs as a stdio server → spawned at Claude Code startup → opens the auth browser tab eagerly on start. Unacceptable UX. | -| Existing `codemie mcp-proxy ` stdio bridge (`src/mcp/stdio-http-bridge.ts`) | Same eager-auth problem (auth runs on the first stdio message, i.e. at startup `initialize`), and tokens are memory-only (re-auth every session). | -| Modifying MCP servers / IdP | Out of our control. | - -The transparent rewriting proxy is the only architecture that satisfies all constraints: -dynamic registration, untouched servers, rewritten `client_name` + `scope`, and Claude -Code's own on-demand OAuth UX (the proxy is passive; the browser opens only when Claude -Code itself initiates authorization). - -## Hard Requirements - -These are strict MUSTs; an implementation that violates any of them is incomplete: - -1. **Multiple MCP servers concurrently.** A single proxy instance MUST serve two or more - upstream MCP servers at the same time, each on its own route, usable simultaneously - from the same Claude Code session. Single-server operation is a degenerate case, not - the design target. Concretely: - - per-route `clientName`/`scopes` overrides applied independently — a rewrite for one - route MUST never leak into another; - - per-route OAuth discovery, metadata cache, and `/as//*` endpoints fully isolated - (route ids namespace every OAuth artifact); - - concurrent traffic — including simultaneous SSE streams and interleaved OAuth flows - on different routes — MUST NOT block or corrupt each other; - - a degraded route (upstream down, discovery failed) MUST NOT affect the other routes. -2. **No server/IdP modification** and **no pre-registered clients** — registration stays - fully dynamic (DCR) against the real authorization server. -3. **Lazy auth preserved** — the proxy never initiates authorization; a browser opens only - when Claude Code's own authenticate flow runs. -4. **No token custody** — the proxy stores no tokens, codes, or client credentials. - -## Normative Grounding — MCP Authorization spec (revision 2025-11-25) - -Source: . -Facts the design relies on: - -1. **Discovery chain.** Client gets `401` + `WWW-Authenticate: Bearer - resource_metadata="…"`, fetches the Protected Resource Metadata (PRM, RFC 9728), reads - `authorization_servers[]`, then fetches Authorization Server (AS) metadata trying - well-known URLs **in a mandated priority order** (RFC 8414 path-insertion, then OIDC - path-insertion, then OIDC path-appending). If the `WWW-Authenticate` header is absent, - the client probes `/.well-known/oauth-protected-resource[/]` itself. -2. **Scope selection strategy (client-side, normative).** Priority 1: the `scope` parameter - in the `WWW-Authenticate` challenge — *"Clients MUST treat the scopes provided in the - challenge as authoritative."* Priority 2: `scopes_supported` from the PRM. Otherwise: - omit `scope`. ⇒ **The proxy can steer a compliant client's scope request by rewriting the - challenge header and PRM** — the authorize/token rewrites below are a defensive backstop. -3. **Client registration priority.** Pre-registered → Client ID Metadata Documents (CIMD) → - DCR. A client uses CIMD only if AS metadata advertises - `client_id_metadata_document_supported: true`. With CIMD the AS fetches `client_name` - from a **client-hosted HTTPS document** (Anthropic's), which we cannot rewrite. ⇒ **The - proxy MUST strip this flag from the AS metadata it serves** to force the DCR path, where - `client_name` is a plain JSON field passing through us. -4. **Resource indicators (RFC 8707).** The client MUST send - `resource=` in **both** authorization and token requests, and - the MCP server MUST validate token audience against its own canonical URI. Since the - client only knows the *proxy* URL, ⇒ **the proxy MUST rewrite `resource` back to the - upstream canonical URI** on authorize and token requests, or every issued token is - rejected upstream. -5. **PKCE.** The client MUST verify `code_challenge_methods_supported` exists in AS - metadata and refuse to proceed otherwise. ⇒ the proxy MUST preserve this field. - `code_challenge`/`code_verifier` originate in the client and pass through untouched. -6. **PRM `resource` field.** The client validates that the PRM's `resource` matches the - server URL it is talking to. ⇒ the proxy's PRM must carry the **proxy** MCP URL, while - the upstream's PRM `resource` value is what we use for the `resource`-parameter rewrite. -7. **Step-up / insufficient-scope handling.** `403` + `WWW-Authenticate: - error="insufficient_scope", scope="…"` triggers client re-authorization with the - challenged scopes. ⇒ the proxy applies the same header rewrite on 403 as on 401. - -## Design - -### Topology - -``` -Claude Code mcp-auth-proxy (127.0.0.1:) Upstream ------------ ----------------------------------- -------- -http://127.0.0.1:42800/radar ──► route "radar" ── MCP pass-through ───────────► https://mcp.epam.com/mcp/radar - /.well-known/oauth-protected-resource/radar (RS + its real AS, untouched) - /.well-known/oauth-authorization-server/as/radar - /as/radar/register | /authorize | /token ────► real AS endpoints -Browser ───(302 via /as/radar/authorize)───────────────────────────────────────► real IdP consent page -IdP ───(redirect straight to Claude Code's localhost callback; NOT via proxy)──► Claude Code -``` - -- One proxy instance serves N upstream servers via first-path-segment routing - (`/radar`, `/xyz`, …). Each route has its own PRM, AS metadata, and `/as//*` OAuth - endpoints. -- The proxy is **stateless with respect to auth**: it stores no tokens, no codes, no client - records. It only caches upstream *metadata* documents (with TTL). Claude Code remains the - OAuth client of record; its token cache, refresh handling, and `/mcp` authenticate UX are - untouched. -- The per-route AS issuer is `http://127.0.0.1:/as/` (a path-bearing issuer, so - the client constructs the three well-known variants listed in Route Map rows 4–6). - -### Route Map - -| # | Proxy endpoint | Method | Action | -|---|---|---|---| -| 1 | `/` (and any subpath) | ALL | Stream pass-through to upstream MCP URL. Rewrite `WWW-Authenticate` on 401/403 responses (see R1). | -| 2 | `/.well-known/oauth-protected-resource/` | GET | Serve rewritten upstream PRM (see R2). | -| 3 | `/.well-known/oauth-protected-resource` | GET | Only when exactly one route is configured: alias of row 2. Otherwise 404. | -| 4 | `/.well-known/oauth-authorization-server/as/` | GET | Serve rewritten upstream AS metadata (see R3). | -| 5 | `/.well-known/openid-configuration/as/` | GET | Alias of row 4 (OIDC path-insertion variant). | -| 6 | `/as//.well-known/openid-configuration` | GET | Alias of row 4 (OIDC path-appending variant). | -| 7 | `/as//register` | POST | Rewrite DCR body (see R4), forward to upstream `registration_endpoint`, relay response verbatim. | -| 8 | `/as//authorize` | GET | Rewrite query (see R5), `302` to upstream `authorization_endpoint`. | -| 9 | `/as//token` | POST | Rewrite form body (see R6), forward to upstream `token_endpoint`, relay response verbatim. | -| 10 | `/as//revoke` | POST | Only if upstream advertises `revocation_endpoint`: forward verbatim. | -| 11 | anything else | ALL | `404` JSON error. | - -### Rewrite Rules - -**R1 — MCP pass-through (`/`).** -- Forward method, path remainder, query, headers, and body to ``; stream both - directions **without buffering** (MCP streamable HTTP uses SSE responses and long-lived - GET streams). Propagate client aborts upstream. Never apply body parsing or compression - middleware on this route. -- Strip hop-by-hop headers (`Connection`, `Keep-Alive`, `Transfer-Encoding`, `TE`, - `Upgrade`, `Proxy-*`); set `Host` to the upstream host. Pass `Authorization` and - `Mcp-Session-Id` through untouched in both directions. -- On upstream `401`, and on `403` whose `WWW-Authenticate` contains - `error="insufficient_scope"`: rewrite the `WWW-Authenticate` header — - `resource_metadata` → `http://127.0.0.1:/.well-known/oauth-protected-resource/`; - if `scopes` is configured for the route, set/inject `scope=""` - (this is the authoritative scope signal per spec fact 2). Preserve all other challenge - parameters. If upstream sent no `WWW-Authenticate` at all on a 401, inject one - (`Bearer resource_metadata="…"` + optional `scope`) so discovery always lands on the proxy. - -**R2 — Protected Resource Metadata.** -- Obtain the upstream PRM once per route (probe - `/.well-known/oauth-protected-resource/`, then the root - variant; also accept a `resource_metadata` URL captured from a live upstream 401). Cache - (default TTL 300 s) plus the parsed upstream canonical `resource` value for R5/R6. -- Serve it with: `resource` → `http://127.0.0.1:/` (no trailing slash); - `authorization_servers` → `["http://127.0.0.1:/as/"]`; - `scopes_supported` → configured `scopes` (only if configured; else pass through). - All other fields pass through. -- v1 limitation: if upstream PRM lists multiple authorization servers, use the first and - log a warning. - -**R3 — Authorization Server metadata.** -- Fetch upstream AS metadata from the upstream PRM's first `authorization_servers` issuer, - trying the spec's well-known variants in priority order. Cache with TTL. Serve for all - three proxy variants (route-map rows 4–6) with: - - `issuer` → `http://127.0.0.1:/as/` - - `authorization_endpoint` → `http://127.0.0.1:/as//authorize` - - `token_endpoint` → `http://127.0.0.1:/as//token` - - `registration_endpoint` → `http://127.0.0.1:/as//register` (if upstream lacks - one, fail route startup — DCR is mandatory for this proxy's purpose) - - `revocation_endpoint` → `http://127.0.0.1:/as//revoke` (only if present upstream) - - **delete `client_id_metadata_document_supported`** (spec fact 3) - - `scopes_supported` → configured `scopes` (only if configured) - - `code_challenge_methods_supported` → pass through unchanged (MUST stay present) - - everything else (grant types, auth methods, JWKS URI, etc.) passes through unchanged. - Endpoints intentionally NOT proxied (e.g. `jwks_uri`, `userinfo_endpoint`) keep their - real upstream URLs. - -**R4 — Dynamic Client Registration (`POST /as//register`).** -- Parse the JSON body (limit 64 KB). Apply overrides: - - `client_name` → configured `clientName` (if configured) - - `scope` → configured `scopes` space-joined (if configured; inject if absent) -- Everything else (`redirect_uris` — Claude Code's dynamic localhost callback, - `grant_types`, `response_types`, `token_endpoint_auth_method`, …) passes through - untouched. Forward to the upstream `registration_endpoint`; relay status + JSON response - verbatim — the upstream-issued `client_id` flows straight back to Claude Code - (registration stays fully dynamic). - -**R5 — Authorization redirect (`GET /as//authorize`).** -- Browser-facing. Take the incoming query string and rewrite: - - `scope` → configured `scopes` space-joined (if configured) - - `resource` → upstream canonical resource URI (from cached upstream PRM `resource`; - fallback: configured `upstreamUrl` normalized without trailing slash) -- `client_id`, `redirect_uri`, `state`, `code_challenge`, `code_challenge_method`, - `response_type`, and unknown params pass through untouched. Respond `302` with - `Location: ?`. Never follow the - redirect server-side; never log the full query (it is not secret, but codes/state later - in the flow are — keep one uniform rule: query values are never logged). -- The IdP's post-consent redirect goes directly to Claude Code's `redirect_uri` - (localhost) and never touches the proxy. - -**R6 — Token exchange (`POST /as//token`).** -- Parse `application/x-www-form-urlencoded` body (limit 64 KB). For all grant types - (`authorization_code`, `refresh_token`): - - `resource` → upstream canonical resource URI (same source as R5) - - `scope` → configured `scopes` space-joined (only if the field is present and `scopes` - is configured) -- `code`, `code_verifier`, `client_id`, `redirect_uri`, `refresh_token` pass through - untouched. Forward to upstream `token_endpoint`; relay status + body verbatim. **Never - log request or response bodies on this route.** - -### Why this preserves lazy auth - -The proxy never initiates anything. The full OAuth choreography (401 → discovery → DCR → -browser → callback → token) is still driven by Claude Code exactly as natively, triggered by -the user's `/mcp` → authenticate action. The proxy is a pure request/response transformer. - -## Configuration - -File: `/mcp-auth-proxy.json` (resolve via `getCodemiePath()` from -`src/utils/paths.ts` — never hardcode `~/.codemie`). - -```json -{ - "port": 42800, - "servers": { - "radar": { - "upstreamUrl": "https://mcp.epam.com/mcp/radar", - "clientName": "EPAM Approved MCP Client", - "scopes": ["openid", "profile", "mcp:access"] - }, - "other": { - "upstreamUrl": "https://mcp.epam.com/mcp/other", - "clientName": "EPAM Approved MCP Client" - } - } -} -``` - -Semantics: -- `port` (optional, default `42800`) — listen port; bind host is always `127.0.0.1` - (explicit IPv4 loopback, same rationale as `src/bin/proxy-daemon.ts`). -- Route id = object key; must match `^[a-z0-9][a-z0-9-]*$` and not collide with reserved - prefixes (`as`, `.well-known`). -- `upstreamUrl` (required) — canonical upstream MCP endpoint, `https://` only. -- `clientName` (optional) — DCR `client_name` override. Absent ⇒ pass through. -- `scopes` (optional, non-empty string array) — scope override applied at every point - listed in R1/R2/R3/R4/R5/R6. Absent ⇒ all scope values pass through untouched. - -Validation errors must be reported at startup with the offending key path -(use the project's error classes from `src/utils/errors.ts`, not generic `Error`). - -## CLI - -``` -codemie mcp-auth-proxy start [--config ] [--port ] [--foreground] -codemie mcp-auth-proxy stop -codemie mcp-auth-proxy status -``` - -- `start` — default detached daemon following the existing pattern - (`src/cli/commands/proxy/daemon-manager.ts` + `src/bin/proxy-daemon.ts`): spawn a - detached entry point `src/bin/mcp-auth-proxy-daemon.ts`, write a state file - (`/mcp-auth-proxy.state.json`: pid, port, routes, startedAt), handle - SIGTERM/SIGINT cleanup. `--foreground` runs in-process (useful for debugging; - `CODEMIE_DEBUG=true` for verbose logs). -- `status` — read the state file, verify the pid is alive and the port answers a - `GET /healthz` (proxy serves `{"status":"ok","routes":[…]}`), print per-route upstream - URL and the effective `claude mcp add` command for each route: - `claude mcp add --scope local --transport http http://127.0.0.1:/` -- `stop` — SIGTERM the daemon pid, remove the state file. -- On `start`, print the ready-to-copy `claude mcp add` line(s). - -No self-healing watcher in v1 (unlike the SSO proxy) — the proxy holds no session state, -so a crash only requires a manual restart; Claude Code re-auth is unaffected because -tokens live in Claude Code. - -## Architecture Placement & File Changes - -Follows the 5-layer architecture (`.ai-run/guides/architecture/architecture.md`): -`CLI → module core → utils`, no layer skipping, ES modules with `.js` import extensions, -`logger` (never `console.log`), `sanitizeLogArgs()` for anything header/body-adjacent. - -| File | Purpose | -|---|---| -| `src/mcp/auth-proxy/types.ts` | `AuthProxyConfig`, `RouteConfig`, cached-metadata types. Explicit exported types, no `any`. | -| `src/mcp/auth-proxy/config.ts` | Load + validate config file. | -| `src/mcp/auth-proxy/metadata-cache.ts` | Upstream PRM/AS-metadata discovery (well-known priority order), TTL cache, upstream canonical `resource` resolution. | -| `src/mcp/auth-proxy/rewrites.ts` | **Pure functions**: `rewriteChallengeHeader`, `rewritePrm`, `rewriteAsMetadata`, `rewriteRegistrationBody`, `rewriteAuthorizeQuery`, `rewriteTokenBody`. No I/O — this is the unit-testable core. | -| `src/mcp/auth-proxy/server.ts` | `McpAuthProxy` class: Node `http` server, route dispatch, streaming pass-through (use `undici`/native fetch with `duplex: 'half'`; no Express on the MCP route). `start(): Promise<{port,url}>`, `stop()`. | -| `src/bin/mcp-auth-proxy-daemon.ts` | Detached daemon entry (mirror `src/bin/proxy-daemon.ts`: parseArgs, state file, signal handling). | -| `src/cli/commands/mcp-auth-proxy.ts` | Commander command (`start`/`stop`/`status`), mirroring `src/cli/commands/mcp-proxy.ts` registration style. | -| `src/index.ts` / CLI registration point | Register the new command where `createMcpProxyCommand()` is registered. | -| `package.json` | Add the daemon bin to the build if entry points are enumerated. | - -Naming note: the existing `codemie mcp-proxy` (stdio bridge) is a different tool and must -remain untouched. The new command is `mcp-auth-proxy`; keep the distinction clear in -`--help` texts. - -## Error Handling & Logging - -- Upstream unreachable / TLS failure on any proxied call → `502` with - `{"error":"upstream_unreachable","route":""}`; log at `warn` with the upstream host - only (no full URLs with queries). -- Unknown route → `404 {"error":"unknown_route"}`. -- Metadata discovery failure for a route → keep the proxy up, mark the route degraded in - `/healthz`, return `502` on its endpoints; retry discovery on next request (respecting a - short negative-cache, e.g. 10 s). -- **Never log**: `Authorization` headers, token endpoint bodies (either direction), - authorization codes, `code_verifier`, query strings on `/as/*` routes. Debug logging must - go through `logger.debug` + `sanitizeLogArgs`. What MAY be logged: method, route id, - path, status, duration, and *names* of rewritten fields (e.g. `rewrote: client_name, scope`). - -## Security Considerations - -- Bind strictly to `127.0.0.1`. Refuse to start with a non-loopback bind (no config option - for it in v1) — the proxy relays bearer tokens and must never be network-exposed. -- Plain HTTP is acceptable only because of the loopback bind (OAuth 2.1 loopback - exemption). Claude Code accepts `http://127.0.0.1` MCP URLs. -- The proxy is not a token store: no tokens, codes, or client secrets are persisted or - cached. Restarting it leaks/loses nothing. -- Body size limits (64 KB) on `/as//register` and `/as//token`; no limit on the - streaming MCP route. -- The spec's confused-deputy warning targets proxies with a *static* downstream client_id; - this proxy forwards DCR dynamically per client and user consent still happens at the - real IdP, so it does not apply. -- Scope override is a *rewrite*, not an escalation: the IdP still enforces what it actually - grants; consent UI shows the overridden client name — this is the intended, authorized - behavior for our own servers/IdP (EPAM-internal use). - -## Non-Goals (v1) - -- CIMD support (deliberately disabled via metadata rewrite). -- Multiple `authorization_servers` per upstream (first one wins, warn). -- TLS termination / non-loopback exposure / multi-user or shared deployment. -- Token caching, refresh orchestration, or any OAuth client behavior in the proxy itself. -- Rewriting MCP JSON-RPC payloads (bodies on the MCP route are opaque). -- Self-healing watcher / auto-restart. -- Windows service installation (daemon uses the same detached-spawn approach as the - existing proxy; platform quirks inherit whatever `daemon-manager.ts` already handles). - -## Acceptance Criteria - -1. `codemie mcp-auth-proxy start` with the example config; then - `claude mcp add --scope local --transport http epam-radar http://127.0.0.1:42800/radar`. -2. Starting Claude Code does **not** open a browser. -3. `/mcp` → authenticate for `epam-radar` opens the IdP page: the consent screen shows the - configured `clientName`, and the authorization request carries exactly the configured - `scopes` and `resource=https://mcp.epam.com/mcp/radar` (verify via IdP logs or the - browser URL bar). -4. DCR observed upstream contains the overridden `client_name`; the issued `client_id` is - dynamic (different per registration). -5. After auth, MCP tools of the upstream server are listable and callable through the - proxy, including SSE-streamed responses. -6. Token refresh (long session) succeeds through `/as/radar/token` without re-opening a - browser; no 401 loops. -7. **Multi-server (hard requirement 1):** with at least two routes configured, both are - added to the same Claude Code session, each authenticates independently (distinct DCR - registrations, per-route `clientName`/`scopes` visible at the IdP), and tools on both - are callable concurrently — including while one route's upstream is deliberately - stopped, which must leave the other route fully functional. -8. `codemie mcp-auth-proxy status` reports healthy routes; `stop` terminates the daemon - and removes the state file. -9. `CODEMIE_DEBUG=true` logs show rewritten field names but no tokens, codes, verifiers, - or `/as/*` query strings. -10. `npm run lint`, `npm run typecheck`, `npm run build` pass (zero-warning policy). - -## Verification Aids - -- The pure functions in `rewrites.ts` are designed for direct unit testing (Vitest, - per `.ai-run/guides/testing/testing-patterns.md`). Per repo policy, write tests only when - explicitly requested in the implementation task. -- Manual OAuth-flow tracing: run the proxy in `--foreground` with `CODEMIE_DEBUG=true` and - use MCP Inspector or Claude Code `/mcp` against `http://127.0.0.1:42800/radar`. -- A canned upstream for local testing can be faked with any MCP server + any OIDC provider - supporting DCR (e.g. Keycloak dev realm) if the real EPAM upstream is unavailable. - -## References - -- MCP Authorization spec (2025-11-25): -- RFC 9728 (Protected Resource Metadata), RFC 8414 (AS Metadata), RFC 7591 (DCR), - RFC 8707 (Resource Indicators), OAuth 2.1 draft-13. -- Claude Code scope/client bugs motivating this work: anthropics/claude-code#7744, #12077, - #68853, #26675; modelcontextprotocol/modelcontextprotocol#653 (`claudeai` scope). -- In-repo prior art: `src/mcp/stdio-http-bridge.ts` + `src/mcp/auth/mcp-oauth-provider.ts` - (existing MCP OAuth client, eager-auth stdio bridge — the tool this proxy deliberately - differs from), `src/bin/proxy-daemon.ts` + `src/cli/commands/proxy/daemon-manager.ts` - (daemon/state-file pattern to mirror), `docs/ARCHITECTURE-PROXY.md` (SSO proxy design). From d9421f9d98ea973b63cfa1eb602c18e273f6b08b Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 14:46:09 +0200 Subject: [PATCH 15/36] fix(utils): hide detached daemon console window on Windows mcp-auth-proxy-windows Task 1 Generated with AI Co-Authored-By: codemie-ai --- src/utils/__tests__/spawn-detached.test.ts | 46 ++++++++++++++++++++++ src/utils/processes.ts | 3 ++ 2 files changed, 49 insertions(+) create mode 100644 src/utils/__tests__/spawn-detached.test.ts diff --git a/src/utils/__tests__/spawn-detached.test.ts b/src/utils/__tests__/spawn-detached.test.ts new file mode 100644 index 00000000..294359d5 --- /dev/null +++ b/src/utils/__tests__/spawn-detached.test.ts @@ -0,0 +1,46 @@ +/** + * processes.ts — spawnDetached platform-conditional options. + * Isolated file: mocks node:child_process file-wide, so it must not share a + * module scope with the npm-utility suite in processes.test.ts. + * @group unit + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import os from 'node:os'; + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(() => ({ unref: vi.fn(), pid: 4242 })), + exec: vi.fn(), +})); + +import { spawn } from 'node:child_process'; +import { spawnDetached } from '../processes.js'; + +describe('spawnDetached', () => { + beforeEach(() => { + vi.mocked(spawn).mockClear(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('hides the console window on Windows (windowsHide: true)', () => { + vi.spyOn(os, 'platform').mockReturnValue('win32'); + const pid = spawnDetached('node', ['daemon.js']); + expect(pid).toBe(4242); + expect(spawn).toHaveBeenCalledWith( + 'node', + ['daemon.js'], + expect.objectContaining({ detached: true, stdio: 'ignore', windowsHide: true }) + ); + }); + + it('does not hide the console window off Windows (windowsHide: false)', () => { + vi.spyOn(os, 'platform').mockReturnValue('linux'); + spawnDetached('node', ['daemon.js']); + expect(spawn).toHaveBeenCalledWith( + 'node', + ['daemon.js'], + expect.objectContaining({ windowsHide: false }) + ); + }); +}); diff --git a/src/utils/processes.ts b/src/utils/processes.ts index 41fc1fda..c51f7805 100644 --- a/src/utils/processes.ts +++ b/src/utils/processes.ts @@ -117,6 +117,9 @@ export function spawnDetached( env: options.env, detached: true, stdio: options.stdio ?? 'ignore', + // Detached background daemons must never flash a console window on Windows + // (matches the exec.ts / BaseAgentAdapter idiom for attached spawns). + windowsHide: os.platform() === 'win32', }); child.unref(); return child.pid ?? -1; From c5f3775764a1ac6fd013c5b68a812513cada43e8 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 14:48:13 +0200 Subject: [PATCH 16/36] fix(proxy): treat EPERM as alive in mcp-auth-proxy liveness check mcp-auth-proxy-windows Task 2 Generated with AI Co-Authored-By: codemie-ai --- src/mcp/auth-proxy/__tests__/state.test.ts | 22 +++++++++++++++++++++- src/mcp/auth-proxy/state.ts | 7 +++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/mcp/auth-proxy/__tests__/state.test.ts b/src/mcp/auth-proxy/__tests__/state.test.ts index 039cff60..03617627 100644 --- a/src/mcp/auth-proxy/__tests__/state.test.ts +++ b/src/mcp/auth-proxy/__tests__/state.test.ts @@ -2,7 +2,7 @@ * mcp-auth-proxy daemon state file tests * @group unit */ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { existsSync } from 'node:fs'; @@ -57,4 +57,24 @@ describe('auth proxy state file', () => { expect(isProcessAlive(process.pid)).toBe(true); expect(isProcessAlive(2 ** 30)).toBe(false); }); + + it('isProcessAlive: EPERM means the process exists (Windows), ESRCH means dead', () => { + const killSpy = vi.spyOn(process, 'kill'); + + killSpy.mockImplementationOnce(() => { + const err = new Error('operation not permitted') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + }); + expect(isProcessAlive(424242)).toBe(true); + + killSpy.mockImplementationOnce(() => { + const err = new Error('no such process') as NodeJS.ErrnoException; + err.code = 'ESRCH'; + throw err; + }); + expect(isProcessAlive(424242)).toBe(false); + + killSpy.mockRestore(); + }); }); diff --git a/src/mcp/auth-proxy/state.ts b/src/mcp/auth-proxy/state.ts index efcc43bd..9824b12e 100644 --- a/src/mcp/auth-proxy/state.ts +++ b/src/mcp/auth-proxy/state.ts @@ -46,7 +46,10 @@ export function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; - } catch { - return false; + } catch (error) { + // EPERM: the process exists but this user cannot signal it (common on + // Windows) — that still means "alive". Only a genuine "no such process" + // (ESRCH) means dead. + return (error as NodeJS.ErrnoException).code === 'EPERM'; } } From 68542575be49ce40c3d41e36f450b6bf060f41d9 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 14:50:01 +0200 Subject: [PATCH 17/36] feat(proxy): reserve 'shutdown' route id for the control endpoint mcp-auth-proxy-windows Task 3 Generated with AI Co-Authored-By: codemie-ai --- src/mcp/auth-proxy/__tests__/config.test.ts | 2 +- src/mcp/auth-proxy/config.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/mcp/auth-proxy/__tests__/config.test.ts b/src/mcp/auth-proxy/__tests__/config.test.ts index a2cc40ed..e0bf1248 100644 --- a/src/mcp/auth-proxy/__tests__/config.test.ts +++ b/src/mcp/auth-proxy/__tests__/config.test.ts @@ -57,7 +57,7 @@ describe('validateAuthProxyConfig', () => { ).toThrow(/servers\./); }); - it.each(['as', 'healthz'])('rejects reserved route id %j', (id) => { + it.each(['as', 'healthz', 'shutdown'])('rejects reserved route id %j', (id) => { expect(() => validateAuthProxyConfig({ servers: { [id]: { upstreamUrl: 'https://x.example' } } }) ).toThrow(/reserved/); diff --git a/src/mcp/auth-proxy/config.ts b/src/mcp/auth-proxy/config.ts index 2d9f0ade..f82ffd8f 100644 --- a/src/mcp/auth-proxy/config.ts +++ b/src/mcp/auth-proxy/config.ts @@ -14,9 +14,10 @@ export const AUTH_PROXY_CONFIG_FILE = 'mcp-auth-proxy.json'; export const AUTH_PROXY_STATE_FILE = 'mcp-auth-proxy.state.json'; const ROUTE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; -// `as` + `.well-known` are reserved by the route map; `healthz` by the health endpoint -// (design D6 — a route named "healthz" would shadow GET /healthz). -const RESERVED_ROUTE_IDS = new Set(['as', '.well-known', 'healthz']); +// `as` + `.well-known` are reserved by the route map; `healthz` by the health +// endpoint and `shutdown` by the graceful-shutdown control endpoint (design D6 — +// a route named "healthz"/"shutdown" would shadow GET /healthz / POST /shutdown). +const RESERVED_ROUTE_IDS = new Set(['as', '.well-known', 'healthz', 'shutdown']); export function getDefaultConfigPath(): string { return getCodemiePath(AUTH_PROXY_CONFIG_FILE); From d76e5f56022316a1f5de5eecdb08b34133068f58 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 14:52:34 +0200 Subject: [PATCH 18/36] feat(proxy): add loopback POST /shutdown control endpoint mcp-auth-proxy-windows Task 4 Generated with AI Co-Authored-By: codemie-ai --- src/mcp/auth-proxy/__tests__/server.test.ts | 32 ++++++++++++++++++++- src/mcp/auth-proxy/server.ts | 23 ++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/mcp/auth-proxy/__tests__/server.test.ts b/src/mcp/auth-proxy/__tests__/server.test.ts index 1da6e45f..4a210135 100644 --- a/src/mcp/auth-proxy/__tests__/server.test.ts +++ b/src/mcp/auth-proxy/__tests__/server.test.ts @@ -2,7 +2,7 @@ * mcp-auth-proxy server tests — real HTTP against a local fake upstream (MCP RS + AS). * @group unit */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import http from 'node:http'; import net from 'node:net'; import type { AddressInfo } from 'node:net'; @@ -342,6 +342,36 @@ describe('McpAuthProxy', () => { } }); + it('POST /shutdown → 202 and fires the shutdown callback once; GET /shutdown → 405', async () => { + const onShutdown = vi.fn(); + const controllable = new McpAuthProxy( + { port: 0, servers: { radar: { upstreamUrl: `${upstream.origin}/mcp/radar` } } }, + onShutdown + ); + const { url } = await controllable.start(); + try { + const wrongMethod = await fetch(`${url}/shutdown`, { method: 'GET' }); + expect(wrongMethod.status).toBe(405); + expect(onShutdown).not.toHaveBeenCalled(); + + const res = await fetch(`${url}/shutdown`, { method: 'POST' }); + expect(res.status).toBe(202); + expect((await res.json()) as JsonObject).toEqual({ status: 'shutting_down' }); + await vi.waitFor(() => expect(onShutdown).toHaveBeenCalledTimes(1)); + } finally { + await controllable.stop(); + } + }); + + it('intercepts /shutdown before route lookup without disturbing normal routes', async () => { + const res = await fetch(`${proxyOrigin}/radar`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Bearer tok-abc' }, + body: '{}', + }); + expect(res.status).toBe(200); + }); + it('404s unknown routes and the root PRM alias when multiple routes exist', async () => { const unknown = await fetch(`${proxyOrigin}/nope`); expect(unknown.status).toBe(404); diff --git a/src/mcp/auth-proxy/server.ts b/src/mcp/auth-proxy/server.ts index 5d2703fb..155a112e 100644 --- a/src/mcp/auth-proxy/server.ts +++ b/src/mcp/auth-proxy/server.ts @@ -113,7 +113,10 @@ export class McpAuthProxy { private readonly client: UpstreamClient; private readonly metadata: MetadataCache; - constructor(private readonly config: AuthProxyConfig) { + constructor( + private readonly config: AuthProxyConfig, + private readonly onShutdownRequested?: () => void + ) { this.port = config.port; this.client = new UpstreamClient(); this.metadata = new MetadataCache((url) => this.client.fetchJson(url)); @@ -207,6 +210,9 @@ export class McpAuthProxy { if (req.method === 'GET' && url.pathname === '/healthz') { kind = 'healthz'; this.serveHealth(res); + } else if (url.pathname === '/shutdown') { + kind = 'shutdown'; + this.handleShutdown(req, res); } else if (segments[0] === '.well-known') { [kind, routeId] = await this.handleWellKnown(req, res, segments); } else if (segments[0] === 'as' && segments.length >= 2) { @@ -544,6 +550,21 @@ export class McpAuthProxy { // ── Health + errors ─────────────────────────────────────────────────────── + /** + * Graceful-shutdown control endpoint (loopback-only, like the whole server). + * POST → ack 202, then run the daemon's own cleanup AFTER the response has + * flushed so the caller (CLI stop) reliably receives the ack. Cross-platform: + * this is how Windows shuts down gracefully, since it has no POSIX signals. + */ + private handleShutdown(req: http.IncomingMessage, res: http.ServerResponse): void { + if (req.method !== 'POST') { + sendJson(res, 405, { error: 'method_not_allowed' }); + return; + } + res.on('finish', () => this.onShutdownRequested?.()); + sendJson(res, 202, { status: 'shutting_down' }); + } + private serveHealth(res: http.ServerResponse): void { const routes = Object.entries(this.config.servers).map(([id, route]) => ({ id, From 125e673b1301cf8799e179bf817f4bae83f2a827 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 14:55:37 +0200 Subject: [PATCH 19/36] feat(proxy): route graceful shutdown through endpoint and signals mcp-auth-proxy-windows Task 5 Generated with AI Co-Authored-By: codemie-ai --- src/mcp/auth-proxy/runtime.ts | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/mcp/auth-proxy/runtime.ts b/src/mcp/auth-proxy/runtime.ts index c24dbf34..b4b207ab 100644 --- a/src/mcp/auth-proxy/runtime.ts +++ b/src/mcp/auth-proxy/runtime.ts @@ -32,16 +32,30 @@ export async function runAuthProxyDaemon(options: RunDaemonOptions = {}): Promis } const stateFile = options.stateFile ?? getDefaultStatePath(); - const proxy = new McpAuthProxy(config); - const { port, url } = await proxy.start(); const routes = Object.keys(config.servers); + // One idempotent graceful path shared by the /shutdown endpoint and POSIX + // signals: stop the server (drains SSE), clear state, exit. On Windows the + // endpoint is the only path that runs this — a signal there is a hard kill, + // so this must be reachable over HTTP, not just via SIGTERM/SIGINT. + let shuttingDown = false; + function gracefulShutdown(): void { + if (shuttingDown) { + return; + } + shuttingDown = true; + void stop().then(() => process.exit(0)); + } + + const proxy = new McpAuthProxy(config, gracefulShutdown); + const { port, url } = await proxy.start(); + await writeAuthProxyState( { pid: process.pid, port, routes, startedAt: new Date().toISOString() }, stateFile ); - const stop = async (): Promise => { + async function stop(): Promise { try { await proxy.stop(); } catch { @@ -52,12 +66,10 @@ export async function runAuthProxyDaemon(options: RunDaemonOptions = {}): Promis } catch { // Best-effort cleanup } - }; - const onSignal = (): void => { - void stop().then(() => process.exit(0)); - }; - process.on('SIGTERM', onSignal); - process.on('SIGINT', onSignal); + } + + process.on('SIGTERM', gracefulShutdown); + process.on('SIGINT', gracefulShutdown); logger.debug(`[mcp-auth-proxy] Daemon running at ${url} (routes: ${routes.join(', ')})`); return { proxy, port, url, routes, stop }; From 1d24eee778099dfa36c08cf8ee094f252e1ad1f6 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 14:58:28 +0200 Subject: [PATCH 20/36] feat(cli): stop mcp-auth-proxy via graceful control endpoint first mcp-auth-proxy-windows Task 6 Generated with AI Co-Authored-By: codemie-ai --- src/cli/commands/mcp-auth-proxy.ts | 50 ++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/mcp-auth-proxy.ts b/src/cli/commands/mcp-auth-proxy.ts index ce6bd02a..ed6aadee 100644 --- a/src/cli/commands/mcp-auth-proxy.ts +++ b/src/cli/commands/mcp-auth-proxy.ts @@ -86,6 +86,33 @@ function fetchHealth(port: number): Promise<{ status: string; routes: HealthzRou }); } +/** + * Ask the daemon to shut itself down gracefully via the loopback control + * endpoint. Cross-platform graceful stop (Windows has no POSIX signals, so a + * signal there is a hard kill that skips the daemon's cleanup). Resolves true + * if the daemon acknowledged (2xx), false on any error/timeout — the caller + * then falls back to OS signals. + */ +function requestShutdown(port: number): Promise { + return new Promise((resolveShutdown) => { + const request = http.request( + { host: '127.0.0.1', port, path: '/shutdown', method: 'POST', timeout: 2000 }, + (res) => { + res.resume(); // drain the 202 body so the socket can close + resolveShutdown( + res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300 + ); + } + ); + request.on('error', () => resolveShutdown(false)); + request.on('timeout', () => { + request.destroy(); + resolveShutdown(false); + }); + request.end(); + }); +} + export function createMcpAuthProxyCommand(): Command { const command = new Command('mcp-auth-proxy'); command.description( @@ -196,19 +223,38 @@ export function createMcpAuthProxyCommand(): Command { console.log(chalk.yellow('mcp-auth-proxy is not running')); return; } - process.kill(state.pid, 'SIGTERM'); + // Graceful first (works on Windows and POSIX): ask the daemon to run its + // own proxy.stop() + exit via the loopback control endpoint. + await requestShutdown(state.port); for (let i = 0; i < 50; i++) { await new Promise((r) => setTimeout(r, 100)); if (!isProcessAlive(state.pid)) { break; } } + + // Fallback only if it did not exit: SIGTERM (POSIX graceful / Windows hard + // kill), then SIGKILL. Skipped entirely when the daemon already shut down. + if (isProcessAlive(state.pid)) { + logger.warn('[mcp-auth-proxy] Graceful shutdown timed out; sending SIGTERM'); + try { + process.kill(state.pid, 'SIGTERM'); + } catch { + // Already gone between the check and the signal — fine. + } + for (let i = 0; i < 50; i++) { + await new Promise((r) => setTimeout(r, 100)); + if (!isProcessAlive(state.pid)) { + break; + } + } + } if (isProcessAlive(state.pid)) { logger.warn('[mcp-auth-proxy] Daemon ignored SIGTERM; escalating to SIGKILL'); try { process.kill(state.pid, 'SIGKILL'); } catch { - // Already gone between the check and the signal — fine. + // Already gone — fine. } } await clearAuthProxyState(); From 73d730f646f9d6aa560ed7c598a9d0257506197a Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 15:06:40 +0200 Subject: [PATCH 21/36] refactor(cli): skip graceful poll when mcp-auth-proxy shutdown not acked Addresses code-review CR-W-001: use requestShutdown's ack result so a wedged daemon escalates to SIGTERM immediately instead of polling ~5s first. Happy path unchanged. mcp-auth-proxy-windows Generated with AI Co-Authored-By: codemie-ai --- src/cli/commands/mcp-auth-proxy.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/cli/commands/mcp-auth-proxy.ts b/src/cli/commands/mcp-auth-proxy.ts index ed6aadee..e9b4ad80 100644 --- a/src/cli/commands/mcp-auth-proxy.ts +++ b/src/cli/commands/mcp-auth-proxy.ts @@ -224,12 +224,16 @@ export function createMcpAuthProxyCommand(): Command { return; } // Graceful first (works on Windows and POSIX): ask the daemon to run its - // own proxy.stop() + exit via the loopback control endpoint. - await requestShutdown(state.port); - for (let i = 0; i < 50; i++) { - await new Promise((r) => setTimeout(r, 100)); - if (!isProcessAlive(state.pid)) { - break; + // own proxy.stop() + exit via the loopback control endpoint. Only wait for + // a self-exit when it acked — a wedged/unreachable daemon returns false + // immediately, so skip straight to the signal fallback instead of polling. + const acked = await requestShutdown(state.port); + if (acked) { + for (let i = 0; i < 50; i++) { + await new Promise((r) => setTimeout(r, 100)); + if (!isProcessAlive(state.pid)) { + break; + } } } From 24e3d586fdf1e39b87d507b2a42bccefc907d44d Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 15:21:39 +0200 Subject: [PATCH 22/36] docs: add mcp-auth-proxy-windows sdlc-light task artifacts mcp-auth-proxy-windows Generated with AI Co-Authored-By: codemie-ai --- .../actual-complexity.json | 77 +++ .../code-review-final.json | 24 + .../code-review.diff | 384 ++++++++++++ .../gate-plan.json | 17 + .../2026-07-05-mcp-auth-proxy-windows/plan.md | 590 ++++++++++++++++++ .../qa-report.md | 37 ++ .../reviewed_base.txt | 1 + .../reviewed_head.txt | 1 + .../technical-analysis.md | 169 +++++ 9 files changed, 1300 insertions(+) create mode 100644 docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/actual-complexity.json create mode 100644 docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/code-review-final.json create mode 100644 docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/code-review.diff create mode 100644 docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/gate-plan.json create mode 100644 docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/plan.md create mode 100644 docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/qa-report.md create mode 100644 docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/reviewed_base.txt create mode 100644 docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/reviewed_head.txt create mode 100644 docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/technical-analysis.md diff --git a/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/actual-complexity.json b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/actual-complexity.json new file mode 100644 index 00000000..67ecf25a --- /dev/null +++ b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/actual-complexity.json @@ -0,0 +1,77 @@ +{ + "task": "Make `codemie mcp-auth-proxy` shut down correctly on Windows via a loopback POST /shutdown control endpoint, plus windowsHide on detached spawn and EPERM-as-alive in the liveness check.", + "generated": "2026-07-05", + "dimensions": { + "component_scope": { + "score": 3, + "label": "M", + "affected": "CLI mcp-auth-proxy stop/start command, McpAuthProxy HTTP server (handleShutdown + constructor callback), daemon runtime (gracefulShutdown wiring), config validator (reserved 'shutdown' route id), spawnDetached process helper, isProcessAlive state helper", + "layers": "CLI, Service (daemon HTTP server + runtime), Utils" + }, + "requirements_clarity": { + "score": 2, + "label": "S", + "status": "Clear", + "gaps": "Windows-specific behavior (hard-kill on SIGTERM, EPERM from signal-0, windowsHide) is not verifiable on the Linux dev platform and relies on documented Node/OS semantics" + }, + "technical_risk": { + "score": 3, + "label": "M", + "risk_factors": "Cross-platform process control (Windows has no POSIX signals and hard-kills on SIGTERM, skipping handlers); a loopback control endpoint added to a proxy that relays bearer tokens; graceful-shutdown ordering (ack must flush via res.on('finish') before cleanup, SSE sockets must be drained); Windows paths untestable on the Linux dev box", + "mitigation": "Additive and reversible; /shutdown is loopback-only, guarded by a reserved route id and a POST method check; CLI retains SIGTERM->SIGKILL fallback when the daemon does not ack; +6 TDD tests cover the endpoint (202/405 + callback), EPERM liveness, and windowsHide" + }, + "file_change_estimate": { + "score": 4, + "label": "L", + "modified_files": 9, + "modified_file_list": [ + "src/cli/commands/mcp-auth-proxy.ts", + "src/mcp/auth-proxy/config.ts", + "src/mcp/auth-proxy/runtime.ts", + "src/mcp/auth-proxy/server.ts", + "src/mcp/auth-proxy/state.ts", + "src/utils/processes.ts", + "src/mcp/auth-proxy/__tests__/config.test.ts", + "src/mcp/auth-proxy/__tests__/server.test.ts", + "src/mcp/auth-proxy/__tests__/state.test.ts" + ], + "new_files": 1, + "new_file_list": [ + "src/utils/__tests__/spawn-detached.test.ts" + ], + "affected_dirs": [ + "src/cli/commands", + "src/mcp/auth-proxy", + "src/mcp/auth-proxy/__tests__", + "src/utils", + "src/utils/__tests__" + ] + }, + "dependencies": { + "score": 1, + "label": "XS", + "new_packages": [], + "version_changes": [] + }, + "affected_layers": { + "score": 3, + "label": "M", + "layers_changed": ["CLI", "Service", "Utils"], + "schema_migration": false, + "cross_system": false + } + }, + "total": 16, + "size": "M", + "band_range": "15-20", + "files_changed": 10, + "routing": "brainstorming", + "key_reasoning": [ + { "dimension": "file_change_estimate", "reason": "10 files changed (9 modified + 1 new isolated test) spanning three subsystems — CLI command, mcp/auth-proxy daemon, and utils — lands in the 7-10 band (L), even though each edit is small and additive (210 insertions / 24 deletions)." }, + { "dimension": "component_scope", "reason": "The graceful-shutdown feature threads one callback through CLI stop -> server handleShutdown -> runtime gracefulShutdown, coordinated across three components, plus two independent one-line fixes (windowsHide, EPERM); breadth is moderate but the coordination pattern is clear." }, + { "dimension": "technical_risk", "reason": "Cross-platform process lifecycle control that cannot be exercised on the Linux dev platform, adding an unauthenticated (loopback-only) control endpoint to a bearer-token-relaying proxy; mitigated by reserved-id guarding, a SIGTERM->SIGKILL fallback, and +6 TDD tests." }, + { "dimension": "affected_layers", "reason": "Touches CLI command surface, the daemon Service layer (HTTP server + runtime), and shared Utils (process spawn + state helpers); no DB migration and no new external integration." } + ], + "red_flags_applied": [], + "split_recommendation": null +} diff --git a/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/code-review-final.json b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/code-review-final.json new file mode 100644 index 00000000..23c47014 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/code-review-final.json @@ -0,0 +1,24 @@ +{ + "gate_id": "code-review.final", + "mode": "hitl", + "reviewed_base": "9c6eecd", + "reviewed_head": "8911c8a", + "verdict": "approve", + "confidence": "high", + "summary": "The Windows graceful-shutdown changeset is correct and does not regress POSIX behavior. The endpoint reliably acks before exit, gracefulShutdown is properly idempotent across the endpoint and signal paths, there is no TDZ/use-before-init hazard at runtime, and the isProcessAlive/windowsHide changes are sound. Only one minor, non-blocking latency observation was found; the /shutdown DoS surface is an accepted tradeoff under the loopback-only single-user threat model.", + "findings": [ + { + "id": "CR-W-001", + "severity": "low", + "file": "src/cli/commands/mcp-auth-proxy.ts", + "line": 228, + "title": "requestShutdown boolean result is discarded; wedged daemon incurs a redundant 5s poll before SIGTERM", + "detail": "`await requestShutdown(state.port)` ignores the returned boolean. When the daemon process is alive but its HTTP server is unresponsive/not listening, requestShutdown returns false immediately (ECONNREFUSED), yet the code still runs the full 50x100ms graceful poll (~5s) before escalating to SIGTERM, adding ~5s to the wedged-daemon stop path. Functionally correct; just slower than necessary in that edge case.", + "recommendation": "Branch on the boolean: only run the graceful poll when requestShutdown acked (true); on false, go straight to the signal fallback.", + "verified": true, + "resolution": "applied" + } + ], + "human_decision": "approve", + "human_note": "Approved. Applied the low-sev CR-W-001 polish inline (uses the boolean, speeds the wedged-daemon path) as a follow-up commit; happy path unchanged. No second review round required for an approve + reviewer-suggested low-sev polish." +} diff --git a/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/code-review.diff b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/code-review.diff new file mode 100644 index 00000000..7dd7432c --- /dev/null +++ b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/code-review.diff @@ -0,0 +1,384 @@ +diff --git a/src/cli/commands/mcp-auth-proxy.ts b/src/cli/commands/mcp-auth-proxy.ts +index ce6bd02..ed6aade 100644 +--- a/src/cli/commands/mcp-auth-proxy.ts ++++ b/src/cli/commands/mcp-auth-proxy.ts +@@ -86,6 +86,33 @@ function fetchHealth(port: number): Promise<{ status: string; routes: HealthzRou + }); + } + ++/** ++ * Ask the daemon to shut itself down gracefully via the loopback control ++ * endpoint. Cross-platform graceful stop (Windows has no POSIX signals, so a ++ * signal there is a hard kill that skips the daemon's cleanup). Resolves true ++ * if the daemon acknowledged (2xx), false on any error/timeout — the caller ++ * then falls back to OS signals. ++ */ ++function requestShutdown(port: number): Promise { ++ return new Promise((resolveShutdown) => { ++ const request = http.request( ++ { host: '127.0.0.1', port, path: '/shutdown', method: 'POST', timeout: 2000 }, ++ (res) => { ++ res.resume(); // drain the 202 body so the socket can close ++ resolveShutdown( ++ res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300 ++ ); ++ } ++ ); ++ request.on('error', () => resolveShutdown(false)); ++ request.on('timeout', () => { ++ request.destroy(); ++ resolveShutdown(false); ++ }); ++ request.end(); ++ }); ++} ++ + export function createMcpAuthProxyCommand(): Command { + const command = new Command('mcp-auth-proxy'); + command.description( +@@ -196,19 +223,38 @@ export function createMcpAuthProxyCommand(): Command { + console.log(chalk.yellow('mcp-auth-proxy is not running')); + return; + } +- process.kill(state.pid, 'SIGTERM'); ++ // Graceful first (works on Windows and POSIX): ask the daemon to run its ++ // own proxy.stop() + exit via the loopback control endpoint. ++ await requestShutdown(state.port); + for (let i = 0; i < 50; i++) { + await new Promise((r) => setTimeout(r, 100)); + if (!isProcessAlive(state.pid)) { + break; + } + } ++ ++ // Fallback only if it did not exit: SIGTERM (POSIX graceful / Windows hard ++ // kill), then SIGKILL. Skipped entirely when the daemon already shut down. ++ if (isProcessAlive(state.pid)) { ++ logger.warn('[mcp-auth-proxy] Graceful shutdown timed out; sending SIGTERM'); ++ try { ++ process.kill(state.pid, 'SIGTERM'); ++ } catch { ++ // Already gone between the check and the signal — fine. ++ } ++ for (let i = 0; i < 50; i++) { ++ await new Promise((r) => setTimeout(r, 100)); ++ if (!isProcessAlive(state.pid)) { ++ break; ++ } ++ } ++ } + if (isProcessAlive(state.pid)) { + logger.warn('[mcp-auth-proxy] Daemon ignored SIGTERM; escalating to SIGKILL'); + try { + process.kill(state.pid, 'SIGKILL'); + } catch { +- // Already gone between the check and the signal — fine. ++ // Already gone — fine. + } + } + await clearAuthProxyState(); +diff --git a/src/mcp/auth-proxy/__tests__/config.test.ts b/src/mcp/auth-proxy/__tests__/config.test.ts +index a2cc40e..e0bf124 100644 +--- a/src/mcp/auth-proxy/__tests__/config.test.ts ++++ b/src/mcp/auth-proxy/__tests__/config.test.ts +@@ -57,7 +57,7 @@ describe('validateAuthProxyConfig', () => { + ).toThrow(/servers\./); + }); + +- it.each(['as', 'healthz'])('rejects reserved route id %j', (id) => { ++ it.each(['as', 'healthz', 'shutdown'])('rejects reserved route id %j', (id) => { + expect(() => + validateAuthProxyConfig({ servers: { [id]: { upstreamUrl: 'https://x.example' } } }) + ).toThrow(/reserved/); +diff --git a/src/mcp/auth-proxy/__tests__/server.test.ts b/src/mcp/auth-proxy/__tests__/server.test.ts +index 1da6e45..4a21013 100644 +--- a/src/mcp/auth-proxy/__tests__/server.test.ts ++++ b/src/mcp/auth-proxy/__tests__/server.test.ts +@@ -2,7 +2,7 @@ + * mcp-auth-proxy server tests — real HTTP against a local fake upstream (MCP RS + AS). + * @group unit + */ +-import { describe, it, expect, beforeAll, afterAll } from 'vitest'; ++import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; + import http from 'node:http'; + import net from 'node:net'; + import type { AddressInfo } from 'node:net'; +@@ -342,6 +342,36 @@ describe('McpAuthProxy', () => { + } + }); + ++ it('POST /shutdown → 202 and fires the shutdown callback once; GET /shutdown → 405', async () => { ++ const onShutdown = vi.fn(); ++ const controllable = new McpAuthProxy( ++ { port: 0, servers: { radar: { upstreamUrl: `${upstream.origin}/mcp/radar` } } }, ++ onShutdown ++ ); ++ const { url } = await controllable.start(); ++ try { ++ const wrongMethod = await fetch(`${url}/shutdown`, { method: 'GET' }); ++ expect(wrongMethod.status).toBe(405); ++ expect(onShutdown).not.toHaveBeenCalled(); ++ ++ const res = await fetch(`${url}/shutdown`, { method: 'POST' }); ++ expect(res.status).toBe(202); ++ expect((await res.json()) as JsonObject).toEqual({ status: 'shutting_down' }); ++ await vi.waitFor(() => expect(onShutdown).toHaveBeenCalledTimes(1)); ++ } finally { ++ await controllable.stop(); ++ } ++ }); ++ ++ it('intercepts /shutdown before route lookup without disturbing normal routes', async () => { ++ const res = await fetch(`${proxyOrigin}/radar`, { ++ method: 'POST', ++ headers: { 'content-type': 'application/json', authorization: 'Bearer tok-abc' }, ++ body: '{}', ++ }); ++ expect(res.status).toBe(200); ++ }); ++ + it('404s unknown routes and the root PRM alias when multiple routes exist', async () => { + const unknown = await fetch(`${proxyOrigin}/nope`); + expect(unknown.status).toBe(404); +diff --git a/src/mcp/auth-proxy/__tests__/state.test.ts b/src/mcp/auth-proxy/__tests__/state.test.ts +index 039cff6..0361762 100644 +--- a/src/mcp/auth-proxy/__tests__/state.test.ts ++++ b/src/mcp/auth-proxy/__tests__/state.test.ts +@@ -2,7 +2,7 @@ + * mcp-auth-proxy daemon state file tests + * @group unit + */ +-import { describe, it, expect, afterEach } from 'vitest'; ++import { describe, it, expect, afterEach, vi } from 'vitest'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + import { existsSync } from 'node:fs'; +@@ -57,4 +57,24 @@ describe('auth proxy state file', () => { + expect(isProcessAlive(process.pid)).toBe(true); + expect(isProcessAlive(2 ** 30)).toBe(false); + }); ++ ++ it('isProcessAlive: EPERM means the process exists (Windows), ESRCH means dead', () => { ++ const killSpy = vi.spyOn(process, 'kill'); ++ ++ killSpy.mockImplementationOnce(() => { ++ const err = new Error('operation not permitted') as NodeJS.ErrnoException; ++ err.code = 'EPERM'; ++ throw err; ++ }); ++ expect(isProcessAlive(424242)).toBe(true); ++ ++ killSpy.mockImplementationOnce(() => { ++ const err = new Error('no such process') as NodeJS.ErrnoException; ++ err.code = 'ESRCH'; ++ throw err; ++ }); ++ expect(isProcessAlive(424242)).toBe(false); ++ ++ killSpy.mockRestore(); ++ }); + }); +diff --git a/src/mcp/auth-proxy/config.ts b/src/mcp/auth-proxy/config.ts +index 2d9f0ad..f82ffd8 100644 +--- a/src/mcp/auth-proxy/config.ts ++++ b/src/mcp/auth-proxy/config.ts +@@ -14,9 +14,10 @@ export const AUTH_PROXY_CONFIG_FILE = 'mcp-auth-proxy.json'; + export const AUTH_PROXY_STATE_FILE = 'mcp-auth-proxy.state.json'; + + const ROUTE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +-// `as` + `.well-known` are reserved by the route map; `healthz` by the health endpoint +-// (design D6 — a route named "healthz" would shadow GET /healthz). +-const RESERVED_ROUTE_IDS = new Set(['as', '.well-known', 'healthz']); ++// `as` + `.well-known` are reserved by the route map; `healthz` by the health ++// endpoint and `shutdown` by the graceful-shutdown control endpoint (design D6 — ++// a route named "healthz"/"shutdown" would shadow GET /healthz / POST /shutdown). ++const RESERVED_ROUTE_IDS = new Set(['as', '.well-known', 'healthz', 'shutdown']); + + export function getDefaultConfigPath(): string { + return getCodemiePath(AUTH_PROXY_CONFIG_FILE); +diff --git a/src/mcp/auth-proxy/runtime.ts b/src/mcp/auth-proxy/runtime.ts +index c24dbf3..b4b207a 100644 +--- a/src/mcp/auth-proxy/runtime.ts ++++ b/src/mcp/auth-proxy/runtime.ts +@@ -32,16 +32,30 @@ export async function runAuthProxyDaemon(options: RunDaemonOptions = {}): Promis + } + const stateFile = options.stateFile ?? getDefaultStatePath(); + +- const proxy = new McpAuthProxy(config); +- const { port, url } = await proxy.start(); + const routes = Object.keys(config.servers); + ++ // One idempotent graceful path shared by the /shutdown endpoint and POSIX ++ // signals: stop the server (drains SSE), clear state, exit. On Windows the ++ // endpoint is the only path that runs this — a signal there is a hard kill, ++ // so this must be reachable over HTTP, not just via SIGTERM/SIGINT. ++ let shuttingDown = false; ++ function gracefulShutdown(): void { ++ if (shuttingDown) { ++ return; ++ } ++ shuttingDown = true; ++ void stop().then(() => process.exit(0)); ++ } ++ ++ const proxy = new McpAuthProxy(config, gracefulShutdown); ++ const { port, url } = await proxy.start(); ++ + await writeAuthProxyState( + { pid: process.pid, port, routes, startedAt: new Date().toISOString() }, + stateFile + ); + +- const stop = async (): Promise => { ++ async function stop(): Promise { + try { + await proxy.stop(); + } catch { +@@ -52,12 +66,10 @@ export async function runAuthProxyDaemon(options: RunDaemonOptions = {}): Promis + } catch { + // Best-effort cleanup + } +- }; +- const onSignal = (): void => { +- void stop().then(() => process.exit(0)); +- }; +- process.on('SIGTERM', onSignal); +- process.on('SIGINT', onSignal); ++ } ++ ++ process.on('SIGTERM', gracefulShutdown); ++ process.on('SIGINT', gracefulShutdown); + + logger.debug(`[mcp-auth-proxy] Daemon running at ${url} (routes: ${routes.join(', ')})`); + return { proxy, port, url, routes, stop }; +diff --git a/src/mcp/auth-proxy/server.ts b/src/mcp/auth-proxy/server.ts +index 5d2703f..155a112 100644 +--- a/src/mcp/auth-proxy/server.ts ++++ b/src/mcp/auth-proxy/server.ts +@@ -113,7 +113,10 @@ export class McpAuthProxy { + private readonly client: UpstreamClient; + private readonly metadata: MetadataCache; + +- constructor(private readonly config: AuthProxyConfig) { ++ constructor( ++ private readonly config: AuthProxyConfig, ++ private readonly onShutdownRequested?: () => void ++ ) { + this.port = config.port; + this.client = new UpstreamClient(); + this.metadata = new MetadataCache((url) => this.client.fetchJson(url)); +@@ -207,6 +210,9 @@ export class McpAuthProxy { + if (req.method === 'GET' && url.pathname === '/healthz') { + kind = 'healthz'; + this.serveHealth(res); ++ } else if (url.pathname === '/shutdown') { ++ kind = 'shutdown'; ++ this.handleShutdown(req, res); + } else if (segments[0] === '.well-known') { + [kind, routeId] = await this.handleWellKnown(req, res, segments); + } else if (segments[0] === 'as' && segments.length >= 2) { +@@ -544,6 +550,21 @@ export class McpAuthProxy { + + // ── Health + errors ─────────────────────────────────────────────────────── + ++ /** ++ * Graceful-shutdown control endpoint (loopback-only, like the whole server). ++ * POST → ack 202, then run the daemon's own cleanup AFTER the response has ++ * flushed so the caller (CLI stop) reliably receives the ack. Cross-platform: ++ * this is how Windows shuts down gracefully, since it has no POSIX signals. ++ */ ++ private handleShutdown(req: http.IncomingMessage, res: http.ServerResponse): void { ++ if (req.method !== 'POST') { ++ sendJson(res, 405, { error: 'method_not_allowed' }); ++ return; ++ } ++ res.on('finish', () => this.onShutdownRequested?.()); ++ sendJson(res, 202, { status: 'shutting_down' }); ++ } ++ + private serveHealth(res: http.ServerResponse): void { + const routes = Object.entries(this.config.servers).map(([id, route]) => ({ + id, +diff --git a/src/mcp/auth-proxy/state.ts b/src/mcp/auth-proxy/state.ts +index efcc43b..9824b12 100644 +--- a/src/mcp/auth-proxy/state.ts ++++ b/src/mcp/auth-proxy/state.ts +@@ -46,7 +46,10 @@ export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; +- } catch { +- return false; ++ } catch (error) { ++ // EPERM: the process exists but this user cannot signal it (common on ++ // Windows) — that still means "alive". Only a genuine "no such process" ++ // (ESRCH) means dead. ++ return (error as NodeJS.ErrnoException).code === 'EPERM'; + } + } +diff --git a/src/utils/__tests__/spawn-detached.test.ts b/src/utils/__tests__/spawn-detached.test.ts +new file mode 100644 +index 0000000..294359d +--- /dev/null ++++ b/src/utils/__tests__/spawn-detached.test.ts +@@ -0,0 +1,46 @@ ++/** ++ * processes.ts — spawnDetached platform-conditional options. ++ * Isolated file: mocks node:child_process file-wide, so it must not share a ++ * module scope with the npm-utility suite in processes.test.ts. ++ * @group unit ++ */ ++import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; ++import os from 'node:os'; ++ ++vi.mock('node:child_process', () => ({ ++ spawn: vi.fn(() => ({ unref: vi.fn(), pid: 4242 })), ++ exec: vi.fn(), ++})); ++ ++import { spawn } from 'node:child_process'; ++import { spawnDetached } from '../processes.js'; ++ ++describe('spawnDetached', () => { ++ beforeEach(() => { ++ vi.mocked(spawn).mockClear(); ++ }); ++ afterEach(() => { ++ vi.restoreAllMocks(); ++ }); ++ ++ it('hides the console window on Windows (windowsHide: true)', () => { ++ vi.spyOn(os, 'platform').mockReturnValue('win32'); ++ const pid = spawnDetached('node', ['daemon.js']); ++ expect(pid).toBe(4242); ++ expect(spawn).toHaveBeenCalledWith( ++ 'node', ++ ['daemon.js'], ++ expect.objectContaining({ detached: true, stdio: 'ignore', windowsHide: true }) ++ ); ++ }); ++ ++ it('does not hide the console window off Windows (windowsHide: false)', () => { ++ vi.spyOn(os, 'platform').mockReturnValue('linux'); ++ spawnDetached('node', ['daemon.js']); ++ expect(spawn).toHaveBeenCalledWith( ++ 'node', ++ ['daemon.js'], ++ expect.objectContaining({ windowsHide: false }) ++ ); ++ }); ++}); +diff --git a/src/utils/processes.ts b/src/utils/processes.ts +index 41fc1fd..c51f780 100644 +--- a/src/utils/processes.ts ++++ b/src/utils/processes.ts +@@ -117,6 +117,9 @@ export function spawnDetached( + env: options.env, + detached: true, + stdio: options.stdio ?? 'ignore', ++ // Detached background daemons must never flash a console window on Windows ++ // (matches the exec.ts / BaseAgentAdapter idiom for attached spawns). ++ windowsHide: os.platform() === 'win32', + }); + child.unref(); + return child.pid ?? -1; diff --git a/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/gate-plan.json b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/gate-plan.json new file mode 100644 index 00000000..67e89bd0 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/gate-plan.json @@ -0,0 +1,17 @@ +{ + "task": "mcp-auth-proxy-windows", + "branch": "feat/mcp-auth-proxy", + "reviewed_range": "9c6eecd..HEAD", + "ui": false, + "feature_verification": "skipped (no user-visible surface)", + "gates": [ + { "id": "license-check", "command": "npm run license-check", "status": "passed" }, + { "id": "lint", "command": "eslint '{src,tests}/**/*.ts' --max-warnings=0", "status": "passed" }, + { "id": "typecheck", "command": "tsc --noEmit", "status": "passed" }, + { "id": "build", "command": "tsc && tsc-alias && npm run copy-plugin", "status": "passed" }, + { "id": "unit", "command": "vitest run src", "status": "passed", "detail": "2278 passed, 1 skipped" }, + { "id": "integration", "command": "vitest run tests/integration", "status": "passed", "detail": "220 passed, 1 skipped" }, + { "id": "commitlint", "command": "commitlint on 9c6eecd..HEAD", "status": "passed", "detail": "7/7" } + ], + "overall": "passed" +} diff --git a/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/plan.md b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/plan.md new file mode 100644 index 00000000..ad3b781d --- /dev/null +++ b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/plan.md @@ -0,0 +1,590 @@ +# mcp-auth-proxy Windows Compatibility Implementation Plan + +> **For agentic workers:** Implement task-by-task with `superpowers:test-driven-development`. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `codemie mcp-auth-proxy` start, stop, and report status correctly on a Windows host — including *graceful* shutdown (SSE drain) — without regressing POSIX behavior. + +**Architecture:** Windows has no POSIX signals — `process.kill(pid, 'SIGTERM')` unconditionally force-terminates the daemon, so its `SIGTERM`/`SIGINT` cleanup handlers never run (confirmed against Node docs: *"On Windows … Sending SIGINT, SIGTERM, or SIGKILL causes unconditional termination"*). We add a cross-platform loopback control channel: the daemon serves `POST /shutdown` (127.0.0.1-only, already loopback-bound) which invokes its own graceful `proxy.stop()` (drains SSE) → clears state → exits. The CLI `stop` POSTs to it first on **all** platforms, then falls back to the existing `SIGTERM→SIGKILL` sequence only if the daemon is unresponsive. Two smaller Windows fixes ride along: `windowsHide` on the detached spawn (no console-window flash) and treating `EPERM` from signal-0 as "process alive" (Windows liveness). + +**Tech Stack:** Node.js `node:http` + `node:child_process` (`spawn` with `windowsHide`), `process.kill`, Vitest 4 (`vi.mock`/`vi.spyOn`/`vi.waitFor`). + +**Non-goals:** Windows *service* registration (already de-scoped in the design doc). No new dependencies. No change to the OAuth rewrite logic. + +--- + +## File Structure + +| File | Change | Responsibility | +|---|---|---| +| `src/utils/processes.ts` | Modify `spawnDetached` (110-123) | Add `windowsHide: os.platform()==='win32'` — hides the detached daemon's console window on Windows (also fixes SSO + codebase daemons; `os` already imported). | +| `src/mcp/auth-proxy/state.ts` | Modify `isProcessAlive` (45-52) | `EPERM` ⇒ process exists ⇒ alive; only genuine "no such process" ⇒ dead. | +| `src/mcp/auth-proxy/config.ts` | Modify `RESERVED_ROUTE_IDS` (19) | Reserve `shutdown` so a user route can't shadow the control endpoint. | +| `src/mcp/auth-proxy/server.ts` | Modify `McpAuthProxy` (109-236) | Accept an `onShutdownRequested` callback; serve `POST /shutdown` → 202 then fire the callback after the response flushes; `405` on non-POST. | +| `src/mcp/auth-proxy/runtime.ts` | Modify `runAuthProxyDaemon` (28-64) | Build an idempotent `gracefulShutdown` (stop → exit); wire it to both the proxy's `onShutdownRequested` and `SIGTERM`/`SIGINT`. | +| `src/cli/commands/mcp-auth-proxy.ts` | Modify `stop` action (189-216) + add `requestShutdown` helper | POST `/shutdown` first (graceful, cross-platform), poll for exit, then `SIGTERM→SIGKILL` fallback only if still alive. | + +**Test files:** `src/utils/__tests__/processes.test.ts` (new), `src/mcp/auth-proxy/__tests__/state.test.ts` (extend), `src/mcp/auth-proxy/__tests__/config.test.ts` (extend), `src/mcp/auth-proxy/__tests__/server.test.ts` (extend). + +--- + +## Task 1: `windowsHide` on the detached daemon spawn + +**Files:** +- Modify: `src/utils/processes.ts:115-121` +- Test: `src/utils/__tests__/processes.test.ts` (create) + +Test-first: yes — a test asserting `spawnDetached` passes `windowsHide: true` when `os.platform()` is `win32` and `false` otherwise. + +- [ ] **Step 1: Write the failing test** + +Create `src/utils/__tests__/processes.test.ts`: + +```typescript +/** + * processes.ts — spawnDetached platform-conditional options. + * @group unit + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import os from 'node:os'; + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(() => ({ unref: vi.fn(), pid: 4242 })), + exec: vi.fn(), +})); + +import { spawn } from 'node:child_process'; +import { spawnDetached } from '../processes.js'; + +describe('spawnDetached', () => { + beforeEach(() => { + vi.mocked(spawn).mockClear(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('hides the console window on Windows (windowsHide: true)', () => { + vi.spyOn(os, 'platform').mockReturnValue('win32'); + const pid = spawnDetached('node', ['daemon.js']); + expect(pid).toBe(4242); + expect(spawn).toHaveBeenCalledWith( + 'node', + ['daemon.js'], + expect.objectContaining({ detached: true, stdio: 'ignore', windowsHide: true }) + ); + }); + + it('does not hide the console window off Windows (windowsHide: false)', () => { + vi.spyOn(os, 'platform').mockReturnValue('linux'); + spawnDetached('node', ['daemon.js']); + expect(spawn).toHaveBeenCalledWith( + 'node', + ['daemon.js'], + expect.objectContaining({ windowsHide: false }) + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/utils/__tests__/processes.test.ts` +Expected: FAIL — the `objectContaining({ windowsHide: true })` assertion fails because `spawnDetached` passes no `windowsHide` key. + +- [ ] **Step 3: Write minimal implementation** + +In `src/utils/processes.ts`, change the `spawnDetached` body (currently lines 115-121): + +```typescript +export function spawnDetached( + command: string, + args: string[] = [], + options: DetachedSpawnOptions = {} +): number { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + detached: true, + stdio: options.stdio ?? 'ignore', + // Detached background daemons must never flash a console window on Windows + // (matches the exec.ts / BaseAgentAdapter idiom for attached spawns). + windowsHide: os.platform() === 'win32', + }); + child.unref(); + return child.pid ?? -1; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/utils/__tests__/processes.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/processes.ts src/utils/__tests__/processes.test.ts +git commit -m "fix(utils): hide detached daemon console window on Windows + +mcp-auth-proxy-windows Task 1" +``` + +--- + +## Task 2: `isProcessAlive` treats `EPERM` as alive on Windows + +**Files:** +- Modify: `src/mcp/auth-proxy/state.ts:45-52` +- Test: `src/mcp/auth-proxy/__tests__/state.test.ts` (extend) + +Test-first: yes — a test that mocks `process.kill` to throw `EPERM` (⇒ alive) and `ESRCH` (⇒ dead). + +- [ ] **Step 1: Write the failing test** + +In `src/mcp/auth-proxy/__tests__/state.test.ts`, add `vi` to the vitest import (line 5 becomes `import { describe, it, expect, afterEach, vi } from 'vitest';`) and append this test inside the `describe('auth proxy state file', …)` block (after the existing `isProcessAlive` test at line 59): + +```typescript + it('isProcessAlive: EPERM means the process exists (Windows), ESRCH means dead', () => { + const killSpy = vi.spyOn(process, 'kill'); + + killSpy.mockImplementationOnce(() => { + const err = new Error('operation not permitted') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + }); + expect(isProcessAlive(424242)).toBe(true); + + killSpy.mockImplementationOnce(() => { + const err = new Error('no such process') as NodeJS.ErrnoException; + err.code = 'ESRCH'; + throw err; + }); + expect(isProcessAlive(424242)).toBe(false); + + killSpy.mockRestore(); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/state.test.ts` +Expected: FAIL — current `isProcessAlive` catches *all* throws and returns `false`, so the `EPERM ⇒ true` assertion fails. + +- [ ] **Step 3: Write minimal implementation** + +Replace `isProcessAlive` in `src/mcp/auth-proxy/state.ts` (45-52): + +```typescript +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM: the process exists but this user cannot signal it (common on + // Windows) — that still means "alive". Only a genuine "no such process" + // (ESRCH) means dead. + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/state.test.ts` +Expected: PASS (all 4 tests, including the new one and the unchanged `2**30 ⇒ false`, which throws `ESRCH`). + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/auth-proxy/state.ts src/mcp/auth-proxy/__tests__/state.test.ts +git commit -m "fix(proxy): treat EPERM as alive in mcp-auth-proxy liveness check + +mcp-auth-proxy-windows Task 2" +``` + +--- + +## Task 3: Reserve the `shutdown` route id + +**Files:** +- Modify: `src/mcp/auth-proxy/config.ts:19` +- Test: `src/mcp/auth-proxy/__tests__/config.test.ts:60` + +Test-first: yes — the existing `it.each(['as', 'healthz'])('rejects reserved route id …')` gains `'shutdown'`. + +- [ ] **Step 1: Write the failing test** + +In `src/mcp/auth-proxy/__tests__/config.test.ts`, change the reserved-id case (line 60) from: + +```typescript + it.each(['as', 'healthz'])('rejects reserved route id %j', (id) => { +``` + +to: + +```typescript + it.each(['as', 'healthz', 'shutdown'])('rejects reserved route id %j', (id) => { +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/config.test.ts` +Expected: FAIL for `id = 'shutdown'` — it is not yet reserved, so `validateAuthProxyConfig` does not throw `/reserved/`. + +- [ ] **Step 3: Write minimal implementation** + +In `src/mcp/auth-proxy/config.ts`, update `RESERVED_ROUTE_IDS` (line 19) and its comment (17-18): + +```typescript +// `as` + `.well-known` are reserved by the route map; `healthz` by the health +// endpoint and `shutdown` by the graceful-shutdown control endpoint (design D6 — +// a route named "healthz"/"shutdown" would shadow GET /healthz / POST /shutdown). +const RESERVED_ROUTE_IDS = new Set(['as', '.well-known', 'healthz', 'shutdown']); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/config.test.ts` +Expected: PASS (reserved-id case now covers 3 ids). + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/auth-proxy/config.ts src/mcp/auth-proxy/__tests__/config.test.ts +git commit -m "feat(proxy): reserve 'shutdown' route id for the control endpoint + +mcp-auth-proxy-windows Task 3" +``` + +--- + +## Task 4: Serve `POST /shutdown` from the daemon + +**Files:** +- Modify: `src/mcp/auth-proxy/server.ts` — constructor (116-120), dispatch (196-224), add `handleShutdown` +- Test: `src/mcp/auth-proxy/__tests__/server.test.ts` (extend) + +Test-first: yes — `POST /shutdown` ⇒ `202` and the `onShutdownRequested` callback fires once (after the response flushes); `GET /shutdown` ⇒ `405` and the callback does not fire. + +- [ ] **Step 1: Write the failing test** + +In `src/mcp/auth-proxy/__tests__/server.test.ts`, add `vi` to the vitest import (line 5 becomes `import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';`) and append this test inside the `describe('McpAuthProxy', …)` block (it reuses the outer `upstream` created in `beforeAll`): + +```typescript + it('POST /shutdown → 202 and fires the shutdown callback once; GET /shutdown → 405', async () => { + const onShutdown = vi.fn(); + const controllable = new McpAuthProxy( + { port: 0, servers: { radar: { upstreamUrl: `${upstream.origin}/mcp/radar` } } }, + onShutdown + ); + const { url } = await controllable.start(); + try { + const wrongMethod = await fetch(`${url}/shutdown`, { method: 'GET' }); + expect(wrongMethod.status).toBe(405); + expect(onShutdown).not.toHaveBeenCalled(); + + const res = await fetch(`${url}/shutdown`, { method: 'POST' }); + expect(res.status).toBe(202); + expect((await res.json()) as JsonObject).toEqual({ status: 'shutting_down' }); + await vi.waitFor(() => expect(onShutdown).toHaveBeenCalledTimes(1)); + } finally { + await controllable.stop(); + } + }); + + it('does not treat a configured route named like the control path as /shutdown', async () => { + // /shutdown is intercepted before route lookup; a normal MCP route still passes through. + const res = await fetch(`${proxyOrigin}/radar`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Bearer tok-abc' }, + body: '{}', + }); + expect(res.status).toBe(200); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/server.test.ts` +Expected: FAIL — the `McpAuthProxy` constructor takes only `config` (a 2nd arg is ignored), and `/shutdown` currently falls through to `404 unknown_route`, so both the `405` and `202` assertions fail. + +- [ ] **Step 3: Write minimal implementation** + +In `src/mcp/auth-proxy/server.ts`: + +**(a)** Extend the constructor (116-120) to accept and store the callback: + +```typescript + constructor( + private readonly config: AuthProxyConfig, + private readonly onShutdownRequested?: () => void + ) { + this.port = config.port; + this.client = new UpstreamClient(); + this.metadata = new MetadataCache((url) => this.client.fetchJson(url)); + } +``` + +**(b)** In `handleRequest`, add the `/shutdown` branch immediately after the `/healthz` branch (the new `else if` goes between the current lines 209 and 210, before the `.well-known` branch): + +```typescript + if (req.method === 'GET' && url.pathname === '/healthz') { + kind = 'healthz'; + this.serveHealth(res); + } else if (url.pathname === '/shutdown') { + kind = 'shutdown'; + this.handleShutdown(req, res); + } else if (segments[0] === '.well-known') { +``` + +**(c)** Add the `handleShutdown` method (place it next to `serveHealth`, in the "Health + errors" section around line 547): + +```typescript + /** + * Graceful-shutdown control endpoint (loopback-only, like the whole server). + * POST → ack 202, then run the daemon's own cleanup AFTER the response has + * flushed so the caller (CLI stop) reliably receives the ack. Cross-platform: + * this is how Windows shuts down gracefully, since it has no POSIX signals. + */ + private handleShutdown(req: http.IncomingMessage, res: http.ServerResponse): void { + if (req.method !== 'POST') { + sendJson(res, 405, { error: 'method_not_allowed' }); + return; + } + res.on('finish', () => this.onShutdownRequested?.()); + sendJson(res, 202, { status: 'shutting_down' }); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/server.test.ts` +Expected: PASS (all existing tests plus the 2 new ones). Existing constructor calls (`new McpAuthProxy(config)`) still compile because the 2nd arg is optional. + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/auth-proxy/server.ts src/mcp/auth-proxy/__tests__/server.test.ts +git commit -m "feat(proxy): add loopback POST /shutdown control endpoint + +mcp-auth-proxy-windows Task 4" +``` + +--- + +## Task 5: Wire the runtime's graceful shutdown to the endpoint + signals + +**Files:** +- Modify: `src/mcp/auth-proxy/runtime.ts:35, 44-60` + +Test-first: no — this is orchestration around `process.exit` and the full daemon bootstrap (config load + real server). Its delegated behaviors are already covered: the `/shutdown` → callback path by Task 4, graceful `proxy.stop()` SSE drain by the existing `server.test.ts` SSE test, and `EPERM` liveness by Task 2. A dedicated runtime test would have to mock `process.exit` and re-bootstrap config/server for negligible marginal coverage; the idempotency guard is trivial and reviewed here, and the end-to-end path is exercised by the Stage 6 smoke run. + +- [ ] **Step 1: Implement the wiring** + +Replace the body of `runAuthProxyDaemon` from the `const proxy = …` line through the signal registration (currently lines 35-60) with: + +```typescript + const routes = Object.keys(config.servers); + + // One idempotent graceful path shared by the /shutdown endpoint and POSIX + // signals: stop the server (drains SSE), clear state, exit. On Windows the + // endpoint is the only path that runs this — a signal there is a hard kill. + let shuttingDown = false; + const gracefulShutdown = (): void => { + if (shuttingDown) { + return; + } + shuttingDown = true; + void stop().then(() => process.exit(0)); + }; + + const proxy = new McpAuthProxy(config, gracefulShutdown); + const { port, url } = await proxy.start(); + + await writeAuthProxyState( + { pid: process.pid, port, routes, startedAt: new Date().toISOString() }, + stateFile + ); + + async function stop(): Promise { + try { + await proxy.stop(); + } catch { + // Best-effort shutdown + } + try { + await clearAuthProxyState(stateFile); + } catch { + // Best-effort cleanup + } + } + + process.on('SIGTERM', gracefulShutdown); + process.on('SIGINT', gracefulShutdown); +``` + +Note: `stop` is now a hoisted `function` declaration so it can be referenced by `gracefulShutdown` (defined above `proxy`) while still being the `RunningDaemon.stop` returned at the end. The returned object keeps `stop` (unchanged signature: `() => Promise`), so `RunningDaemon` and the `--foreground`/bin callers are unaffected. + +- [ ] **Step 2: Verify the suite compiles + passes** + +Run: `npx vitest run src/mcp/auth-proxy` +Expected: PASS — no test constructs the runtime directly; the server/state/config suites are unaffected. + +Run: `npm run typecheck` +Expected: PASS — `McpAuthProxy`'s new optional 2nd ctor arg and the reordered `stop` declaration typecheck. + +- [ ] **Step 3: Commit** + +```bash +git add src/mcp/auth-proxy/runtime.ts +git commit -m "feat(proxy): route graceful shutdown through endpoint and signals + +mcp-auth-proxy-windows Task 5" +``` + +--- + +## Task 6: CLI `stop` — request graceful shutdown over HTTP, fall back to signals + +**Files:** +- Modify: `src/cli/commands/mcp-auth-proxy.ts` — add `requestShutdown` helper (near `fetchHealth`, ~68), rewrite `stop` action (189-216) + +Test-first: no — the CLI command file has no existing test harness, and the `stop` action is orchestration over `requestShutdown` (the `POST /shutdown` endpoint proven by Task 4), `isProcessAlive` (Task 2), and `process.kill` (unchanged). It is verified end-to-end by the Stage 6 real daemon smoke (start → `stop` → assert graceful drain + state cleared). Adding a Vitest harness here would require mocking `http`, `process.kill`, and the state file for little marginal value under `sdlc-light`. + +- [ ] **Step 1: Add the `requestShutdown` helper** + +In `src/cli/commands/mcp-auth-proxy.ts`, add after `fetchHealth` (which ends at line 87): + +```typescript +/** + * Ask the daemon to shut itself down gracefully via the loopback control + * endpoint. Cross-platform graceful stop (Windows has no POSIX signals). + * Resolves true if the daemon acknowledged (2xx), false on any error/timeout — + * the caller then falls back to OS signals. + */ +function requestShutdown(port: number): Promise { + return new Promise((resolveShutdown) => { + const request = http.request( + { host: '127.0.0.1', port, path: '/shutdown', method: 'POST', timeout: 2000 }, + (res) => { + res.resume(); // drain the 202 body + resolveShutdown(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300); + } + ); + request.on('error', () => resolveShutdown(false)); + request.on('timeout', () => { + request.destroy(); + resolveShutdown(false); + }); + request.end(); + }); +} +``` + +- [ ] **Step 2: Rewrite the `stop` action** + +Replace the `stop` action body (currently lines 192-216) with: + +```typescript + .action(async () => { + const state = await readAuthProxyState(); + if (!state || !isProcessAlive(state.pid)) { + await clearAuthProxyState(); + console.log(chalk.yellow('mcp-auth-proxy is not running')); + return; + } + + // Graceful first (works on Windows and POSIX): ask the daemon to run its + // own proxy.stop() + exit via the loopback control endpoint. + await requestShutdown(state.port); + for (let i = 0; i < 50; i++) { + await new Promise((r) => setTimeout(r, 100)); + if (!isProcessAlive(state.pid)) { + break; + } + } + + // Fallback only if it did not exit: SIGTERM (POSIX graceful / Windows hard + // kill), then SIGKILL. Skipped entirely when the daemon already shut down. + if (isProcessAlive(state.pid)) { + logger.warn('[mcp-auth-proxy] Graceful shutdown timed out; sending SIGTERM'); + try { + process.kill(state.pid, 'SIGTERM'); + } catch { + // Already gone between the check and the signal — fine. + } + for (let i = 0; i < 50; i++) { + await new Promise((r) => setTimeout(r, 100)); + if (!isProcessAlive(state.pid)) { + break; + } + } + } + if (isProcessAlive(state.pid)) { + logger.warn('[mcp-auth-proxy] Daemon ignored SIGTERM; escalating to SIGKILL'); + try { + process.kill(state.pid, 'SIGKILL'); + } catch { + // Already gone — fine. + } + } + + await clearAuthProxyState(); + console.log(chalk.green('✓ mcp-auth-proxy stopped')); + }); +``` + +- [ ] **Step 3: Typecheck + build** + +Run: `npm run typecheck` +Expected: PASS. + +Run: `npm run build` +Expected: PASS — `dist/` regenerated for the Stage 6 smoke. + +- [ ] **Step 4: Real daemon smoke (POSIX graceful path)** + +Run, from the repo root, in an isolated home so nothing touches the user's real config/state: + +```bash +CODEMIE_HOME="$(mktemp -d)" bash -c ' + printf "%s" "{\"port\":42891,\"servers\":{\"radar\":{\"upstreamUrl\":\"https://mcp.epam.com/mcp/radar\"}}}" > "$CODEMIE_HOME/mcp-auth-proxy.json" + node bin/codemie.js mcp-auth-proxy start --port 42891 + sleep 1 + node bin/codemie.js mcp-auth-proxy status + # direct control-endpoint check: POST /shutdown must ack 202 + curl -s -o /dev/null -w "shutdown_status=%{http_code}\n" -X POST http://127.0.0.1:42891/shutdown || true + sleep 1 + node bin/codemie.js mcp-auth-proxy status + ls "$CODEMIE_HOME/mcp-auth-proxy.state.json" 2>/dev/null && echo "STATE LEFT BEHIND (bad)" || echo "state cleared (good)" +' +``` + +Expected: `start` prints the `claude mcp add` line; first `status` shows running; `shutdown_status=202`; second `status` shows "not running"; `state cleared (good)`. (Confirms the `POST /shutdown` → self-exit → state-clear path end-to-end. On Windows the same `stop` command drives this instead of a signal.) + +- [ ] **Step 5: Commit** + +```bash +git add src/cli/commands/mcp-auth-proxy.ts +git commit -m "feat(cli): stop mcp-auth-proxy via graceful control endpoint first + +mcp-auth-proxy-windows Task 6" +``` + +--- + +## Self-Review + +**Spec coverage** (against the Stage-2 decision "Graceful shutdown endpoint, cross-platform"): +- Console-window flash → Task 1 (`windowsHide`). ✓ +- Windows liveness (`EPERM`) → Task 2. ✓ +- Control-endpoint id safety → Task 3 (reserve `shutdown`). ✓ +- Graceful `POST /shutdown` (daemon side) → Task 4 (endpoint) + Task 5 (runtime wiring). ✓ +- CLI drives graceful stop cross-platform with signal fallback → Task 6. ✓ +- POSIX not regressed → Task 5 keeps `SIGTERM`/`SIGINT` handlers; Task 6 keeps the `SIGTERM→SIGKILL` fallback; existing server SSE-drain test still green. + +**Type consistency:** `onShutdownRequested?: () => void` is the same name in the Task 4 ctor, the Task 5 `gracefulShutdown` wiring, and is invoked only inside `handleShutdown`. `requestShutdown(port: number): Promise` (Task 6) matches its single call site. `isProcessAlive(pid: number): boolean` signature unchanged (Task 2 changes only the body). `stop` remains `() => Promise` in `RunningDaemon` (Task 5). + +**Placeholder scan:** none — every code step shows complete code. + +**Windows-safe items intentionally NOT changed** (confirmed portable in research): `BIND_HOST='127.0.0.1'`, all `path.join`/`getCodemiePath` path building, atomic `tmp`+`rename` state writes, TCP-loopback-only transport. diff --git a/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/qa-report.md b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/qa-report.md new file mode 100644 index 00000000..8e5420b0 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/qa-report.md @@ -0,0 +1,37 @@ +# QA Report — mcp-auth-proxy Windows compatibility + +- **Task:** mcp-auth-proxy-windows +- **Branch:** feat/mcp-auth-proxy +- **Reviewed range:** `9c6eecd..HEAD` (7 commits) +- **UI surface:** none (CLI + background daemon) → feature-verification **skipped** (`ui=false`) + +## Gate Results + +| Gate | Command | Result | +|---|---|---| +| license-check | `npm run license-check` | ✅ PASS (dependency licenses only; no source headers in repo) | +| lint | `eslint '{src,tests}/**/*.ts' --max-warnings=0` | ✅ PASS (zero warnings) | +| typecheck | `tsc --noEmit` | ✅ PASS | +| build | `tsc && tsc-alias && copy-plugin` | ✅ PASS | +| unit | `vitest run src` | ✅ PASS — 2278 passed, 1 skipped (151 files); +6 new tests | +| integration | `vitest run tests/integration` | ✅ PASS — 220 passed, 1 skipped (27 files) | +| commitlint | Conventional Commits on `9c6eecd..HEAD` | ✅ PASS — 7/7 commits conform | + +## New test coverage (+6) + +- `src/utils/__tests__/spawn-detached.test.ts` — 2: `windowsHide: true` on win32, `false` off-Windows. +- `src/mcp/auth-proxy/__tests__/state.test.ts` — +1: `isProcessAlive` EPERM⇒alive, ESRCH⇒dead. +- `src/mcp/auth-proxy/__tests__/config.test.ts` — +1: `shutdown` rejected as reserved route id. +- `src/mcp/auth-proxy/__tests__/server.test.ts` — +2: `POST /shutdown`⇒202+callback-once / `GET`⇒405; `/shutdown` intercepted before route lookup. + +## Non-browser functional evidence (real daemon smoke, isolated CODEMIE_HOME) + +1. `start --port 42892` → daemon detached, `/healthz` → `{"status":"ok",...}`. +2. `POST /shutdown` → `202 {"status":"shutting_down"}` → daemon self-exits (connection refused) → state file cleared. **Proves the cross-platform graceful path** (this is the exact path Windows `stop` uses instead of a signal). +3. `codemie mcp-auth-proxy stop` → `✓ mcp-auth-proxy stopped` in ~722ms (graceful, < 5s), `/healthz` refused after; second `stop` → `mcp-auth-proxy is not running` (idempotent). + +## Code review + +Independent adversarial review (blind lens over the diff + full-file context): **approve, high confidence**. One low-severity finding (CR-W-001: discarded `requestShutdown` boolean) applied inline (commit after review); happy path re-verified unchanged. See `code-review-final.json`. + +**Status: PASSED** diff --git a/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/reviewed_base.txt b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/reviewed_base.txt new file mode 100644 index 00000000..e87cff47 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/reviewed_base.txt @@ -0,0 +1 @@ +9c6eecd diff --git a/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/reviewed_head.txt b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/reviewed_head.txt new file mode 100644 index 00000000..585a2bb7 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/reviewed_head.txt @@ -0,0 +1 @@ +8911c8a8f6ab2bc6cb9d611a981c32f24e9f90b5 diff --git a/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/technical-analysis.md b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/technical-analysis.md new file mode 100644 index 00000000..fa453810 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/technical-analysis.md @@ -0,0 +1,169 @@ +# Technical Research + +**Task**: windows daemon process-spawn signal-handling mcp-auth-proxy +**Generated**: 2026-07-05 +**Research path**: filesystem (codegraph MCP tool not available in this environment; fell back to read-only Explore agents) + +--- + +## 1. Original Context + +Fix the `codemie mcp-auth-proxy` feature so it works correctly on a Windows host. The feature is a local loopback-only HTTP proxy daemon (CLI command `codemie mcp-auth-proxy start|stop|status`) plus a core module. It was just implemented on branch feat/mcp-auth-proxy. The concern: the daemon is spawned detached and stopped via POSIX signals, and Windows lacks POSIX signal semantics. Specifically investigate: (1) how the daemon is spawned — `spawnDetached()` in src/utils/processes.ts and its use in src/cli/commands/mcp-auth-proxy.ts; whether it passes `windowsHide`; (2) how the daemon is stopped — `process.kill(pid, 'SIGTERM')` then poll then `SIGKILL` in src/cli/commands/mcp-auth-proxy.ts, and the daemon-side `process.on('SIGTERM'|'SIGINT')` handlers in src/mcp/auth-proxy/runtime.ts; whether graceful shutdown actually runs on Windows; (3) pid-liveness check `process.kill(pid, 0)` in src/mcp/auth-proxy/state.ts on Windows; (4) any path/port/bind assumptions in src/mcp/auth-proxy/server.ts, config.ts, state.ts that could differ on Windows; (5) how OTHER existing daemons in this repo handle Windows — compare src/cli/commands/proxy/daemon-manager.ts (SSO proxy) and src/cli/commands/codebase/daemon-manager.ts, and note whether they set windowsHide or handle SIGTERM cross-platform, so the fix matches existing repo precedent rather than inventing a new pattern. Also check src/utils/exec.ts and BaseAgentAdapter for the established `os.platform()==='win32'` + `windowsHide` idiom. + +> Caller focus: exact Windows failure modes (graceful shutdown not running because Windows terminates immediately on `process.kill` with a signal name; possible console-window flash from missing `windowsHide`), the established repo idiom for platform-conditional behavior, and the minimal surgical set of files/functions to change — grounded in quoted `file:line` references. + +--- + +## 2. Codebase Findings + +### Existing Implementations + +Confirmed real file paths for the feature: + +- `src/cli/commands/mcp-auth-proxy.ts` — CLI command (`start`/`stop`/`status`); spawns the daemon and drives the SIGTERM→SIGKILL stop sequence. +- `src/bin/mcp-auth-proxy-daemon.ts` → built `bin/mcp-auth-proxy-daemon.js` — the detached daemon entry point actually spawned. Doc header: "Loads the config, starts McpAuthProxy, writes the state file, handles SIGTERM." (`src/bin/mcp-auth-proxy-daemon.ts:5`). +- `src/mcp/auth-proxy/runtime.ts` — installs the `SIGTERM`/`SIGINT` graceful-shutdown handlers. +- `src/mcp/auth-proxy/state.ts` — state file read/write/clear + `isProcessAlive` liveness. +- `src/mcp/auth-proxy/server.ts` — `McpAuthProxy` HTTP server, bind host, `proxy.stop()`. +- `src/mcp/auth-proxy/config.ts` — port and path defaults. +- `src/utils/processes.ts` — the shared `spawnDetached()` helper. + +### Architecture and Layers Affected + +Four layers of the plugin-based architecture are in scope; the fix surface is small and concentrated: + +- **Utility layer** — `src/utils/processes.ts` `spawnDetached()` (the single shared daemon-spawn primitive; also used by the SSO and codebase daemons). +- **CLI layer** — `src/cli/commands/mcp-auth-proxy.ts` (`start` spawn, `stop` kill sequence, `status`). +- **MCP auth-proxy core module** — `src/mcp/auth-proxy/runtime.ts` (signal handlers), `state.ts` (liveness), `server.ts` (bind), `config.ts` (ports/paths). +- **Bin entry** — `src/bin/mcp-auth-proxy-daemon.ts` (detached process root). + +### Integration Points + +- The daemon is spawned via `process.execPath` (node binary directly, so no `.cmd`/shell resolution is needed on Windows) launching the built bin: + - `src/cli/commands/mcp-auth-proxy.ts:129` — `const daemonBin = join(getDirname(import.meta.url), '../../../bin/mcp-auth-proxy-daemon.js');` + - `src/cli/commands/mcp-auth-proxy.ts:130-135` — `spawnDetached(process.execPath, [ daemonBin, '--config', configPath, '--port', String(port), '--state-file', getDefaultStatePath() ]);` — **no options object passed**. +- CLI ↔ daemon handshake is via the **state file** (poll on start, `isProcessAlive` for status, force-clear after kill). CLI ↔ daemon health check is over TCP loopback: `src/cli/commands/mcp-auth-proxy.ts:71` — `{ host: '127.0.0.1', port, path: '/healthz', timeout: 2000 }`. +- There is **no** control channel from CLI to a running daemon other than OS signals — this is the crux of the Windows problem (see §6). + +### Patterns and Conventions + +The established repo idiom for platform-conditional spawning is `windowsHide: isWindows` where `isWindows = os.platform() === 'win32'` — but it is applied only to **foreground/attached** spawns, never to `spawnDetached`: + +- `src/utils/exec.ts:42` — `const isWindows = os.platform() === 'win32';` +- `src/utils/exec.ts:77` — `windowsHide: isWindows, // Hide console window on Windows` +- `src/agents/core/BaseAgentAdapter.ts:678` — `const isWindows = process.platform === 'win32';` +- `src/agents/core/BaseAgentAdapter.ts:742` — `windowsHide: isWindows // Hide console window on Windows` +- `src/cli/commands/skills/lib/run-skills-cli.ts:117` — `windowsHide: os.platform() === 'win32',` + +The shared detached-spawn helper does **not** set `windowsHide` and its options type does not even accept it: + +- `src/utils/processes.ts:104-108` — `export interface DetachedSpawnOptions { cwd?: string; env?: NodeJS.ProcessEnv; stdio?: 'ignore' | 'inherit'; }` +- `src/utils/processes.ts:115-121` — `const child = spawn(command, args, { cwd: options.cwd, env: options.env, detached: true, stdio: options.stdio ?? 'ignore' }); child.unref();` + +Termination convention across the repo is **100% signal-based and platform-agnostic** — `process.kill(pid, 'SIGTERM')` then escalate to `'SIGKILL'`. Grep for `taskkill` across `src/` returned **zero** hits, and **no** code path anywhere branches on `win32`/`os.platform()` when killing a process. Liveness is uniformly `process.kill(pid, 0)` in try/catch. + +Precedent daemons the new feature was modeled on (both share the same Windows gaps): + +- SSO proxy — `src/cli/commands/proxy/daemon-manager.ts:132` `spawnDetached(process.execPath, args);` (no options → no `windowsHide`); stop at `:158` `process.kill(state.pid, 'SIGTERM');` → `:173` `process.kill(state.pid, 'SIGKILL');`; liveness `:59-66` `process.kill(pid, 0)`. +- Codebase UI — `src/cli/commands/codebase/daemon-manager.ts:103-113` `spawnDetached(process.execPath, [...], { stdio: 'ignore' })` (no `windowsHide`); stop at `:156` `process.kill(state.pid, 'SIGTERM');` (no SIGKILL escalation); liveness `:50-57` `process.kill(pid, 0)`. + +**Conclusion on precedent:** the new `mcp-auth-proxy` code already matches the SSO daemon almost verbatim. The Windows defects are therefore **shared by all three daemons**, not unique to this feature. There is no in-repo "correct Windows daemon" to copy — the `windowsHide` idiom exists only for attached spawns, and no cross-platform kill mechanism exists at all. + +--- + +## 3. Documentation Findings + +### Guides and Architecture Docs + +`.ai-run/guides/` is present. Relevant guides read: + +- `.ai-run/guides/development/development-practices.md:131` — "✅ Use `exec()` from `src/utils/exec.ts` for all process spawning"; `:135` "❌ Use `child_process.exec()` directly"; `:377` names `src/utils/processes.ts` and `src/utils/exec.ts` as the process modules. **No `windowsHide` / detached-spawn / graceful-shutdown / signal convention is documented anywhere in the guides.** +- `.ai-run/guides/testing/testing-patterns.md:46,59,70,196` — codifies the `vi.mock()` at module level + `vi.spyOn()` in `beforeEach` / `vi.restoreAllMocks()` in `afterEach` + dynamic-import-after-spy convention the daemon tests already use. +- Cross-platform is mentioned only incidentally elsewhere (`security-practices.md:25` keychain per-OS, `external-integrations.md:168` Windows storage path, `git-workflow.md:22` example branch name) — **none** prescribe `windowsHide`, detached spawn, or shutdown behavior. + +### Architectural Decisions + +- Design doc: `docs/superpowers/specs/2026-07-03-mcp-auth-proxy-design.md`. + - `:135` Non-goals explicitly list **"Windows service"** as out of scope. Ordinary Windows CLI/daemon support is neither promised nor addressed. + - `:43` runtime responsibility: "install SIGTERM/SIGINT cleanup (stop server, unlink state)" — POSIX-signal framing, no Windows note. + - `:46` CLI `stop`: "SIGTERM + poll, SIGKILL escalation, clear state" — assumes POSIX semantics. + - `:44` bin entry "Mirrors `src/bin/proxy-daemon.ts` minus watcher" — confirms the SSO-daemon lineage. + - The doc references `docs/SPEC-mcp-auth-proxy.md` as the "authoritative functional spec" (`:7`) but **that file does not exist in the repo**. + - The spec is **silent on Windows signal/spawn compatibility**. (It does record a macOS loopback nuance — `localhost`→IPv6 `::1` — but no Windows analysis.) +- The Windows follow-up work item `docs/superpowers/tasks/2026-07-05-mcp-auth-proxy-windows/` currently contains **only** `.state.json` (flow `sdlc-light`, branch `feat/mcp-auth-proxy`, phase `main`) — no requirements/spec/plan yet. + +### Derived Conventions + +Because guides are silent on daemon platform handling, the operative conventions are derived from code (§2 Patterns): (a) attach the `windowsHide: os.platform()==='win32'` flag when a spawn could open a console window; (b) never introduce `taskkill` or a `win32` kill branch — the repo standardizes on `process.kill` signals; (c) use `path.join` / `getCodemiePath` and literal `127.0.0.1` for portability. + +--- + +## 4. Testing Landscape + +### Existing Coverage + +- `src/mcp/auth-proxy/__tests__/state.test.ts` — state round-trip/atomicity (`:35`), malformed/missing parse → null (`:41`), `clear` tolerates absence (`:49`), `isProcessAlive` true-for-self / false-for-dead-pid (`:56`). No signal/spawn/Windows coverage. +- `src/mcp/auth-proxy/__tests__/server.test.ts` — SSE pass-through (`:170`), degraded `/healthz` isolation (`:286`) against a local fake `node:http` upstream. No process/signal logic. +- `src/mcp/auth-proxy/__tests__/{rewrites,config,metadata-cache}.test.ts` — pure rewrite/config/discovery logic; unrelated to platform/process. +- Comparison daemons: `src/cli/commands/proxy/__tests__/daemon-manager.test.ts` (state + `isProcessAlive` + stale-pid cleanup `:84,:109`; spawn/kill never run) and `src/cli/commands/codebase/__tests__/daemon-manager.test.ts` (mocks `spawnDetached` `:22`, asserts call args `:93`; real spawn/kill never run). + +### Testing Framework and Patterns + +- Vitest `^4.1.5` (`@vitest/ui ^4.1.5`). Scripts: `test`, `test:unit` (`vitest run src`), `test:integration` (`vitest run tests/integration`), `test:coverage`, `test:run`. Config `vitest.config.ts`. Pre-commit runs `vitest related --run`. +- Mock idiom for daemon spawning: `vi.mock('../../../../utils/processes.js', () => ({ spawnDetached: vi.fn(() => process.pid) }))` (`codebase/__tests__/daemon-manager.test.ts:22`) asserted with `toHaveBeenCalledWith(process.execPath, [...], { stdio: 'ignore' })` (`:93`). +- Path redirection: `vi.mock('.../utils/paths.js', … getCodemieHome: () => tmpdir(), getDirname: () => tmpdir())` (`proxy/__tests__/daemon-manager.test.ts:13`). +- Liveness in tests uses real `process.pid` (alive) vs a large fake pid `2**30` (dead) — `state.test.ts:57`. **No test ever mocks `process.kill` for signal delivery.** + +### Coverage Gaps + +- `src/utils/processes.ts` `spawnDetached` (110-123) — no direct test; only ever mocked away. The `windowsHide` fix lands here with no existing test to catch regressions. +- `src/mcp/auth-proxy/runtime.ts:56-60` — the `SIGTERM`/`SIGINT` graceful-stop+unlink handlers have **no** `runtime.test.ts`. +- `src/cli/commands/mcp-auth-proxy.ts` — the entire command (`start` spawn `:130`, `stop` `:199-213`, `status` `:161`) has **no** test file. +- `src/bin/mcp-auth-proxy-daemon.ts` — untested. +- No start→stop happy-path (spawn → state poll `:137` → SIGTERM shutdown → state cleanup) exercised on any platform. + +--- + +## 5. Configuration and Environment + +### Environment Variables + +- `CODEMIE_HOME` — root for config/state paths (`src/utils/paths.ts:356-362`, `getCodemieHome()` → `CODEMIE_HOME` or `path.join(homedir(), '.codemie')`). The only env var in the feature's runtime path. +- `CODEMIE_DEBUG` — referenced in CLI help text only (`src/cli/commands/mcp-auth-proxy.ts:100,150`). + +### Configuration Files + +- `src/mcp/auth-proxy/config.ts:12` — `export const DEFAULT_AUTH_PROXY_PORT = 42800;` +- `src/mcp/auth-proxy/config.ts:13-14` — filenames `mcp-auth-proxy.json` / `mcp-auth-proxy.state.json`. +- `src/mcp/auth-proxy/config.ts:21-27` — paths via `getCodemiePath(...)` → `src/utils/paths.ts:374-376` `path.join(getCodemieHome(), ...paths)`. All `path.join`; **no hardcoded separators** → Windows-safe. +- State file uses atomic `${stateFile}.tmp` + `rename` (`src/mcp/auth-proxy/state.ts:30-32`) — rename-over-existing works on Windows. + +### Feature Flags and Deployment Concerns + +- Bind is deliberate literal IPv4 loopback (Windows-safe; avoids `localhost`→`::1`): `src/mcp/auth-proxy/server.ts:33` `export const BIND_HOST = '127.0.0.1';`; listen at `:148` `server.listen(this.port, BIND_HOST, () => resolve());`; port-in-use surfaced as `EADDRINUSE` ConfigurationError (`:141-144`). No unix-domain-socket / named-pipe usage — TCP loopback only, portable. **No path/port/bind Windows defect found in server/config/state** beyond the signal/spawn concerns. + +--- + +## 6. Risk Indicators + +Windows failure modes (grounded, with `file:line`): + +- **[HIGH] Graceful shutdown never runs on Windows.** `src/cli/commands/mcp-auth-proxy.ts:199` `process.kill(state.pid, 'SIGTERM');` — on Windows, POSIX signals do not exist; Node ignores the signal name and terminates the target **forcefully and abruptly (SIGKILL-equivalent)**. Consequently the daemon's `src/mcp/auth-proxy/runtime.ts:59-60` `process.on('SIGTERM', onSignal); process.on('SIGINT', onSignal);` handlers never fire, so `proxy.stop()` (server.close + SSE `socket.destroy()`, `runtime.ts:46` → `server.ts:162-176`) and the daemon-side `clearAuthProxyState(stateFile)` (`runtime.ts:51`) are skipped. Net effect: in-flight SSE connections are dropped hard rather than drained. State stays consistent only because the CLI force-removes the state file afterwards (`mcp-auth-proxy.ts:214` `await clearAuthProxyState();`). +- **[MEDIUM] Console-window flash / stray `conhost.exe` from missing `windowsHide`.** `src/utils/processes.ts:115-120` calls `spawn(..., { detached: true, stdio: 'ignore' })` with **no `windowsHide`**, and the options type (`processes.ts:104-108`) does not even accept it. The feature's spawn (`mcp-auth-proxy.ts:130`) passes no options. A detached Node process on Windows opens a visible console window. This diverges from the repo idiom at `exec.ts:77` / `BaseAgentAdapter.ts:742`. **Fixing this in the shared helper also fixes the SSO and codebase daemons** (same omission at `proxy/daemon-manager.ts:132`, `codebase/daemon-manager.ts:103`). +- **[LOW] SIGTERM→poll→SIGKILL escalation is dead code on Windows.** `mcp-auth-proxy.ts:206-213` assumes SIGTERM may be ignored, then escalates. On Windows step 1 already force-kills, so the 5s poll + `SIGKILL` (`:209`) is moot. Harmless, but the graceful-then-force contract does not hold. +- **[LOW] Liveness false-negatives via `EPERM`.** `src/mcp/auth-proxy/state.ts:47` `process.kill(pid, 0);` inside try/catch (`:49-50` returns `false` on any throw). On Windows, signal 0 can raise `EPERM` for a process the caller cannot open, which this code reports as "not alive" — a potential false "not running" and PID-reuse ambiguity. Shared with both other daemons (`proxy/daemon-manager.ts:59`, `codebase/daemon-manager.ts:50`). +- **[Coverage] Zero test coverage on the exact surface being changed** — `spawnDetached`, the CLI `start/stop/status`, the `runtime.ts` signal handlers, and the bin entry all have no tests (§4). A Windows fix has no safety net; regressions on POSIX would go unnoticed. +- **[Novelty] No in-repo precedent for cross-platform daemon shutdown.** `taskkill` appears nowhere; no `win32` kill branch exists. Adding a Windows-graceful shutdown mechanism (e.g. a loopback `/shutdown` control endpoint the CLI POSTs to) would be a **new pattern** with no existing example to match — an architectural decision, not a mechanical fix. +- **[Requirements] Spec silent + explicitly de-scoped Windows.** Design doc lists "Windows service" as a non-goal (`design.md:135`) and never addresses Windows signal/spawn; the follow-up task dir has only `.state.json`. The intended fidelity of "works correctly on Windows" (hard-kill-with-state-cleanup vs. true graceful drain) needs a decision before implementation. + +Windows-safe parts (explicitly not at risk): `BIND_HOST = '127.0.0.1'` (`server.ts:33`), all path construction via `path.join`/`getCodemiePath`, atomic `tmp`+`rename` state writes, TCP-loopback-only (no unix sockets/named pipes). + +--- + +## 7. Summary for Complexity Assessment + +This is a **low-to-medium** complexity, tightly bounded fix touching three layers: the Utility layer (`src/utils/processes.ts`), the CLI layer (`src/cli/commands/mcp-auth-proxy.ts`), and the MCP auth-proxy core module (`runtime.ts`, plus read-only confirmation that `server.ts`/`state.ts`/`config.ts` are already portable). The mechanical part of the fix is genuinely small: the missing-`windowsHide` defect is a **one-line change in `spawnDetached` at `src/utils/processes.ts:115-120`** (add `windowsHide: os.platform() === 'win32'`; `os` is already imported and used elsewhere in that file), which simultaneously repairs the console-window flash for the SSO and codebase daemons — a strict improvement that matches the existing `exec.ts:77` idiom with zero new pattern. Estimated file change surface for the console-window issue alone: 1 file. + +The **graceful-shutdown-on-Windows** concern is where complexity and risk concentrate. `process.kill(pid, 'SIGTERM')` hard-kills on Windows, so the daemon's cleanup handlers never run. The repo offers **no precedent** to copy — termination is signal-based everywhere and there is no `taskkill` or `win32` branch anywhere in `src/`. Two paths exist: (a) the minimal, precedent-aligned option — accept immediate hard-kill on Windows and rely on the CLI's existing post-kill `clearAuthProxyState()` (state stays consistent; SSE sockets drop abruptly), touching ~1–2 files and matching how the SSO/codebase daemons already behave; or (b) a novel loopback control channel (e.g. a `/shutdown` endpoint the CLI POSTs so the daemon runs its own `proxy.stop()`), which is a new architectural pattern spanning CLI + runtime + server (~3 files) and warrants a design decision. The assessor should treat option (b) as the driver if "works correctly on Windows" is interpreted as true graceful drain. + +Test posture is a compounding risk factor: the entire changed surface (`spawnDetached`, the CLI command, the `runtime.ts` signal handlers, the bin entry) is **untested** — existing tests only mock `spawnDetached` away and never exercise `process.kill` signal delivery. Any fix therefore ships without regression coverage on either platform unless new tests are added, and Vitest cannot realistically assert real Windows signal semantics on a Linux CI runner (platform behavior would have to be simulated by mocking `os.platform`/`process.kill`). Net: score the console-window fix as trivial-surgical; score the graceful-shutdown fix as medium with a genuine novelty/decision flag and a testability gap. From 05346d1d63548111623b5356e606076fd8c82fba Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 17:39:16 +0200 Subject: [PATCH 23/36] docs: add mcp-auth-proxy https design and work item Generated with AI Co-Authored-By: codemie-ai --- .../2026-07-05-mcp-auth-proxy-https-design.md | 217 ++++++++++++++++++ .../work-items/mcp-auth-proxy-https.md | 40 ++++ .../work-items/work-item-events.jsonl | 5 + 3 files changed, 262 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-05-mcp-auth-proxy-https-design.md create mode 100644 docs/superpowers/work-items/mcp-auth-proxy-https.md diff --git a/docs/superpowers/specs/2026-07-05-mcp-auth-proxy-https-design.md b/docs/superpowers/specs/2026-07-05-mcp-auth-proxy-https-design.md new file mode 100644 index 00000000..4d0152f9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-mcp-auth-proxy-https-design.md @@ -0,0 +1,217 @@ +# mcp-auth-proxy HTTPS Support — Design + +- **Date:** 2026-07-05 +- **Run:** 20260705-1519-feat-mcp-auth-proxy +- **Work item:** docs/superpowers/work-items/mcp-auth-proxy-https.md +- **Issue:** local/ISSUE-mcp-auth-proxy-claude-desktop-https.md +- **Branch:** feat/mcp-auth-proxy (stacked on PR #407) + +## Problem + +Claude Desktop's `custom3p-mcp` OAuth client hard-refuses to open non-`https://` +authorize URLs. `McpAuthProxy` hardcodes a plain-HTTP loopback origin +(`http://127.0.0.1:`, `server.ts:125-127`), and that origin is threaded +into every OAuth AS-metadata rewrite (rule R3). Every proxy route requiring +interactive OAuth therefore fails in Claude Desktop with "Connection to Server +failed", while Claude Code CLI (no https guard for loopback) works. + +## Decisions already fixed at requirements (do not re-litigate) + +1. **Local CA + leaf certificate, pure JS** — generated by the CLI, stored + under the CodeMie home; no external binary. +2. **Explicit `codemie mcp-auth-proxy trust` command** — `start` never touches + the OS trust store; `trust --uninstall` is in scope (spec.clarification). +3. **Opt-in TLS** — config `"tls": true` or `--tls` flag; default stays plain + HTTP. + +## Approaches considered + +- **A. In-process TLS termination with a locally-generated CA (chosen).** + `https.createServer({ key, cert }, handler)` on the same loopback port; + leaf issued by a local CA the user explicitly trusts once. Matches the + issue's suggested fix; fully offline; works for both the system browser + (authorize redirect) and Electron's net stack (token calls) once the CA is + in the OS store. +- **B. Publicly-trusted certificate for a loopback DNS name** (e.g. a + `*.localhost.direct`-style shared cert, or ACME for a real domain pointing + at 127.0.0.1). Rejected: shared-key certs are compromised by design; ACME + needs a domain, renewals, and network — absurd overhead for a loopback dev + proxy. +- **C. Wait for a Claude Desktop loopback allowlist / document CLI-only.** + Rejected as the fix (guard behavior is outside our control), but the + CLI-only limitation stays documented until users run `trust`. + +## Architecture + +One new module owns all certificate concerns; every other change is a thin +protocol switch. No behavioral changes to `rewrites.ts`, `metadata-cache.ts`, +or `upstream-client.ts` — `RewriteContext.proxyOrigin` already carries +whatever `McpAuthProxy.origin` returns. `types.ts` gets a type-only addition +(`AuthProxyConfig.tls`). + +``` +src/mcp/auth-proxy/ +├── certs.ts NEW — CA + leaf lifecycle, PEM/key IO, trust-store commands +├── server.ts http.createServer | https.createServer switch; origin protocol +├── config.ts optional root "tls": boolean (default false) +├── runtime.ts tls option; loads cert material; state gains tls flag +├── state.ts AuthProxyState + tls: boolean +└── types.ts AuthProxyConfig.tls (type only) +src/bin/mcp-auth-proxy-daemon.ts --tls flag passthrough +src/cli/commands/mcp-auth-proxy.ts start --tls; trust [--uninstall]; https-aware status/stop +``` + +### certs.ts — certificate lifecycle (new) + +Dependency: **`@peculiar/x509`** (v2, MIT, pure TS/JS, WebCrypto-based — wired +to `node:crypto` `webcrypto`; verified current 2026-07-05). node:crypto alone +cannot create/sign X.509 certificates. + +Layout (all under `getCodemiePath('mcp-auth-proxy-tls')`): + +| File | Content | Mode | +|---|---|---| +| `ca.crt` | CA certificate, PEM | 0644 | +| `ca.key` | CA private key, PKCS#8 PEM | 0600 | +| `server.crt` | Leaf certificate, PEM | 0644 | +| `server.key` | Leaf private key, PKCS#8 PEM | 0600 | + +Certificate profile (ECDSA P-256, SHA-256 — small, fast, universally trusted): + +- **CA:** CN `CodeMie mcp-auth-proxy CA @ ` (unique per + machine, mkcert precedent), BasicConstraints `cA=true, pathLen=0` (critical), + KeyUsage `keyCertSign|cRLSign` (critical), validity 10 years, random serial. +- **Leaf:** CN `127.0.0.1`, SAN `IP:127.0.0.1, DNS:localhost` (Chromium + ignores CN), EKU `serverAuth`, KeyUsage `digitalSignature`, validity + **825 days** (Apple/Chromium cap for locally-trusted certs), random serial. + +API: + +- `ensureAuthProxyCerts(): Promise` — idempotent. Creates the + directory and CA if missing; (re)issues the leaf when missing, expired, + expiring within 30 days, or its SAN set differs from the expected one. + Returns `{ keyPem, certPem, caCertPem, caPath }` for the server and CLI. + A missing/expired CA regenerates both and logs a warning that `trust` + must be re-run. Key files written with `mode: 0o600` (atomic tmp+rename, + same idiom as state.ts). +- `getTrustInstructions(caPath)` / trust-store command builders — used by the + `trust` command (below); pure functions returning `{ command, args }` per + platform so tests can assert them without touching a real trust store. + +### server.ts — protocol switch + +- Constructor takes optional TLS material: + `new McpAuthProxy(config, onShutdownRequested?, tls?: { key, cert })`. +- `start()` builds `https.createServer({ key, cert }, handler)` when material + is present, else `http.createServer(handler)` — everything downstream + (`http.IncomingMessage`/`ServerResponse`, sockets set, request dispatch) is + type-compatible; `private server?: http.Server | https.Server`. +- `get origin()` returns `${this.tls ? 'https' : 'http'}://${BIND_HOST}:${this.port}` — + the single line that flips every advertised OAuth URL (R3 reads + `proxyOrigin` from `ctx()`). + +### config.ts / types.ts + +- Optional root key `"tls"`: must be a boolean when present; default `false`. + Validation error names the key path, matching existing style. +- `AuthProxyConfig` gains `tls: boolean`. + +### runtime.ts / daemon entry + +- `RunDaemonOptions.tls?: boolean` — CLI/daemon override, like `port`. +- When effective `tls === true`: `await ensureAuthProxyCerts()` before + constructing `McpAuthProxy` (fail fast with `ConfigurationError` if + generation fails). State file gains `tls: boolean` so CLI helpers know the + protocol. +- `src/bin/mcp-auth-proxy-daemon.ts` parses `--tls` and forwards it. + +### CLI command (`mcp-auth-proxy.ts`) + +- `start --tls` — enables TLS regardless of config (config `"tls": true` also + enables; flag wins only in the enabling direction, no `--no-tls`). + When TLS is active, printed `claude mcp add` hints use `https://` and a + gray hint line points at `codemie mcp-auth-proxy trust` + the + `NODE_EXTRA_CA_CERTS=` note for Node-based clients (Claude Code CLI + does not read the OS trust store). `start` with TLS also warns explicitly + that routes previously registered with `http://` URLs must be re-registered + with the printed `https://` URLs (requirements resolved decision 3). +- `status` / `stop` — read `state.tls`; when true, `fetchHealth` and + `requestShutdown` use `node:https` with `ca: ` (never + `rejectUnauthorized: false`). Shared tiny helper picks http/https + agent + options from state. +- **`trust`** (new subcommand) — ensures certs exist (generation is + side-effect-free), then installs `ca.crt` into the user trust store: + - Windows: `certutil -addstore -user Root ` (no elevation). + - macOS: `security add-trusted-cert -k ` + (OS prompts). + - Linux: NSS user DB — `certutil -d sql:$HOME/.pki/nssdb -A -t C,, -n + "CodeMie mcp-auth-proxy CA" -i ` (Chromium/Electron read NSS); + if NSS `certutil` is absent, print manual instructions incl. the + system-store path. All installs idempotent. + - `trust --uninstall` — removes only the CodeMie CA (matched by the exact + nickname/CN it was installed under): `certutil -delstore -user Root`, + `security delete-certificate -c`, NSS `certutil -D`. Idempotent when + absent. + - Commands run via `exec()` from `src/utils/processes.ts` (repo rule). + - Prints cert path, SHA-256 fingerprint, and expiry either way. + +## Data flow (TLS enabled) + +1. `start --tls` → `ensureAuthProxyCerts()` → daemon spawned with `--tls`. +2. Daemon: `runAuthProxyDaemon` loads certs, `McpAuthProxy` listens with + `https.createServer`; state file `{ pid, port, routes, startedAt, tls: true }`. +3. Client discovery: R3 metadata now advertises + `https://127.0.0.1:/as//...` — Claude Desktop's https guard + passes; the system browser opens the authorize URL and, with the CA + trusted, negotiates TLS against SAN `IP:127.0.0.1`. +4. `status`/`stop` talk https to the same origin using the local CA as the + explicit trust anchor. + +## Error handling + +- TLS requested but cert generation/read fails → `ConfigurationError` naming + the tls directory; daemon refuses to start (fail fast, existing pattern). +- `trust` on unsupported/undetectable platform or missing tool → non-zero + exit with the manual instructions printed (not a crash). +- Leaf expired while daemon running: connections fail TLS; next `start` + re-issues (825-day validity makes this an edge case; no in-process rotation + — YAGNI, documented). +- `stop` fallback path (SIGTERM→SIGKILL) is protocol-independent — unchanged. + +## Security + +- CA key `0600`, never logged, never leaves the machine; CA signs only the + proxy's own loopback leaves (pathLen 0). +- Trust-store modification only ever happens inside the explicit `trust` + command (user decision recorded at requirements gate). +- No `rejectUnauthorized: false` anywhere — CLI helpers pin the local CA. +- Leaf ≤ 825 days keeps Apple/Chromium acceptance. +- Logs keep the existing rule: no headers/bodies/keys, cert *paths* and + fingerprints only. + +## Testing (Vitest, co-located `__tests__/`) + +- `certs.test.ts` — CA/leaf profile assertions by parsing generated PEM with + `node:crypto` `X509Certificate` (SANs, CA flag, EKU, validity windows), + 0600 key mode, idempotency (second call reuses), leaf reissue on expiry + window (fake timers / injected clock), trust command builders per platform. +- `config.test.ts` — `tls` accepts boolean, rejects non-boolean, defaults + false. +- `server.test.ts` — with generated ephemeral certs: origin is `https://…`, + `/healthz` answers over TLS (client pins the test CA); plain-HTTP path + regression-guarded. +- `state.test.ts` — round-trips `tls` flag. +- CLI tests — `stop`/`status` pick https when `state.tls`; `trust` invokes the + right platform command (exec mocked); `--uninstall` targets only our CA. + +## Out of scope + +- Changing the default protocol (stays HTTP until trust is fully automatic). +- In-process certificate rotation / SIGHUP reload. +- System-wide (root) trust stores on Linux; documented manual step instead. +- Any change to rewrite rules, metadata cache, or upstream client. + +## New dependency + +`@peculiar/x509` (runtime): MIT, pure JS, WebCrypto-driven; must pass the +license-check gate (MIT is allowed). diff --git a/docs/superpowers/work-items/mcp-auth-proxy-https.md b/docs/superpowers/work-items/mcp-auth-proxy-https.md new file mode 100644 index 00000000..68fe3f7a --- /dev/null +++ b/docs/superpowers/work-items/mcp-auth-proxy-https.md @@ -0,0 +1,40 @@ +# Work Item: HTTPS support for `codemie mcp-auth-proxy` + +- **ID:** mcp-auth-proxy-https +- **Status:** Open +- **External Ticket:** (none — external sync pending) +- **Source:** local/ISSUE-mcp-auth-proxy-claude-desktop-https.md (free-form + issue report) +- **Related:** [mcp-auth-proxy](mcp-auth-proxy.md) — base feature, branch `feat/mcp-auth-proxy` +- **Branch:** feat/mcp-auth-proxy (stacked on PR #407) + +## Summary + +Claude Desktop's `custom3p-mcp` OAuth client refuses to open non-`https://` +authorize URLs, so every `mcp-auth-proxy` route requiring interactive OAuth +fails in Claude Desktop with a generic "Connection to Server failed" toast. +The proxy hardcodes a plain-HTTP loopback origin (`http://127.0.0.1:`) +in `src/mcp/auth-proxy/server.ts` and threads it into every OAuth AS metadata +rewrite (issuer / authorization_endpoint / token_endpoint / +registration_endpoint). Add local TLS termination so the proxy's origin can be +`https://127.0.0.1:` and Claude Desktop can complete the browser-based +OAuth authorize step. + +## Acceptance Criteria + +Sourced from the issue's root-cause analysis and suggested next steps; final +criteria confirmed at the requirements phase. + +## Linked Artifacts + +- local/ISSUE-mcp-auth-proxy-claude-desktop-https.md +- docs/superpowers/runs/20260705-1519-feat-mcp-auth-proxy/requirements.md + +## History + +| When | Event | Notes | +|---|---|---| +| 2026-07-05T15:22 | created | Work item created by requirements-intake (run 20260705-1519-feat-mcp-auth-proxy) from free-form input + local issue report | +| 2026-07-05T15:22 | external-sync | pending — no ticket adapter invoked at intake; prepare_for_development to be emitted after branch guard | +| 2026-07-05T15:22 | linked-artifact | requirements.md written by requirements-intake | +| 2026-07-05T15:31 | assigned | Branch guard (HITL): continue on feat/mcp-auth-proxy, decision proceed-dirty (.codemie/codemie-cli.config.json left unstaged; analytics report never staged); upstream in-sync after fetch | +| 2026-07-05T15:31 | adapter-warning | prepare_for_development: no lifecycle adapter configured; external sync pending | diff --git a/docs/superpowers/work-items/work-item-events.jsonl b/docs/superpowers/work-items/work-item-events.jsonl index 13a5cc51..659cc526 100644 --- a/docs/superpowers/work-items/work-item-events.jsonl +++ b/docs/superpowers/work-items/work-item-events.jsonl @@ -5,3 +5,8 @@ {"schema":1,"ts":"2026-07-04T00:50:00+02:00","event":"work_item.adapter_warning","run_id":"20260703-1845-mcp-auth-proxy","phase":8,"actor":"sdlc-pipeline","summary":"record_delivery_audit: no lifecycle adapter; QA passed, external sync pending","artifacts":["docs/superpowers/runs/20260703-1845-mcp-auth-proxy/qa-report.md"],"data":{"intent":"record_delivery_audit"}} {"schema":1,"ts":"2026-07-04T01:05:00+02:00","event":"work_item.transitioned","run_id":"20260703-1845-mcp-auth-proxy","phase":10,"actor":"sdlc-pipeline","summary":"Status -> Ready for review; branch feat/mcp-auth-proxy ready for MR/PR","artifacts":["docs/superpowers/runs/20260703-1845-mcp-auth-proxy/qa-report.md"],"data":{"status":"Ready for review","branch":"feat/mcp-auth-proxy"}} {"schema":1,"ts":"2026-07-04T01:05:00+02:00","event":"work_item.adapter_warning","run_id":"20260703-1845-mcp-auth-proxy","phase":10,"actor":"sdlc-pipeline","summary":"complete_or_handoff: no lifecycle adapter configured; handoff recorded locally, external sync pending","artifacts":[],"data":{"intent":"complete_or_handoff"}} +{"schema":1,"ts":"2026-07-05T15:22:00+00:00","event":"work_item.created","run_id":"20260705-1519-feat-mcp-auth-proxy","phase":1,"actor":"requirements-intake","summary":"Work item mcp-auth-proxy-https created from free-form input + local/ISSUE-mcp-auth-proxy-claude-desktop-https.md","artifacts":["docs/superpowers/work-items/mcp-auth-proxy-https.md"],"data":{"related":"mcp-auth-proxy"}} +{"schema":1,"ts":"2026-07-05T15:22:00+00:00","event":"work_item.linked_artifact","run_id":"20260705-1519-feat-mcp-auth-proxy","phase":1,"actor":"requirements-intake","summary":"requirements.md linked to mcp-auth-proxy-https","artifacts":["docs/superpowers/runs/20260705-1519-feat-mcp-auth-proxy/requirements.md"],"data":{}} +{"schema":1,"ts":"2026-07-05T15:22:00+00:00","event":"work_item.adapter_warning","run_id":"20260705-1519-feat-mcp-auth-proxy","phase":1,"actor":"requirements-intake","summary":"No ticket adapter invoked at intake; external sync pending","artifacts":[],"data":{"external_sync":"pending"}} +{"schema":1,"ts":"2026-07-05T15:31:00+00:00","event":"work_item.assigned","run_id":"20260705-1519-feat-mcp-auth-proxy","phase":2,"actor":"sdlc-pipeline","summary":"Branch feat/mcp-auth-proxy assigned (HITL continue; proceed-dirty with exclusions)","artifacts":["docs/superpowers/work-items/mcp-auth-proxy-https.md"],"data":{"branch":"feat/mcp-auth-proxy"}} +{"schema":1,"ts":"2026-07-05T15:31:00+00:00","event":"work_item.adapter_warning","run_id":"20260705-1519-feat-mcp-auth-proxy","phase":2,"actor":"sdlc-pipeline","summary":"prepare_for_development: no lifecycle adapter configured; continuing locally, external sync pending","artifacts":[],"data":{"intent":"prepare_for_development","external_sync":"pending"}} From 741fd29267179ec8e2abf9e466b4b4b9c74b7149 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 17:54:53 +0200 Subject: [PATCH 24/36] docs: add mcp-auth-proxy https implementation plan Generated with AI Co-Authored-By: codemie-ai --- .../plans/2026-07-05-mcp-auth-proxy-https.md | 1529 +++++++++++++++++ .../2026-07-05-mcp-auth-proxy-https-design.md | 8 +- 2 files changed, 1534 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-05-mcp-auth-proxy-https.md diff --git a/docs/superpowers/plans/2026-07-05-mcp-auth-proxy-https.md b/docs/superpowers/plans/2026-07-05-mcp-auth-proxy-https.md new file mode 100644 index 00000000..3a26c64f --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-mcp-auth-proxy-https.md @@ -0,0 +1,1529 @@ +# mcp-auth-proxy HTTPS Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let `codemie mcp-auth-proxy` serve its loopback listener over HTTPS with a locally-generated CA so Claude Desktop's https-only OAuth guard passes. + +**Architecture:** One new module (`certs.ts`) owns the CA + leaf certificate lifecycle; `server.ts` gets a thin `http`/`https` switch and a protocol-aware `origin`; a new `client.ts` centralizes CA-pinned loopback control-plane requests for the CLI; a new `trust.ts` builds per-OS trust-store commands for the explicit `trust [--uninstall]` subcommand. TLS is opt-in (`"tls": true` config or `--tls` flag); default stays plain HTTP. + +**Tech Stack:** Node 22 `node:https`/`node:crypto` (webcrypto), `@peculiar/x509@^1.14.3` (pure JS, MIT — **pin v1: v2.0.0 fails at import with an undeclared `reflect-metadata` requirement; v1.14.3 declares it and works, verified live 2026-07-05**), Vitest. + +**Design:** `docs/superpowers/specs/2026-07-05-mcp-auth-proxy-https-design.md`. Repo rules: ES modules with `.js` import suffixes, no `any`, `exec()` from `src/utils/processes.ts`, `getCodemiePath()` for home paths (honors `CODEMIE_HOME`), Conventional Commits with the AI footer: + +``` +Generated with AI + +Co-Authored-By: codemie-ai +``` + +Run all test commands with `rtk proxy` prefix if plain npm output appears garbled by the shell hook: `rtk proxy npx vitest run `. + +--- + +### Task 1: Config + types — opt-in `tls` flag + +**Test-first: yes — config.test.ts: `tls: true` round-trips and non-boolean `tls` is rejected; both fail because `AuthProxyConfig` has no `tls` member and the validator ignores the key.** + +**Files:** +- Modify: `src/mcp/auth-proxy/types.ts:19-29` +- Modify: `src/mcp/auth-proxy/config.ts:53-98` +- Test: `src/mcp/auth-proxy/__tests__/config.test.ts` + +- [ ] **Step 1: Write the failing tests** — append inside the existing top-level `describe` in `config.test.ts` (it already imports `validateAuthProxyConfig`; the existing valid fixture uses `servers: { radar: { upstreamUrl: 'https://mcp.example.com/radar' } }` — reuse that shape): + +```typescript +describe('tls flag', () => { + it('defaults tls to false when absent', () => { + const config = validateAuthProxyConfig({ + servers: { radar: { upstreamUrl: 'https://mcp.example.com/radar' } }, + }); + expect(config.tls).toBe(false); + }); + + it('accepts tls: true', () => { + const config = validateAuthProxyConfig({ + tls: true, + servers: { radar: { upstreamUrl: 'https://mcp.example.com/radar' } }, + }); + expect(config.tls).toBe(true); + }); + + it('rejects non-boolean tls', () => { + expect(() => + validateAuthProxyConfig({ + tls: 'yes', + servers: { radar: { upstreamUrl: 'https://mcp.example.com/radar' } }, + }) + ).toThrow(/"tls" must be a boolean/); + }); +}); +``` + +- [ ] **Step 2: Run to verify RED** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/config.test.ts` +Expected: 2 FAIL (`config.tls` is `undefined`, no throw for `'yes'`), rest pass. + +- [ ] **Step 3: Implement** — in `types.ts`, extend both interfaces: + +```typescript +export interface AuthProxyConfig { + port: number; + /** Serve the loopback listener over HTTPS with the locally-generated CA (default false). */ + tls: boolean; + servers: Record; +} + +export interface AuthProxyDaemonState { + pid: number; + port: number; + routes: string[]; + startedAt: string; + /** True when the daemon listener speaks HTTPS (absent in pre-TLS state files = false). */ + tls?: boolean; +} +``` + +In `config.ts` `validateAuthProxyConfig`, after the `port` block and before the `servers` block: + +```typescript + let tls = false; + if (root.tls !== undefined) { + if (typeof root.tls !== 'boolean') { + throw new ConfigurationError('mcp-auth-proxy config: "tls" must be a boolean'); + } + tls = root.tls; + } +``` + +and change the return to `return { port, tls, servers };`. + +- [ ] **Step 4: Run to verify GREEN** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/config.test.ts src/mcp/auth-proxy/__tests__/server.test.ts` +Expected: all PASS (server tests construct `AuthProxyConfig` literals — if any object literal now misses `tls`, TS is structural at runtime; vitest does not typecheck, but `npx tsc --noEmit` in Step 5 must pass, so add `tls: false` to any config literal the compiler flags). + +- [ ] **Step 5: Typecheck** — Run: `npx tsc --noEmit`. Fix every `Property 'tls' is missing` error by adding `tls: false` to the literal (test fixtures in `server.test.ts`, and `runtime.ts` is dealt with in Task 4 — for now `loadAuthProxyConfig` already returns it). + +- [ ] **Step 6: Commit** + +```bash +git add src/mcp/auth-proxy/types.ts src/mcp/auth-proxy/config.ts src/mcp/auth-proxy/__tests__/config.test.ts src/mcp/auth-proxy/__tests__/server.test.ts +git commit -m "feat(proxy): add opt-in tls flag to mcp-auth-proxy config" +``` + +(Include the AI footer in every commit in this plan.) + +--- + +### Task 2: `certs.ts` — local CA + leaf certificate lifecycle + +**Test-first: yes — certs.test.ts fails with "Cannot find module '../certs.js'".** + +**Files:** +- Create: `src/mcp/auth-proxy/certs.ts` +- Test: `src/mcp/auth-proxy/__tests__/certs.test.ts` +- Modify: `package.json` (add dependency) + +- [ ] **Step 1: Install the dependency** + +```bash +npm install @peculiar/x509@^1.14.3 +``` + +Verify: `node -e "console.log(require('@peculiar/x509/package.json').version)"` prints `1.14.x`. + +- [ ] **Step 2: Write the failing tests** + +```typescript +/** + * certs.ts tests — real certificate generation into a temp dir (no mocks: + * @peculiar/x509 is pure JS and fast; assertions parse output with node:crypto). + * @group unit + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, stat, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { X509Certificate } from 'node:crypto'; +import { ensureAuthProxyCerts, getAuthProxyTlsPaths } from '../certs.js'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'codemie-tls-')); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('ensureAuthProxyCerts', () => { + it('generates a CA and a leaf with the required profile', async () => { + const material = await ensureAuthProxyCerts(dir); + + const ca = new X509Certificate(material.caCertPem); + const leaf = new X509Certificate(material.certPem); + + expect(ca.ca).toBe(true); + expect(ca.subject).toContain('CN=CodeMie mcp-auth-proxy CA'); + expect(material.caCommonName).toContain('CodeMie mcp-auth-proxy CA'); + + expect(leaf.ca).toBe(false); + expect(leaf.subjectAltName).toContain('127.0.0.1'); + expect(leaf.subjectAltName).toContain('localhost'); + expect(leaf.checkIssued(ca)).toBe(true); + expect(leaf.keyUsage).toBeDefined(); + + // Leaf validity <= 825 days (Apple/Chromium local-trust cap) + const days = + (new Date(leaf.validTo).getTime() - new Date(leaf.validFrom).getTime()) / 86_400_000; + expect(days).toBeLessThanOrEqual(825); + + expect(material.keyPem).toContain('BEGIN PRIVATE KEY'); + }); + + it('writes private keys with 0600 permissions', async () => { + await ensureAuthProxyCerts(dir); + const paths = getAuthProxyTlsPaths(dir); + for (const keyFile of [paths.caKey, paths.serverKey]) { + const mode = (await stat(keyFile)).mode & 0o777; + expect(mode).toBe(0o600); + } + }); + + it('is idempotent — second call reuses both certificates', async () => { + const first = await ensureAuthProxyCerts(dir); + const second = await ensureAuthProxyCerts(dir); + expect(second.caCertPem).toBe(first.caCertPem); + expect(second.certPem).toBe(first.certPem); + }); + + it('reissues the leaf (same CA) when the leaf file is corrupt', async () => { + const first = await ensureAuthProxyCerts(dir); + const paths = getAuthProxyTlsPaths(dir); + const { writeFile } = await import('node:fs/promises'); + await writeFile(paths.serverCert, 'not a certificate', 'utf-8'); + + const second = await ensureAuthProxyCerts(dir); + expect(second.caCertPem).toBe(first.caCertPem); + expect(second.certPem).not.toBe(first.certPem); + const leaf = new X509Certificate(second.certPem); + expect(leaf.checkIssued(new X509Certificate(second.caCertPem))).toBe(true); + }); + + it('regenerates everything when the CA is missing', async () => { + const first = await ensureAuthProxyCerts(dir); + const paths = getAuthProxyTlsPaths(dir); + await rm(paths.caCert); + + const second = await ensureAuthProxyCerts(dir); + expect(second.caCertPem).not.toBe(first.caCertPem); + expect(second.certPem).not.toBe(first.certPem); + }); + + it('exposes the on-disk PEM contents matching the returned material', async () => { + const material = await ensureAuthProxyCerts(dir); + const paths = getAuthProxyTlsPaths(dir); + expect(await readFile(paths.caCert, 'utf-8')).toBe(material.caCertPem); + expect(await readFile(paths.serverCert, 'utf-8')).toBe(material.certPem); + }); +}); +``` + +- [ ] **Step 3: Run to verify RED** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/certs.test.ts` +Expected: FAIL — `Cannot find module '../certs.js'`. + +- [ ] **Step 4: Implement `src/mcp/auth-proxy/certs.ts`** + +```typescript +/** + * MCP Auth Proxy — local TLS certificate lifecycle. + * + * Generates and maintains a machine-local CA plus a loopback leaf certificate + * so the proxy can serve https://127.0.0.1 (Claude Desktop refuses non-https + * OAuth authorize URLs). Pure JS via @peculiar/x509 over node:crypto webcrypto + * — node:crypto alone cannot create/sign X.509 certificates. + * + * Trust-store installation is NOT done here — see trust.ts and the explicit + * `codemie mcp-auth-proxy trust` command (design decision: start never touches + * the OS trust store). + */ +import * as x509 from '@peculiar/x509'; +import { webcrypto, X509Certificate } from 'node:crypto'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { hostname, userInfo } from 'node:os'; +import { join } from 'node:path'; +import { getCodemiePath } from '../../utils/paths.js'; +import { ConfigurationError } from '../../utils/errors.js'; +import { logger } from '../../utils/logger.js'; + +x509.cryptoProvider.set(webcrypto); + +export const AUTH_PROXY_TLS_DIR = 'mcp-auth-proxy-tls'; +export const AUTH_PROXY_CA_NAME_PREFIX = 'CodeMie mcp-auth-proxy CA'; + +const SIGNING_ALG = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const; +const CA_VALIDITY_DAYS = 3650; +// Apple/Chromium reject locally-trusted leaves valid longer than 825 days. +const LEAF_VALIDITY_DAYS = 825; +// Reissue the leaf when it has less than this many days left. +const LEAF_RENEWAL_WINDOW_DAYS = 30; +const DAY_MS = 86_400_000; +// Backdate notBefore to tolerate small clock skew between generator and clients. +const CLOCK_SKEW_MS = 5 * 60_000; + +export interface AuthProxyTlsPaths { + dir: string; + caCert: string; + caKey: string; + serverCert: string; + serverKey: string; +} + +export interface TlsMaterial { + /** Leaf private key, PKCS#8 PEM. */ + keyPem: string; + /** Leaf certificate, PEM. */ + certPem: string; + /** CA certificate, PEM — the trust anchor `trust` installs and CLI clients pin. */ + caCertPem: string; + /** CA subject CN — the exact name trust-store entries are created/removed under. */ + caCommonName: string; + paths: AuthProxyTlsPaths; +} + +export function getAuthProxyTlsPaths(dir: string = getCodemiePath(AUTH_PROXY_TLS_DIR)): AuthProxyTlsPaths { + return { + dir, + caCert: join(dir, 'ca.crt'), + caKey: join(dir, 'ca.key'), + serverCert: join(dir, 'server.crt'), + serverKey: join(dir, 'server.key'), + }; +} + +/** Atomic tmp+rename write (state.ts idiom); mode applies to the new file. */ +async function writeFileAtomic(path: string, content: string, mode: number): Promise { + const tmp = `${path}.tmp`; + await writeFile(tmp, content, { encoding: 'utf-8', mode }); + await rename(tmp, path); +} + +function extractCommonName(subject: string): string { + for (const line of subject.split('\n')) { + if (line.startsWith('CN=')) { + return line.slice(3); + } + } + return subject; +} + +function pemToDer(pem: string): ArrayBuffer { + return x509.PemConverter.decode(pem)[0]; +} + +async function exportPrivateKeyPem(key: CryptoKey): Promise { + const der = await webcrypto.subtle.exportKey('pkcs8', key); + return x509.PemConverter.encode(der, 'PRIVATE KEY'); +} + +async function importPrivateKey(pem: string): Promise { + return webcrypto.subtle.importKey('pkcs8', pemToDer(pem), SIGNING_ALG, false, ['sign']); +} + +/** DN-safe: strip characters that would change RDN parsing. */ +function dnSafe(value: string): string { + return value.replace(/[^A-Za-z0-9._@-]/g, '-'); +} + +interface CaMaterial { + certPem: string; + keyPem: string; +} + +async function generateCa(): Promise { + const keys = await webcrypto.subtle.generateKey(SIGNING_ALG, true, ['sign', 'verify']); + const now = Date.now(); + const identity = `${dnSafe(userInfo().username)}@${dnSafe(hostname())}`; + const issuedOn = new Date(now).toISOString().slice(0, 10); + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + name: `CN=${AUTH_PROXY_CA_NAME_PREFIX} ${identity} ${issuedOn}, O=CodeMie`, + notBefore: new Date(now - CLOCK_SKEW_MS), + notAfter: new Date(now + CA_VALIDITY_DAYS * DAY_MS), + signingAlgorithm: SIGNING_ALG, + keys, + extensions: [ + new x509.BasicConstraintsExtension(true, 0, true), + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, + true + ), + await x509.SubjectKeyIdentifierExtension.create(keys.publicKey), + ], + }); + return { certPem: cert.toString('pem'), keyPem: await exportPrivateKeyPem(keys.privateKey) }; +} + +async function issueLeaf(ca: CaMaterial): Promise<{ certPem: string; keyPem: string }> { + const caCert = new x509.X509Certificate(ca.certPem); + const caKey = await importPrivateKey(ca.keyPem); + const keys = await webcrypto.subtle.generateKey(SIGNING_ALG, true, ['sign', 'verify']); + const now = Date.now(); + const cert = await x509.X509CertificateGenerator.create({ + subject: 'CN=127.0.0.1, O=CodeMie', + issuer: caCert.subject, + notBefore: new Date(now - CLOCK_SKEW_MS), + notAfter: new Date(now + LEAF_VALIDITY_DAYS * DAY_MS), + signingAlgorithm: SIGNING_ALG, + publicKey: keys.publicKey, + signingKey: caKey, + extensions: [ + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true), + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.serverAuth], true), + new x509.SubjectAlternativeNameExtension([ + { type: 'ip', value: '127.0.0.1' }, + { type: 'dns', value: 'localhost' }, + ]), + ], + }); + return { certPem: cert.toString('pem'), keyPem: await exportPrivateKeyPem(keys.privateKey) }; +} + +/** Parse a PEM cert defensively; null means "treat as absent and regenerate". */ +async function readCertificate(path: string): Promise { + try { + return new X509Certificate(await readFile(path, 'utf-8')); + } catch { + return null; + } +} + +function isUsable(cert: X509Certificate, renewalWindowMs: number): boolean { + const now = Date.now(); + return ( + new Date(cert.validFrom).getTime() <= now && + new Date(cert.validTo).getTime() - renewalWindowMs > now + ); +} + +function leafMatchesProfile(leaf: X509Certificate, ca: X509Certificate): boolean { + const san = leaf.subjectAltName ?? ''; + return leaf.checkIssued(ca) && san.includes('127.0.0.1') && san.includes('localhost'); +} + +/** + * Idempotently ensures a usable CA + leaf under `dir` and returns the material. + * Regenerates the CA (and leaf) when the CA is missing/corrupt/expired — and + * warns that `trust` must be re-run. Reissues only the leaf when it is + * missing/corrupt/expiring/mismatched. + */ +export async function ensureAuthProxyCerts( + dir: string = getCodemiePath(AUTH_PROXY_TLS_DIR) +): Promise { + const paths = getAuthProxyTlsPaths(dir); + try { + await mkdir(dir, { recursive: true }); + + let caCert = await readCertificate(paths.caCert); + let caKeyPem: string | null = null; + if (caCert !== null && isUsable(caCert, 0)) { + try { + caKeyPem = await readFile(paths.caKey, 'utf-8'); + } catch { + caKeyPem = null; + } + } + + if (caCert === null || caKeyPem === null) { + const generated = await generateCa(); + await writeFileAtomic(paths.caKey, generated.keyPem, 0o600); + await writeFileAtomic(paths.caCert, generated.certPem, 0o644); + caCert = new X509Certificate(generated.certPem); + caKeyPem = generated.keyPem; + logger.warn( + '[mcp-auth-proxy] Generated a new local CA — run `codemie mcp-auth-proxy trust` to (re)install it in the OS trust store' + ); + } + + const caCertPem = caCert.toString(); + const leaf = await readCertificate(paths.serverCert); + let leafKeyPem: string | null = null; + if (leaf !== null && isUsable(leaf, LEAF_RENEWAL_WINDOW_DAYS * DAY_MS) && leafMatchesProfile(leaf, caCert)) { + try { + leafKeyPem = await readFile(paths.serverKey, 'utf-8'); + } catch { + leafKeyPem = null; + } + } + + let leafCertPem: string; + if (leaf === null || leafKeyPem === null || !isUsable(leaf, LEAF_RENEWAL_WINDOW_DAYS * DAY_MS) || !leafMatchesProfile(leaf, caCert)) { + const issued = await issueLeaf({ certPem: caCertPem, keyPem: caKeyPem }); + await writeFileAtomic(paths.serverKey, issued.keyPem, 0o600); + await writeFileAtomic(paths.serverCert, issued.certPem, 0o644); + leafCertPem = issued.certPem; + leafKeyPem = issued.keyPem; + } else { + leafCertPem = leaf.toString(); + } + + return { + keyPem: leafKeyPem, + certPem: leafCertPem, + caCertPem, + caCommonName: extractCommonName(caCert.subject), + paths, + }; + } catch (error) { + if (error instanceof ConfigurationError) { + throw error; + } + throw new ConfigurationError( + `mcp-auth-proxy TLS setup failed in ${dir}: ${(error as Error).message}` + ); + } +} +``` + +Note: `caCert.toString()` on `node:crypto`'s `X509Certificate` returns the PEM. When the CA was just generated we re-wrap it through `X509Certificate` so the returned `caCertPem` is byte-identical to what `readCertificate` will produce on the next call — that keeps the idempotency test's `toBe` comparison honest. If the PEM strings differ only by trailing newline between `@peculiar/x509` output and node's `toString()`, normalize by always returning the on-disk file content instead: `caCertPem = await readFile(paths.caCert, 'utf-8')` and `leafCertPem = await readFile(paths.serverCert, 'utf-8')` at the end. Prefer the read-back variant — it also makes the "on-disk matches returned" test trivially true. + +- [ ] **Step 5: Run to verify GREEN** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/certs.test.ts` +Expected: all PASS. If the idempotency test fails on PEM formatting differences, apply the read-back variant from the note above. + +- [ ] **Step 6: Typecheck + lint** — Run: `npx tsc --noEmit && npx eslint 'src/mcp/auth-proxy/certs.ts' 'src/mcp/auth-proxy/__tests__/certs.test.ts' --max-warnings=0` + +- [ ] **Step 7: Commit** + +```bash +git add package.json package-lock.json src/mcp/auth-proxy/certs.ts src/mcp/auth-proxy/__tests__/certs.test.ts +git commit -m "feat(proxy): add local CA and leaf certificate lifecycle for mcp-auth-proxy tls" +``` + +--- + +### Task 3: `server.ts` — HTTPS listener + protocol-aware origin + +**Test-first: yes — server.test.ts: constructing `McpAuthProxy` with TLS material must yield an `https://` origin and serve `/healthz` over TLS; fails because the constructor ignores the third argument and always builds `http.createServer`.** + +**Files:** +- Modify: `src/mcp/auth-proxy/server.ts:12,109-163` +- Test: `src/mcp/auth-proxy/__tests__/server.test.ts` + +- [ ] **Step 1: Write the failing tests** — add a new `describe` block at the end of `server.test.ts` (top-level, alongside the existing ones): + +```typescript +import https from 'node:https'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ensureAuthProxyCerts } from '../certs.js'; + +describe('tls listener', () => { + let tlsDir: string; + + beforeAll(async () => { + tlsDir = await mkdtemp(join(tmpdir(), 'codemie-tls-server-')); + }); + + afterAll(async () => { + await rm(tlsDir, { recursive: true, force: true }); + }); + + it('serves https with an https:// origin when TLS material is provided', async () => { + const material = await ensureAuthProxyCerts(tlsDir); + const proxy = new McpAuthProxy( + { port: 0, tls: true, servers: { radar: { upstreamUrl: 'https://mcp.example.com/radar' } } }, + undefined, + { keyPem: material.keyPem, certPem: material.certPem } + ); + const { port, url } = await proxy.start(); + try { + expect(url).toBe(`https://127.0.0.1:${port}`); + expect(proxy.origin.startsWith('https://')).toBe(true); + + const body = await new Promise((resolve, reject) => { + const req = https.get( + { host: '127.0.0.1', port, path: '/healthz', ca: material.caCertPem }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + } + ); + req.on('error', reject); + }); + expect(JSON.parse(body).status).toBe('ok'); + } finally { + await proxy.stop(); + } + }); + + it('keeps a plain http origin without TLS material', async () => { + const proxy = new McpAuthProxy({ + port: 0, + tls: false, + servers: { radar: { upstreamUrl: 'https://mcp.example.com/radar' } }, + }); + const { url } = await proxy.start(); + try { + expect(url.startsWith('http://127.0.0.1:')).toBe(true); + } finally { + await proxy.stop(); + } + }); +}); +``` + +(Adjust config literals to include `tls` per Task 1. If the existing file already imports `beforeAll`/`afterAll` from vitest, reuse; otherwise extend the import.) + +- [ ] **Step 2: Run to verify RED** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/server.test.ts` +Expected: FAIL — `url` is `http://…` (extra constructor arg ignored) and the `https.get` rejects (`EPROTO`/socket hang up: plain HTTP server receiving a TLS hello). + +- [ ] **Step 3: Implement** — in `server.ts`: + +Add the import and material type: + +```typescript +import https from 'node:https'; +``` + +```typescript +/** PEM key + cert for the loopback listener; presence switches the server to HTTPS. */ +export interface ServerTlsMaterial { + keyPem: string; + certPem: string; +} +``` + +Change the class fields/constructor/origin/start: + +```typescript +export class McpAuthProxy { + private server?: http.Server | https.Server; + private port: number; + private readonly sockets = new Set(); + private readonly client: UpstreamClient; + private readonly metadata: MetadataCache; + + constructor( + private readonly config: AuthProxyConfig, + private readonly onShutdownRequested?: () => void, + private readonly tls?: ServerTlsMaterial + ) { + this.port = config.port; + this.client = new UpstreamClient(); + this.metadata = new MetadataCache((url) => this.client.fetchJson(url)); + } + + get origin(): string { + return `${this.tls ? 'https' : 'http'}://${BIND_HOST}:${this.port}`; + } +``` + +and inside `start()` replace the `http.createServer` call: + +```typescript + const handler = (req: http.IncomingMessage, res: http.ServerResponse): void => { + void this.handleRequest(req, res); + }; + const server = this.tls + ? https.createServer({ key: this.tls.keyPem, cert: this.tls.certPem }, handler) + : http.createServer(handler); +``` + +Everything else (`server.on('connection')`, listen, `stop()`) is untouched — `https.Server` shares the socket/close API. + +- [ ] **Step 4: Run to verify GREEN** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/server.test.ts` +Expected: all PASS (old plain-HTTP suites prove no regression). + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/auth-proxy/server.ts src/mcp/auth-proxy/__tests__/server.test.ts +git commit -m "feat(proxy): serve mcp-auth-proxy over https when tls material is provided" +``` + +--- + +### Task 4: Runtime + daemon entry + state — TLS end to end in the daemon + +**Test-first: yes — new runtime.test.ts: `runAuthProxyDaemon` with a `tls: true` config must answer `/healthz` over HTTPS and persist `tls: true` in the state file; fails because runtime neither generates certs nor passes material.** + +**Files:** +- Modify: `src/mcp/auth-proxy/runtime.ts` +- Modify: `src/bin/mcp-auth-proxy-daemon.ts:10-31` +- Create: `src/mcp/auth-proxy/__tests__/runtime.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +/** + * runtime tests — real daemon runtime in-process with an isolated CODEMIE_HOME. + * @group unit + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import https from 'node:https'; + +let home: string; + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'codemie-home-')); + vi.stubEnv('CODEMIE_HOME', home); +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + await rm(home, { recursive: true, force: true }); +}); + +describe('runAuthProxyDaemon tls', () => { + it('starts an https listener and records tls in the state file', async () => { + const configPath = join(home, 'mcp-auth-proxy.json'); + const stateFile = join(home, 'mcp-auth-proxy.state.json'); + await writeFile( + configPath, + JSON.stringify({ + port: 0, + tls: true, + servers: { radar: { upstreamUrl: 'https://mcp.example.com/radar' } }, + }), + 'utf-8' + ); + + // Dynamic imports AFTER the env stub so getCodemiePath sees the temp home. + const { runAuthProxyDaemon } = await import('../runtime.js'); + const { ensureAuthProxyCerts } = await import('../certs.js'); + + const daemon = await runAuthProxyDaemon({ configPath, stateFile }); + try { + expect(daemon.url.startsWith('https://127.0.0.1:')).toBe(true); + + const state = JSON.parse(await readFile(stateFile, 'utf-8')) as { tls?: boolean }; + expect(state.tls).toBe(true); + + const material = await ensureAuthProxyCerts(); // reuses the same CODEMIE_HOME certs + const body = await new Promise((resolve, reject) => { + const req = https.get( + { host: '127.0.0.1', port: daemon.port, path: '/healthz', ca: material.caCertPem }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + } + ); + req.on('error', reject); + }); + expect(JSON.parse(body).status).toBe('ok'); + } finally { + await daemon.stop(); + } + }); + + it('stays plain http when tls is off', async () => { + const configPath = join(home, 'mcp-auth-proxy.json'); + const stateFile = join(home, 'mcp-auth-proxy.state.json'); + await writeFile( + configPath, + JSON.stringify({ port: 0, servers: { radar: { upstreamUrl: 'https://mcp.example.com/radar' } } }), + 'utf-8' + ); + const { runAuthProxyDaemon } = await import('../runtime.js'); + const daemon = await runAuthProxyDaemon({ configPath, stateFile }); + try { + expect(daemon.url.startsWith('http://127.0.0.1:')).toBe(true); + const state = JSON.parse(await readFile(stateFile, 'utf-8')) as { tls?: boolean }; + expect(state.tls).toBe(false); + } finally { + await daemon.stop(); + } + }); +}); +``` + +Caveat: `runAuthProxyDaemon` registers `process.on('SIGTERM'/'SIGINT')` handlers per call. In tests this only accumulates listeners (harmless warnings at worst); if Node prints a MaxListeners warning, raise the limit once in the test file with `process.setMaxListeners(20)`. + +- [ ] **Step 2: Run to verify RED** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/runtime.test.ts` +Expected: first test FAILS — `daemon.url` starts with `http://` and `state.tls` is `undefined`. + +- [ ] **Step 3: Implement** — in `runtime.ts`: + +```typescript +import { ensureAuthProxyCerts } from './certs.js'; +import type { ServerTlsMaterial } from './server.js'; +``` + +Extend the options interface: + +```typescript +export interface RunDaemonOptions { + configPath?: string; + port?: number; + stateFile?: string; + /** Force TLS on regardless of the config file (CLI --tls / daemon --tls). */ + tls?: boolean; +} +``` + +In `runAuthProxyDaemon`, after the `config.port` override: + +```typescript + const tlsEnabled = options.tls === true || config.tls; + let tlsMaterial: ServerTlsMaterial | undefined; + if (tlsEnabled) { + const material = await ensureAuthProxyCerts(); + tlsMaterial = { keyPem: material.keyPem, certPem: material.certPem }; + } +``` + +change the constructor call to `new McpAuthProxy(config, gracefulShutdown, tlsMaterial);` and the state write to: + +```typescript + await writeAuthProxyState( + { pid: process.pid, port, routes, startedAt: new Date().toISOString(), tls: tlsEnabled }, + stateFile + ); +``` + +In `src/bin/mcp-auth-proxy-daemon.ts`, add the flag to `parseArgs` options and the runtime call: + +```typescript + tls: { type: 'boolean' }, +``` + +```typescript + await runAuthProxyDaemon({ + configPath: values.config as string | undefined, + port, + stateFile: values['state-file'] as string | undefined, + tls: values.tls === true, + }); +``` + +(`tls: false` and `tls` absent are equivalent — `config.tls` still applies; the flag only forces on, matching the design.) + +- [ ] **Step 4: Run to verify GREEN** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/runtime.test.ts` +Expected: both PASS. + +- [ ] **Step 5: Full module regression + typecheck** + +Run: `npx vitest run src/mcp/auth-proxy && npx tsc --noEmit` +Expected: all PASS, no type errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/mcp/auth-proxy/runtime.ts src/bin/mcp-auth-proxy-daemon.ts src/mcp/auth-proxy/__tests__/runtime.test.ts +git commit -m "feat(proxy): wire tls through mcp-auth-proxy runtime, daemon flag, and state" +``` + +--- + +### Task 5: `client.ts` — CA-pinned loopback control-plane client + +**Test-first: yes — client.test.ts fails with "Cannot find module '../client.js'"; the tls case additionally proves CA pinning against a real https proxy.** + +**Files:** +- Create: `src/mcp/auth-proxy/client.ts` (move `fetchHealth` + `requestShutdown` out of the CLI command, add protocol/CA awareness) +- Test: `src/mcp/auth-proxy/__tests__/client.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```typescript +/** + * client.ts tests — control-plane requests against a real McpAuthProxy, + * plain HTTP and CA-pinned HTTPS. + * @group unit + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { McpAuthProxy } from '../server.js'; +import { ensureAuthProxyCerts } from '../certs.js'; +import { fetchHealth, requestShutdown } from '../client.js'; +import type { TlsMaterial } from '../certs.js'; + +const SERVERS = { radar: { upstreamUrl: 'https://mcp.example.com/radar' } }; + +describe('auth-proxy control-plane client', () => { + describe('plain http daemon', () => { + let proxy: McpAuthProxy; + let port: number; + + beforeAll(async () => { + proxy = new McpAuthProxy({ port: 0, tls: false, servers: SERVERS }); + ({ port } = await proxy.start()); + }); + + afterAll(async () => { + await proxy.stop(); + }); + + it('fetchHealth reads /healthz', async () => { + const health = await fetchHealth({ port, tls: false }); + expect(health.status).toBe('ok'); + expect(health.routes[0]?.id).toBe('radar'); + }); + + it('requestShutdown acks 202 and reports true', async () => { + let requested = false; + const p = new McpAuthProxy({ port: 0, tls: false, servers: SERVERS }, () => { + requested = true; + }); + const started = await p.start(); + expect(await requestShutdown({ port: started.port, tls: false })).toBe(true); + await p.stop(); + expect(requested).toBe(true); + }); + + it('requestShutdown resolves false when nothing listens', async () => { + expect(await requestShutdown({ port, tls: false, portOverride: 1 })).toBe(false); + }); + }); + + describe('https daemon', () => { + let tlsDir: string; + let material: TlsMaterial; + let proxy: McpAuthProxy; + let port: number; + + beforeAll(async () => { + tlsDir = await mkdtemp(join(tmpdir(), 'codemie-tls-client-')); + material = await ensureAuthProxyCerts(tlsDir); + proxy = new McpAuthProxy( + { port: 0, tls: true, servers: SERVERS }, + undefined, + { keyPem: material.keyPem, certPem: material.certPem } + ); + ({ port } = await proxy.start()); + }); + + afterAll(async () => { + await proxy.stop(); + await rm(tlsDir, { recursive: true, force: true }); + }); + + it('fetchHealth pins the local CA over https', async () => { + const health = await fetchHealth({ port, tls: true, caPem: material.caCertPem }); + expect(health.status).toBe('ok'); + }); + + it('fetchHealth without the CA rejects (no rejectUnauthorized bypass)', async () => { + await expect(fetchHealth({ port, tls: true })).rejects.toThrow(); + }); + }); +}); +``` + +Drop the `portOverride` oddity — replace that third plain-http test with a direct unused-port call: + +```typescript + it('requestShutdown resolves false when nothing listens', async () => { + const dead = new McpAuthProxy({ port: 0, tls: false, servers: SERVERS }); + const started = await dead.start(); + await dead.stop(); // port now free + expect(await requestShutdown({ port: started.port, tls: false })).toBe(false); + }); +``` + +- [ ] **Step 2: Run to verify RED** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/client.test.ts` +Expected: FAIL — `Cannot find module '../client.js'`. + +- [ ] **Step 3: Implement `src/mcp/auth-proxy/client.ts`** — move the two helpers from `src/cli/commands/mcp-auth-proxy.ts:68-114` and parameterize the transport: + +```typescript +/** + * MCP Auth Proxy — loopback control-plane client (CLI side). + * + * Talks to the daemon's /healthz and /shutdown endpoints over whichever + * protocol the daemon listener speaks. For TLS daemons the locally-generated + * CA is pinned explicitly (`ca:`) — never rejectUnauthorized: false. + */ +import http from 'node:http'; +import https from 'node:https'; +import type { RouteStatus } from './types.js'; + +export interface DaemonEndpoint { + port: number; + /** True when the daemon listener speaks HTTPS (from the daemon state file). */ + tls?: boolean; + /** CA certificate PEM to pin for TLS daemons. */ + caPem?: string; +} + +export interface HealthzRoute { + id: string; + upstreamUrl: string; + status: RouteStatus; +} + +export interface HealthzResponse { + status: string; + routes: HealthzRoute[]; +} + +interface LoopbackRequestOptions { + host: string; + port: number; + path: string; + method?: string; + timeout: number; + ca?: string; +} + +function transportFor(endpoint: DaemonEndpoint): typeof http | typeof https { + return endpoint.tls === true ? https : http; +} + +function baseOptions(endpoint: DaemonEndpoint, path: string): LoopbackRequestOptions { + return { + host: '127.0.0.1', + port: endpoint.port, + path, + timeout: 2000, + ...(endpoint.tls === true && endpoint.caPem !== undefined ? { ca: endpoint.caPem } : {}), + }; +} + +export function fetchHealth(endpoint: DaemonEndpoint): Promise { + return new Promise((resolveHealth, rejectHealth) => { + const request = transportFor(endpoint).get(baseOptions(endpoint, '/healthz'), (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + try { + resolveHealth(JSON.parse(Buffer.concat(chunks).toString('utf-8')) as HealthzResponse); + } catch (error) { + rejectHealth(error as Error); + } + }); + }); + request.on('error', rejectHealth); + request.on('timeout', () => request.destroy(new Error('healthz timed out'))); + }); +} + +/** + * Ask the daemon to shut itself down gracefully via the loopback control + * endpoint. Cross-platform graceful stop (Windows has no POSIX signals, so a + * signal there is a hard kill that skips the daemon's cleanup). Resolves true + * if the daemon acknowledged (2xx), false on any error/timeout — the caller + * then falls back to OS signals. + */ +export function requestShutdown(endpoint: DaemonEndpoint): Promise { + return new Promise((resolveShutdown) => { + const request = transportFor(endpoint).request( + { ...baseOptions(endpoint, '/shutdown'), method: 'POST' }, + (res) => { + res.resume(); // drain the 202 body so the socket can close + resolveShutdown( + res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300 + ); + } + ); + request.on('error', () => resolveShutdown(false)); + request.on('timeout', () => { + request.destroy(); + resolveShutdown(false); + }); + request.end(); + }); +} +``` + +- [ ] **Step 4: Run to verify GREEN** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/client.test.ts` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/auth-proxy/client.ts src/mcp/auth-proxy/__tests__/client.test.ts +git commit -m "feat(proxy): add ca-pinned loopback control-plane client for mcp-auth-proxy" +``` + +--- + +### Task 6: `trust.ts` — per-OS trust-store command builders + action runner + +**Test-first: yes — trust.test.ts fails with "Cannot find module '../trust.js'".** + +**Files:** +- Create: `src/mcp/auth-proxy/trust.ts` +- Test: `src/mcp/auth-proxy/__tests__/trust.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```typescript +/** + * trust.ts tests — pure command builders + injected-exec action runner. + * No real trust store is ever touched. + * @group unit + */ +import { describe, it, expect, vi } from 'vitest'; +import { applyTrust, buildTrustCommands, getManualTrustInstructions } from '../trust.js'; + +const CA_PATH = '/home/u/.codemie/mcp-auth-proxy-tls/ca.crt'; +const CN = 'CodeMie mcp-auth-proxy CA u@host 2026-07-05'; + +describe('buildTrustCommands', () => { + it('win32 install/uninstall target the user Root store', () => { + expect(buildTrustCommands('win32', CA_PATH, CN, 'install')).toEqual([ + { command: 'certutil', args: ['-addstore', '-user', 'Root', CA_PATH] }, + ]); + expect(buildTrustCommands('win32', CA_PATH, CN, 'uninstall')).toEqual([ + { command: 'certutil', args: ['-delstore', '-user', 'Root', CN] }, + ]); + }); + + it('darwin install/uninstall use the login keychain', () => { + const install = buildTrustCommands('darwin', CA_PATH, CN, 'install'); + expect(install?.[0]?.command).toBe('security'); + expect(install?.[0]?.args).toContain('add-trusted-cert'); + expect(install?.[0]?.args).toContain(CA_PATH); + + const uninstall = buildTrustCommands('darwin', CA_PATH, CN, 'uninstall'); + expect(uninstall?.[0]?.args).toContain('delete-certificate'); + expect(uninstall?.[0]?.args).toContain(CN); + }); + + it('linux targets the NSS user DB (what Chromium/Electron read)', () => { + const install = buildTrustCommands('linux', CA_PATH, CN, 'install'); + expect(install?.[0]?.command).toBe('certutil'); + expect(install?.[0]?.args.join(' ')).toContain('.pki/nssdb'); + expect(install?.[0]?.args).toContain(CN); + + const uninstall = buildTrustCommands('linux', CA_PATH, CN, 'uninstall'); + expect(uninstall?.[0]?.args).toContain('-D'); + expect(uninstall?.[0]?.args).toContain(CN); + }); + + it('returns null for unsupported platforms', () => { + expect(buildTrustCommands('freebsd', CA_PATH, CN, 'install')).toBeNull(); + }); +}); + +describe('applyTrust', () => { + it('runs the built commands and reports success', async () => { + const exec = vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: '' }); + const result = await applyTrust('install', { + platform: 'win32', + exec, + caPath: CA_PATH, + caCommonName: CN, + }); + expect(result.ok).toBe(true); + expect(exec).toHaveBeenCalledWith('certutil', ['-addstore', '-user', 'Root', CA_PATH]); + }); + + it('fails with manual instructions when the tool exits non-zero', async () => { + const exec = vi.fn().mockResolvedValue({ code: 1, stdout: '', stderr: 'denied' }); + const result = await applyTrust('install', { + platform: 'linux', + exec, + caPath: CA_PATH, + caCommonName: CN, + }); + expect(result.ok).toBe(false); + expect(result.manual).toContain(CA_PATH); + }); + + it('fails with manual instructions when the tool is missing (exec throws)', async () => { + const exec = vi.fn().mockRejectedValue(new Error('spawn certutil ENOENT')); + const result = await applyTrust('install', { + platform: 'linux', + exec, + caPath: CA_PATH, + caCommonName: CN, + }); + expect(result.ok).toBe(false); + expect(result.manual).toContain(CA_PATH); + }); + + it('returns manual instructions directly on unsupported platforms', async () => { + const exec = vi.fn(); + const result = await applyTrust('install', { + platform: 'freebsd', + exec, + caPath: CA_PATH, + caCommonName: CN, + }); + expect(result.ok).toBe(false); + expect(exec).not.toHaveBeenCalled(); + }); +}); + +describe('getManualTrustInstructions', () => { + it('mentions the CA path and all three platforms', () => { + const text = getManualTrustInstructions(CA_PATH); + expect(text).toContain(CA_PATH); + expect(text).toMatch(/certutil/); + expect(text).toMatch(/security add-trusted-cert/); + expect(text).toMatch(/nssdb/); + }); +}); +``` + +- [ ] **Step 2: Run to verify RED** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/trust.test.ts` +Expected: FAIL — `Cannot find module '../trust.js'`. + +- [ ] **Step 3: Implement `src/mcp/auth-proxy/trust.ts`** + +```typescript +/** + * MCP Auth Proxy — OS trust-store integration for the locally-generated CA. + * + * Only the explicit `codemie mcp-auth-proxy trust` command calls this — + * `start` never modifies the trust store (requirements decision, confirmed at + * a security escalation gate). Uninstall matches the CA's exact subject CN so + * only the CodeMie CA is ever removed. + */ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export type TrustAction = 'install' | 'uninstall'; + +export interface TrustCommand { + command: string; + args: string[]; +} + +export interface TrustDeps { + platform: NodeJS.Platform; + /** exec() from src/utils/processes.ts (injected for testability). */ + exec: (command: string, args: string[]) => Promise<{ code: number; stdout: string; stderr: string }>; + caPath: string; + caCommonName: string; +} + +export interface TrustResult { + ok: boolean; + /** Manual per-OS instructions when the automated path is unavailable/failed. */ + manual?: string; +} + +export function buildTrustCommands( + platform: NodeJS.Platform, + caPath: string, + caCommonName: string, + action: TrustAction +): TrustCommand[] | null { + switch (platform) { + case 'win32': + return action === 'install' + ? [{ command: 'certutil', args: ['-addstore', '-user', 'Root', caPath] }] + : [{ command: 'certutil', args: ['-delstore', '-user', 'Root', caCommonName] }]; + case 'darwin': { + const loginKeychain = join(homedir(), 'Library', 'Keychains', 'login.keychain-db'); + return action === 'install' + ? [{ command: 'security', args: ['add-trusted-cert', '-k', loginKeychain, caPath] }] + : [{ command: 'security', args: ['delete-certificate', '-c', caCommonName, loginKeychain] }]; + } + case 'linux': { + const nssDb = `sql:${join(homedir(), '.pki', 'nssdb')}`; + return action === 'install' + ? [{ command: 'certutil', args: ['-d', nssDb, '-A', '-t', 'C,,', '-n', caCommonName, '-i', caPath] }] + : [{ command: 'certutil', args: ['-d', nssDb, '-D', '-n', caCommonName] }]; + } + default: + return null; + } +} + +export function getManualTrustInstructions(caPath: string): string { + return [ + `Install the CA manually — certificate file: ${caPath}`, + ` Windows : certutil -addstore -user Root "${caPath}"`, + ` macOS : security add-trusted-cert -k ~/Library/Keychains/login.keychain-db "${caPath}"`, + ` Linux : certutil -d sql:$HOME/.pki/nssdb -A -t C,, -n "CodeMie mcp-auth-proxy CA" -i "${caPath}"`, + ' (requires libnss3-tools; Chromium/Electron read the NSS user DB)', + ].join('\n'); +} + +/** + * Runs the trust-store commands for the platform. Never throws: an unusable + * tool or unsupported platform yields { ok: false, manual } so the CLI can + * print instructions and exit non-zero. + */ +export async function applyTrust(action: TrustAction, deps: TrustDeps): Promise { + const commands = buildTrustCommands(deps.platform, deps.caPath, deps.caCommonName, action); + if (commands === null) { + return { ok: false, manual: getManualTrustInstructions(deps.caPath) }; + } + for (const { command, args } of commands) { + try { + const result = await deps.exec(command, args); + if (result.code !== 0) { + return { ok: false, manual: getManualTrustInstructions(deps.caPath) }; + } + } catch { + return { ok: false, manual: getManualTrustInstructions(deps.caPath) }; + } + } + return { ok: true }; +} +``` + +- [ ] **Step 4: Run to verify GREEN** + +Run: `npx vitest run src/mcp/auth-proxy/__tests__/trust.test.ts` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/auth-proxy/trust.ts src/mcp/auth-proxy/__tests__/trust.test.ts +git commit -m "feat(proxy): add os trust-store command builders and runner for the local ca" +``` + +--- + +### Task 7: CLI wiring — `start --tls`, https-aware `status`/`stop`, `trust` subcommand + +**Test-first: no — thin command glue over the tested modules (client.ts, certs.ts, trust.ts); behavior is covered by Tasks 2–6 tests plus the Task 8 smoke. No new branching logic beyond option passing and output formatting.** + +**Files:** +- Modify: `src/cli/commands/mcp-auth-proxy.ts` + +- [ ] **Step 1: Rewire imports** — remove the local `fetchHealth`, `requestShutdown`, and `HealthzRoute` (lines 62–114 in the current file, plus the now-unused `http` import) and import instead: + +```typescript +import { readFile } from 'node:fs/promises'; +import { platform } from 'node:os'; +import { exec } from '../../utils/processes.js'; +import { fetchHealth, requestShutdown } from '../../mcp/auth-proxy/client.js'; +import type { DaemonEndpoint } from '../../mcp/auth-proxy/client.js'; +import { ensureAuthProxyCerts, getAuthProxyTlsPaths } from '../../mcp/auth-proxy/certs.js'; +import { applyTrust } from '../../mcp/auth-proxy/trust.js'; +import type { AuthProxyDaemonState } from '../../mcp/auth-proxy/types.js'; +``` + +Add one endpoint helper next to `printAddCommands`: + +```typescript +/** Builds the control-plane endpoint from daemon state, loading the CA for TLS daemons. */ +async function daemonEndpoint(state: AuthProxyDaemonState): Promise { + if (state.tls !== true) { + return { port: state.port, tls: false }; + } + try { + return { port: state.port, tls: true, caPem: await readFile(getAuthProxyTlsPaths().caCert, 'utf-8') }; + } catch { + // CA file missing — request will fail TLS verification and callers fall back. + return { port: state.port, tls: true }; + } +} +``` + +- [ ] **Step 2: Protocol-aware output** — change `printAddCommands` to take the protocol: + +```typescript +function printAddCommands(port: number, routes: string[], tls: boolean): void { + const protocol = tls ? 'https' : 'http'; + console.log(chalk.bold('\nAdd to Claude Code:')); + for (const id of routes) { + console.log( + ` claude mcp add --scope local --transport http ${id} ${protocol}://127.0.0.1:${port}/${id}` + ); + } +} + +function printTlsHints(): void { + console.log( + chalk.yellow( + '\n⚠ TLS is enabled: routes previously registered with http:// URLs must be re-registered with the https:// URLs above.' + ) + ); + console.log( + chalk.gray( + 'If the browser or Claude Desktop rejects the certificate, run: codemie mcp-auth-proxy trust\n' + + 'Node-based clients (e.g. Claude Code CLI) do not read the OS trust store — set NODE_EXTRA_CA_CERTS=' + + getAuthProxyTlsPaths().caCert + ) + ); +} +``` + +- [ ] **Step 3: `start` action** — add the option and thread TLS through: + +```typescript + .option('--tls', 'Serve HTTPS with the locally-generated CodeMie CA (see the trust subcommand)') +``` + +Inside the action (`opts` type gains `tls?: boolean`): + +- After `const config = await loadAuthProxyConfig(configPath);` add + `const tls = opts.tls === true || config.tls;` and + `if (tls) { await ensureAuthProxyCerts(); }` (fail-fast + material ready before the daemon spawns). +- The already-running branch: `printAddCommands(existing.port, existing.routes, existing.tls === true);` +- Foreground branch: `await runAuthProxyDaemon({ configPath, port, tls: opts.tls === true });` and + `console.log(chalk.green(\`✓ mcp-auth-proxy running (foreground) on ${tls ? 'https' : 'http'}://127.0.0.1:${port}\`)); printAddCommands(port, routes, tls); if (tls) { printTlsHints(); }` +- Detached spawn args: `...(opts.tls === true ? ['--tls'] : [])` appended after `'--state-file', getDefaultStatePath(),`. +- Success poll branch: + `console.log(chalk.green(\`✓ mcp-auth-proxy started on ${state.tls === true ? 'https' : 'http'}://127.0.0.1:${state.port} (pid ${state.pid})\`)); printAddCommands(state.port, state.routes, state.tls === true); if (state.tls === true) { printTlsHints(); }` + +- [ ] **Step 4: `status` action** — protocol-aware URL line and endpoint-based health call: + +```typescript + const protocol = state.tls === true ? 'https' : 'http'; + console.log( + chalk.green( + `✓ mcp-auth-proxy running on ${protocol}://127.0.0.1:${state.port} (pid ${state.pid}, started ${state.startedAt})` + ) + ); + try { + const health = await fetchHealth(await daemonEndpoint(state)); + for (const route of health.routes) { + const marker = + route.status === 'degraded' ? chalk.red('✗ degraded') : chalk.green(`✓ ${route.status}`); + console.log(` ${route.id}: ${marker} → ${route.upstreamUrl}`); + console.log( + ` claude mcp add --scope local --transport http ${route.id} ${protocol}://127.0.0.1:${state.port}/${route.id}` + ); + } + } catch { + console.log(chalk.red(' ✗ Daemon process is alive but /healthz did not answer')); + } +``` + +- [ ] **Step 5: `stop` action** — single-line change: `const acked = await requestShutdown(await daemonEndpoint(state));` (the poll/SIGTERM/SIGKILL fallback stays untouched). + +- [ ] **Step 6: `trust` subcommand** — add after the `stop` command: + +```typescript + command + .command('trust') + .description('Install (or remove) the locally-generated CA in the OS user trust store') + .option('--uninstall', 'Remove the CodeMie CA from the trust store instead of installing it') + .action(async (opts: { uninstall?: boolean }) => { + try { + const material = await ensureAuthProxyCerts(); // side-effect-free beyond ~/.codemie file generation + const action = opts.uninstall === true ? 'uninstall' : 'install'; + const result = await applyTrust(action, { + platform: platform(), + exec: (cmd, args) => exec(cmd, args), + caPath: material.paths.caCert, + caCommonName: material.caCommonName, + }); + + const { X509Certificate } = await import('node:crypto'); + const ca = new X509Certificate(material.caCertPem); + console.log(`CA certificate : ${material.paths.caCert}`); + console.log(`Subject CN : ${material.caCommonName}`); + console.log(`SHA-256 : ${ca.fingerprint256}`); + console.log(`Valid until : ${ca.validTo}`); + + if (result.ok) { + console.log( + chalk.green(action === 'install' ? '✓ CA installed in the user trust store' : '✓ CA removed from the user trust store') + ); + } else { + console.log(chalk.yellow(`Automated ${action} was not possible on this system.`)); + if (result.manual !== undefined) { + console.log(result.manual); + } + process.exitCode = 1; + } + } catch (error) { + printError(error, '[mcp-auth-proxy] trust failed'); + } + }); +``` + +- [ ] **Step 7: Verify** — Run: `npx tsc --noEmit && npx eslint 'src/cli/commands/mcp-auth-proxy.ts' --max-warnings=0 && npx vitest run src/mcp/auth-proxy src/utils/__tests__/spawn-detached.test.ts` +Expected: clean typecheck/lint, all tests PASS. + +- [ ] **Step 8: Commit** + +```bash +git add src/cli/commands/mcp-auth-proxy.ts +git commit -m "feat(cli): add --tls start flag, trust subcommand, and https-aware status/stop" +``` + +--- + +### Task 8: End-to-end smoke (isolated CODEMIE_HOME, real daemon) + +**Test-first: no — this is a functional verification step, not new code. Every behavior it exercises already has unit coverage above.** + +**Files:** none (build + shell only) + +- [ ] **Step 1: Build** — Run: `npm run build`. Expected: success. + +- [ ] **Step 2: TLS daemon smoke** + +```bash +export SMOKE_HOME=$(mktemp -d) +CODEMIE_HOME=$SMOKE_HOME node bin/codemie.js --version # run one-time migrations quietly +cat > $SMOKE_HOME/mcp-auth-proxy.json <<'EOF' +{ "port": 42899, "tls": true, + "servers": { "radar": { "upstreamUrl": "https://mcp.example.com/radar" } } } +EOF +CODEMIE_HOME=$SMOKE_HOME node bin/codemie.js mcp-auth-proxy start +# wait for readiness, then: +curl --cacert $SMOKE_HOME/mcp-auth-proxy-tls/ca.crt https://127.0.0.1:42899/healthz +``` + +Expected: start prints `✓ mcp-auth-proxy started on https://127.0.0.1:42899 …`, https URLs in the add hints, the re-registration warning, and the trust hint; curl returns `{"status":"ok",...}` **with certificate verification on** (no `-k`). + +- [ ] **Step 3: Graceful stop over TLS** + +```bash +CODEMIE_HOME=$SMOKE_HOME node bin/codemie.js mcp-auth-proxy stop +``` + +Expected: `✓ mcp-auth-proxy stopped` quickly (graceful path over https, no SIGTERM warning in debug logs); state file removed. + +- [ ] **Step 4: Plain HTTP regression smoke** — same config without `"tls": true`; start, `curl http://127.0.0.1:42899/healthz`, stop. Expected: identical behavior to the pre-change proxy. + +- [ ] **Step 5: Cleanup** — `rm -rf $SMOKE_HOME`. No commit (no file changes). + +--- + +### Task 9: Documentation + +**Test-first: no — documentation only.** + +**Files:** +- Modify: `docs/COMMANDS.md` (the `codemie mcp-auth-proxy` section) + +- [ ] **Step 1: Update `docs/COMMANDS.md`** — in the existing `mcp-auth-proxy` section: + - Add `--tls` to the `start` options table/list: "Serve HTTPS with the locally-generated CodeMie CA (config equivalent: `"tls": true`). Default: plain HTTP." + - Add a `trust` subcommand entry: install/uninstall the local CA in the user trust store (Windows `certutil -addstore -user Root`, macOS login keychain via `security add-trusted-cert`, Linux NSS user DB via `certutil` from libnss3-tools); `--uninstall` removes only the CodeMie CA (matched by its subject CN). + - Add a short "HTTPS / Claude Desktop" note: why TLS exists (Desktop refuses non-https authorize URLs), that enabling TLS changes the origin so `http://` registrations must be re-registered, and that Node-based clients need `NODE_EXTRA_CA_CERTS=~/.codemie/mcp-auth-proxy-tls/ca.crt` since they do not read the OS trust store. + +- [ ] **Step 2: Commit** + +```bash +git add docs/COMMANDS.md +git commit -m "docs: document mcp-auth-proxy --tls and trust subcommand" +``` + +--- + +## Self-review notes (performed at plan time) + +- **Spec coverage:** design §certs.ts → Task 2; §server.ts → Task 3; §config/types → Task 1; §runtime/daemon → Task 4; §CLI (start/status/stop) → Tasks 5+7; §trust → Tasks 6+7; §data flow/smoke → Task 8; re-registration warning + NODE_EXTRA_CA_CERTS note (spec follow-up) → Tasks 7+9; dependency pin note → header + Task 2. +- **Type consistency:** `TlsMaterial{keyPem,certPem,caCertPem,caCommonName,paths}` (T2) consumed in T3 tests, T4 runtime, T5 tests, T7 CLI; `ServerTlsMaterial{keyPem,certPem}` (T3) built in T4; `DaemonEndpoint{port,tls,caPem}` (T5) built by T7 `daemonEndpoint`; `applyTrust(action, {platform,exec,caPath,caCommonName})` (T6) called identically in T7. +- **Deviation risk called out:** PEM string identity in T2 idempotency test — mitigation (read-back variant) included inline. diff --git a/docs/superpowers/specs/2026-07-05-mcp-auth-proxy-https-design.md b/docs/superpowers/specs/2026-07-05-mcp-auth-proxy-https-design.md index 4d0152f9..488223b5 100644 --- a/docs/superpowers/specs/2026-07-05-mcp-auth-proxy-https-design.md +++ b/docs/superpowers/specs/2026-07-05-mcp-auth-proxy-https-design.md @@ -63,9 +63,11 @@ src/cli/commands/mcp-auth-proxy.ts start --tls; trust [--uninstall]; https-aware ### certs.ts — certificate lifecycle (new) -Dependency: **`@peculiar/x509`** (v2, MIT, pure TS/JS, WebCrypto-based — wired -to `node:crypto` `webcrypto`; verified current 2026-07-05). node:crypto alone -cannot create/sign X.509 certificates. +Dependency: **`@peculiar/x509@^1.14.3`** (MIT, pure TS/JS, WebCrypto-based — +wired to `node:crypto` `webcrypto`; full CA→leaf flow verified live +2026-07-05). Pinned to v1: v2.0.0 fails at import with an undeclared +`reflect-metadata` requirement; v1.14.3 declares it and works out of the box. +node:crypto alone cannot create/sign X.509 certificates. Layout (all under `getCodemiePath('mcp-auth-proxy-tls')`): From 42619a4f9432041828434c53dbd58d2394e8ea3d Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 19:13:36 +0200 Subject: [PATCH 25/36] feat(proxy): add opt-in tls flag to mcp-auth-proxy config Generated with AI Co-Authored-By: codemie-ai --- src/mcp/auth-proxy/__tests__/config.test.ts | 18 ++++++++++++++++++ src/mcp/auth-proxy/__tests__/server.test.ts | 4 +++- src/mcp/auth-proxy/config.ts | 10 +++++++++- src/mcp/auth-proxy/types.ts | 4 ++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/mcp/auth-proxy/__tests__/config.test.ts b/src/mcp/auth-proxy/__tests__/config.test.ts index e0bf1248..5d83190d 100644 --- a/src/mcp/auth-proxy/__tests__/config.test.ts +++ b/src/mcp/auth-proxy/__tests__/config.test.ts @@ -92,6 +92,24 @@ describe('validateAuthProxyConfig', () => { }); }); +describe('tls flag', () => { + it('defaults tls to false when absent', () => { + const config = validateAuthProxyConfig({ servers: validServers }); + expect(config.tls).toBe(false); + }); + + it('accepts tls: true', () => { + const config = validateAuthProxyConfig({ tls: true, servers: validServers }); + expect(config.tls).toBe(true); + }); + + it('rejects non-boolean tls', () => { + expect(() => validateAuthProxyConfig({ tls: 'yes', servers: validServers })).toThrow( + /"tls" must be a boolean/ + ); + }); +}); + describe('loadAuthProxyConfig', () => { it('throws ConfigurationError for a missing config file', async () => { const missing = join(tmpdir(), `mcp-auth-proxy-missing-${process.pid}.json`); diff --git a/src/mcp/auth-proxy/__tests__/server.test.ts b/src/mcp/auth-proxy/__tests__/server.test.ts index 4a210135..31d9a54b 100644 --- a/src/mcp/auth-proxy/__tests__/server.test.ts +++ b/src/mcp/auth-proxy/__tests__/server.test.ts @@ -115,6 +115,7 @@ describe('McpAuthProxy', () => { upstream = await createFakeUpstream(); const config: AuthProxyConfig = { port: 0, + tls: false, servers: { radar: { upstreamUrl: `${upstream.origin}/mcp/radar`, @@ -345,7 +346,7 @@ describe('McpAuthProxy', () => { it('POST /shutdown → 202 and fires the shutdown callback once; GET /shutdown → 405', async () => { const onShutdown = vi.fn(); const controllable = new McpAuthProxy( - { port: 0, servers: { radar: { upstreamUrl: `${upstream.origin}/mcp/radar` } } }, + { port: 0, tls: false, servers: { radar: { upstreamUrl: `${upstream.origin}/mcp/radar` } } }, onShutdown ); const { url } = await controllable.start(); @@ -387,6 +388,7 @@ describe('McpAuthProxy single-route alias', () => { const upstream = await createFakeUpstream(); const config: AuthProxyConfig = { port: 0, + tls: false, servers: { radar: { upstreamUrl: `${upstream.origin}/mcp/radar` } }, }; const solo = new McpAuthProxy(config); diff --git a/src/mcp/auth-proxy/config.ts b/src/mcp/auth-proxy/config.ts index f82ffd8f..1ebc9466 100644 --- a/src/mcp/auth-proxy/config.ts +++ b/src/mcp/auth-proxy/config.ts @@ -71,6 +71,14 @@ export function validateAuthProxyConfig(parsed: unknown): AuthProxyConfig { port = root.port; } + let tls = false; + if (root.tls !== undefined) { + if (typeof root.tls !== 'boolean') { + throw new ConfigurationError('mcp-auth-proxy config: "tls" must be a boolean'); + } + tls = root.tls; + } + if (typeof root.servers !== 'object' || root.servers === null || Array.isArray(root.servers)) { throw new ConfigurationError( 'mcp-auth-proxy config: "servers" must be an object mapping route ids to server configs' @@ -95,7 +103,7 @@ export function validateAuthProxyConfig(parsed: unknown): AuthProxyConfig { servers[id] = validateRoute(value, `servers.${id}`); } - return { port, servers }; + return { port, tls, servers }; } function validateRoute(value: unknown, keyPath: string): RouteConfig { diff --git a/src/mcp/auth-proxy/types.ts b/src/mcp/auth-proxy/types.ts index db1d7c38..401f92d6 100644 --- a/src/mcp/auth-proxy/types.ts +++ b/src/mcp/auth-proxy/types.ts @@ -18,6 +18,8 @@ export interface RouteConfig { export interface AuthProxyConfig { port: number; + /** Serve the loopback listener over HTTPS with the locally-generated CA (default false). */ + tls: boolean; servers: Record; } @@ -26,6 +28,8 @@ export interface AuthProxyDaemonState { port: number; routes: string[]; startedAt: string; + /** True when the daemon listener speaks HTTPS (absent in pre-TLS state files = false). */ + tls?: boolean; } export type RouteStatus = 'ok' | 'degraded' | 'unknown'; From 6cfe172bb70804db4b878d493f99a2427966032c Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 19:20:54 +0200 Subject: [PATCH 26/36] feat(proxy): add local CA and leaf certificate lifecycle for mcp-auth-proxy tls Generated with AI Co-Authored-By: codemie-ai --- package-lock.json | 226 +++++++++++++++++- package.json | 1 + src/mcp/auth-proxy/__tests__/certs.test.ts | 93 ++++++++ src/mcp/auth-proxy/certs.ts | 260 +++++++++++++++++++++ 4 files changed, 568 insertions(+), 12 deletions(-) create mode 100644 src/mcp/auth-proxy/__tests__/certs.test.ts create mode 100644 src/mcp/auth-proxy/certs.ts diff --git a/package-lock.json b/package-lock.json index fdbb615e..9da1fa50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@langchain/openai": "^1.1.0", "@modelcontextprotocol/client": "2.0.0-alpha.2", "@modelcontextprotocol/server": "2.0.0-alpha.2", + "@peculiar/x509": "^1.14.3", "chalk": "^5.3.0", "cli-table3": "^0.6.5", "codemie-sdk": "^0.1.428", @@ -2324,7 +2325,6 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.29.tgz", "integrity": "sha512-BPoegTtIdZX4gl2kxcSXAlLrrJFl1cxeRsk9DM/wlIuvyPrFwjWqrEK5NwF5diDt5XSArhQxIFaifGAl4F7fgw==", "license": "MIT", - "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -2542,6 +2542,163 @@ "node": ">= 8" } }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -3687,7 +3844,6 @@ "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3797,7 +3953,6 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -4117,7 +4272,6 @@ "integrity": "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.1.5", "fflate": "^0.8.2", @@ -4168,7 +4322,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4289,6 +4442,20 @@ "node": ">=8" } }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4951,7 +5118,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -5418,7 +5584,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7826,7 +7991,6 @@ "resolved": "https://registry.npmjs.org/openai/-/openai-6.9.0.tgz", "integrity": "sha512-n2sJRYmM+xfJ0l3OfH8eNnIyv3nQY7L08gZQu3dw6wSdfPtKAk92L83M2NIP5SS8Cl/bsBBG3yKzEOjkx0O+7A==", "license": "Apache-2.0", - "peer": true, "bin": { "openai": "bin/cli" }, @@ -8252,6 +8416,24 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -8372,6 +8554,12 @@ "node": ">=8.10.0" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -9272,6 +9460,24 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -9329,7 +9535,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9411,7 +9616,6 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -9487,7 +9691,6 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -9778,7 +9981,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index c9952423..594efcc5 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "@langchain/openai": "^1.1.0", "@modelcontextprotocol/client": "2.0.0-alpha.2", "@modelcontextprotocol/server": "2.0.0-alpha.2", + "@peculiar/x509": "^1.14.3", "chalk": "^5.3.0", "cli-table3": "^0.6.5", "codemie-sdk": "^0.1.428", diff --git a/src/mcp/auth-proxy/__tests__/certs.test.ts b/src/mcp/auth-proxy/__tests__/certs.test.ts new file mode 100644 index 00000000..091734d1 --- /dev/null +++ b/src/mcp/auth-proxy/__tests__/certs.test.ts @@ -0,0 +1,93 @@ +/** + * certs.ts tests — real certificate generation into a temp dir (no mocks: + * @peculiar/x509 is pure JS and fast; assertions parse output with node:crypto). + * @group unit + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, stat, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { X509Certificate } from 'node:crypto'; +import { ensureAuthProxyCerts, getAuthProxyTlsPaths } from '../certs.js'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'codemie-tls-')); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('ensureAuthProxyCerts', () => { + it('generates a CA and a leaf with the required profile', async () => { + const material = await ensureAuthProxyCerts(dir); + + const ca = new X509Certificate(material.caCertPem); + const leaf = new X509Certificate(material.certPem); + + expect(ca.ca).toBe(true); + expect(ca.subject).toContain('CN=CodeMie mcp-auth-proxy CA'); + expect(material.caCommonName).toContain('CodeMie mcp-auth-proxy CA'); + + expect(leaf.ca).toBe(false); + expect(leaf.subjectAltName).toContain('127.0.0.1'); + expect(leaf.subjectAltName).toContain('localhost'); + expect(leaf.checkIssued(ca)).toBe(true); + expect(leaf.keyUsage).toBeDefined(); + + // Leaf validity <= 825 days (Apple/Chromium local-trust cap) + const days = + (new Date(leaf.validTo).getTime() - new Date(leaf.validFrom).getTime()) / 86_400_000; + expect(days).toBeLessThanOrEqual(825); + + expect(material.keyPem).toContain('BEGIN PRIVATE KEY'); + }); + + it('writes private keys with 0600 permissions', async () => { + await ensureAuthProxyCerts(dir); + const paths = getAuthProxyTlsPaths(dir); + for (const keyFile of [paths.caKey, paths.serverKey]) { + const mode = (await stat(keyFile)).mode & 0o777; + expect(mode).toBe(0o600); + } + }); + + it('is idempotent — second call reuses both certificates', async () => { + const first = await ensureAuthProxyCerts(dir); + const second = await ensureAuthProxyCerts(dir); + expect(second.caCertPem).toBe(first.caCertPem); + expect(second.certPem).toBe(first.certPem); + }); + + it('reissues the leaf (same CA) when the leaf file is corrupt', async () => { + const first = await ensureAuthProxyCerts(dir); + const paths = getAuthProxyTlsPaths(dir); + const { writeFile } = await import('node:fs/promises'); + await writeFile(paths.serverCert, 'not a certificate', 'utf-8'); + + const second = await ensureAuthProxyCerts(dir); + expect(second.caCertPem).toBe(first.caCertPem); + expect(second.certPem).not.toBe(first.certPem); + const leaf = new X509Certificate(second.certPem); + expect(leaf.checkIssued(new X509Certificate(second.caCertPem))).toBe(true); + }); + + it('regenerates everything when the CA is missing', async () => { + const first = await ensureAuthProxyCerts(dir); + const paths = getAuthProxyTlsPaths(dir); + await rm(paths.caCert); + + const second = await ensureAuthProxyCerts(dir); + expect(second.caCertPem).not.toBe(first.caCertPem); + expect(second.certPem).not.toBe(first.certPem); + }); + + it('exposes the on-disk PEM contents matching the returned material', async () => { + const material = await ensureAuthProxyCerts(dir); + const paths = getAuthProxyTlsPaths(dir); + expect(await readFile(paths.caCert, 'utf-8')).toBe(material.caCertPem); + expect(await readFile(paths.serverCert, 'utf-8')).toBe(material.certPem); + }); +}); diff --git a/src/mcp/auth-proxy/certs.ts b/src/mcp/auth-proxy/certs.ts new file mode 100644 index 00000000..2183b9c0 --- /dev/null +++ b/src/mcp/auth-proxy/certs.ts @@ -0,0 +1,260 @@ +/** + * MCP Auth Proxy — local TLS certificate lifecycle. + * + * Generates and maintains a machine-local CA plus a loopback leaf certificate + * so the proxy can serve https://127.0.0.1 (Claude Desktop refuses non-https + * OAuth authorize URLs). Pure JS via @peculiar/x509 over node:crypto webcrypto + * — node:crypto alone cannot create/sign X.509 certificates. + * + * Trust-store installation is NOT done here — see trust.ts and the explicit + * `codemie mcp-auth-proxy trust` command (design decision: start never touches + * the OS trust store). + */ +import * as x509 from '@peculiar/x509'; +import { webcrypto, X509Certificate } from 'node:crypto'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { hostname, userInfo } from 'node:os'; +import { join } from 'node:path'; +import { getCodemiePath } from '../../utils/paths.js'; +import { ConfigurationError } from '../../utils/errors.js'; +import { logger } from '../../utils/logger.js'; + +x509.cryptoProvider.set(webcrypto); + +export const AUTH_PROXY_TLS_DIR = 'mcp-auth-proxy-tls'; +export const AUTH_PROXY_CA_NAME_PREFIX = 'CodeMie mcp-auth-proxy CA'; + +const SIGNING_ALG = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const; +const CA_VALIDITY_DAYS = 3650; +// Apple/Chromium reject locally-trusted leaves valid longer than 825 days. +const LEAF_VALIDITY_DAYS = 825; +// Reissue the leaf when it has less than this many days left. +const LEAF_RENEWAL_WINDOW_DAYS = 30; +const DAY_MS = 86_400_000; +// Backdate notBefore to tolerate small clock skew between generator and clients. +const CLOCK_SKEW_MS = 5 * 60_000; + +export interface AuthProxyTlsPaths { + dir: string; + caCert: string; + caKey: string; + serverCert: string; + serverKey: string; +} + +export interface TlsMaterial { + /** Leaf private key, PKCS#8 PEM. */ + keyPem: string; + /** Leaf certificate, PEM. */ + certPem: string; + /** CA certificate, PEM — the trust anchor `trust` installs and CLI clients pin. */ + caCertPem: string; + /** CA subject CN — the exact name trust-store entries are created/removed under. */ + caCommonName: string; + paths: AuthProxyTlsPaths; +} + +export function getAuthProxyTlsPaths( + dir: string = getCodemiePath(AUTH_PROXY_TLS_DIR) +): AuthProxyTlsPaths { + return { + dir, + caCert: join(dir, 'ca.crt'), + caKey: join(dir, 'ca.key'), + serverCert: join(dir, 'server.crt'), + serverKey: join(dir, 'server.key'), + }; +} + +/** Atomic tmp+rename write (state.ts idiom); mode applies to the new file. */ +async function writeFileAtomic(path: string, content: string, mode: number): Promise { + const tmp = `${path}.tmp`; + await writeFile(tmp, content, { encoding: 'utf-8', mode }); + await rename(tmp, path); +} + +function extractCommonName(subject: string): string { + for (const line of subject.split('\n')) { + if (line.startsWith('CN=')) { + return line.slice(3); + } + } + return subject; +} + +function pemToDer(pem: string): ArrayBuffer { + return x509.PemConverter.decode(pem)[0]; +} + +async function exportPrivateKeyPem(key: webcrypto.CryptoKey): Promise { + const der = await webcrypto.subtle.exportKey('pkcs8', key); + return x509.PemConverter.encode(der, 'PRIVATE KEY'); +} + +async function importPrivateKey(pem: string): Promise { + return webcrypto.subtle.importKey('pkcs8', pemToDer(pem), SIGNING_ALG, false, ['sign']); +} + +/** DN-safe: strip characters that would change RDN parsing. */ +function dnSafe(value: string): string { + return value.replace(/[^A-Za-z0-9._@-]/g, '-'); +} + +interface CaMaterial { + certPem: string; + keyPem: string; +} + +async function generateCa(): Promise { + const keys = await webcrypto.subtle.generateKey(SIGNING_ALG, true, ['sign', 'verify']); + const now = Date.now(); + const identity = `${dnSafe(userInfo().username)}@${dnSafe(hostname())}`; + const issuedOn = new Date(now).toISOString().slice(0, 10); + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + name: `CN=${AUTH_PROXY_CA_NAME_PREFIX} ${identity} ${issuedOn}, O=CodeMie`, + notBefore: new Date(now - CLOCK_SKEW_MS), + notAfter: new Date(now + CA_VALIDITY_DAYS * DAY_MS), + signingAlgorithm: SIGNING_ALG, + keys, + extensions: [ + new x509.BasicConstraintsExtension(true, 0, true), + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, + true + ), + await x509.SubjectKeyIdentifierExtension.create(keys.publicKey), + ], + }); + return { certPem: cert.toString('pem'), keyPem: await exportPrivateKeyPem(keys.privateKey) }; +} + +async function issueLeaf(ca: CaMaterial): Promise<{ certPem: string; keyPem: string }> { + const caCert = new x509.X509Certificate(ca.certPem); + const caKey = await importPrivateKey(ca.keyPem); + const keys = await webcrypto.subtle.generateKey(SIGNING_ALG, true, ['sign', 'verify']); + const now = Date.now(); + const cert = await x509.X509CertificateGenerator.create({ + subject: 'CN=127.0.0.1, O=CodeMie', + issuer: caCert.subject, + notBefore: new Date(now - CLOCK_SKEW_MS), + // Anchor to notBefore so total validity stays within the 825-day cap + // despite the clock-skew backdating. + notAfter: new Date(now - CLOCK_SKEW_MS + LEAF_VALIDITY_DAYS * DAY_MS), + signingAlgorithm: SIGNING_ALG, + publicKey: keys.publicKey, + signingKey: caKey, + extensions: [ + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true), + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.serverAuth], true), + new x509.SubjectAlternativeNameExtension([ + { type: 'ip', value: '127.0.0.1' }, + { type: 'dns', value: 'localhost' }, + ]), + ], + }); + return { certPem: cert.toString('pem'), keyPem: await exportPrivateKeyPem(keys.privateKey) }; +} + +/** Parse a PEM cert defensively; null means "treat as absent and regenerate". */ +async function readCertificate(path: string): Promise { + try { + return new X509Certificate(await readFile(path, 'utf-8')); + } catch { + return null; + } +} + +function isUsable(cert: X509Certificate, renewalWindowMs: number): boolean { + const now = Date.now(); + return ( + new Date(cert.validFrom).getTime() <= now && + new Date(cert.validTo).getTime() - renewalWindowMs > now + ); +} + +function leafMatchesProfile(leaf: X509Certificate, ca: X509Certificate): boolean { + const san = leaf.subjectAltName ?? ''; + // checkIssued() only matches issuer/subject names; verify() proves the + // signature so a leaf from a previous (regenerated) same-name CA is rejected. + return ( + leaf.checkIssued(ca) && + leaf.verify(ca.publicKey) && + san.includes('127.0.0.1') && + san.includes('localhost') + ); +} + +/** + * Idempotently ensures a usable CA + leaf under `dir` and returns the material. + * Regenerates the CA (and leaf) when the CA is missing/corrupt/expired — and + * warns that `trust` must be re-run. Reissues only the leaf when it is + * missing/corrupt/expiring/mismatched. + */ +export async function ensureAuthProxyCerts( + dir: string = getCodemiePath(AUTH_PROXY_TLS_DIR) +): Promise { + const paths = getAuthProxyTlsPaths(dir); + try { + await mkdir(dir, { recursive: true }); + + let caCert = await readCertificate(paths.caCert); + let caKeyPem: string | null = null; + if (caCert !== null && isUsable(caCert, 0)) { + try { + caKeyPem = await readFile(paths.caKey, 'utf-8'); + } catch { + caKeyPem = null; + } + } + + if (caCert === null || caKeyPem === null) { + const generated = await generateCa(); + await writeFileAtomic(paths.caKey, generated.keyPem, 0o600); + await writeFileAtomic(paths.caCert, generated.certPem, 0o644); + caCert = new X509Certificate(generated.certPem); + caKeyPem = generated.keyPem; + logger.warn( + '[mcp-auth-proxy] Generated a new local CA — run `codemie mcp-auth-proxy trust` to (re)install it in the OS trust store' + ); + } + + const leaf = await readCertificate(paths.serverCert); + let leafKeyPem: string | null = null; + const leafOk = + leaf !== null && + isUsable(leaf, LEAF_RENEWAL_WINDOW_DAYS * DAY_MS) && + leafMatchesProfile(leaf, caCert); + if (leafOk) { + try { + leafKeyPem = await readFile(paths.serverKey, 'utf-8'); + } catch { + leafKeyPem = null; + } + } + + if (!leafOk || leafKeyPem === null) { + const issued = await issueLeaf({ certPem: caCert.toString(), keyPem: caKeyPem }); + await writeFileAtomic(paths.serverKey, issued.keyPem, 0o600); + await writeFileAtomic(paths.serverCert, issued.certPem, 0o644); + leafKeyPem = issued.keyPem; + } + + // Return the on-disk bytes so repeated calls compare byte-identical + // (PEM formatting can differ between @peculiar/x509 output and node's + // X509Certificate.toString()). + return { + keyPem: leafKeyPem, + certPem: await readFile(paths.serverCert, 'utf-8'), + caCertPem: await readFile(paths.caCert, 'utf-8'), + caCommonName: extractCommonName(caCert.subject), + paths, + }; + } catch (error) { + if (error instanceof ConfigurationError) { + throw error; + } + throw new ConfigurationError( + `mcp-auth-proxy TLS setup failed in ${dir}: ${(error as Error).message}` + ); + } +} From eb4f844b0629b0dd4b63578996ae7c3acebeeca6 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 19:25:54 +0200 Subject: [PATCH 27/36] feat(proxy): serve mcp-auth-proxy over https when tls material is provided Generated with AI Co-Authored-By: codemie-ai --- src/mcp/auth-proxy/__tests__/server.test.ts | 60 +++++++++++++++++++++ src/mcp/auth-proxy/server.ts | 21 ++++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/mcp/auth-proxy/__tests__/server.test.ts b/src/mcp/auth-proxy/__tests__/server.test.ts index 31d9a54b..c527583b 100644 --- a/src/mcp/auth-proxy/__tests__/server.test.ts +++ b/src/mcp/auth-proxy/__tests__/server.test.ts @@ -4,9 +4,14 @@ */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import http from 'node:http'; +import https from 'node:https'; import net from 'node:net'; import type { AddressInfo } from 'node:net'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { McpAuthProxy } from '../server.js'; +import { ensureAuthProxyCerts } from '../certs.js'; import type { AuthProxyConfig, JsonObject } from '../types.js'; interface FakeUpstream { @@ -403,3 +408,58 @@ describe('McpAuthProxy single-route alias', () => { } }); }); + +describe('tls listener', () => { + let tlsDir: string; + + beforeAll(async () => { + tlsDir = await mkdtemp(join(tmpdir(), 'codemie-tls-server-')); + }); + + afterAll(async () => { + await rm(tlsDir, { recursive: true, force: true }); + }); + + it('serves https with an https:// origin when TLS material is provided', async () => { + const material = await ensureAuthProxyCerts(tlsDir); + const proxy = new McpAuthProxy( + { port: 0, tls: true, servers: { radar: { upstreamUrl: 'https://mcp.example.com/radar' } } }, + undefined, + { keyPem: material.keyPem, certPem: material.certPem } + ); + const { port, url } = await proxy.start(); + try { + expect(url).toBe(`https://127.0.0.1:${port}`); + expect(proxy.origin.startsWith('https://')).toBe(true); + + const body = await new Promise((resolve, reject) => { + const req = https.get( + { host: '127.0.0.1', port, path: '/healthz', ca: material.caCertPem }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + } + ); + req.on('error', reject); + }); + expect(JSON.parse(body).status).toBe('ok'); + } finally { + await proxy.stop(); + } + }); + + it('keeps a plain http origin without TLS material', async () => { + const proxy = new McpAuthProxy({ + port: 0, + tls: false, + servers: { radar: { upstreamUrl: 'https://mcp.example.com/radar' } }, + }); + const { url } = await proxy.start(); + try { + expect(url.startsWith('http://127.0.0.1:')).toBe(true); + } finally { + await proxy.stop(); + } + }); +}); diff --git a/src/mcp/auth-proxy/server.ts b/src/mcp/auth-proxy/server.ts index 155a112e..d7ca2697 100644 --- a/src/mcp/auth-proxy/server.ts +++ b/src/mcp/auth-proxy/server.ts @@ -10,6 +10,7 @@ * field NAMES only — never headers, bodies, or query strings on OAuth routes. */ import http from 'node:http'; +import https from 'node:https'; import type { Socket } from 'node:net'; import { pipeline } from 'node:stream/promises'; import { ConfigurationError } from '../../utils/errors.js'; @@ -34,6 +35,12 @@ export const BIND_HOST = '127.0.0.1'; const OAUTH_BODY_LIMIT_BYTES = 64 * 1024; +/** PEM key + cert for the loopback listener; presence switches the server to HTTPS. */ +export interface ServerTlsMaterial { + keyPem: string; + certPem: string; +} + /** Hop-by-hop headers (RFC 9110 §7.6.1) — never forwarded in either direction. */ const HOP_BY_HOP_HEADERS = new Set([ 'connection', @@ -107,7 +114,7 @@ function isUpstreamNetworkError(error: unknown): boolean { } export class McpAuthProxy { - private server?: http.Server; + private server?: http.Server | https.Server; private port: number; private readonly sockets = new Set(); private readonly client: UpstreamClient; @@ -115,7 +122,8 @@ export class McpAuthProxy { constructor( private readonly config: AuthProxyConfig, - private readonly onShutdownRequested?: () => void + private readonly onShutdownRequested?: () => void, + private readonly tls?: ServerTlsMaterial ) { this.port = config.port; this.client = new UpstreamClient(); @@ -123,16 +131,19 @@ export class McpAuthProxy { } get origin(): string { - return `http://${BIND_HOST}:${this.port}`; + return `${this.tls ? 'https' : 'http'}://${BIND_HOST}:${this.port}`; } async start(): Promise<{ port: number; url: string }> { if (this.server) { throw new ConfigurationError('mcp-auth-proxy server is already running'); } - const server = http.createServer((req, res) => { + const handler = (req: http.IncomingMessage, res: http.ServerResponse): void => { void this.handleRequest(req, res); - }); + }; + const server = this.tls + ? https.createServer({ key: this.tls.keyPem, cert: this.tls.certPem }, handler) + : http.createServer(handler); server.on('connection', (socket) => { this.sockets.add(socket); socket.on('close', () => this.sockets.delete(socket)); From f2d514aac2c91e5d62dcdf1b282a263c0ad86ccb Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 19:32:17 +0200 Subject: [PATCH 28/36] feat(proxy): wire tls through mcp-auth-proxy runtime, daemon flag, and state Generated with AI Co-Authored-By: codemie-ai --- src/bin/mcp-auth-proxy-daemon.ts | 2 + src/mcp/auth-proxy/__tests__/runtime.test.ts | 84 ++++++++++++++++++++ src/mcp/auth-proxy/runtime.ts | 15 +++- 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 src/mcp/auth-proxy/__tests__/runtime.test.ts diff --git a/src/bin/mcp-auth-proxy-daemon.ts b/src/bin/mcp-auth-proxy-daemon.ts index 273dc240..50c39b04 100644 --- a/src/bin/mcp-auth-proxy-daemon.ts +++ b/src/bin/mcp-auth-proxy-daemon.ts @@ -12,6 +12,7 @@ const { values } = parseArgs({ config: { type: 'string' }, port: { type: 'string' }, 'state-file': { type: 'string' }, + tls: { type: 'boolean' }, }, strict: false, }); @@ -28,6 +29,7 @@ try { configPath: values.config as string | undefined, port, stateFile: values['state-file'] as string | undefined, + tls: values.tls === true, }); } catch (error) { process.stderr.write(`[mcp-auth-proxy-daemon] Failed to start: ${(error as Error).message}\n`); diff --git a/src/mcp/auth-proxy/__tests__/runtime.test.ts b/src/mcp/auth-proxy/__tests__/runtime.test.ts new file mode 100644 index 00000000..16f9abef --- /dev/null +++ b/src/mcp/auth-proxy/__tests__/runtime.test.ts @@ -0,0 +1,84 @@ +/** + * runtime tests — real daemon runtime in-process with an isolated CODEMIE_HOME. + * @group unit + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import https from 'node:https'; + +let home: string; + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'codemie-home-')); + vi.stubEnv('CODEMIE_HOME', home); +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + await rm(home, { recursive: true, force: true }); +}); + +describe('runAuthProxyDaemon tls', () => { + it('starts an https listener and records tls in the state file', async () => { + const configPath = join(home, 'mcp-auth-proxy.json'); + const stateFile = join(home, 'mcp-auth-proxy.state.json'); + await writeFile( + configPath, + JSON.stringify({ + tls: true, + servers: { radar: { upstreamUrl: 'https://mcp.example.com/radar' } }, + }), + 'utf-8' + ); + + // Dynamic imports AFTER the env stub so getCodemiePath sees the temp home. + const { runAuthProxyDaemon } = await import('../runtime.js'); + const { ensureAuthProxyCerts } = await import('../certs.js'); + + // port: 0 via options — the config validator (by design) rejects 0 in the file. + const daemon = await runAuthProxyDaemon({ configPath, stateFile, port: 0 }); + try { + expect(daemon.url.startsWith('https://127.0.0.1:')).toBe(true); + + const state = JSON.parse(await readFile(stateFile, 'utf-8')) as { tls?: boolean }; + expect(state.tls).toBe(true); + + const material = await ensureAuthProxyCerts(); // reuses the same CODEMIE_HOME certs + const body = await new Promise((resolve, reject) => { + const req = https.get( + { host: '127.0.0.1', port: daemon.port, path: '/healthz', ca: material.caCertPem }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + } + ); + req.on('error', reject); + }); + expect(JSON.parse(body).status).toBe('ok'); + } finally { + await daemon.stop(); + } + }); + + it('stays plain http when tls is off', async () => { + const configPath = join(home, 'mcp-auth-proxy.json'); + const stateFile = join(home, 'mcp-auth-proxy.state.json'); + await writeFile( + configPath, + JSON.stringify({ servers: { radar: { upstreamUrl: 'https://mcp.example.com/radar' } } }), + 'utf-8' + ); + const { runAuthProxyDaemon } = await import('../runtime.js'); + const daemon = await runAuthProxyDaemon({ configPath, stateFile, port: 0 }); + try { + expect(daemon.url.startsWith('http://127.0.0.1:')).toBe(true); + const state = JSON.parse(await readFile(stateFile, 'utf-8')) as { tls?: boolean }; + expect(state.tls).toBe(false); + } finally { + await daemon.stop(); + } + }); +}); diff --git a/src/mcp/auth-proxy/runtime.ts b/src/mcp/auth-proxy/runtime.ts index b4b207ab..16bc310c 100644 --- a/src/mcp/auth-proxy/runtime.ts +++ b/src/mcp/auth-proxy/runtime.ts @@ -7,14 +7,18 @@ * holds no session state, so a crash only needs a manual restart. */ import { logger } from '../../utils/logger.js'; +import { ensureAuthProxyCerts } from './certs.js'; import { getDefaultStatePath, loadAuthProxyConfig } from './config.js'; import { McpAuthProxy } from './server.js'; +import type { ServerTlsMaterial } from './server.js'; import { clearAuthProxyState, writeAuthProxyState } from './state.js'; export interface RunDaemonOptions { configPath?: string; port?: number; stateFile?: string; + /** Force TLS on regardless of the config file (CLI --tls / daemon --tls). */ + tls?: boolean; } export interface RunningDaemon { @@ -32,6 +36,13 @@ export async function runAuthProxyDaemon(options: RunDaemonOptions = {}): Promis } const stateFile = options.stateFile ?? getDefaultStatePath(); + const tlsEnabled = options.tls === true || config.tls; + let tlsMaterial: ServerTlsMaterial | undefined; + if (tlsEnabled) { + const material = await ensureAuthProxyCerts(); + tlsMaterial = { keyPem: material.keyPem, certPem: material.certPem }; + } + const routes = Object.keys(config.servers); // One idempotent graceful path shared by the /shutdown endpoint and POSIX @@ -47,11 +58,11 @@ export async function runAuthProxyDaemon(options: RunDaemonOptions = {}): Promis void stop().then(() => process.exit(0)); } - const proxy = new McpAuthProxy(config, gracefulShutdown); + const proxy = new McpAuthProxy(config, gracefulShutdown, tlsMaterial); const { port, url } = await proxy.start(); await writeAuthProxyState( - { pid: process.pid, port, routes, startedAt: new Date().toISOString() }, + { pid: process.pid, port, routes, startedAt: new Date().toISOString(), tls: tlsEnabled }, stateFile ); From ed5c55069e7a9d8153e343cbf17928d9149a93f3 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 19:36:45 +0200 Subject: [PATCH 29/36] feat(proxy): add ca-pinned loopback control-plane client for mcp-auth-proxy Generated with AI Co-Authored-By: codemie-ai --- src/mcp/auth-proxy/__tests__/client.test.ts | 87 ++++++++++++++++++ src/mcp/auth-proxy/client.ts | 97 +++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 src/mcp/auth-proxy/__tests__/client.test.ts create mode 100644 src/mcp/auth-proxy/client.ts diff --git a/src/mcp/auth-proxy/__tests__/client.test.ts b/src/mcp/auth-proxy/__tests__/client.test.ts new file mode 100644 index 00000000..12528f2c --- /dev/null +++ b/src/mcp/auth-proxy/__tests__/client.test.ts @@ -0,0 +1,87 @@ +/** + * client.ts tests — control-plane requests against a real McpAuthProxy, + * plain HTTP and CA-pinned HTTPS. + * @group unit + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { McpAuthProxy } from '../server.js'; +import { ensureAuthProxyCerts } from '../certs.js'; +import type { TlsMaterial } from '../certs.js'; +import { fetchHealth, requestShutdown } from '../client.js'; + +const SERVERS = { radar: { upstreamUrl: 'https://mcp.example.com/radar' } }; + +describe('auth-proxy control-plane client', () => { + describe('plain http daemon', () => { + let proxy: McpAuthProxy; + let port: number; + + beforeAll(async () => { + proxy = new McpAuthProxy({ port: 0, tls: false, servers: SERVERS }); + ({ port } = await proxy.start()); + }); + + afterAll(async () => { + await proxy.stop(); + }); + + it('fetchHealth reads /healthz', async () => { + const health = await fetchHealth({ port, tls: false }); + expect(health.status).toBe('ok'); + expect(health.routes[0]?.id).toBe('radar'); + }); + + it('requestShutdown acks 202 and reports true', async () => { + let requested = false; + const p = new McpAuthProxy({ port: 0, tls: false, servers: SERVERS }, () => { + requested = true; + }); + const started = await p.start(); + expect(await requestShutdown({ port: started.port, tls: false })).toBe(true); + await p.stop(); + expect(requested).toBe(true); + }); + + it('requestShutdown resolves false when nothing listens', async () => { + const dead = new McpAuthProxy({ port: 0, tls: false, servers: SERVERS }); + const started = await dead.start(); + await dead.stop(); // port now free + expect(await requestShutdown({ port: started.port, tls: false })).toBe(false); + }); + }); + + describe('https daemon', () => { + let tlsDir: string; + let material: TlsMaterial; + let proxy: McpAuthProxy; + let port: number; + + beforeAll(async () => { + tlsDir = await mkdtemp(join(tmpdir(), 'codemie-tls-client-')); + material = await ensureAuthProxyCerts(tlsDir); + proxy = new McpAuthProxy( + { port: 0, tls: true, servers: SERVERS }, + undefined, + { keyPem: material.keyPem, certPem: material.certPem } + ); + ({ port } = await proxy.start()); + }); + + afterAll(async () => { + await proxy.stop(); + await rm(tlsDir, { recursive: true, force: true }); + }); + + it('fetchHealth pins the local CA over https', async () => { + const health = await fetchHealth({ port, tls: true, caPem: material.caCertPem }); + expect(health.status).toBe('ok'); + }); + + it('fetchHealth without the CA rejects (no rejectUnauthorized bypass)', async () => { + await expect(fetchHealth({ port, tls: true })).rejects.toThrow(); + }); + }); +}); diff --git a/src/mcp/auth-proxy/client.ts b/src/mcp/auth-proxy/client.ts new file mode 100644 index 00000000..6166cdcc --- /dev/null +++ b/src/mcp/auth-proxy/client.ts @@ -0,0 +1,97 @@ +/** + * MCP Auth Proxy — loopback control-plane client (CLI side). + * + * Talks to the daemon's /healthz and /shutdown endpoints over whichever + * protocol the daemon listener speaks. For TLS daemons the locally-generated + * CA is pinned explicitly (`ca:`) — never rejectUnauthorized: false. + */ +import http from 'node:http'; +import https from 'node:https'; +import type { RouteStatus } from './types.js'; + +export interface DaemonEndpoint { + port: number; + /** True when the daemon listener speaks HTTPS (from the daemon state file). */ + tls?: boolean; + /** CA certificate PEM to pin for TLS daemons. */ + caPem?: string; +} + +export interface HealthzRoute { + id: string; + upstreamUrl: string; + status: RouteStatus; +} + +export interface HealthzResponse { + status: string; + routes: HealthzRoute[]; +} + +interface LoopbackRequestOptions { + host: string; + port: number; + path: string; + method?: string; + timeout: number; + ca?: string; +} + +function transportFor(endpoint: DaemonEndpoint): typeof http | typeof https { + return endpoint.tls === true ? https : http; +} + +function baseOptions(endpoint: DaemonEndpoint, path: string): LoopbackRequestOptions { + return { + host: '127.0.0.1', + port: endpoint.port, + path, + timeout: 2000, + ...(endpoint.tls === true && endpoint.caPem !== undefined ? { ca: endpoint.caPem } : {}), + }; +} + +export function fetchHealth(endpoint: DaemonEndpoint): Promise { + return new Promise((resolveHealth, rejectHealth) => { + const request = transportFor(endpoint).get(baseOptions(endpoint, '/healthz'), (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + try { + resolveHealth(JSON.parse(Buffer.concat(chunks).toString('utf-8')) as HealthzResponse); + } catch (error) { + rejectHealth(error as Error); + } + }); + }); + request.on('error', rejectHealth); + request.on('timeout', () => request.destroy(new Error('healthz timed out'))); + }); +} + +/** + * Ask the daemon to shut itself down gracefully via the loopback control + * endpoint. Cross-platform graceful stop (Windows has no POSIX signals, so a + * signal there is a hard kill that skips the daemon's cleanup). Resolves true + * if the daemon acknowledged (2xx), false on any error/timeout — the caller + * then falls back to OS signals. + */ +export function requestShutdown(endpoint: DaemonEndpoint): Promise { + return new Promise((resolveShutdown) => { + const request = transportFor(endpoint).request( + { ...baseOptions(endpoint, '/shutdown'), method: 'POST' }, + (res) => { + res.resume(); // drain the 202 body so the socket can close + resolveShutdown( + res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300 + ); + } + ); + request.on('error', () => resolveShutdown(false)); + request.on('timeout', () => { + request.destroy(); + resolveShutdown(false); + }); + request.end(); + }); +} From ee2c24a43b7799aa5c653e92ecfe68beea06d717 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 19:41:26 +0200 Subject: [PATCH 30/36] feat(proxy): add os trust-store command builders and runner for the local ca Generated with AI Co-Authored-By: codemie-ai --- src/mcp/auth-proxy/__tests__/trust.test.ts | 107 +++++++++++++++++++++ src/mcp/auth-proxy/trust.ts | 100 +++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 src/mcp/auth-proxy/__tests__/trust.test.ts create mode 100644 src/mcp/auth-proxy/trust.ts diff --git a/src/mcp/auth-proxy/__tests__/trust.test.ts b/src/mcp/auth-proxy/__tests__/trust.test.ts new file mode 100644 index 00000000..419ca060 --- /dev/null +++ b/src/mcp/auth-proxy/__tests__/trust.test.ts @@ -0,0 +1,107 @@ +/** + * trust.ts tests — pure command builders + injected-exec action runner. + * No real trust store is ever touched. + * @group unit + */ +import { describe, it, expect, vi } from 'vitest'; +import { applyTrust, buildTrustCommands, getManualTrustInstructions } from '../trust.js'; + +const CA_PATH = '/home/u/.codemie/mcp-auth-proxy-tls/ca.crt'; +const CN = 'CodeMie mcp-auth-proxy CA u@host 2026-07-05'; + +describe('buildTrustCommands', () => { + it('win32 install/uninstall target the user Root store', () => { + expect(buildTrustCommands('win32', CA_PATH, CN, 'install')).toEqual([ + { command: 'certutil', args: ['-addstore', '-user', 'Root', CA_PATH] }, + ]); + expect(buildTrustCommands('win32', CA_PATH, CN, 'uninstall')).toEqual([ + { command: 'certutil', args: ['-delstore', '-user', 'Root', CN] }, + ]); + }); + + it('darwin install/uninstall use the login keychain', () => { + const install = buildTrustCommands('darwin', CA_PATH, CN, 'install'); + expect(install?.[0]?.command).toBe('security'); + expect(install?.[0]?.args).toContain('add-trusted-cert'); + expect(install?.[0]?.args).toContain(CA_PATH); + + const uninstall = buildTrustCommands('darwin', CA_PATH, CN, 'uninstall'); + expect(uninstall?.[0]?.args).toContain('delete-certificate'); + expect(uninstall?.[0]?.args).toContain(CN); + }); + + it('linux targets the NSS user DB (what Chromium/Electron read)', () => { + const install = buildTrustCommands('linux', CA_PATH, CN, 'install'); + expect(install?.[0]?.command).toBe('certutil'); + expect(install?.[0]?.args.join(' ')).toContain('.pki/nssdb'); + expect(install?.[0]?.args).toContain(CN); + + const uninstall = buildTrustCommands('linux', CA_PATH, CN, 'uninstall'); + expect(uninstall?.[0]?.args).toContain('-D'); + expect(uninstall?.[0]?.args).toContain(CN); + }); + + it('returns null for unsupported platforms', () => { + expect(buildTrustCommands('freebsd', CA_PATH, CN, 'install')).toBeNull(); + }); +}); + +describe('applyTrust', () => { + it('runs the built commands and reports success', async () => { + const exec = vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: '' }); + const result = await applyTrust('install', { + platform: 'win32', + exec, + caPath: CA_PATH, + caCommonName: CN, + }); + expect(result.ok).toBe(true); + expect(exec).toHaveBeenCalledWith('certutil', ['-addstore', '-user', 'Root', CA_PATH]); + }); + + it('fails with manual instructions when the tool exits non-zero', async () => { + const exec = vi.fn().mockResolvedValue({ code: 1, stdout: '', stderr: 'denied' }); + const result = await applyTrust('install', { + platform: 'linux', + exec, + caPath: CA_PATH, + caCommonName: CN, + }); + expect(result.ok).toBe(false); + expect(result.manual).toContain(CA_PATH); + }); + + it('fails with manual instructions when the tool is missing (exec throws)', async () => { + const exec = vi.fn().mockRejectedValue(new Error('spawn certutil ENOENT')); + const result = await applyTrust('install', { + platform: 'linux', + exec, + caPath: CA_PATH, + caCommonName: CN, + }); + expect(result.ok).toBe(false); + expect(result.manual).toContain(CA_PATH); + }); + + it('returns manual instructions directly on unsupported platforms', async () => { + const exec = vi.fn(); + const result = await applyTrust('install', { + platform: 'freebsd', + exec, + caPath: CA_PATH, + caCommonName: CN, + }); + expect(result.ok).toBe(false); + expect(exec).not.toHaveBeenCalled(); + }); +}); + +describe('getManualTrustInstructions', () => { + it('mentions the CA path and all three platforms', () => { + const text = getManualTrustInstructions(CA_PATH); + expect(text).toContain(CA_PATH); + expect(text).toMatch(/certutil/); + expect(text).toMatch(/security add-trusted-cert/); + expect(text).toMatch(/nssdb/); + }); +}); diff --git a/src/mcp/auth-proxy/trust.ts b/src/mcp/auth-proxy/trust.ts new file mode 100644 index 00000000..63738762 --- /dev/null +++ b/src/mcp/auth-proxy/trust.ts @@ -0,0 +1,100 @@ +/** + * MCP Auth Proxy — OS trust-store integration for the locally-generated CA. + * + * Only the explicit `codemie mcp-auth-proxy trust` command calls this — + * `start` never modifies the trust store (requirements decision, confirmed at + * a security escalation gate). Uninstall matches the CA's exact subject CN so + * only the CodeMie CA is ever removed. + */ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export type TrustAction = 'install' | 'uninstall'; + +export interface TrustCommand { + command: string; + args: string[]; +} + +export interface TrustDeps { + platform: NodeJS.Platform; + /** exec() from src/utils/processes.ts (injected for testability). */ + exec: ( + command: string, + args: string[] + ) => Promise<{ code: number; stdout: string; stderr: string }>; + caPath: string; + caCommonName: string; +} + +export interface TrustResult { + ok: boolean; + /** Manual per-OS instructions when the automated path is unavailable/failed. */ + manual?: string; +} + +export function buildTrustCommands( + platform: NodeJS.Platform, + caPath: string, + caCommonName: string, + action: TrustAction +): TrustCommand[] | null { + switch (platform) { + case 'win32': + return action === 'install' + ? [{ command: 'certutil', args: ['-addstore', '-user', 'Root', caPath] }] + : [{ command: 'certutil', args: ['-delstore', '-user', 'Root', caCommonName] }]; + case 'darwin': { + const loginKeychain = join(homedir(), 'Library', 'Keychains', 'login.keychain-db'); + return action === 'install' + ? [{ command: 'security', args: ['add-trusted-cert', '-k', loginKeychain, caPath] }] + : [{ command: 'security', args: ['delete-certificate', '-c', caCommonName, loginKeychain] }]; + } + case 'linux': { + const nssDb = `sql:${join(homedir(), '.pki', 'nssdb')}`; + return action === 'install' + ? [ + { + command: 'certutil', + args: ['-d', nssDb, '-A', '-t', 'C,,', '-n', caCommonName, '-i', caPath], + }, + ] + : [{ command: 'certutil', args: ['-d', nssDb, '-D', '-n', caCommonName] }]; + } + default: + return null; + } +} + +export function getManualTrustInstructions(caPath: string): string { + return [ + `Install the CA manually — certificate file: ${caPath}`, + ` Windows : certutil -addstore -user Root "${caPath}"`, + ` macOS : security add-trusted-cert -k ~/Library/Keychains/login.keychain-db "${caPath}"`, + ` Linux : certutil -d sql:$HOME/.pki/nssdb -A -t C,, -n "CodeMie mcp-auth-proxy CA" -i "${caPath}"`, + ' (requires libnss3-tools; Chromium/Electron read the NSS user DB)', + ].join('\n'); +} + +/** + * Runs the trust-store commands for the platform. Never throws: an unusable + * tool or unsupported platform yields { ok: false, manual } so the CLI can + * print instructions and exit non-zero. + */ +export async function applyTrust(action: TrustAction, deps: TrustDeps): Promise { + const commands = buildTrustCommands(deps.platform, deps.caPath, deps.caCommonName, action); + if (commands === null) { + return { ok: false, manual: getManualTrustInstructions(deps.caPath) }; + } + for (const { command, args } of commands) { + try { + const result = await deps.exec(command, args); + if (result.code !== 0) { + return { ok: false, manual: getManualTrustInstructions(deps.caPath) }; + } + } catch { + return { ok: false, manual: getManualTrustInstructions(deps.caPath) }; + } + } + return { ok: true }; +} From 1acf32e7dc7b46cf25a81d0f537804b3d0712a47 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 19:49:11 +0200 Subject: [PATCH 31/36] feat(cli): add --tls start flag, trust subcommand, and https-aware status/stop Generated with AI Co-Authored-By: codemie-ai --- src/cli/commands/mcp-auth-proxy.ts | 179 ++++++++++++++++++----------- 1 file changed, 113 insertions(+), 66 deletions(-) diff --git a/src/cli/commands/mcp-auth-proxy.ts b/src/cli/commands/mcp-auth-proxy.ts index e9b4ad80..93e7b45b 100644 --- a/src/cli/commands/mcp-auth-proxy.ts +++ b/src/cli/commands/mcp-auth-proxy.ts @@ -7,7 +7,8 @@ */ import { Command } from 'commander'; import chalk from 'chalk'; -import http from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { platform } from 'node:os'; import { join, resolve } from 'node:path'; import { ConfigurationError, @@ -17,7 +18,7 @@ import { } from '../../utils/errors.js'; import { getDirname } from '../../utils/paths.js'; import { logger } from '../../utils/logger.js'; -import { spawnDetached } from '../../utils/processes.js'; +import { exec, spawnDetached } from '../../utils/processes.js'; import { getDefaultConfigPath, getDefaultStatePath, @@ -29,7 +30,11 @@ import { isProcessAlive, readAuthProxyState, } from '../../mcp/auth-proxy/state.js'; -import type { RouteStatus } from '../../mcp/auth-proxy/types.js'; +import { fetchHealth, requestShutdown } from '../../mcp/auth-proxy/client.js'; +import type { DaemonEndpoint } from '../../mcp/auth-proxy/client.js'; +import { ensureAuthProxyCerts, getAuthProxyTlsPaths } from '../../mcp/auth-proxy/certs.js'; +import { applyTrust } from '../../mcp/auth-proxy/trust.js'; +import type { AuthProxyDaemonState } from '../../mcp/auth-proxy/types.js'; function parsePortOption(value: string | undefined): number | undefined { if (!value) { @@ -52,65 +57,46 @@ function printError(error: unknown, label: string): never { process.exit(1); } -function printAddCommands(port: number, routes: string[]): void { +function printAddCommands(port: number, routes: string[], tls: boolean): void { + const protocol = tls ? 'https' : 'http'; console.log(chalk.bold('\nAdd to Claude Code:')); for (const id of routes) { - console.log(` claude mcp add --scope local --transport http ${id} http://127.0.0.1:${port}/${id}`); + console.log( + ` claude mcp add --scope local --transport http ${id} ${protocol}://127.0.0.1:${port}/${id}` + ); } } -interface HealthzRoute { - id: string; - upstreamUrl: string; - status: RouteStatus; -} - -function fetchHealth(port: number): Promise<{ status: string; routes: HealthzRoute[] }> { - return new Promise((resolveHealth, rejectHealth) => { - const request = http.get( - { host: '127.0.0.1', port, path: '/healthz', timeout: 2000 }, - (res) => { - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => { - try { - resolveHealth(JSON.parse(Buffer.concat(chunks).toString('utf-8'))); - } catch (error) { - rejectHealth(error as Error); - } - }); - } - ); - request.on('error', rejectHealth); - request.on('timeout', () => request.destroy(new Error('healthz timed out'))); - }); +function printTlsHints(): void { + console.log( + chalk.yellow( + '\n⚠ TLS is enabled: routes previously registered with http:// URLs must be re-registered with the https:// URLs above.' + ) + ); + console.log( + chalk.gray( + 'If the browser or Claude Desktop rejects the certificate, run: codemie mcp-auth-proxy trust\n' + + 'Node-based clients (e.g. Claude Code CLI) do not read the OS trust store — set NODE_EXTRA_CA_CERTS=' + + getAuthProxyTlsPaths().caCert + ) + ); } -/** - * Ask the daemon to shut itself down gracefully via the loopback control - * endpoint. Cross-platform graceful stop (Windows has no POSIX signals, so a - * signal there is a hard kill that skips the daemon's cleanup). Resolves true - * if the daemon acknowledged (2xx), false on any error/timeout — the caller - * then falls back to OS signals. - */ -function requestShutdown(port: number): Promise { - return new Promise((resolveShutdown) => { - const request = http.request( - { host: '127.0.0.1', port, path: '/shutdown', method: 'POST', timeout: 2000 }, - (res) => { - res.resume(); // drain the 202 body so the socket can close - resolveShutdown( - res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300 - ); - } - ); - request.on('error', () => resolveShutdown(false)); - request.on('timeout', () => { - request.destroy(); - resolveShutdown(false); - }); - request.end(); - }); +/** Builds the control-plane endpoint from daemon state, loading the CA for TLS daemons. */ +async function daemonEndpoint(state: AuthProxyDaemonState): Promise { + if (state.tls !== true) { + return { port: state.port, tls: false }; + } + try { + return { + port: state.port, + tls: true, + caPem: await readFile(getAuthProxyTlsPaths().caCert, 'utf-8'), + }; + } catch { + // CA file missing — request will fail TLS verification and callers fall back. + return { port: state.port, tls: true }; + } } export function createMcpAuthProxyCommand(): Command { @@ -125,7 +111,8 @@ export function createMcpAuthProxyCommand(): Command { .option('--config ', 'Config file path (default: /mcp-auth-proxy.json)') .option('--port ', 'Override the configured listen port') .option('--foreground', 'Run in the foreground (debugging; CODEMIE_DEBUG=true for verbose logs)') - .action(async (opts: { config?: string; port?: string; foreground?: boolean }) => { + .option('--tls', 'Serve HTTPS with the locally-generated CodeMie CA (see the trust subcommand)') + .action(async (opts: { config?: string; port?: string; foreground?: boolean; tls?: boolean }) => { try { const existing = await readAuthProxyState(); if (existing && isProcessAlive(existing.pid)) { @@ -134,7 +121,7 @@ export function createMcpAuthProxyCommand(): Command { `✓ mcp-auth-proxy already running on http://127.0.0.1:${existing.port} (pid ${existing.pid})` ) ); - printAddCommands(existing.port, existing.routes); + printAddCommands(existing.port, existing.routes, existing.tls === true); return; } await clearAuthProxyState(); @@ -143,11 +130,22 @@ export function createMcpAuthProxyCommand(): Command { const config = await loadAuthProxyConfig(configPath); // fail fast with the offending key path const port = parsePortOption(opts.port) ?? config.port; const routes = Object.keys(config.servers); + const tls = opts.tls === true || config.tls; + if (tls) { + await ensureAuthProxyCerts(); // fail fast on cert problems before spawning + } if (opts.foreground) { - await runAuthProxyDaemon({ configPath, port }); - console.log(chalk.green(`✓ mcp-auth-proxy running (foreground) on http://127.0.0.1:${port}`)); - printAddCommands(port, routes); + await runAuthProxyDaemon({ configPath, port, tls: opts.tls === true }); + console.log( + chalk.green( + `✓ mcp-auth-proxy running (foreground) on ${tls ? 'https' : 'http'}://127.0.0.1:${port}` + ) + ); + printAddCommands(port, routes, tls); + if (tls) { + printTlsHints(); + } console.log(chalk.gray('Press Ctrl+C to stop.')); return; } @@ -159,6 +157,7 @@ export function createMcpAuthProxyCommand(): Command { '--config', configPath, '--port', String(port), '--state-file', getDefaultStatePath(), + ...(opts.tls === true ? ['--tls'] : []), ]); for (let i = 0; i < 50; i++) { @@ -166,9 +165,14 @@ export function createMcpAuthProxyCommand(): Command { const state = await readAuthProxyState(); if (state && isProcessAlive(state.pid)) { console.log( - chalk.green(`✓ mcp-auth-proxy started on http://127.0.0.1:${state.port} (pid ${state.pid})`) + chalk.green( + `✓ mcp-auth-proxy started on ${state.tls === true ? 'https' : 'http'}://127.0.0.1:${state.port} (pid ${state.pid})` + ) ); - printAddCommands(state.port, state.routes); + printAddCommands(state.port, state.routes, state.tls === true); + if (state.tls === true) { + printTlsHints(); + } return; } } @@ -193,19 +197,20 @@ export function createMcpAuthProxyCommand(): Command { console.log(chalk.yellow('mcp-auth-proxy is not running')); return; } + const protocol = state.tls === true ? 'https' : 'http'; console.log( chalk.green( - `✓ mcp-auth-proxy running on http://127.0.0.1:${state.port} (pid ${state.pid}, started ${state.startedAt})` + `✓ mcp-auth-proxy running on ${protocol}://127.0.0.1:${state.port} (pid ${state.pid}, started ${state.startedAt})` ) ); try { - const health = await fetchHealth(state.port); + const health = await fetchHealth(await daemonEndpoint(state)); for (const route of health.routes) { const marker = route.status === 'degraded' ? chalk.red('✗ degraded') : chalk.green(`✓ ${route.status}`); console.log(` ${route.id}: ${marker} → ${route.upstreamUrl}`); console.log( - ` claude mcp add --scope local --transport http ${route.id} http://127.0.0.1:${state.port}/${route.id}` + ` claude mcp add --scope local --transport http ${route.id} ${protocol}://127.0.0.1:${state.port}/${route.id}` ); } } catch { @@ -227,7 +232,7 @@ export function createMcpAuthProxyCommand(): Command { // own proxy.stop() + exit via the loopback control endpoint. Only wait for // a self-exit when it acked — a wedged/unreachable daemon returns false // immediately, so skip straight to the signal fallback instead of polling. - const acked = await requestShutdown(state.port); + const acked = await requestShutdown(await daemonEndpoint(state)); if (acked) { for (let i = 0; i < 50; i++) { await new Promise((r) => setTimeout(r, 100)); @@ -265,5 +270,47 @@ export function createMcpAuthProxyCommand(): Command { console.log(chalk.green('✓ mcp-auth-proxy stopped')); }); + command + .command('trust') + .description('Install (or remove) the locally-generated CA in the OS user trust store') + .option('--uninstall', 'Remove the CodeMie CA from the trust store instead of installing it') + .action(async (opts: { uninstall?: boolean }) => { + try { + const material = await ensureAuthProxyCerts(); + const action = opts.uninstall === true ? 'uninstall' : 'install'; + const result = await applyTrust(action, { + platform: platform(), + exec: (cmd, args) => exec(cmd, args), + caPath: material.paths.caCert, + caCommonName: material.caCommonName, + }); + + const { X509Certificate } = await import('node:crypto'); + const ca = new X509Certificate(material.caCertPem); + console.log(`CA certificate : ${material.paths.caCert}`); + console.log(`Subject CN : ${material.caCommonName}`); + console.log(`SHA-256 : ${ca.fingerprint256}`); + console.log(`Valid until : ${ca.validTo}`); + + if (result.ok) { + console.log( + chalk.green( + action === 'install' + ? '✓ CA installed in the user trust store' + : '✓ CA removed from the user trust store' + ) + ); + } else { + console.log(chalk.yellow(`Automated ${action} was not possible on this system.`)); + if (result.manual !== undefined) { + console.log(result.manual); + } + process.exitCode = 1; + } + } catch (error) { + printError(error, '[mcp-auth-proxy] trust failed'); + } + }); + return command; } From 6cc642dc75c2457fd3ab9eff3c0b944ffde114bf Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 19:55:45 +0200 Subject: [PATCH 32/36] docs: document mcp-auth-proxy --tls and trust subcommand Generated with AI Co-Authored-By: codemie-ai --- docs/COMMANDS.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index a7dbc77c..956f6389 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -22,7 +22,7 @@ codemie self-update # Update CodeMie CLI itself codemie doctor [options] # Health check and diagnostics codemie plugin # Manage native plugins codemie mcp-proxy # Stdio-to-HTTP MCP proxy with OAuth support -codemie mcp-auth-proxy # OAuth-rewriting proxy daemon for remote MCP servers (client_name/scope/resource overrides; config: ~/.codemie/mcp-auth-proxy.json) +codemie mcp-auth-proxy # OAuth-rewriting proxy daemon for remote MCP servers (client_name/scope/resource overrides; config: ~/.codemie/mcp-auth-proxy.json) codemie codebase # Manage Codebase Memory graph UI codemie version # Show version information ``` @@ -139,6 +139,42 @@ CodeMie cannot forcibly log you out from Claude Desktop. It can only write the C --version # Output the version number ``` +## MCP Auth Proxy Commands + +```bash +codemie mcp-auth-proxy start # Start the proxy daemon (detached by default) +codemie mcp-auth-proxy start --tls # Serve HTTPS with the locally-generated CodeMie CA +codemie mcp-auth-proxy status # Show daemon status and per-route health +codemie mcp-auth-proxy stop # Stop the daemon and remove its state file +codemie mcp-auth-proxy trust # Install the CodeMie CA in the OS user trust store +codemie mcp-auth-proxy trust --uninstall # Remove the CodeMie CA from the trust store +``` + +`start` options: + +- `--config ` — config file path (default: `~/.codemie/mcp-auth-proxy.json`) +- `--port ` — override the configured listen port +- `--foreground` — run in the foreground (debugging; `CODEMIE_DEBUG=true` for verbose logs) +- `--tls` — serve the loopback listener over HTTPS using a locally-generated CA + leaf certificate (ECDSA P-256; CA stored under `~/.codemie/mcp-auth-proxy-tls/`). Config-file equivalent: `"tls": true` at the root of `mcp-auth-proxy.json`. Default is plain HTTP. + +### `codemie mcp-auth-proxy trust` + +Installs the locally-generated CA into the OS **user** trust store so browsers and Claude Desktop accept the proxy's HTTPS certificate: + +- Windows: `certutil -addstore -user Root` +- macOS: login keychain via `security add-trusted-cert` +- Linux: NSS user DB (`~/.pki/nssdb`, requires the libnss3-tools `certutil`; this is what Chromium/Electron read) + +Prints the CA path, subject CN, SHA-256 fingerprint, and validity. On unsupported systems or missing tools it prints manual instructions and exits non-zero. `--uninstall` removes only the CodeMie CA (matched by its exact subject CN). + +### HTTPS / Claude Desktop + +Claude Desktop refuses to open non-`https` OAuth authorize URLs. With TLS enabled the proxy advertises its OAuth endpoints as `https://127.0.0.1:/...`, so the Desktop authorize flow works once the CA is trusted (`codemie mcp-auth-proxy trust`). + +- With TLS enabled, the printed `claude mcp add` hints use `https://` URLs, and `start` warns that routes previously registered with `http://` URLs must be re-registered. +- `status` / `stop` are protocol-aware: they read the daemon state's `tls` flag and pin the local CA for control-plane requests (no certificate-check bypass). +- Node-based clients (e.g. Claude Code CLI) do not read the OS trust store — for TLS routes set `NODE_EXTRA_CA_CERTS=~/.codemie/mcp-auth-proxy-tls/ca.crt`, or keep the default plain-HTTP mode. + ## Agent Shortcuts Direct access to agents with automatic configuration. From 7bc2cfe22acc3eb73acd81d24a08da0de114acba Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 22:36:55 +0200 Subject: [PATCH 33/36] fix(proxy): address https review findings CR-001..CR-008 CR-001 protocol-aware already-running banner + tls mismatch warning CR-002 uninstall reads existing CA only, never regenerates material CR-003 trust exec timeout + stderr surfaced on failure CR-004 tests: EKU serverAuth assertion + expiry-window reissue CR-005 leaf validity clamped to CA expiry CR-006 corrupt/mismatched key material detected and self-healed CR-007 linux NSS DB bootstrap + system-store manual fallback CR-008 idempotent uninstall-when-absent + action-aware manual text Generated with AI Co-Authored-By: codemie-ai --- src/cli/commands/mcp-auth-proxy.ts | 68 +++++++--- src/mcp/auth-proxy/__tests__/certs.test.ts | 147 ++++++++++++++++++++- src/mcp/auth-proxy/__tests__/trust.test.ts | 131 ++++++++++++++---- src/mcp/auth-proxy/certs.ts | 125 ++++++++++++------ src/mcp/auth-proxy/trust.ts | 89 +++++++++++-- 5 files changed, 464 insertions(+), 96 deletions(-) diff --git a/src/cli/commands/mcp-auth-proxy.ts b/src/cli/commands/mcp-auth-proxy.ts index 93e7b45b..58a63e41 100644 --- a/src/cli/commands/mcp-auth-proxy.ts +++ b/src/cli/commands/mcp-auth-proxy.ts @@ -32,7 +32,11 @@ import { } from '../../mcp/auth-proxy/state.js'; import { fetchHealth, requestShutdown } from '../../mcp/auth-proxy/client.js'; import type { DaemonEndpoint } from '../../mcp/auth-proxy/client.js'; -import { ensureAuthProxyCerts, getAuthProxyTlsPaths } from '../../mcp/auth-proxy/certs.js'; +import { + ensureAuthProxyCerts, + getAuthProxyTlsPaths, + readExistingCaInfo, +} from '../../mcp/auth-proxy/certs.js'; import { applyTrust } from '../../mcp/auth-proxy/trust.js'; import type { AuthProxyDaemonState } from '../../mcp/auth-proxy/types.js'; @@ -116,12 +120,20 @@ export function createMcpAuthProxyCommand(): Command { try { const existing = await readAuthProxyState(); if (existing && isProcessAlive(existing.pid)) { + const existingTls = existing.tls === true; console.log( chalk.green( - `✓ mcp-auth-proxy already running on http://127.0.0.1:${existing.port} (pid ${existing.pid})` + `✓ mcp-auth-proxy already running on ${existingTls ? 'https' : 'http'}://127.0.0.1:${existing.port} (pid ${existing.pid})` ) ); - printAddCommands(existing.port, existing.routes, existing.tls === true); + if (opts.tls === true && !existingTls) { + console.log( + chalk.yellow( + '⚠ --tls has no effect: the daemon is already running WITHOUT TLS. Restart it to enable TLS: codemie mcp-auth-proxy stop && codemie mcp-auth-proxy start --tls' + ) + ); + } + printAddCommands(existing.port, existing.routes, existingTls); return; } await clearAuthProxyState(); @@ -276,32 +288,52 @@ export function createMcpAuthProxyCommand(): Command { .option('--uninstall', 'Remove the CodeMie CA from the trust store instead of installing it') .action(async (opts: { uninstall?: boolean }) => { try { - const material = await ensureAuthProxyCerts(); const action = opts.uninstall === true ? 'uninstall' : 'install'; + if (action === 'install') { + await ensureAuthProxyCerts(); + } + // Uninstall must never (re)generate material: a fresh CA has a new + // dated CN, so uninstalling by it would leave the old CA trusted. + const ca = await readExistingCaInfo(); + if (ca === null) { + // Only reachable for uninstall — install just ensured the material. + console.log( + chalk.yellow( + `No local CA found at ${getAuthProxyTlsPaths().caCert} — nothing to uninstall` + ) + ); + return; + } + const result = await applyTrust(action, { platform: platform(), - exec: (cmd, args) => exec(cmd, args), - caPath: material.paths.caCert, - caCommonName: material.caCommonName, + exec: (cmd, args) => exec(cmd, args, { timeout: 30_000 }), + caPath: ca.caPath, + caCommonName: ca.caCommonName, }); - const { X509Certificate } = await import('node:crypto'); - const ca = new X509Certificate(material.caCertPem); - console.log(`CA certificate : ${material.paths.caCert}`); - console.log(`Subject CN : ${material.caCommonName}`); + console.log(`CA certificate : ${ca.caPath}`); + console.log(`Subject CN : ${ca.caCommonName}`); console.log(`SHA-256 : ${ca.fingerprint256}`); console.log(`Valid until : ${ca.validTo}`); if (result.ok) { - console.log( - chalk.green( - action === 'install' - ? '✓ CA installed in the user trust store' - : '✓ CA removed from the user trust store' - ) - ); + if (result.notFound === true) { + console.log(chalk.gray('CA was not present in the trust store (already removed)')); + } else { + console.log( + chalk.green( + action === 'install' + ? '✓ CA installed in the user trust store' + : '✓ CA removed from the user trust store' + ) + ); + } } else { console.log(chalk.yellow(`Automated ${action} was not possible on this system.`)); + if (result.detail !== undefined) { + console.log(chalk.gray(result.detail)); + } if (result.manual !== undefined) { console.log(result.manual); } diff --git a/src/mcp/auth-proxy/__tests__/certs.test.ts b/src/mcp/auth-proxy/__tests__/certs.test.ts index 091734d1..f604325c 100644 --- a/src/mcp/auth-proxy/__tests__/certs.test.ts +++ b/src/mcp/auth-proxy/__tests__/certs.test.ts @@ -4,11 +4,15 @@ * @group unit */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, stat, readFile } from 'node:fs/promises'; +import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { X509Certificate } from 'node:crypto'; -import { ensureAuthProxyCerts, getAuthProxyTlsPaths } from '../certs.js'; +import { webcrypto, X509Certificate } from 'node:crypto'; +import * as x509 from '@peculiar/x509'; +import { ensureAuthProxyCerts, getAuthProxyTlsPaths, readExistingCaInfo } from '../certs.js'; + +const ALG = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const; +const DAY_MS = 86_400_000; let dir: string; @@ -20,6 +24,17 @@ afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); +async function exportPkcs8Pem(key: webcrypto.CryptoKey): Promise { + const der = await webcrypto.subtle.exportKey('pkcs8', key); + return x509.PemConverter.encode(der, 'PRIVATE KEY'); +} + +async function importCaSigningKey(pem: string): Promise { + return webcrypto.subtle.importKey('pkcs8', x509.PemConverter.decode(pem)[0], ALG, false, [ + 'sign', + ]); +} + describe('ensureAuthProxyCerts', () => { it('generates a CA and a leaf with the required profile', async () => { const material = await ensureAuthProxyCerts(dir); @@ -37,6 +52,12 @@ describe('ensureAuthProxyCerts', () => { expect(leaf.checkIssued(ca)).toBe(true); expect(leaf.keyUsage).toBeDefined(); + // EKU must include serverAuth — Chromium rejects locally-trusted leaves without it. + const eku = new x509.X509Certificate(material.certPem).getExtension( + x509.ExtendedKeyUsageExtension + ); + expect(eku?.usages).toContain(x509.ExtendedKeyUsage.serverAuth); + // Leaf validity <= 825 days (Apple/Chromium local-trust cap) const days = (new Date(leaf.validTo).getTime() - new Date(leaf.validFrom).getTime()) / 86_400_000; @@ -64,7 +85,6 @@ describe('ensureAuthProxyCerts', () => { it('reissues the leaf (same CA) when the leaf file is corrupt', async () => { const first = await ensureAuthProxyCerts(dir); const paths = getAuthProxyTlsPaths(dir); - const { writeFile } = await import('node:fs/promises'); await writeFile(paths.serverCert, 'not a certificate', 'utf-8'); const second = await ensureAuthProxyCerts(dir); @@ -74,6 +94,70 @@ describe('ensureAuthProxyCerts', () => { expect(leaf.checkIssued(new X509Certificate(second.caCertPem))).toBe(true); }); + it('reissues the leaf when it is inside the 30-day renewal window', async () => { + const first = await ensureAuthProxyCerts(dir); + const paths = getAuthProxyTlsPaths(dir); + + // Replace the leaf with a near-expiry one issued by the same on-disk CA. + const caCert = new x509.X509Certificate(await readFile(paths.caCert, 'utf-8')); + const caKey = await importCaSigningKey(await readFile(paths.caKey, 'utf-8')); + const keys = await webcrypto.subtle.generateKey(ALG, true, ['sign', 'verify']); + const now = Date.now(); + const nearExpiry = await x509.X509CertificateGenerator.create({ + subject: 'CN=127.0.0.1, O=CodeMie', + issuer: caCert.subject, + notBefore: new Date(now - 60_000), + notAfter: new Date(now + 10 * DAY_MS), // < LEAF_RENEWAL_WINDOW_DAYS + signingAlgorithm: ALG, + publicKey: keys.publicKey, + signingKey: caKey, + extensions: [ + new x509.SubjectAlternativeNameExtension([ + { type: 'ip', value: '127.0.0.1' }, + { type: 'dns', value: 'localhost' }, + ]), + ], + }); + await writeFile(paths.serverCert, nearExpiry.toString('pem'), 'utf-8'); + await writeFile(paths.serverKey, await exportPkcs8Pem(keys.privateKey), 'utf-8'); + + const second = await ensureAuthProxyCerts(dir); + expect(second.caCertPem).toBe(first.caCertPem); + expect(second.certPem).not.toBe(nearExpiry.toString('pem')); + const leaf = new X509Certificate(second.certPem); + const daysLeft = (new Date(leaf.validTo).getTime() - Date.now()) / DAY_MS; + expect(daysLeft).toBeGreaterThan(30); + expect(daysLeft).toBeLessThanOrEqual(825); + }); + + it('clamps the leaf validity to the CA expiry (leaf never outlives its issuer)', async () => { + // Plant a CA expiring sooner than the 825-day leaf default. + const keys = await webcrypto.subtle.generateKey(ALG, true, ['sign', 'verify']); + const now = Date.now(); + const caNotAfter = new Date(now + 100 * DAY_MS); + const shortCa = await x509.X509CertificateGenerator.createSelfSigned({ + name: 'CN=CodeMie mcp-auth-proxy CA short-lived, O=CodeMie', + notBefore: new Date(now - 60_000), + notAfter: caNotAfter, + signingAlgorithm: ALG, + keys, + extensions: [ + new x509.BasicConstraintsExtension(true, 0, true), + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, + true + ), + ], + }); + const paths = getAuthProxyTlsPaths(dir); + await writeFile(paths.caCert, shortCa.toString('pem'), 'utf-8'); + await writeFile(paths.caKey, await exportPkcs8Pem(keys.privateKey), 'utf-8'); + + const material = await ensureAuthProxyCerts(dir); + const leaf = new X509Certificate(material.certPem); + expect(new Date(leaf.validTo).getTime()).toBeLessThanOrEqual(caNotAfter.getTime()); + }); + it('regenerates everything when the CA is missing', async () => { const first = await ensureAuthProxyCerts(dir); const paths = getAuthProxyTlsPaths(dir); @@ -84,6 +168,43 @@ describe('ensureAuthProxyCerts', () => { expect(second.certPem).not.toBe(first.certPem); }); + it('regenerates the CA when ca.key is corrupt (self-healing contract)', async () => { + const first = await ensureAuthProxyCerts(dir); + const paths = getAuthProxyTlsPaths(dir); + await writeFile(paths.caKey, 'not a private key', 'utf-8'); + + const second = await ensureAuthProxyCerts(dir); + expect(second.caCertPem).not.toBe(first.caCertPem); + const leaf = new X509Certificate(second.certPem); + expect(leaf.verify(new X509Certificate(second.caCertPem).publicKey)).toBe(true); + }); + + it('reissues the leaf (same CA) when server.key is corrupt', async () => { + const first = await ensureAuthProxyCerts(dir); + const paths = getAuthProxyTlsPaths(dir); + await writeFile(paths.serverKey, 'not a private key', 'utf-8'); + + const second = await ensureAuthProxyCerts(dir); + expect(second.caCertPem).toBe(first.caCertPem); + expect(second.certPem).not.toBe(first.certPem); + expect(second.keyPem).toContain('BEGIN PRIVATE KEY'); + }); + + it('self-heals a mismatched ca.crt/ca.key pair when issuing a leaf', async () => { + const first = await ensureAuthProxyCerts(dir); + const paths = getAuthProxyTlsPaths(dir); + // Simulate a crash between the ca.key and ca.crt writes: the key parses + // fine but does not match the certificate. + const strangerKeys = await webcrypto.subtle.generateKey(ALG, true, ['sign', 'verify']); + await writeFile(paths.caKey, await exportPkcs8Pem(strangerKeys.privateKey), 'utf-8'); + await rm(paths.serverCert); // force a leaf issuance + + const second = await ensureAuthProxyCerts(dir); + expect(second.caCertPem).not.toBe(first.caCertPem); + const leaf = new X509Certificate(second.certPem); + expect(leaf.verify(new X509Certificate(second.caCertPem).publicKey)).toBe(true); + }); + it('exposes the on-disk PEM contents matching the returned material', async () => { const material = await ensureAuthProxyCerts(dir); const paths = getAuthProxyTlsPaths(dir); @@ -91,3 +212,21 @@ describe('ensureAuthProxyCerts', () => { expect(await readFile(paths.serverCert, 'utf-8')).toBe(material.certPem); }); }); + +describe('readExistingCaInfo', () => { + it('returns null when no CA exists on disk and creates nothing', async () => { + expect(await readExistingCaInfo(dir)).toBeNull(); + await expect(readdir(dir)).resolves.toEqual([]); + }); + + it('describes the on-disk CA without touching the material', async () => { + const material = await ensureAuthProxyCerts(dir); + const ca = new X509Certificate(material.caCertPem); + expect(await readExistingCaInfo(dir)).toEqual({ + caPath: material.paths.caCert, + caCommonName: material.caCommonName, + fingerprint256: ca.fingerprint256, + validTo: ca.validTo, + }); + }); +}); diff --git a/src/mcp/auth-proxy/__tests__/trust.test.ts b/src/mcp/auth-proxy/__tests__/trust.test.ts index 419ca060..b111d6ee 100644 --- a/src/mcp/auth-proxy/__tests__/trust.test.ts +++ b/src/mcp/auth-proxy/__tests__/trust.test.ts @@ -1,14 +1,27 @@ /** * trust.ts tests — pure command builders + injected-exec action runner. - * No real trust store is ever touched. + * No real trust store (or real NSS DB) is ever touched. * @group unit */ import { describe, it, expect, vi } from 'vitest'; import { applyTrust, buildTrustCommands, getManualTrustInstructions } from '../trust.js'; +import type { TrustDeps } from '../trust.js'; const CA_PATH = '/home/u/.codemie/mcp-auth-proxy-tls/ca.crt'; const CN = 'CodeMie mcp-auth-proxy CA u@host 2026-07-05'; +function deps(overrides: Partial): TrustDeps { + return { + platform: 'linux', + exec: vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: '' }), + caPath: CA_PATH, + caCommonName: CN, + // Hermetic default: never probe/create the real ~/.pki/nssdb in tests. + nssDbExists: () => true, + ...overrides, + }; +} + describe('buildTrustCommands', () => { it('win32 install/uninstall target the user Root store', () => { expect(buildTrustCommands('win32', CA_PATH, CN, 'install')).toEqual([ @@ -49,59 +62,123 @@ describe('buildTrustCommands', () => { describe('applyTrust', () => { it('runs the built commands and reports success', async () => { const exec = vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: '' }); - const result = await applyTrust('install', { - platform: 'win32', - exec, - caPath: CA_PATH, - caCommonName: CN, - }); + const result = await applyTrust('install', deps({ platform: 'win32', exec })); expect(result.ok).toBe(true); expect(exec).toHaveBeenCalledWith('certutil', ['-addstore', '-user', 'Root', CA_PATH]); }); it('fails with manual instructions when the tool exits non-zero', async () => { const exec = vi.fn().mockResolvedValue({ code: 1, stdout: '', stderr: 'denied' }); - const result = await applyTrust('install', { - platform: 'linux', - exec, - caPath: CA_PATH, - caCommonName: CN, - }); + const result = await applyTrust('install', deps({ exec })); expect(result.ok).toBe(false); expect(result.manual).toContain(CA_PATH); }); + it('surfaces the failing command stderr as detail', async () => { + const exec = vi.fn().mockResolvedValue({ code: 1, stdout: '', stderr: 'denied by policy' }); + const result = await applyTrust('install', deps({ platform: 'win32', exec })); + expect(result.ok).toBe(false); + expect(result.detail).toContain('denied by policy'); + }); + it('fails with manual instructions when the tool is missing (exec throws)', async () => { const exec = vi.fn().mockRejectedValue(new Error('spawn certutil ENOENT')); - const result = await applyTrust('install', { - platform: 'linux', - exec, - caPath: CA_PATH, - caCommonName: CN, - }); + const result = await applyTrust('install', deps({ exec })); expect(result.ok).toBe(false); expect(result.manual).toContain(CA_PATH); + expect(result.detail).toContain('ENOENT'); }); it('returns manual instructions directly on unsupported platforms', async () => { const exec = vi.fn(); - const result = await applyTrust('install', { - platform: 'freebsd', - exec, - caPath: CA_PATH, - caCommonName: CN, - }); + const result = await applyTrust('install', deps({ platform: 'freebsd', exec })); expect(result.ok).toBe(false); expect(exec).not.toHaveBeenCalled(); }); + + it('linux install bootstraps a missing NSS DB before adding the CA', async () => { + const exec = vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: '' }); + const createNssDbDir = vi.fn().mockResolvedValue(undefined); + const result = await applyTrust( + 'install', + deps({ exec, nssDbExists: () => false, createNssDbDir }) + ); + expect(result.ok).toBe(true); + expect(createNssDbDir).toHaveBeenCalledOnce(); + expect(exec).toHaveBeenCalledTimes(2); + expect(exec).toHaveBeenNthCalledWith(1, 'certutil', [ + '-d', + expect.stringContaining('nssdb'), + '-N', + '--empty-password', + ]); + expect(exec).toHaveBeenNthCalledWith(2, 'certutil', expect.arrayContaining(['-A', CN])); + }); + + it('linux install skips the bootstrap when the NSS DB exists', async () => { + const exec = vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: '' }); + const createNssDbDir = vi.fn(); + const result = await applyTrust('install', deps({ exec, createNssDbDir })); + expect(result.ok).toBe(true); + expect(createNssDbDir).not.toHaveBeenCalled(); + expect(exec).toHaveBeenCalledTimes(1); + expect(exec).toHaveBeenCalledWith('certutil', expect.arrayContaining(['-A'])); + }); + + it('uninstall treats the platform not-found result as idempotent success', async () => { + const exec = vi.fn().mockResolvedValue({ + code: 1, + stdout: '', + stderr: 'CertUtil: -delstore command FAILED: 0x80092004', + }); + const result = await applyTrust('uninstall', deps({ platform: 'win32', exec })); + expect(result.ok).toBe(true); + expect(result.notFound).toBe(true); + }); + + it('uninstall on a fresh linux profile (no NSS DB) is idempotent success', async () => { + const exec = vi.fn().mockResolvedValue({ + code: 255, + stdout: '', + stderr: 'certutil: could not find certificate named "x": SEC_ERROR_BAD_DATABASE', + }); + const result = await applyTrust('uninstall', deps({ exec })); + expect(result.ok).toBe(true); + expect(result.notFound).toBe(true); + }); + + it('a genuinely failed uninstall keeps the failure path with delete instructions', async () => { + const exec = vi.fn().mockResolvedValue({ code: 1, stdout: '', stderr: 'permission denied' }); + const result = await applyTrust('uninstall', deps({ platform: 'darwin', exec })); + expect(result.ok).toBe(false); + expect(result.notFound).toBeUndefined(); + expect(result.detail).toContain('permission denied'); + expect(result.manual).toContain('delete-certificate'); + expect(result.manual).toContain(CN); + }); }); describe('getManualTrustInstructions', () => { - it('mentions the CA path and all three platforms', () => { - const text = getManualTrustInstructions(CA_PATH); + it('install text mentions the CA path and all three platforms', () => { + const text = getManualTrustInstructions(CA_PATH, CN); expect(text).toContain(CA_PATH); expect(text).toMatch(/certutil/); expect(text).toMatch(/security add-trusted-cert/); expect(text).toMatch(/nssdb/); }); + + it('install text includes the Linux system-store fallback and the NSS note', () => { + const text = getManualTrustInstructions(CA_PATH, CN, 'install'); + expect(text).toContain('/usr/local/share/ca-certificates/codemie-mcp-auth-proxy.crt'); + expect(text).toContain('update-ca-certificates'); + expect(text).toMatch(/NSS user DB/); + expect(text).toMatch(/Claude Desktop/); + }); + + it('uninstall text lists the per-platform delete commands with the CN', () => { + const text = getManualTrustInstructions(CA_PATH, CN, 'uninstall'); + expect(text).toContain(`certutil -delstore -user Root "${CN}"`); + expect(text).toContain(`security delete-certificate -c "${CN}"`); + expect(text).toContain(`-D -n "${CN}"`); + }); }); diff --git a/src/mcp/auth-proxy/certs.ts b/src/mcp/auth-proxy/certs.ts index 2183b9c0..ef5767f0 100644 --- a/src/mcp/auth-proxy/certs.ts +++ b/src/mcp/auth-proxy/certs.ts @@ -133,13 +133,17 @@ async function issueLeaf(ca: CaMaterial): Promise<{ certPem: string; keyPem: str const caKey = await importPrivateKey(ca.keyPem); const keys = await webcrypto.subtle.generateKey(SIGNING_ALG, true, ['sign', 'verify']); const now = Date.now(); + const notBefore = new Date(now - CLOCK_SKEW_MS); const cert = await x509.X509CertificateGenerator.create({ subject: 'CN=127.0.0.1, O=CodeMie', issuer: caCert.subject, - notBefore: new Date(now - CLOCK_SKEW_MS), + notBefore, // Anchor to notBefore so total validity stays within the 825-day cap - // despite the clock-skew backdating. - notAfter: new Date(now - CLOCK_SKEW_MS + LEAF_VALIDITY_DAYS * DAY_MS), + // despite the clock-skew backdating; clamp to the CA expiry so the leaf + // never outlives its issuer (chain validation would fail mid-leaf-life). + notAfter: new Date( + Math.min(notBefore.getTime() + LEAF_VALIDITY_DAYS * DAY_MS, caCert.notAfter.getTime()) + ), signingAlgorithm: SIGNING_ALG, publicKey: keys.publicKey, signingKey: caKey, @@ -184,11 +188,76 @@ function leafMatchesProfile(leaf: X509Certificate, ca: X509Certificate): boolean ); } +/** Reads a PKCS#8 key and proves it parses; null = treat as absent and reissue. */ +async function loadPrivateKeyPem(path: string): Promise { + try { + const pem = await readFile(path, 'utf-8'); + await importPrivateKey(pem); + return pem; + } catch { + return null; + } +} + +interface LoadedCa { + cert: X509Certificate; + keyPem: string; +} + +/** Null when the CA cert is missing/corrupt/expired or its key does not parse. */ +async function loadCa(paths: AuthProxyTlsPaths): Promise { + const cert = await readCertificate(paths.caCert); + if (cert === null || !isUsable(cert, 0)) { + return null; + } + const keyPem = await loadPrivateKeyPem(paths.caKey); + return keyPem === null ? null : { cert, keyPem }; +} + +async function generateAndStoreCa(paths: AuthProxyTlsPaths): Promise { + const generated = await generateCa(); + await writeFileAtomic(paths.caKey, generated.keyPem, 0o600); + await writeFileAtomic(paths.caCert, generated.certPem, 0o644); + logger.warn( + '[mcp-auth-proxy] Generated a new local CA — run `codemie mcp-auth-proxy trust` to (re)install it in the OS trust store' + ); + return { cert: new X509Certificate(generated.certPem), keyPem: generated.keyPem }; +} + +export interface ExistingCaInfo { + caPath: string; + caCommonName: string; + fingerprint256: string; + validTo: string; +} + +/** + * Describes the on-disk CA without generating anything — `trust --uninstall` + * must never mint new material (a fresh CA has a new dated CN, so uninstall + * would remove the wrong trust-store entry). Null when missing/unparseable. + */ +export async function readExistingCaInfo( + dir: string = getCodemiePath(AUTH_PROXY_TLS_DIR) +): Promise { + const paths = getAuthProxyTlsPaths(dir); + const cert = await readCertificate(paths.caCert); + if (cert === null) { + return null; + } + return { + caPath: paths.caCert, + caCommonName: extractCommonName(cert.subject), + fingerprint256: cert.fingerprint256, + validTo: cert.validTo, + }; +} + /** * Idempotently ensures a usable CA + leaf under `dir` and returns the material. - * Regenerates the CA (and leaf) when the CA is missing/corrupt/expired — and - * warns that `trust` must be re-run. Reissues only the leaf when it is - * missing/corrupt/expiring/mismatched. + * Regenerates the CA (and leaf) when the CA is missing/corrupt/expired, its key + * does not parse, or the cert/key pair mismatches — and warns that `trust` must + * be re-run. Reissues only the leaf when it is missing/corrupt/expiring/ + * mismatched or its key does not parse. */ export async function ensureAuthProxyCerts( dir: string = getCodemiePath(AUTH_PROXY_TLS_DIR) @@ -197,43 +266,23 @@ export async function ensureAuthProxyCerts( try { await mkdir(dir, { recursive: true }); - let caCert = await readCertificate(paths.caCert); - let caKeyPem: string | null = null; - if (caCert !== null && isUsable(caCert, 0)) { - try { - caKeyPem = await readFile(paths.caKey, 'utf-8'); - } catch { - caKeyPem = null; - } - } - - if (caCert === null || caKeyPem === null) { - const generated = await generateCa(); - await writeFileAtomic(paths.caKey, generated.keyPem, 0o600); - await writeFileAtomic(paths.caCert, generated.certPem, 0o644); - caCert = new X509Certificate(generated.certPem); - caKeyPem = generated.keyPem; - logger.warn( - '[mcp-auth-proxy] Generated a new local CA — run `codemie mcp-auth-proxy trust` to (re)install it in the OS trust store' - ); - } + let ca = (await loadCa(paths)) ?? (await generateAndStoreCa(paths)); const leaf = await readCertificate(paths.serverCert); - let leafKeyPem: string | null = null; const leafOk = leaf !== null && isUsable(leaf, LEAF_RENEWAL_WINDOW_DAYS * DAY_MS) && - leafMatchesProfile(leaf, caCert); - if (leafOk) { - try { - leafKeyPem = await readFile(paths.serverKey, 'utf-8'); - } catch { - leafKeyPem = null; - } - } + leafMatchesProfile(leaf, ca.cert); + let leafKeyPem = leafOk ? await loadPrivateKeyPem(paths.serverKey) : null; - if (!leafOk || leafKeyPem === null) { - const issued = await issueLeaf({ certPem: caCert.toString(), keyPem: caKeyPem }); + if (leafKeyPem === null) { + let issued = await issueLeaf({ certPem: ca.cert.toString(), keyPem: ca.keyPem }); + // Signing succeeds even when ca.key does not match ca.crt (e.g. a crash + // between the two writes), so prove the chain and self-heal once. + if (!new X509Certificate(issued.certPem).verify(ca.cert.publicKey)) { + ca = await generateAndStoreCa(paths); + issued = await issueLeaf({ certPem: ca.cert.toString(), keyPem: ca.keyPem }); + } await writeFileAtomic(paths.serverKey, issued.keyPem, 0o600); await writeFileAtomic(paths.serverCert, issued.certPem, 0o644); leafKeyPem = issued.keyPem; @@ -246,7 +295,7 @@ export async function ensureAuthProxyCerts( keyPem: leafKeyPem, certPem: await readFile(paths.serverCert, 'utf-8'), caCertPem: await readFile(paths.caCert, 'utf-8'), - caCommonName: extractCommonName(caCert.subject), + caCommonName: extractCommonName(ca.cert.subject), paths, }; } catch (error) { diff --git a/src/mcp/auth-proxy/trust.ts b/src/mcp/auth-proxy/trust.ts index 63738762..59fb6bcc 100644 --- a/src/mcp/auth-proxy/trust.ts +++ b/src/mcp/auth-proxy/trust.ts @@ -6,6 +6,8 @@ * a security escalation gate). Uninstall matches the CA's exact subject CN so * only the CodeMie CA is ever removed. */ +import { existsSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -25,14 +27,30 @@ export interface TrustDeps { ) => Promise<{ code: number; stdout: string; stderr: string }>; caPath: string; caCommonName: string; + /** Linux NSS DB presence probe (injected for testability; defaults to existsSync). */ + nssDbExists?: () => boolean; + /** Creates the NSS DB directory (injected for testability; defaults to recursive mkdir). */ + createNssDbDir?: () => Promise; } export interface TrustResult { ok: boolean; + /** Uninstall only: the CA was not in the trust store — idempotent success. */ + notFound?: boolean; + /** stderr (or error message) of the failing command, for user-facing diagnostics. */ + detail?: string; /** Manual per-OS instructions when the automated path is unavailable/failed. */ manual?: string; } +/** Platform "certificate not found" errors — uninstall treats these as already removed. */ +const UNINSTALL_NOT_FOUND_PATTERN = + /not\s?found|no matching|could not (?:be )?found|unable to find|CRYPT_E_NOT_FOUND|SEC_ERROR_UNRECOGNIZED_OID|SEC_ERROR_BAD_DATABASE|0x80092004/i; + +function nssDbDir(): string { + return join(homedir(), '.pki', 'nssdb'); +} + export function buildTrustCommands( platform: NodeJS.Platform, caPath: string, @@ -51,7 +69,7 @@ export function buildTrustCommands( : [{ command: 'security', args: ['delete-certificate', '-c', caCommonName, loginKeychain] }]; } case 'linux': { - const nssDb = `sql:${join(homedir(), '.pki', 'nssdb')}`; + const nssDb = `sql:${nssDbDir()}`; return action === 'install' ? [ { @@ -66,16 +84,42 @@ export function buildTrustCommands( } } -export function getManualTrustInstructions(caPath: string): string { +export function getManualTrustInstructions( + caPath: string, + caCommonName: string, + action: TrustAction = 'install' +): string { + if (action === 'uninstall') { + return [ + `Remove the CA manually — subject CN: ${caCommonName}`, + ` Windows : certutil -delstore -user Root "${caCommonName}"`, + ` macOS : security delete-certificate -c "${caCommonName}" ~/Library/Keychains/login.keychain-db`, + ` Linux : certutil -d sql:$HOME/.pki/nssdb -D -n "${caCommonName}"`, + ].join('\n'); + } return [ `Install the CA manually — certificate file: ${caPath}`, ` Windows : certutil -addstore -user Root "${caPath}"`, ` macOS : security add-trusted-cert -k ~/Library/Keychains/login.keychain-db "${caPath}"`, - ` Linux : certutil -d sql:$HOME/.pki/nssdb -A -t C,, -n "CodeMie mcp-auth-proxy CA" -i "${caPath}"`, - ' (requires libnss3-tools; Chromium/Electron read the NSS user DB)', + ` Linux : certutil -d sql:$HOME/.pki/nssdb -A -t C,, -n "${caCommonName}" -i "${caPath}"`, + ' (requires libnss3-tools; Chromium/Electron read the NSS user DB, so this', + ' command is what Claude Desktop needs)', + ' Linux system store (fallback when NSS certutil is unavailable):', + ` sudo cp "${caPath}" /usr/local/share/ca-certificates/codemie-mcp-auth-proxy.crt && sudo update-ca-certificates`, ].join('\n'); } +function failure(deps: TrustDeps, action: TrustAction, detail: string): TrustResult { + const result: TrustResult = { + ok: false, + manual: getManualTrustInstructions(deps.caPath, deps.caCommonName, action), + }; + if (detail !== '') { + result.detail = detail; + } + return result; +} + /** * Runs the trust-store commands for the platform. Never throws: an unusable * tool or unsupported platform yields { ok: false, manual } so the CLI can @@ -84,17 +128,44 @@ export function getManualTrustInstructions(caPath: string): string { export async function applyTrust(action: TrustAction, deps: TrustDeps): Promise { const commands = buildTrustCommands(deps.platform, deps.caPath, deps.caCommonName, action); if (commands === null) { - return { ok: false, manual: getManualTrustInstructions(deps.caPath) }; + return failure(deps, action, ''); } + + if (deps.platform === 'linux' && action === 'install') { + // Fresh profiles have no ~/.pki/nssdb, and `certutil -A` cannot create one. + const dbExists = deps.nssDbExists ? deps.nssDbExists() : existsSync(nssDbDir()); + if (!dbExists) { + try { + if (deps.createNssDbDir) { + await deps.createNssDbDir(); + } else { + await mkdir(nssDbDir(), { recursive: true }); + } + } catch (error) { + return failure(deps, action, (error as Error).message); + } + commands.unshift({ + command: 'certutil', + args: ['-d', `sql:${nssDbDir()}`, '-N', '--empty-password'], + }); + } + } + + let notFound = false; for (const { command, args } of commands) { try { const result = await deps.exec(command, args); if (result.code !== 0) { - return { ok: false, manual: getManualTrustInstructions(deps.caPath) }; + const output = `${result.stdout}\n${result.stderr}`; + if (action === 'uninstall' && UNINSTALL_NOT_FOUND_PATTERN.test(output)) { + notFound = true; // already absent — idempotent success + continue; + } + return failure(deps, action, result.stderr.trim() || result.stdout.trim()); } - } catch { - return { ok: false, manual: getManualTrustInstructions(deps.caPath) }; + } catch (error) { + return failure(deps, action, (error as Error).message); } } - return { ok: true }; + return notFound ? { ok: true, notFound: true } : { ok: true }; } From 55a44035abf526c479839f1fb411d5d428c9c0c3 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 5 Jul 2026 22:54:01 +0200 Subject: [PATCH 34/36] docs: transition mcp-auth-proxy-https work item to ready for review Generated with AI Co-Authored-By: codemie-ai --- docs/superpowers/work-items/mcp-auth-proxy-https.md | 12 +++++++++++- docs/superpowers/work-items/work-item-events.jsonl | 3 +++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/work-items/mcp-auth-proxy-https.md b/docs/superpowers/work-items/mcp-auth-proxy-https.md index 68fe3f7a..86071f35 100644 --- a/docs/superpowers/work-items/mcp-auth-proxy-https.md +++ b/docs/superpowers/work-items/mcp-auth-proxy-https.md @@ -1,7 +1,7 @@ # Work Item: HTTPS support for `codemie mcp-auth-proxy` - **ID:** mcp-auth-proxy-https -- **Status:** Open +- **Status:** Ready for review - **External Ticket:** (none — external sync pending) - **Source:** local/ISSUE-mcp-auth-proxy-claude-desktop-https.md (free-form + issue report) - **Related:** [mcp-auth-proxy](mcp-auth-proxy.md) — base feature, branch `feat/mcp-auth-proxy` @@ -28,6 +28,11 @@ criteria confirmed at the requirements phase. - local/ISSUE-mcp-auth-proxy-claude-desktop-https.md - docs/superpowers/runs/20260705-1519-feat-mcp-auth-proxy/requirements.md +- docs/superpowers/specs/2026-07-05-mcp-auth-proxy-https-design.md +- docs/superpowers/plans/2026-07-05-mcp-auth-proxy-https.md +- docs/superpowers/runs/20260705-1519-feat-mcp-auth-proxy/qa-report.md +- docs/superpowers/runs/20260705-1519-feat-mcp-auth-proxy/code-review-final.json +- docs/superpowers/runs/20260705-1519-feat-mcp-auth-proxy/code-review-check.json ## History @@ -38,3 +43,8 @@ criteria confirmed at the requirements phase. | 2026-07-05T15:22 | linked-artifact | requirements.md written by requirements-intake | | 2026-07-05T15:31 | assigned | Branch guard (HITL): continue on feat/mcp-auth-proxy, decision proceed-dirty (.codemie/codemie-cli.config.json left unstaged; analytics report never staged); upstream in-sync after fetch | | 2026-07-05T15:31 | adapter-warning | prepare_for_development: no lifecycle adapter configured; external sync pending | +| 2026-07-05T18:05 | implementation | 9 plan tasks via subagents (commits a61344f..da3e5cb); TDD evidence 9/9 valid | +| 2026-07-05T19:20 | code-review | Final round (rerun at user request): request-changes CR-001..CR-008 -> fix-up 83f266d -> check round approve (8/8 resolved) | +| 2026-07-05T19:32 | qa | All 8 guide gates PASS (unit 2320, integration 220, commitlint 9/9); feature-verification not required; record_delivery_audit adapter missing (external sync pending) | +| 2026-07-05T19:45 | complexity | Actual 25/36 (L), delta +1 vs heuristic 24 | +| 2026-07-05T19:45 | transitioned | Status -> Ready for review; branch feat/mcp-auth-proxy ready for MR (stacks onto PR #407); complete_or_handoff adapter missing (external sync pending) | diff --git a/docs/superpowers/work-items/work-item-events.jsonl b/docs/superpowers/work-items/work-item-events.jsonl index 659cc526..ffea24b5 100644 --- a/docs/superpowers/work-items/work-item-events.jsonl +++ b/docs/superpowers/work-items/work-item-events.jsonl @@ -10,3 +10,6 @@ {"schema":1,"ts":"2026-07-05T15:22:00+00:00","event":"work_item.adapter_warning","run_id":"20260705-1519-feat-mcp-auth-proxy","phase":1,"actor":"requirements-intake","summary":"No ticket adapter invoked at intake; external sync pending","artifacts":[],"data":{"external_sync":"pending"}} {"schema":1,"ts":"2026-07-05T15:31:00+00:00","event":"work_item.assigned","run_id":"20260705-1519-feat-mcp-auth-proxy","phase":2,"actor":"sdlc-pipeline","summary":"Branch feat/mcp-auth-proxy assigned (HITL continue; proceed-dirty with exclusions)","artifacts":["docs/superpowers/work-items/mcp-auth-proxy-https.md"],"data":{"branch":"feat/mcp-auth-proxy"}} {"schema":1,"ts":"2026-07-05T15:31:00+00:00","event":"work_item.adapter_warning","run_id":"20260705-1519-feat-mcp-auth-proxy","phase":2,"actor":"sdlc-pipeline","summary":"prepare_for_development: no lifecycle adapter configured; continuing locally, external sync pending","artifacts":[],"data":{"intent":"prepare_for_development","external_sync":"pending"}} +{"schema":1,"ts":"2026-07-05T19:32:20+00:00","event":"work_item.adapter_warning","run_id":"20260705-1519-feat-mcp-auth-proxy","phase":8,"actor":"sdlc-pipeline","summary":"record_delivery_audit for mcp-auth-proxy-https: adapter missing; QA PASSED evidence local-only","artifacts":["docs/superpowers/runs/20260705-1519-feat-mcp-auth-proxy/qa-report.md"],"data":{"intent":"record_delivery_audit","external_sync":"pending"}} +{"schema":1,"ts":"2026-07-05T19:45:00+00:00","event":"work_item.transitioned","run_id":"20260705-1519-feat-mcp-auth-proxy","phase":10,"actor":"sdlc-pipeline","summary":"mcp-auth-proxy-https -> Ready for review; branch feat/mcp-auth-proxy ready for MR","artifacts":["docs/superpowers/work-items/mcp-auth-proxy-https.md"],"data":{"status":"Ready for review"}} +{"schema":1,"ts":"2026-07-05T19:45:00+00:00","event":"work_item.adapter_warning","run_id":"20260705-1519-feat-mcp-auth-proxy","phase":10,"actor":"sdlc-pipeline","summary":"complete_or_handoff: no lifecycle adapter configured; external sync pending","artifacts":[],"data":{"intent":"complete_or_handoff","external_sync":"pending"}} From 097f3f0cbab3fe25947005f26238aa45db4ca918 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Mon, 6 Jul 2026 00:03:19 +0200 Subject: [PATCH 35/36] fix(proxy): make mcp-auth-proxy suite pass on Windows CI - trust.ts: build the Linux NSS DB path with path.posix.join so the certutil command never carries host backslashes (production fix). - certs.test.ts: skip the 0600 key-mode assertion on win32 (Windows secures files via ACLs, not POSIX mode bits). - runtime.test.ts: tolerate the shared logger's held append stream on /logs when removing the temp home (Windows cannot unlink open files). Generated with AI Co-Authored-By: codemie-ai --- src/mcp/auth-proxy/__tests__/certs.test.ts | 19 ++++++++++++------- src/mcp/auth-proxy/__tests__/runtime.test.ts | 12 +++++++++++- src/mcp/auth-proxy/trust.ts | 6 ++++-- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/mcp/auth-proxy/__tests__/certs.test.ts b/src/mcp/auth-proxy/__tests__/certs.test.ts index f604325c..9f65113b 100644 --- a/src/mcp/auth-proxy/__tests__/certs.test.ts +++ b/src/mcp/auth-proxy/__tests__/certs.test.ts @@ -66,14 +66,19 @@ describe('ensureAuthProxyCerts', () => { expect(material.keyPem).toContain('BEGIN PRIVATE KEY'); }); - it('writes private keys with 0600 permissions', async () => { - await ensureAuthProxyCerts(dir); - const paths = getAuthProxyTlsPaths(dir); - for (const keyFile of [paths.caKey, paths.serverKey]) { - const mode = (await stat(keyFile)).mode & 0o777; - expect(mode).toBe(0o600); + // Windows secures files via ACLs, not POSIX mode bits, so stat().mode never + // reflects the requested 0o600 there — the assertion is Unix-only. + it.skipIf(process.platform === 'win32')( + 'writes private keys with 0600 permissions', + async () => { + await ensureAuthProxyCerts(dir); + const paths = getAuthProxyTlsPaths(dir); + for (const keyFile of [paths.caKey, paths.serverKey]) { + const mode = (await stat(keyFile)).mode & 0o777; + expect(mode).toBe(0o600); + } } - }); + ); it('is idempotent — second call reuses both certificates', async () => { const first = await ensureAuthProxyCerts(dir); diff --git a/src/mcp/auth-proxy/__tests__/runtime.test.ts b/src/mcp/auth-proxy/__tests__/runtime.test.ts index 16f9abef..1de8b113 100644 --- a/src/mcp/auth-proxy/__tests__/runtime.test.ts +++ b/src/mcp/auth-proxy/__tests__/runtime.test.ts @@ -17,7 +17,17 @@ beforeEach(async () => { afterEach(async () => { vi.unstubAllEnvs(); - await rm(home, { recursive: true, force: true }); + try { + await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } catch (error) { + // The process-wide logger keeps an append stream open on /logs/debug-*.log + // for the whole run; Windows cannot unlink an open file, so removing the temp + // home can fail with ENOTEMPTY/EPERM/EBUSY. The OS reaps tmpdir — don't fail the test. + const code = (error as NodeJS.ErrnoException).code ?? ''; + if (!['ENOTEMPTY', 'EPERM', 'EBUSY'].includes(code)) { + throw error; + } + } }); describe('runAuthProxyDaemon tls', () => { diff --git a/src/mcp/auth-proxy/trust.ts b/src/mcp/auth-proxy/trust.ts index 59fb6bcc..4ad8baf2 100644 --- a/src/mcp/auth-proxy/trust.ts +++ b/src/mcp/auth-proxy/trust.ts @@ -9,7 +9,7 @@ import { existsSync } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { join, posix } from 'node:path'; export type TrustAction = 'install' | 'uninstall'; @@ -48,7 +48,9 @@ const UNINSTALL_NOT_FOUND_PATTERN = /not\s?found|no matching|could not (?:be )?found|unable to find|CRYPT_E_NOT_FOUND|SEC_ERROR_UNRECOGNIZED_OID|SEC_ERROR_BAD_DATABASE|0x80092004/i; function nssDbDir(): string { - return join(homedir(), '.pki', 'nssdb'); + // POSIX separators always: this path is only ever consumed by Linux `certutil` + // (the leaf command runs on Linux), so it must not pick up host backslashes. + return posix.join(homedir(), '.pki', 'nssdb'); } export function buildTrustCommands( From 528d80d0c73258feb61d650774c3419cb3c98a78 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Mon, 6 Jul 2026 00:16:28 +0200 Subject: [PATCH 36/36] test(proxy): release logger handle before temp cleanup on Windows The daemon's shared logger opens a lifetime append stream on /logs/debug-*.log. Windows cannot unlink an open file, so the previous retry-based rm compounded across the tree and blew the 10s afterEach hook timeout. Release the handle with logger.close() before removing the temp home, and make rm fail-fast so the insurance path can never hang. No-op on Linux, which deletes open files fine. Generated with AI Co-Authored-By: codemie-ai --- src/mcp/auth-proxy/__tests__/runtime.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/mcp/auth-proxy/__tests__/runtime.test.ts b/src/mcp/auth-proxy/__tests__/runtime.test.ts index 1de8b113..a7d2036e 100644 --- a/src/mcp/auth-proxy/__tests__/runtime.test.ts +++ b/src/mcp/auth-proxy/__tests__/runtime.test.ts @@ -7,6 +7,7 @@ import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import https from 'node:https'; +import { logger } from '../../../utils/logger.js'; let home: string; @@ -17,12 +18,16 @@ beforeEach(async () => { afterEach(async () => { vi.unstubAllEnvs(); + // The daemon's logger opens a lifetime append stream on /logs/debug-*.log. + // Windows cannot unlink an open file, so release the handle before removing the + // temp home; otherwise rm retries compound and blow the hook timeout. No-op on + // Linux (which deletes open files fine) and when no stream was opened. + await logger.close(); try { - await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + await rm(home, { recursive: true, force: true, maxRetries: 2, retryDelay: 50 }); } catch (error) { - // The process-wide logger keeps an append stream open on /logs/debug-*.log - // for the whole run; Windows cannot unlink an open file, so removing the temp - // home can fail with ENOTEMPTY/EPERM/EBUSY. The OS reaps tmpdir — don't fail the test. + // Insurance only: if a handle is somehow still held, don't fail the test — + // the OS reaps tmpdir. Fail-fast retries above ensure this never hangs. const code = (error as NodeJS.ErrnoException).code ?? ''; if (!['ENOTEMPTY', 'EPERM', 'EBUSY'].includes(code)) { throw error;