From 1323fe55c8a6eabe38f744598bb1f69b15ecdb8b Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Thu, 9 Apr 2026 10:09:42 +0200 Subject: [PATCH 1/6] feat(proxy): add MCP authorization flow proxy plugin Intercept MCP OAuth auth requests through CodeMie proxy for traceability and client_name branding. Introduces /mcp_auth and /mcp_relay URL scheme with response URL rewriting, origin discovery, SSRF protection via private-network filtering, and TTL-based origin expiration. Generated with AI Co-Authored-By: codemie-ai --- .gitignore | 3 + .gitleaks.toml | 3 +- src/agents/core/BaseAgentAdapter.ts | 4 + .../plugins/sso/proxy/plugins/index.ts | 4 +- .../sso/proxy/plugins/mcp-auth.plugin.ts | 1339 +++++++++++++++++ .../plugins/sso/proxy/plugins/types.ts | 25 +- src/providers/plugins/sso/proxy/sso.proxy.ts | 34 +- 7 files changed, 1407 insertions(+), 5 deletions(-) create mode 100644 src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts diff --git a/.gitignore b/.gitignore index a85ef785..a288658c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ dist/ .env.local .env.*.local +# MCP config (contains API keys) +.mcp.json + # IDE .vscode/ .idea/ diff --git a/.gitleaks.toml b/.gitleaks.toml index 45f84294..39f2b965 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -12,5 +12,6 @@ description = "Exclude test files and build artifacts containing intentional fak paths = [ '''src/utils/__tests__/sanitize\.test\.ts$''', '''dist/''', - '''\.idea/''' + '''\.idea/''', + '''\.mcp\.json$''' ] diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index 59ac3ec3..288b3570 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -831,8 +831,12 @@ export abstract class BaseAgentAdapter implements AgentAdapter { const repository = env.CODEMIE_REPOSITORY || 'unknown'; const branch = env.CODEMIE_GIT_BRANCH; + // Fixed proxy port (e.g., for stable MCP auth URLs across restarts) + const port = env.CODEMIE_PROXY_PORT ? parseInt(env.CODEMIE_PROXY_PORT, 10) : undefined; + return { targetApiUrl, + port, clientType: this.metadata.ssoConfig?.clientType || 'unknown', timeout: timeoutMs, model: env.CODEMIE_MODEL, diff --git a/src/providers/plugins/sso/proxy/plugins/index.ts b/src/providers/plugins/sso/proxy/plugins/index.ts index 4b189fff..73f528ec 100644 --- a/src/providers/plugins/sso/proxy/plugins/index.ts +++ b/src/providers/plugins/sso/proxy/plugins/index.ts @@ -6,6 +6,7 @@ */ import { getPluginRegistry } from './registry.js'; +import { MCPAuthPlugin } from './mcp-auth.plugin.js'; import { EndpointBlockerPlugin } from './endpoint-blocker.plugin.js'; import { SSOAuthPlugin } from './sso-auth.plugin.js'; import { JWTAuthPlugin } from './jwt-auth.plugin.js'; @@ -22,6 +23,7 @@ export function registerCorePlugins(): void { const registry = getPluginRegistry(); // Register in any order (priority determines execution order) + registry.register(new MCPAuthPlugin()); // Priority 3 - MCP auth relay routing registry.register(new EndpointBlockerPlugin()); // Priority 5 - blocks unwanted endpoints early registry.register(new SSOAuthPlugin()); registry.register(new JWTAuthPlugin()); @@ -35,7 +37,7 @@ export function registerCorePlugins(): void { registerCorePlugins(); // Re-export for convenience -export { EndpointBlockerPlugin, SSOAuthPlugin, JWTAuthPlugin, HeaderInjectionPlugin, RequestSanitizerPlugin, LoggingPlugin }; +export { MCPAuthPlugin, EndpointBlockerPlugin, SSOAuthPlugin, JWTAuthPlugin, HeaderInjectionPlugin, RequestSanitizerPlugin, LoggingPlugin }; export { SSOSessionSyncPlugin } from './sso.session-sync.plugin.js'; export { getPluginRegistry, resetPluginRegistry } from './registry.js'; export * from './types.js'; diff --git a/src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts b/src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts new file mode 100644 index 00000000..a7af9e63 --- /dev/null +++ b/src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts @@ -0,0 +1,1339 @@ +/** + * MCP Authorization Proxy Plugin + * Priority: 3 (runs before endpoint blocker, auth, and all other plugins) + * + * Proxies the MCP OAuth authorization flow so that: + * 1. All auth traffic is routed through the CodeMie proxy + * 2. `client_name` is replaced with "Codemie CLI" in dynamic client registration + * + * URL scheme: + * - /mcp_auth?original= → Initial MCP connection + * - /mcp_relay/// → Relayed requests (per-flow scoped) + * + * The root_b64 segment carries the root MCP server origin for per-flow isolation. + * The relay_b64 segment identifies the actual target origin (may differ from root + * when the auth server is on a separate host). + * + * Response URL rewriting replaces external URLs with proxy relay URLs so that + * the MCP client (Claude Code CLI) routes all subsequent requests through the proxy. + * + * Security: + * - SSRF protection: private/loopback origins are rejected (hostname + DNS resolution) + * - Per-flow origin scoping: discovered origins are tagged with their root MCP server + * origin and relay requests validate the root-relay association + * - Buffering is restricted to auth metadata responses; post-auth MCP traffic streams through + */ + +import { IncomingMessage, ServerResponse } from 'http'; +import { URL } from 'url'; +import { lookup } from 'dns/promises'; +import { gunzip, inflate, brotliDecompress } from 'zlib'; +import { promisify } from 'util'; +import { ProxyPlugin, PluginContext, ProxyInterceptor } from './types.js'; +import { ProxyContext } from '../proxy-types.js'; +import { ProxyHTTPClient } from '../proxy-http-client.js'; +import { logger } from '../../../../../utils/logger.js'; + +const gunzipAsync = promisify(gunzip); +const inflateAsync = promisify(inflate); +const brotliDecompressAsync = promisify(brotliDecompress); + +// ─── URL Utilities ─────────────────────────────────────────────────────────── + +/** Base64url encode (RFC 4648 §5): URL-safe, no padding */ +function base64urlEncode(str: string): string { + return Buffer.from(str, 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** Base64url decode */ +function base64urlDecode(encoded: string): string { + // Restore standard base64 + let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); + // Re-add padding + while (base64.length % 4 !== 0) { + base64 += '='; + } + return Buffer.from(base64, 'base64').toString('utf-8'); +} + +/** Extract origin (scheme + host + port) from a URL string */ +function getOrigin(urlStr: string): string { + try { + const u = new URL(urlStr); + return u.origin; // e.g. "https://example.com" or "https://example.com:8443" + } catch { + return ''; + } +} + +/** Check if a string looks like an absolute HTTP(S) URL */ +function isAbsoluteUrl(str: string): boolean { + return str.startsWith('http://') || str.startsWith('https://'); +} + +// JSON field names whose values are token audience identifiers, NOT URLs to access. +// These must not be rewritten by the generic URL rewriter. +// Note: 'resource' is handled separately in rewriteJsonValue — it gets special +// bidirectional rewriting (to proxy URL in responses, back to original in requests). +const SKIP_REWRITE_FIELDS = new Set([ + 'aud', 'audience', 'redirect_uri', 'redirect_uris', + 'issuer', // OIDC issuer — rewriting breaks token issuer validation +]); + +// Auth server metadata fields whose URLs are browser-facing and must NOT be rewritten. +// The browser must navigate directly to the real auth server for login flows because: +// - Cookies/sessions are domain-scoped (won't work through localhost proxy) +// - SAML/OIDC federation redirects require the real auth server domain +// - The auth server's HTML/JS pages reference its own origin +// Programmatic endpoints (token_endpoint, registration_endpoint) ARE rewritten. +const BROWSER_FACING_FIELDS = new Set([ + 'authorization_endpoint', + 'end_session_endpoint', +]); + +// Max response body size for buffered MCP auth responses (1MB). +// Auth metadata payloads are typically 1-10KB. This prevents OOM from +// malicious or misconfigured upstreams. +const MAX_RESPONSE_SIZE = 1024 * 1024; + +// Max number of discovered origins to prevent Set explosion from malicious responses. +// A normal MCP auth flow discovers 2-3 origins (MCP server + auth server). +const MAX_KNOWN_ORIGINS = 50; + +// Max number of distinct MCP server origins accepted via /mcp_auth. +// Bounds the SSRF surface: the proxy is localhost-only but this prevents +// unbounded use as a generic forwarder. A typical setup has 1-3 MCP servers. +const MAX_MCP_SERVER_ORIGINS = 10; + +// TTL for discovered origins in milliseconds (30 minutes). +// Bounds the window during which cross-flow origin leakage can occur. +// Refreshed on each access, so active flows keep their origins alive. +const ORIGIN_TTL_MS = 30 * 60 * 1000; + +/** + * Check if a URL origin points to a private, loopback, or link-local network. + * Prevents SSRF through malicious auth server metadata that advertises internal hosts. + */ +function isPrivateOrLoopbackOrigin(origin: string): boolean { + try { + const url = new URL(origin); + const hostname = url.hostname.toLowerCase(); + + // Loopback + if (hostname === 'localhost' || hostname === '::1' || hostname === '[::1]') return true; + + // IPv4 ranges + const parts = hostname.split('.'); + if (parts.length === 4 && parts.every(p => /^\d+$/.test(p))) { + const [a, b] = parts.map(Number); + if (a === 127) return true; // 127.0.0.0/8 loopback + if (a === 10) return true; // 10.0.0.0/8 private + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private + if (a === 192 && b === 168) return true; // 192.168.0.0/16 private + if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + if (a === 0) return true; // 0.0.0.0/8 + } + + // IPv6 (may be bracketed in URL hostnames) + const ipv6 = hostname.replace(/^\[|\]$/g, ''); + if (ipv6.startsWith('fc') || ipv6.startsWith('fd')) return true; // fc00::/7 ULA + if (ipv6.startsWith('fe80')) return true; // fe80::/10 link-local + + return false; + } catch { + return true; // Can't parse — reject to be safe + } +} + +/** + * Check if a resolved IP address is in a private, loopback, or link-local range. + * Used for DNS resolution SSRF validation (catches DNS rebinding attacks where + * a public hostname resolves to an internal IP). + */ +function isPrivateOrLoopbackIP(ip: string): boolean { + // Handle IPv4-mapped IPv6 (::ffff:x.x.x.x) + const ipv4Mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); + if (ipv4Mapped) { + return isPrivateOrLoopbackIP(ipv4Mapped[1]); + } + + // IPv4 + const parts = ip.split('.'); + if (parts.length === 4 && parts.every(p => /^\d+$/.test(p))) { + const [a, b] = parts.map(Number); + if (a === 127) return true; // 127.0.0.0/8 loopback + if (a === 10) return true; // 10.0.0.0/8 private + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private + if (a === 192 && b === 168) return true; // 192.168.0.0/16 private + if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + if (a === 0) return true; // 0.0.0.0/8 + } + + // IPv6 + const normalized = ip.toLowerCase(); + if (normalized === '::1' || normalized === '::') return true; + if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true; // ULA + if (normalized.startsWith('fe80')) return true; // Link-local + + return false; +} + +/** + * Resolve a hostname via DNS and check if it points to a private/loopback IP. + * Defense-in-depth against DNS rebinding SSRF attacks. + * + * Note: There is an inherent TOCTOU window between this check and the actual + * HTTP connection (the hostname could re-resolve differently). This is mitigated + * by OS-level DNS caching and the short interval between check and connect. + */ +async function resolvesToPrivateIP(hostname: string): Promise { + // Skip DNS resolution for IP literals — already checked by isPrivateOrLoopbackOrigin + if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(':')) { + return false; + } + try { + const { address } = await lookup(hostname); + return isPrivateOrLoopbackIP(address); + } catch { + // DNS resolution failed — let the HTTP client handle the error naturally + return false; + } +} + +/** + * Normalize a URL to origin + pathname (lowercased) for endpoint comparison. + * Strips query params so that `https://auth/register?foo=1` matches `https://auth/register`. + */ +function normalizeEndpointUrl(url: string): string { + try { + const parsed = new URL(url); + return (parsed.origin + parsed.pathname).toLowerCase(); + } catch { + return url.toLowerCase(); + } +} + +// Query parameter names that may contain sensitive auth data and must be masked in logs. +const SENSITIVE_QUERY_PARAMS = new Set([ + 'code', 'state', 'token', 'access_token', 'refresh_token', + 'id_token', 'session_state', 'client_secret', +]); + +/** + * Mask sensitive query parameter values in a URL for safe logging. + * Handles nested URLs: if a parameter value itself contains a URL with + * sensitive params (e.g., original=https://idp/callback?code=abc&state=xyz), + * those nested values are also masked. + */ +function sanitizeUrlForLog(url: string): string { + const queryStart = url.indexOf('?'); + if (queryStart === -1) return url; + + const basePath = url.slice(0, queryStart); + const queryString = url.slice(queryStart + 1); + + const sanitizedParams = queryString.split('&').map(param => { + const eqIdx = param.indexOf('='); + if (eqIdx === -1) return param; + const key = param.slice(0, eqIdx).toLowerCase(); + if (SENSITIVE_QUERY_PARAMS.has(key)) { + return `${param.slice(0, eqIdx)}=***`; + } + // Recursively sanitize nested URLs in parameter values + const value = param.slice(eqIdx + 1); + if (isAbsoluteUrl(value) || isAbsoluteUrl(decodeURIComponentSafe(value))) { + const sanitizedValue = sanitizeUrlForLog(decodeURIComponentSafe(value)); + return `${param.slice(0, eqIdx)}=${sanitizedValue}`; + } + return param; + }); + + return `${basePath}?${sanitizedParams.join('&')}`; +} + +/** Safe decodeURIComponent that returns the input on failure */ +function decodeURIComponentSafe(str: string): string { + try { return decodeURIComponent(str); } catch { return str; } +} + +// ─── Plugin ────────────────────────────────────────────────────────────────── + +export class MCPAuthPlugin implements ProxyPlugin { + id = '@codemie/proxy-mcp-auth'; + name = 'MCP Auth Proxy'; + version = '1.0.0'; + priority = 3; // Before endpoint blocker (5) and auth (10) + + async createInterceptor(context: PluginContext): Promise { + return new MCPAuthInterceptor(context); + } +} + +// ─── Origin Entry ─────────────────────────────────────────────────────────── + +interface KnownOriginEntry { + /** Last-access timestamp for TTL-based expiration */ + timestamp: number; + /** Root MCP server origins that discovered this origin (per-flow scoping) */ + rootOrigins: Set; +} + +// ─── Interceptor ───────────────────────────────────────────────────────────── + +class MCPAuthInterceptor implements ProxyInterceptor { + name = 'mcp-auth'; + + /** Proxy's own base URL, set on first MCP auth request */ + private proxyBaseUrl: string | null = null; + + /** + * Known external origins discovered from auth metadata responses. + * Map from origin → entry with TTL and per-flow root origin scoping. + * Origins are ONLY added through validated auth flow responses (WWW-Authenticate, + * auth server metadata, Location redirects) — never from caller-supplied URLs. + * Private/loopback origins are rejected to prevent SSRF. + * + * Per-flow scoping: each discovered origin is tagged with the root MCP server + * origin it was discovered from. Relay requests validate that the relay origin + * was discovered from the root origin carried in the URL. This prevents + * cross-flow origin leakage (flow A's discovered origins are not usable by flow B). + */ + private knownOrigins = new Map(); + + /** + * Normalized URLs (origin+pathname) of discovered registration endpoints. + * Used to match client_name replacement targets beyond the /register path heuristic. + */ + private discoveredRegistrationEndpoints = new Set(); + + /** + * Normalized URLs (origin+pathname) of all discovered auth endpoints + * (token, registration, authorization, jwks, etc.). + * Used for buffering decisions in isAuthMetadataResponse beyond path heuristics. + */ + private discoveredAuthEndpoints = new Set(); + + /** + * Distinct MCP server origins accessed via /mcp_auth. + * Bounded by MAX_MCP_SERVER_ORIGINS to prevent unbounded use as a generic forwarder. + */ + private mcpServerOrigins = new Set(); + + /** + * Mapping from original MCP server URLs to their proxy /mcp_auth URLs. + * Used for bidirectional 'resource' field rewriting: + * - Response: resource "https://real-server/path" → "http://localhost:PORT/mcp_auth?original=https://real-server/path" + * - Request: reverse mapping before forwarding to auth server + * This is needed because the MCP SDK validates that the resource metadata's + * 'resource' field matches the URL the client originally connected to. + */ + private mcpUrlMapping = new Map(); + + constructor(private pluginContext: PluginContext) {} + + async onProxyStop(): Promise { + this.proxyBaseUrl = null; + this.knownOrigins.clear(); + this.mcpServerOrigins.clear(); + this.mcpUrlMapping.clear(); + this.discoveredRegistrationEndpoints.clear(); + this.discoveredAuthEndpoints.clear(); + } + + /** + * Check if an origin is known, not expired, and (optionally) associated with a + * specific root MCP server origin. Refreshes TTL on access. + */ + private isKnownOrigin(origin: string, rootOrigin?: string): boolean { + const entry = this.knownOrigins.get(origin); + if (!entry) return false; + if (Date.now() - entry.timestamp > ORIGIN_TTL_MS) { + this.knownOrigins.delete(origin); + logger.debug(`[${this.name}] Origin expired and removed: ${origin}`); + return false; + } + // Per-flow validation: if rootOrigin specified, verify association + if (rootOrigin && !entry.rootOrigins.has(rootOrigin)) { + logger.debug(`[${this.name}] Origin ${origin} not associated with root ${rootOrigin}`); + return false; + } + entry.timestamp = Date.now(); // Refresh on access + return true; + } + + /** + * Add a discovered origin with private-network validation, TTL, and root tagging. + * If the origin already exists, adds the rootOrigin to its set and refreshes TTL. + */ + private addKnownOrigin(origin: string, rootOrigin: string): boolean { + const existing = this.knownOrigins.get(origin); + if (existing) { + existing.timestamp = Date.now(); + existing.rootOrigins.add(rootOrigin); + return true; + } + if (isPrivateOrLoopbackOrigin(origin)) { + logger.debug(`[${this.name}] Rejected private/loopback origin: ${origin}`); + return false; + } + // Sweep expired entries before checking capacity so stale origins + // don't prevent legitimate new origins from being added. + if (this.knownOrigins.size >= MAX_KNOWN_ORIGINS) { + this.sweepExpiredOrigins(); + } + if (this.knownOrigins.size >= MAX_KNOWN_ORIGINS) return false; + this.knownOrigins.set(origin, { + timestamp: Date.now(), + rootOrigins: new Set([rootOrigin]), + }); + return true; + } + + /** Remove all expired origins from the map. */ + private sweepExpiredOrigins(): void { + const now = Date.now(); + for (const [origin, entry] of this.knownOrigins) { + if (now - entry.timestamp > ORIGIN_TTL_MS) { + this.knownOrigins.delete(origin); + logger.debug(`[${this.name}] Swept expired origin: ${origin}`); + } + } + } + + /** + * Handle MCP auth requests directly, bypassing normal proxy flow. + * Returns true if the request was handled (path matched /mcp_auth or /mcp_relay). + * + * Why this bypasses the standard pipeline: + * MCP auth traffic routes to MCP/auth servers (not LLM APIs), so the standard + * proxy plugins (endpoint blocker, auth injection, request sanitizer) do not apply. + * This plugin implements its own SSRF protection, origin validation, and logging. + */ + async handleRequest( + context: ProxyContext, + _req: IncomingMessage, + res: ServerResponse, + httpClient: ProxyHTTPClient + ): Promise { + const url = context.url; // e.g. "/mcp_auth?original=..." or "/mcp_relay/root/relay/path" + + // Route 1: Initial MCP connection (exact path boundary: /mcp_auth or /mcp_auth?...) + if (url === '/mcp_auth' || url.startsWith('/mcp_auth?')) { + const safeUrl = sanitizeUrlForLog(url); + logger.info(`[${this.name}] ${context.method} ${safeUrl} [${context.requestId}]`); + const startTime = Date.now(); + await this.handleMCPAuth(context, res, httpClient); + logger.info(`[${this.name}] ${context.method} ${safeUrl} → ${res.statusCode} (${Date.now() - startTime}ms) [${context.requestId}]`); + return true; + } + + // Route 2: Relayed request to external host + if (url.startsWith('/mcp_relay/')) { + const safeUrl = sanitizeUrlForLog(url); + logger.info(`[${this.name}] ${context.method} ${safeUrl} [${context.requestId}]`); + const startTime = Date.now(); + await this.handleMCPRelay(context, res, httpClient); + logger.info(`[${this.name}] ${context.method} ${safeUrl} → ${res.statusCode} (${Date.now() - startTime}ms) [${context.requestId}]`); + return true; + } + + // Route 3: RFC 8414 well-known URLs constructed over mcp_relay paths. + // OAuth SDKs construct well-known URLs by inserting .well-known at the URL root: + // /.well-known//mcp_relay/// + // Rewrite to relay form so handleMCPRelay processes it: + // /mcp_relay///.well-known// + if (url.startsWith('/.well-known/') && url.includes('/mcp_relay/')) { + const mcpRelayIdx = url.indexOf('/mcp_relay/'); + const wellKnownPart = url.slice(0, mcpRelayIdx); // e.g. "/.well-known/oauth-authorization-server" + const relaySegment = url.slice(mcpRelayIdx + '/mcp_relay/'.length); // //[?query] + + // Extract root and relay segments, then reconstruct + const firstSlash = relaySegment.indexOf('/'); + if (firstSlash !== -1) { + const rootEnc = relaySegment.slice(0, firstSlash); + const afterRoot = relaySegment.slice(firstSlash + 1); + const secondSlash = afterRoot.indexOf('/'); + const secondQuery = afterRoot.indexOf('?'); + const secondSep = secondSlash === -1 ? secondQuery + : secondQuery === -1 ? secondSlash + : Math.min(secondSlash, secondQuery); + + const relayEnc = secondSep === -1 ? afterRoot : afterRoot.slice(0, secondSep); + const issuerRest = secondSep === -1 ? '' : afterRoot.slice(secondSep); // e.g. "/keycloak_prod/..." + + // Reconstruct: /mcp_relay///.well-known// + const rewrittenUrl = `/mcp_relay/${rootEnc}/${relayEnc}${wellKnownPart}${issuerRest}`; + logger.debug(`[${this.name}] RFC 8414 well-known rewrite: ${url} → ${rewrittenUrl}`); + + context.url = rewrittenUrl; + const safeUrl = sanitizeUrlForLog(rewrittenUrl); + logger.info(`[${this.name}] ${context.method} ${safeUrl} [${context.requestId}]`); + const startTime = Date.now(); + await this.handleMCPRelay(context, res, httpClient); + logger.info(`[${this.name}] ${context.method} ${safeUrl} → ${res.statusCode} (${Date.now() - startTime}ms) [${context.requestId}]`); + return true; + } + } + + // Not an MCP auth request — let normal proxy flow handle it + return false; + } + + // ─── Route Handlers ────────────────────────────────────────────────────── + + /** + * Handle /mcp_auth?original= + * Extracts the real MCP server URL and forwards the request. + */ + private async handleMCPAuth( + context: ProxyContext, + res: ServerResponse, + httpClient: ProxyHTTPClient + ): Promise { + // Set proxy base URL on first request + if (!this.proxyBaseUrl) { + const proxyPort = this.pluginContext.config.port; + this.proxyBaseUrl = `http://localhost:${proxyPort}`; + } + + // Extract original URL from query string + const originalUrl = this.extractOriginalUrl(context.url); + logger.debug(`[${this.name}] Extracted originalUrl: ${originalUrl}`); + if (!originalUrl) { + this.sendError(res, 400, 'Missing or invalid "original" query parameter'); + return; + } + + // SSRF check: reject private/loopback targets in the original URL. + const origin = getOrigin(originalUrl); + logger.debug(`[${this.name}] Original origin: ${origin}`); + if (isPrivateOrLoopbackOrigin(originalUrl)) { + this.sendError(res, 403, 'SSRF blocked: original URL points to private/loopback network'); + return; + } + + // Track and cap distinct MCP server origins to prevent use as a generic forwarder. + // The /mcp_auth path must forward to arbitrary user-configured URLs (by design), + // but we bound the number of distinct origins to limit SSRF surface. + if (origin && !this.mcpServerOrigins.has(origin)) { + if (this.mcpServerOrigins.size >= MAX_MCP_SERVER_ORIGINS) { + this.sendError(res, 403, `MCP server origin limit reached (${MAX_MCP_SERVER_ORIGINS}). Cannot forward to new origins.`); + return; + } + this.mcpServerOrigins.add(origin); + logger.debug(`[${this.name}] Registered MCP server origin: ${origin} (${this.mcpServerOrigins.size}/${MAX_MCP_SERVER_ORIGINS})`); + } + + // The root origin is the MCP server's origin — used for per-flow origin scoping + const rootOrigin = origin || ''; + + // Store mapping for bidirectional 'resource' field rewriting. + // The MCP SDK validates resource metadata's 'resource' field against the connected URL. + const proxyUrl = `${this.proxyBaseUrl}/mcp_auth?original=${originalUrl}`; + this.mcpUrlMapping.set(originalUrl, proxyUrl); + logger.debug(`[${this.name}] Stored resource mapping: "${originalUrl}" → "${proxyUrl}"`); + + logger.debug(`[${this.name}] Initial MCP auth request → ${originalUrl} [rootOrigin=${rootOrigin}]`); + + await this.forwardAndRewrite(context, res, httpClient, originalUrl, rootOrigin); + } + + /** + * Handle /mcp_relay/// + * Decodes both origins, validates the root-relay association, and forwards. + * + * URL scheme: /mcp_relay/// + * The root origin identifies which MCP flow this relay belongs to. + * The relay origin is the actual target host (may differ from root for auth servers). + */ + private async handleMCPRelay( + context: ProxyContext, + res: ServerResponse, + httpClient: ProxyHTTPClient + ): Promise { + // Parse: /mcp_relay/// + const withoutPrefix = context.url.slice('/mcp_relay/'.length); + + // Find first slash — separates encoded_root from encoded_relay + const firstSlash = withoutPrefix.indexOf('/'); + if (firstSlash === -1) { + this.sendError(res, 400, 'Invalid /mcp_relay path: missing relay origin segment'); + return; + } + + const encodedRoot = withoutPrefix.slice(0, firstSlash); + const afterRoot = withoutPrefix.slice(firstSlash + 1); + + // Find second separator (/ or ?) — separates encoded_relay from path + const secondSlash = afterRoot.indexOf('/'); + const queryIdx = afterRoot.indexOf('?'); + const secondSep = secondSlash === -1 ? queryIdx + : queryIdx === -1 ? secondSlash + : Math.min(secondSlash, queryIdx); + + let encodedRelay: string; + let pathAndQuery: string; + + if (secondSep === -1) { + encodedRelay = afterRoot; + pathAndQuery = '/'; + } else if (afterRoot[secondSep] === '?') { + // Query directly on root: /mcp_relay//?x=1 → origin + /?x=1 + encodedRelay = afterRoot.slice(0, secondSep); + pathAndQuery = '/' + afterRoot.slice(secondSep); // → /?x=1 + } else { + encodedRelay = afterRoot.slice(0, secondSep); + pathAndQuery = afterRoot.slice(secondSep); // includes leading / + } + + let decodedRoot: string; + let decodedRelay: string; + try { + decodedRoot = base64urlDecode(encodedRoot); + decodedRelay = base64urlDecode(encodedRelay); + } catch { + this.sendError(res, 400, 'Invalid encoded origin in /mcp_relay path'); + return; + } + + logger.debug(`[${this.name}] Relay parsed: root="${decodedRoot}" relay="${decodedRelay}" pathAndQuery="${pathAndQuery}"`); + + if (!isAbsoluteUrl(decodedRoot) || !isAbsoluteUrl(decodedRelay)) { + this.sendError(res, 400, 'Decoded origin is not a valid URL'); + return; + } + + // Auto-register origins from relay URLs if not already known. + // The MCP SDK may cache auth state (resource metadata URLs) across sessions. + // When a new session starts, knownOrigins is empty but the SDK reuses cached + // /mcp_relay/... URLs from a previous session. The URLs were generated by our + // own proxy, so the encoded origins are trustworthy (still SSRF-checked). + if (!this.knownOrigins.has(decodedRoot)) { + if (!isPrivateOrLoopbackOrigin(decodedRoot)) { + this.addKnownOrigin(decodedRoot, decodedRoot); + this.mcpServerOrigins.add(decodedRoot); + logger.debug(`[${this.name}] Auto-registered root origin from cached relay URL: ${decodedRoot}`); + + // Reconstruct the mcpUrlMapping for resource field rewriting. + // We don't have the full original URL, but the proxy base URL is set. + if (!this.proxyBaseUrl) { + const proxyPort = this.pluginContext.config.port; + this.proxyBaseUrl = `http://localhost:${proxyPort}`; + } + } + } + if (decodedRoot !== decodedRelay && !this.knownOrigins.has(decodedRelay)) { + if (!isPrivateOrLoopbackOrigin(decodedRelay)) { + this.addKnownOrigin(decodedRelay, decodedRoot); + logger.debug(`[${this.name}] Auto-registered relay origin from cached relay URL: ${decodedRelay}`); + } + } + + // Per-flow validation: the relay origin must have been discovered from this root flow + if (!this.isKnownOrigin(decodedRelay, decodedRoot)) { + logger.debug(`[${this.name}] Origin check failed: relay="${decodedRelay}" root="${decodedRoot}" knownOrigins=${JSON.stringify([...this.knownOrigins.entries()].map(([k, v]) => ({ origin: k, roots: [...v.rootOrigins] })))}`); + this.sendError(res, 403, 'Origin not allowed — not discovered through this MCP auth flow'); + return; + } + + const targetUrl = decodedRelay + pathAndQuery; + logger.debug(`[${this.name}] MCP relay request [root=${decodedRoot}] → ${targetUrl}`); + + await this.forwardAndRewrite(context, res, httpClient, targetUrl, decodedRoot); + } + + // ─── Core: Forward + Rewrite ───────────────────────────────────────────── + + /** + * Forward a request to the target URL. + * - Auth metadata responses (401, .well-known, /register, /token) are buffered for URL rewriting. + * - Everything else (authenticated MCP traffic, SSE, binary) is streamed through. + */ + private async forwardAndRewrite( + context: ProxyContext, + res: ServerResponse, + httpClient: ProxyHTTPClient, + targetUrl: string, + rootOrigin: string + ): Promise { + // Modify request body if needed (client_name replacement) + let requestBody = context.requestBody; + const headers = { ...context.headers }; + + // Strip accept-encoding so upstream returns uncompressed JSON responses. + // Needed because the buffer path parses JSON for URL rewriting. We can't know + // which path (buffer vs stream) until after the response arrives, so strip early. + delete headers['accept-encoding']; + + logger.debug(`[${this.name}] forwardAndRewrite: ${context.method} ${targetUrl} [rootOrigin=${rootOrigin}]`); + + // Only rewrite client_name for POST to /register (OAuth dynamic client registration). + // Other JSON payloads must not be mutated. + const isRegEndpoint = this.isRegistrationEndpoint(targetUrl); + if (requestBody && context.method === 'POST' + && headers['content-type']?.includes('application/json') + && isRegEndpoint) { + logger.debug(`[${this.name}] Rewriting client_name in registration request`); + requestBody = this.rewriteRequestBody(requestBody, headers); + } + + // Reverse-rewrite 'resource' parameter: proxy /mcp_auth URL → original URL. + if (requestBody && context.method === 'POST') { + const bodyBefore = requestBody; + requestBody = this.reverseRewriteResourceInBody(requestBody, headers); + if (requestBody !== bodyBefore) { + logger.debug(`[${this.name}] Reverse-rewrote resource in request body`); + } + } + + // DNS resolution SSRF check + const parsedTarget = new URL(targetUrl); + + // Also reverse-rewrite 'resource' in URL query params (authorization endpoint GET) + this.reverseRewriteResourceParam(parsedTarget); + + logger.debug(`[${this.name}] DNS check for hostname: ${parsedTarget.hostname}`); + if (await resolvesToPrivateIP(parsedTarget.hostname)) { + this.sendError(res, 403, 'SSRF blocked: target hostname resolves to private/loopback address'); + return; + } + + // Forward to target + const upstreamResponse = await httpClient.forward(parsedTarget, { + method: context.method, + headers, + body: requestBody || undefined + }); + + const statusCode = upstreamResponse.statusCode || 200; + const contentType = upstreamResponse.headers['content-type'] || ''; + const isJson = contentType.includes('application/json') || contentType.includes('text/json'); + const isAuthMeta = this.isAuthMetadataResponse(targetUrl, statusCode); + const needsBodyRewriting = isJson && isAuthMeta; + + logger.debug(`[${this.name}] Upstream response: status=${statusCode} contentType="${contentType}" isJson=${isJson} isAuthMeta=${isAuthMeta} needsBodyRewriting=${needsBodyRewriting}`); + logger.debug(`[${this.name}] Response headers: ${JSON.stringify(upstreamResponse.headers)}`); + + if (needsBodyRewriting) { + await this.bufferAndRewrite(context, res, upstreamResponse, targetUrl, statusCode, rootOrigin); + } else { + await this.streamThrough(context, res, upstreamResponse, targetUrl, statusCode, rootOrigin); + } + } + + /** + * Buffer response, rewrite URLs in body and headers, send to client. + * Used for auth metadata responses (401, JSON) that need URL rewriting. + */ + private async bufferAndRewrite( + context: ProxyContext, + res: ServerResponse, + upstreamResponse: IncomingMessage, + targetUrl: string, + statusCode: number, + rootOrigin: string + ): Promise { + // Buffer response body (with size limit to prevent OOM) + const chunks: Buffer[] = []; + let totalSize = 0; + for await (const chunk of upstreamResponse) { + totalSize += chunk.length; + if (totalSize > MAX_RESPONSE_SIZE) { + upstreamResponse.destroy(); + this.sendError(res, 502, 'Upstream response too large for MCP auth relay'); + return; + } + chunks.push(Buffer.from(chunk)); + } + let responseBody: Buffer = Buffer.concat(chunks); + + // Decompress if upstream returned compressed content despite stripped accept-encoding. + // Uses async decompression to avoid blocking the event loop. + const contentEncoding = (upstreamResponse.headers['content-encoding'] || '').toLowerCase(); + if (contentEncoding) { + try { + let decompressed: Buffer | undefined; + if (contentEncoding === 'gzip' || contentEncoding === 'x-gzip') { + decompressed = await gunzipAsync(responseBody); + } else if (contentEncoding === 'deflate') { + decompressed = await inflateAsync(responseBody); + } else if (contentEncoding === 'br') { + decompressed = await brotliDecompressAsync(responseBody); + } + if (decompressed) { + // Check decompressed size to prevent decompression bomb attacks + if (decompressed.length > MAX_RESPONSE_SIZE) { + this.sendError(res, 502, 'Decompressed upstream response too large for MCP auth relay'); + return; + } + responseBody = decompressed; + // Remove content-encoding since we've decompressed + delete upstreamResponse.headers['content-encoding']; + } + } catch (err) { + logger.debug(`[${this.name}] Failed to decompress ${contentEncoding} response, passing through: ${err}`); + } + } + + // Rewrite URLs in response body (JSON only) + const contentType = upstreamResponse.headers['content-type'] || ''; + if (contentType.includes('application/json') || contentType.includes('text/json')) { + const bodyBefore = responseBody.toString('utf-8'); + logger.debug(`[${this.name}] Response body BEFORE rewrite (${bodyBefore.length} chars): ${bodyBefore.slice(0, 2000)}`); + responseBody = this.rewriteResponseBody(responseBody, rootOrigin); + const bodyAfter = responseBody.toString('utf-8'); + logger.debug(`[${this.name}] Response body AFTER rewrite (${bodyAfter.length} chars): ${bodyAfter.slice(0, 2000)}`); + } + + // Rewrite URLs in response headers + const responseHeaders = this.rewriteResponseHeaders(upstreamResponse.headers, targetUrl, rootOrigin); + logger.debug(`[${this.name}] Rewritten response headers: ${JSON.stringify(responseHeaders)}`); + + // Send to client + res.statusCode = statusCode; + for (const [key, value] of Object.entries(responseHeaders)) { + if (value !== undefined && !['transfer-encoding', 'connection'].includes(key.toLowerCase())) { + res.setHeader(key, value); + } + } + // Update content-length to match rewritten body + res.setHeader('content-length', String(responseBody.length)); + res.end(responseBody); + + logger.debug(`[${this.name}] Buffered response sent: ${statusCode}, ${responseBody.length} bytes`); + } + + /** + * Stream response through without buffering. + * Used for authenticated MCP traffic (SSE, binary, large responses). + * Only headers are rewritten (Location redirects); body is passed through as-is. + */ + private async streamThrough( + _context: ProxyContext, + res: ServerResponse, + upstreamResponse: IncomingMessage, + targetUrl: string, + statusCode: number, + rootOrigin: string + ): Promise { + // Rewrite URLs in response headers only (no body rewriting) + logger.debug(`[${this.name}] streamThrough: status=${statusCode} targetUrl=${targetUrl}`); + const responseHeaders = this.rewriteResponseHeaders(upstreamResponse.headers, targetUrl, rootOrigin); + + // Set status and headers + res.statusCode = statusCode; + for (const [key, value] of Object.entries(responseHeaders)) { + if (value !== undefined && !['transfer-encoding', 'connection'].includes(key.toLowerCase())) { + res.setHeader(key, value); + } + } + + // Stream body directly to client, honoring backpressure and aborting on disconnect + let bytesSent = 0; + let downstreamClosed = false; + // Resolve any pending drain wait when the client disconnects + let onClose: (() => void) | null = null; + res.on('close', () => { + if (!res.writableFinished) { + downstreamClosed = true; + upstreamResponse.destroy(); + // Unblock any pending drain await so the handler doesn't hang + onClose?.(); + } + }); + + for await (const chunk of upstreamResponse) { + if (downstreamClosed) break; + const canContinue = res.write(chunk); + bytesSent += chunk.length; + // Honor backpressure: wait for drain OR close (whichever fires first) + if (!canContinue && !downstreamClosed) { + await new Promise(resolve => { + onClose = resolve; + res.once('drain', () => { onClose = null; resolve(); }); + }); + } + } + if (!downstreamClosed) { + res.end(); + } + + logger.debug(`[${this.name}] Streamed response: ${statusCode}, ${bytesSent} bytes`); + } + + // ─── Request Body Modification ─────────────────────────────────────────── + + /** + * Replace `client_name` with "Codemie CLI" in JSON request bodies. + * This targets the OAuth dynamic client registration (POST /register). + */ + private rewriteRequestBody(body: Buffer, headers: Record): Buffer { + try { + const parsed = JSON.parse(body.toString('utf-8')); + + if (typeof parsed === 'object' && parsed !== null && 'client_name' in parsed) { + parsed.client_name = 'Codemie CLI'; + const newBody = Buffer.from(JSON.stringify(parsed), 'utf-8'); + headers['content-length'] = String(newBody.length); + logger.debug(`[${this.name}] Replaced client_name with "Codemie CLI"`); + return newBody; + } + } catch { + // Not valid JSON — pass through unchanged + } + return body; + } + + // ─── Response Body URL Rewriting ───────────────────────────────────────── + + /** + * Rewrite external URLs in a JSON response body to proxy relay URLs. + * Discovers new origins from response content (e.g., authorization_servers). + */ + private rewriteResponseBody(body: Buffer, rootOrigin: string): Buffer { + if (!this.proxyBaseUrl) return body; + + try { + const bodyStr = body.toString('utf-8'); + const parsed = JSON.parse(bodyStr); + + // First pass: discover new origins from known fields + this.discoverOrigins(parsed, rootOrigin); + + // Second pass: rewrite URLs + const rewritten = this.rewriteJsonValue(parsed, null, rootOrigin); + return Buffer.from(JSON.stringify(rewritten), 'utf-8'); + } catch { + // Not valid JSON — return unchanged + return body; + } + } + + /** + * Discover new origins from well-known JSON fields. + * Specifically targets `authorization_servers` in protected resource metadata. + */ + private discoverOrigins(obj: unknown, rootOrigin: string): void { + if (typeof obj !== 'object' || obj === null) return; + if (this.knownOrigins.size >= MAX_KNOWN_ORIGINS) return; + + if (Array.isArray(obj)) { + for (const item of obj) { + this.discoverOrigins(item, rootOrigin); + } + return; + } + + const record = obj as Record; + + // authorization_servers: ["https://auth.example.com/realms/r1"] + if (Array.isArray(record.authorization_servers)) { + for (const server of record.authorization_servers) { + if (this.knownOrigins.size >= MAX_KNOWN_ORIGINS) break; + if (typeof server === 'string' && isAbsoluteUrl(server)) { + const origin = getOrigin(server); + if (origin && this.addKnownOrigin(origin, rootOrigin)) { + logger.debug(`[${this.name}] Discovered auth server origin: ${origin} [root=${rootOrigin}]`); + } + } + } + } + + // Auth endpoint fields that may be on a different origin than the auth server. + // e.g., token_endpoint on a CDN or registration_endpoint on a separate service. + const endpointFields = [ + 'token_endpoint', 'registration_endpoint', 'authorization_endpoint', + 'jwks_uri', 'introspection_endpoint', 'revocation_endpoint', + 'userinfo_endpoint', 'end_session_endpoint', + 'device_authorization_endpoint', 'pushed_authorization_request_endpoint', + 'backchannel_authentication_endpoint', 'registration_client_uri', + ]; + for (const field of endpointFields) { + if (this.knownOrigins.size >= MAX_KNOWN_ORIGINS) break; + const value = record[field]; + if (typeof value === 'string' && isAbsoluteUrl(value)) { + // Track origin for relay allowlisting + const origin = getOrigin(value); + if (origin && this.addKnownOrigin(origin, rootOrigin)) { + logger.debug(`[${this.name}] Discovered origin from ${field}: ${origin} [root=${rootOrigin}]`); + } + // Track normalized endpoint URL for buffering and registration detection + const normalized = normalizeEndpointUrl(value); + this.discoveredAuthEndpoints.add(normalized); + if (field === 'registration_endpoint' || field === 'registration_client_uri') { + this.discoveredRegistrationEndpoints.add(normalized); + logger.debug(`[${this.name}] Discovered registration endpoint: ${normalized}`); + } + } + } + + // Recurse into nested objects + for (const value of Object.values(record)) { + if (typeof value === 'object' && value !== null) { + this.discoverOrigins(value, rootOrigin); + } + } + } + + /** + * Recursively rewrite URL strings in a JSON value. + * Skips fields in SKIP_REWRITE_FIELDS (token audience identifiers). + */ + private rewriteJsonValue(value: unknown, parentKey: string | null, rootOrigin: string): unknown { + if (typeof value === 'string') { + // Special handling: 'resource' is a token audience identifier that the MCP SDK + // also validates against the connected URL. Map known MCP server URLs to their + // proxy /mcp_auth URL; leave others unchanged (never rewrite as /mcp_relay). + if (parentKey === 'resource') { + if (isAbsoluteUrl(value)) { + let mapped = this.mcpUrlMapping.get(value); + // When the SDK uses cached relay URLs, mcpUrlMapping is empty because + // handleMCPAuth never ran. Reconstruct the mapping on-the-fly: if the + // resource value's origin is a known MCP server origin (auto-registered + // from the cached relay URL), build the proxy URL from it. + if (!mapped && this.proxyBaseUrl) { + const valOrigin = getOrigin(value); + if (valOrigin && this.mcpServerOrigins.has(valOrigin)) { + mapped = `${this.proxyBaseUrl}/mcp_auth?original=${value}`; + this.mcpUrlMapping.set(value, mapped); + logger.debug(`[${this.name}] resource field: reconstructed mapping for cached session: "${value}" → "${mapped}"`); + } + } + logger.debug(`[${this.name}] resource field: value="${value}" mapped=${mapped ? `"${mapped}"` : 'null (no mapping, keeping as-is)'} mappingKeys=[${[...this.mcpUrlMapping.keys()].join(', ')}]`); + return mapped || value; + } + return value; + } + + // Skip rewriting for token identifiers and browser-facing endpoints + if (parentKey && (SKIP_REWRITE_FIELDS.has(parentKey) || BROWSER_FACING_FIELDS.has(parentKey))) { + return value; + } + if (isAbsoluteUrl(value)) { + return this.rewriteUrl(value, rootOrigin); + } + return value; + } + + if (Array.isArray(value)) { + return value.map(item => this.rewriteJsonValue(item, parentKey, rootOrigin)); + } + + if (typeof value === 'object' && value !== null) { + const result: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + result[key] = this.rewriteJsonValue(val, key, rootOrigin); + } + return result; + } + + return value; + } + + /** + * Rewrite an external URL to a proxy relay URL. + * Uses the two-segment scheme: /mcp_relay/// + * The root segment enables per-flow origin validation in the relay handler. + * Unknown origins are passed through unchanged. + */ + private rewriteUrl(urlStr: string, rootOrigin: string): string { + if (!this.proxyBaseUrl) return urlStr; + + const origin = getOrigin(urlStr); + if (!origin) return urlStr; + + // Actively block private/loopback URLs — prevent the client from ever receiving + // internal-network URLs that a malicious auth server might inject. + if (isPrivateOrLoopbackOrigin(origin)) { + logger.debug(`[${this.name}] Blocked private/loopback URL in response: ${origin}`); + return 'urn:codemie:blocked:private-network'; + } + + if (!this.isKnownOrigin(origin, rootOrigin)) { + logger.debug(`[${this.name}] rewriteUrl: origin "${origin}" not known for root "${rootOrigin}" — passing through`); + return urlStr; // Unknown external origin — don't rewrite + } + + // Extract path + query + fragment after the origin + const pathAndRest = urlStr.slice(origin.length); // e.g. "/path?query=1" + const encodedRoot = base64urlEncode(rootOrigin); + const encodedRelay = base64urlEncode(origin); + + const rewritten = `${this.proxyBaseUrl}/mcp_relay/${encodedRoot}/${encodedRelay}${pathAndRest}`; + logger.debug(`[${this.name}] rewriteUrl: "${urlStr}" → "${rewritten}"`); + return rewritten; + } + + // ─── Response Header Rewriting ─────────────────────────────────────────── + + /** + * Rewrite URLs found in response headers. + * Targets: WWW-Authenticate (resource_metadata), Location (absolute and relative) + */ + private rewriteResponseHeaders( + headers: Record, + upstreamUrl: string, + rootOrigin: string + ): Record { + const result = { ...headers }; + + // Rewrite WWW-Authenticate header (resource_metadata="") + // Handle both single string and string[] (Node.js may expose either form) + const wwwAuth = result['www-authenticate']; + if (typeof wwwAuth === 'string') { + result['www-authenticate'] = this.rewriteWWWAuthenticate(wwwAuth, rootOrigin); + } else if (Array.isArray(wwwAuth)) { + result['www-authenticate'] = wwwAuth.map(v => this.rewriteWWWAuthenticate(v, rootOrigin)); + } + + // Rewrite Location header (redirects — both absolute and relative) + const location = result['location']; + if (typeof location === 'string') { + let absoluteLocation = location; + + // Resolve relative redirects against the upstream URL's origin + if (!isAbsoluteUrl(location)) { + const upstreamOrigin = getOrigin(upstreamUrl); + if (upstreamOrigin) { + try { + absoluteLocation = new URL(location, upstreamUrl).href; + } catch { + // Can't resolve — leave as-is + } + } + } + + if (isAbsoluteUrl(absoluteLocation)) { + const locOrigin = getOrigin(absoluteLocation); + if (locOrigin) { + this.addKnownOrigin(locOrigin, rootOrigin); + } + result['location'] = this.rewriteUrl(absoluteLocation, rootOrigin); + } + } + + return result; + } + + /** + * Rewrite URLs inside a WWW-Authenticate header value. + * Targets: resource_metadata="" + */ + private rewriteWWWAuthenticate(header: string, rootOrigin: string): string { + logger.debug(`[${this.name}] WWW-Authenticate header BEFORE rewrite: ${header}`); + // Match resource_metadata="" + const rewritten = header.replace( + /resource_metadata="([^"]+)"/g, + (_match, url: string) => { + if (isAbsoluteUrl(url)) { + // Discover and register the origin (with SSRF validation and TTL) + const origin = getOrigin(url); + if (origin) { + this.addKnownOrigin(origin, rootOrigin); + } + const rewritten = this.rewriteUrl(url, rootOrigin); + return `resource_metadata="${rewritten}"`; + } + return _match; + } + ); + logger.debug(`[${this.name}] WWW-Authenticate header AFTER rewrite: ${rewritten}`); + return rewritten; + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + /** + * Extract original URL from a proxy /mcp_auth URL. + * Returns null if the URL is not a proxy URL. + */ + private extractOriginalFromProxyUrl(url: string): string | null { + if (!this.proxyBaseUrl) return null; + const mcpAuthPrefix = this.proxyBaseUrl + '/mcp_auth'; + if (!url.startsWith(mcpAuthPrefix)) return null; + const requestPath = url.slice(this.proxyBaseUrl.length); + return this.extractOriginalUrl(requestPath); + } + + /** + * Reverse-rewrite 'resource' query parameter in a URL. + * Converts proxy /mcp_auth URL back to the original MCP server URL. + */ + private reverseRewriteResourceParam(url: URL): void { + const resource = url.searchParams.get('resource'); + if (resource) { + const original = this.extractOriginalFromProxyUrl(resource); + if (original) { + url.searchParams.set('resource', original); + logger.debug(`[${this.name}] Reverse-rewrote resource query param to original URL`); + } + } + } + + /** + * Reverse-rewrite 'resource' field in request body (JSON or form-encoded). + * Converts proxy /mcp_auth URL back to the original MCP server URL. + */ + private reverseRewriteResourceInBody(body: Buffer, headers: Record): Buffer { + const contentType = headers['content-type'] || ''; + + // JSON body (some OAuth implementations accept JSON) + if (contentType.includes('application/json')) { + try { + const parsed = JSON.parse(body.toString('utf-8')); + if (typeof parsed === 'object' && parsed !== null && typeof parsed.resource === 'string') { + const original = this.extractOriginalFromProxyUrl(parsed.resource); + if (original) { + parsed.resource = original; + const newBody = Buffer.from(JSON.stringify(parsed), 'utf-8'); + headers['content-length'] = String(newBody.length); + logger.debug(`[${this.name}] Reverse-rewrote resource in JSON body to original URL`); + return newBody; + } + } + } catch { /* not valid JSON */ } + } + + // Form-encoded body (standard OAuth token requests) + if (contentType.includes('application/x-www-form-urlencoded')) { + try { + const bodyStr = body.toString('utf-8'); + const params = new URLSearchParams(bodyStr); + const resource = params.get('resource'); + if (resource) { + const original = this.extractOriginalFromProxyUrl(resource); + if (original) { + params.set('resource', original); + const newBody = Buffer.from(params.toString(), 'utf-8'); + headers['content-length'] = String(newBody.length); + logger.debug(`[${this.name}] Reverse-rewrote resource in form body to original URL`); + return newBody; + } + } + } catch { /* not valid form data */ } + } + + return body; + } + + /** + * Check if the target URL is an OAuth dynamic client registration endpoint. + * First checks against endpoints discovered from auth server metadata + * (registration_endpoint, registration_client_uri), then falls back to + * the /register path suffix heuristic for pre-metadata requests. + */ + private isRegistrationEndpoint(targetUrl: string): boolean { + // Check against dynamically discovered registration endpoints + const normalized = normalizeEndpointUrl(targetUrl); + if (this.discoveredRegistrationEndpoints.has(normalized)) { + return true; + } + // Fallback: path suffix heuristic for before metadata is discovered + try { + return new URL(targetUrl).pathname.toLowerCase().endsWith('/register'); + } catch { + return false; + } + } + + /** + * Check if a response is auth metadata that needs body URL rewriting. + * First checks against endpoints discovered from auth server metadata, then + * falls back to path heuristics (401, .well-known/, /register, /token, /authorize). + * Discovered endpoints cover non-standard paths that the heuristics would miss. + */ + private isAuthMetadataResponse(targetUrl: string, statusCode: number): boolean { + if (statusCode === 401) return true; + + // Check against dynamically discovered auth endpoints + const normalized = normalizeEndpointUrl(targetUrl); + if (this.discoveredAuthEndpoints.has(normalized)) { + return true; + } + + // Fallback: path heuristics for well-known patterns (before metadata is discovered) + try { + const path = new URL(targetUrl).pathname.toLowerCase(); + return path.includes('/.well-known/') + || path.endsWith('/register') + || path.endsWith('/token') + || path.endsWith('/authorize'); + } catch { + return false; + } + } + + /** + * Extract the original URL from /mcp_auth?original= + * Handles both URL-encoded and raw (unencoded) values. + * + * Order: raw extraction FIRST (preserves unencoded nested query parameters like + * ?original=https://host/p?aud=x&target=https://o/mcp), then URLSearchParams as + * fallback for properly percent-encoded values. URLSearchParams must NOT run first + * because it silently truncates unencoded nested URLs at the first `&`. + */ + private extractOriginalUrl(requestUrl: string): string | null { + let candidate: string | null = null; + + // 1. Raw extraction — takes everything after "original=" to preserve unencoded + // nested query parameters. Boundary check ensures we match a real top-level + // param (at start or preceded by &), not a substring inside another value. + // Contract: when using raw (unencoded) URLs, `original=` must be the last param. + const queryStart = requestUrl.indexOf('?'); + if (queryStart !== -1) { + const queryString = requestUrl.slice(queryStart + 1); + const prefix = 'original='; + const idx = queryString.indexOf(prefix); + if (idx !== -1 && (idx === 0 || queryString[idx - 1] === '&')) { + const rawValue = queryString.slice(idx + prefix.length); + // Only use raw extraction for unencoded URLs (starts with http:// or https://). + // Encoded values (https%3A...) fall through to URLSearchParams which correctly + // separates top-level query params (e.g., ?original=https%3A...&trace=1). + if (isAbsoluteUrl(rawValue)) { + candidate = rawValue; + } + } + } + + // 2. URLSearchParams fallback — handles properly percent-encoded values where + // raw extraction didn't find an absolute URL (e.g., double-encoded values). + if (!candidate) { + try { + const parsed = new URL(requestUrl, 'http://localhost'); + const original = parsed.searchParams.get('original'); + if (original && isAbsoluteUrl(original)) { + candidate = original; + } + } catch { + // Not a valid URL + } + } + + // 3. Validate the candidate is fully parseable as a URL. + // Catches inputs like "http://%zz" that pass isAbsoluteUrl but fail new URL(). + if (candidate) { + try { + new URL(candidate); + return candidate; + } catch { + return null; // Triggers clean 400 "Missing or invalid" response + } + } + + return null; + } + + /** Send a JSON error response */ + private sendError(res: ServerResponse, statusCode: number, message: string): void { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + error: { code: 'MCP_AUTH_ERROR', message } + })); + logger.debug(`[${this.name}] Error ${statusCode}: ${message}`); + } +} diff --git a/src/providers/plugins/sso/proxy/plugins/types.ts b/src/providers/plugins/sso/proxy/plugins/types.ts index 6f917d1c..0f690d9a 100644 --- a/src/providers/plugins/sso/proxy/plugins/types.ts +++ b/src/providers/plugins/sso/proxy/plugins/types.ts @@ -5,11 +5,12 @@ * KISS: Simple, clear interfaces */ -import { IncomingHttpHeaders } from 'http'; +import { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'http'; import { ProxyConfig, ProxyContext } from '../proxy-types.js'; import { logger } from '../../../../../utils/logger.js'; import { SSOCredentials, JWTCredentials } from '../../../../core/types.js'; import type { CodeMieConfigOptions } from '../../../../../env/types.js'; +import type { ProxyHTTPClient } from '../proxy-http-client.js'; /** * Plugin metadata and lifecycle @@ -87,6 +88,28 @@ export interface ProxyInterceptor { /** Called on any error */ onError?(context: ProxyContext, error: Error): Promise; + + /** + * Fully handle a request, bypassing normal proxy forwarding. + * Called BEFORE onRequest hooks. If returns true, ALL normal flow is skipped: + * onRequest, onResponseHeaders, onResponseChunk, onResponseComplete hooks from + * other plugins will NOT run for this request. + * + * This is intentional for traffic that routes to fundamentally different targets + * (e.g., MCP auth servers vs LLM APIs). The handling plugin is responsible for + * its own security guarantees (SSRF protection, logging, auth) since the standard + * pipeline plugins (endpoint blocker, auth injection, request sanitizer) are + * designed for LLM API traffic and do not apply to custom-routed requests. + * + * Use for custom routing (e.g., MCP auth relay to different target URLs). + * Errors thrown here are routed through the normal onError pipeline. + */ + handleRequest?( + context: ProxyContext, + req: IncomingMessage, + res: ServerResponse, + httpClient: ProxyHTTPClient + ): Promise; } /** diff --git a/src/providers/plugins/sso/proxy/sso.proxy.ts b/src/providers/plugins/sso/proxy/sso.proxy.ts index 6bf8de03..f0264c36 100644 --- a/src/providers/plugins/sso/proxy/sso.proxy.ts +++ b/src/providers/plugins/sso/proxy/sso.proxy.ts @@ -140,6 +140,9 @@ export class CodeMieProxy { this.actualPort = address.port; } + // Propagate actual port to config so plugins (e.g., MCP auth) get the real port + this.config.port = this.actualPort; + const gatewayUrl = `http://localhost:${this.actualPort}`; logger.debug(`Proxy started: ${gatewayUrl}`); resolve({ port: this.actualPort, url: gatewayUrl }); @@ -193,6 +196,29 @@ export class CodeMieProxy { // 1. Build context const context = await this.buildContext(req); + // 1.5. Try handleRequest hooks (full custom handling, in priority order). + // When a plugin handles the request (returns true), the standard pipeline + // (onRequest → forward → onResponseHeaders → stream → onResponseComplete) + // is ENTIRELY skipped. This is by design for traffic that targets different + // upstream hosts (e.g., MCP auth servers vs LLM APIs). The handling plugin + // owns all security guarantees for its traffic. See ProxyInterceptor.handleRequest + // in types.ts for the full contract. + for (const interceptor of this.interceptors) { + if (interceptor.handleRequest) { + try { + const handled = await interceptor.handleRequest(context, req, res, this.httpClient); + if (handled) { + logger.debug(`[proxy] Request fully handled by ${interceptor.name}`); + return; + } + } catch (error) { + // Route through the normal error pipeline so onError interceptors run + await this.handleError(error, req, res); + return; + } + } + } + // 2. Run onRequest interceptors (with early termination if blocked) await this.runHook('onRequest', interceptor => interceptor.onRequest?.(context) @@ -498,8 +524,12 @@ export class CodeMieProxy { } } - // Send structured error response - this.sendErrorResponse(res, error, context); + // Send structured error response (or destroy if headers already sent) + if (!res.headersSent) { + this.sendErrorResponse(res, error, context); + } else { + res.destroy(); + } } /** From 5c9d74b3f5bee6d7fe6eef3557181fb4fb7d28a8 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Thu, 9 Apr 2026 22:03:53 +0200 Subject: [PATCH 2/6] feat(proxy): add MCP authorization flow mcp stdio proxy --- bin/mcp-proxy.js | 91 ++++++ package-lock.json | 92 +++++- package.json | 2 + src/cli/commands/mcp-proxy.ts | 57 ++++ src/cli/index.ts | 2 + src/mcp/auth/callback-server.ts | 115 ++++++++ src/mcp/auth/mcp-oauth-provider.ts | 175 +++++++++++ src/mcp/proxy-logger.ts | 33 +++ src/mcp/stdio-http-bridge.ts | 278 ++++++++++++++++++ .../sso/proxy/plugins/mcp-auth.plugin.ts | 8 +- 10 files changed, 843 insertions(+), 10 deletions(-) create mode 100644 bin/mcp-proxy.js create mode 100644 src/cli/commands/mcp-proxy.ts create mode 100644 src/mcp/auth/callback-server.ts create mode 100644 src/mcp/auth/mcp-oauth-provider.ts create mode 100644 src/mcp/proxy-logger.ts create mode 100644 src/mcp/stdio-http-bridge.ts diff --git a/bin/mcp-proxy.js b/bin/mcp-proxy.js new file mode 100644 index 00000000..dc895b28 --- /dev/null +++ b/bin/mcp-proxy.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +/** + * MCP Proxy Entry Point + * + * Lightweight entry point for the stdio-to-HTTP MCP proxy. + * Skips migrations, update checks, and plugin loading to avoid + * any stdout output that would corrupt the JSON-RPC stdio channel. + * + * Usage: + * node bin/mcp-proxy.js + * claude mcp add my-server -- node /path/to/bin/mcp-proxy.js "https://mcp-server/path" + */ + +import { appendFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +// Boot-level file logger (before any imports that might touch stdout) +const logDir = join(homedir(), '.codemie', 'logs'); +const logFile = join(logDir, 'mcp-proxy.log'); +try { mkdirSync(logDir, { recursive: true }); } catch { /* ignore */ } + +function bootLog(msg) { + const line = `[${new Date().toISOString()}] [boot] ${msg}\n`; + try { appendFileSync(logFile, line); } catch { /* ignore */ } +} + +bootLog(`mcp-proxy started, argv: ${JSON.stringify(process.argv)}`); +bootLog(`env: CODEMIE_DEBUG=${process.env.CODEMIE_DEBUG}, MCP_PROXY_DEBUG=${process.env.MCP_PROXY_DEBUG}`); + +const url = process.argv[2]; + +if (!url) { + console.error('Usage: mcp-proxy '); + console.error(' url: MCP server URL to connect to'); + process.exit(1); +} + +try { + new URL(url); +} catch { + bootLog(`Invalid URL: ${url}`); + console.error(`[mcp-proxy] Invalid MCP server URL: ${url}`); + process.exit(1); +} + +bootLog(`URL validated: ${url}`); + +let StdioHttpBridge; +try { + const mod = await import('../dist/mcp/stdio-http-bridge.js'); + StdioHttpBridge = mod.StdioHttpBridge; + bootLog('Bridge module imported successfully'); +} catch (error) { + bootLog(`Failed to import bridge: ${error.message}\n${error.stack}`); + console.error(`[mcp-proxy] Failed to load: ${error.message}`); + process.exit(1); +} + +const bridge = new StdioHttpBridge({ serverUrl: url }); + +const shutdown = async () => { + bootLog('Shutdown signal received'); + try { + await bridge.shutdown(); + } catch (err) { + bootLog(`Shutdown error: ${err.message}`); + } + process.exit(0); +}; + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); +process.on('uncaughtException', (err) => { + bootLog(`Uncaught exception: ${err.message}\n${err.stack}`); + process.exit(1); +}); +process.on('unhandledRejection', (reason) => { + bootLog(`Unhandled rejection: ${reason}`); +}); + +try { + bootLog('Starting bridge...'); + await bridge.start(); + bootLog('Bridge started, listening on stdio'); +} catch (error) { + bootLog(`Fatal error: ${error.message}\n${error.stack}`); + console.error(`[mcp-proxy] Fatal error: ${error.message}`); + process.exit(1); +} diff --git a/package-lock.json b/package-lock.json index b05e8bed..caf23039 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,8 @@ "@langchain/core": "^1.0.4", "@langchain/langgraph": "^1.0.2", "@langchain/openai": "^1.1.0", + "@modelcontextprotocol/client": "^2.0.0-alpha.2", + "@modelcontextprotocol/server": "^2.0.0-alpha.2", "chalk": "^5.3.0", "cli-table3": "^0.6.5", "codemie-sdk": "^0.1.330", @@ -2411,6 +2413,51 @@ "@langchain/core": "^1.0.0" } }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-alpha.2.tgz", + "integrity": "sha512-FxlR5QyBPeCDEDPH2Kx20uygmuy9k2jh6ahUeEYtmVfUxboZZlUEUhn6w0XxnbxpkELcT1qyzTXC8Bqh3c8QUA==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-alpha.2.tgz", + "integrity": "sha512-gmLgdHzlYM8L7Aw/+VE0kxjT25WKamtUSLNhdOgrJq5CrESvqVSoAfWSJJeNPUXNTluQ+dYDGFbKVitdsJtbPA==", + "license": "MIT", + "dependencies": { + "zod": "^4.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4882,7 +4929,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5541,6 +5587,27 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -6639,7 +6706,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jiti": { @@ -6652,6 +6718,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -7788,7 +7863,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7852,6 +7926,15 @@ "node": ">=0.10" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/plimit-lit": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", @@ -8358,7 +8441,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -8371,7 +8453,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9333,7 +9414,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index 62f2bc37..8ba72885 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,8 @@ "@langchain/core": "^1.0.4", "@langchain/langgraph": "^1.0.2", "@langchain/openai": "^1.1.0", + "@modelcontextprotocol/client": "^2.0.0-alpha.2", + "@modelcontextprotocol/server": "^2.0.0-alpha.2", "chalk": "^5.3.0", "cli-table3": "^0.6.5", "codemie-sdk": "^0.1.330", diff --git a/src/cli/commands/mcp-proxy.ts b/src/cli/commands/mcp-proxy.ts new file mode 100644 index 00000000..e5280a5a --- /dev/null +++ b/src/cli/commands/mcp-proxy.ts @@ -0,0 +1,57 @@ +/** + * CLI command: codemie mcp-proxy + * + * Stdio-to-HTTP MCP bridge with built-in OAuth authorization. + * Claude Code spawns this as a stdio MCP server. It connects to the real + * MCP server over streamable HTTP, handling OAuth when required. + * + * Usage: + * claude mcp add --scope project my-server -- codemie mcp-proxy "https://mcp-server.example.com/path" + */ + +import { Command } from 'commander'; +import { StdioHttpBridge } from '../../mcp/stdio-http-bridge.js'; +import { logger } from '../../utils/logger.js'; + +export function createMcpProxyCommand(): Command { + const command = new Command('mcp-proxy'); + + command + .description('Run a stdio-to-HTTP MCP proxy with OAuth support') + .argument('', 'MCP server URL to connect to') + .action(async (url: string) => { + // Validate URL + try { + new URL(url); + } catch { + console.error(`[mcp-proxy] Invalid MCP server URL: ${url}`); + process.exit(1); + } + + const bridge = new StdioHttpBridge({ serverUrl: url }); + + // Graceful shutdown on signals + const shutdown = async () => { + try { + logger.debug('[mcp-proxy] Received shutdown signal'); + await bridge.shutdown(); + } catch (err) { + logger.debug(`[mcp-proxy] Error during shutdown: ${(err as Error).message}`); + } + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + try { + await bridge.start(); + } catch (error) { + console.error(`[mcp-proxy] Fatal error: ${(error as Error).message}`); + logger.debug(`[mcp-proxy] Fatal error: ${(error as Error).stack}`); + process.exit(1); + } + }); + + return command; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 1a00b655..51ed5467 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -31,6 +31,7 @@ import { createOpencodeMetricsCommand } from './commands/opencode-metrics.js'; import { createTestMetricsCommand } from './commands/test-metrics.js'; import { createModelsCommand } from './commands/models.js'; import { createAssistantsCommand } from './commands/assistants/index.js'; +import { createMcpProxyCommand } from './commands/mcp-proxy.js'; import { FirstTimeExperience } from './first-time.js'; import { getDirname } from '../utils/paths.js'; @@ -74,6 +75,7 @@ program.addCommand(createPluginCommand()); program.addCommand(createOpencodeMetricsCommand()); program.addCommand(createTestMetricsCommand()); program.addCommand(createModelsCommand()); +program.addCommand(createMcpProxyCommand()); // Check for --task option before parsing commands const taskIndex = process.argv.indexOf('--task'); diff --git a/src/mcp/auth/callback-server.ts b/src/mcp/auth/callback-server.ts new file mode 100644 index 00000000..b86a7448 --- /dev/null +++ b/src/mcp/auth/callback-server.ts @@ -0,0 +1,115 @@ +/** + * Ephemeral localhost HTTP server for receiving OAuth authorization callbacks. + * + * Starts on an OS-assigned port, waits for a single callback with an authorization + * code, then shuts down. Used during the MCP OAuth browser-based authorization flow. + */ + +import { createServer, type Server } from 'http'; +import { URL } from 'url'; +import { logger } from '../../utils/logger.js'; + +export interface CallbackResult { + code: string; + state?: string; +} + +/** + * Start an ephemeral callback server and return the redirect URL and a promise + * that resolves with the authorization code when the callback is received. + */ +export async function startCallbackServer(options?: { + timeoutMs?: number; +}): Promise<{ + redirectUrl: string; + waitForCallback: Promise; + close: () => void; +}> { + const timeoutMs = options?.timeoutMs ?? 120_000; // 2 minutes default + + let settled = false; + let resolveCallback: (result: CallbackResult) => void; + let rejectCallback: (error: Error) => void; + const waitForCallback = new Promise((resolve, reject) => { + resolveCallback = resolve; + rejectCallback = reject; + }); + + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + fn(); + }; + + const server: Server = createServer((req, res) => { + const url = new URL(req.url || '/', `http://localhost`); + + if (url.pathname !== '/callback') { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + return; + } + + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + const errorDescription = url.searchParams.get('error_description'); + + if (error) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Authorization failed

You can close this tab.

'); + settle(() => rejectCallback(new Error(`OAuth error: ${error}${errorDescription ? ` — ${errorDescription}` : ''}`))); + return; + } + + if (!code) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end('

Missing authorization code

'); + settle(() => rejectCallback(new Error('Missing authorization code in callback'))); + return; + } + + const state = url.searchParams.get('state') || undefined; + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Authorization successful

You can close this tab.

'); + + settle(() => resolveCallback({ code, state })); + }); + + // Listen on OS-assigned port + await new Promise((resolve, reject) => { + server.listen(0, 'localhost', () => resolve()); + server.on('error', reject); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Failed to get callback server address'); + } + + const redirectUrl = `http://localhost:${address.port}/callback`; + logger.debug(`[mcp-proxy] OAuth callback server listening on ${redirectUrl}`); + + // Timeout: reject if no callback received within timeoutMs + const timer = setTimeout(() => { + settle(() => rejectCallback(new Error(`OAuth authorization timed out after ${timeoutMs / 1000}s`))); + server.close(); + }, timeoutMs); + + // Auto-close server after callback (success or error) + const originalWait = waitForCallback; + const cleanupWait = originalWait.finally(() => { + clearTimeout(timer); + server.close(); + logger.debug('[mcp-proxy] OAuth callback server closed'); + }); + + const close = () => { + settle(() => rejectCallback(new Error('Callback server closed'))); + clearTimeout(timer); + server.close(); + }; + + return { redirectUrl, waitForCallback: cleanupWait, close }; +} diff --git a/src/mcp/auth/mcp-oauth-provider.ts b/src/mcp/auth/mcp-oauth-provider.ts new file mode 100644 index 00000000..d2d10872 --- /dev/null +++ b/src/mcp/auth/mcp-oauth-provider.ts @@ -0,0 +1,175 @@ +/** + * MCP OAuth Client Provider + * + * Implements the OAuthClientProvider interface from the MCP SDK for browser-based + * OAuth authorization code flow. All state is memory-only (no persistent storage). + * + * Flow: 401 → resource metadata → auth server metadata → dynamic client registration + * (client_name = "Claude Code") → browser authorization → callback → token exchange. + */ + +import { execFile } from 'child_process'; +import { logger } from '../../utils/logger.js'; +import { startCallbackServer, type CallbackResult } from './callback-server.js'; + +import type { + OAuthClientProvider, + OAuthClientMetadata, + OAuthClientInformationMixed, + OAuthTokens, +} from '@modelcontextprotocol/client'; + +/** + * In-memory OAuth provider for MCP authorization code flow. + * Tokens and client info are stored in memory only — re-auth required each session. + */ +export class McpOAuthProvider implements OAuthClientProvider { + private _redirectUrl: string | undefined; + private _clientInfo: OAuthClientInformationMixed | undefined; + private _tokens: OAuthTokens | undefined; + private _codeVerifier: string | undefined; + + // Callback server state (active during authorization) + private callbackWait: Promise | undefined; + private callbackClose: (() => void) | undefined; + + get redirectUrl(): string | undefined { + return this._redirectUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return { + client_name: 'Claude Code (stage_onehub_core)', + redirect_uris: this._redirectUrl ? [this._redirectUrl] : [], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }; + } + + /** + * Pre-start the callback server so that clientMetadata.redirect_uris is + * populated before the SDK calls registerClient(). Must be called before + * connecting the HTTP transport. + */ + async ensureCallbackServer(): Promise { + if (this.callbackWait) return; + const { redirectUrl, waitForCallback, close } = await startCallbackServer(); + this._redirectUrl = redirectUrl; + this.callbackWait = waitForCallback; + this.callbackClose = close; + logger.debug(`[mcp-proxy] Callback server pre-started: ${redirectUrl}`); + } + + clientInformation(): OAuthClientInformationMixed | undefined { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformationMixed): void { + this._clientInfo = info; + logger.debug('[mcp-proxy] Saved client information (memory-only)'); + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + logger.debug('[mcp-proxy] Saved OAuth tokens (memory-only)'); + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + // Start ephemeral callback server if not already running + if (!this.callbackWait) { + const { redirectUrl, waitForCallback, close } = await startCallbackServer(); + this._redirectUrl = redirectUrl; + this.callbackWait = waitForCallback; + this.callbackClose = close; + } + + const url = authorizationUrl.toString(); + logger.debug(`[mcp-proxy] Opening browser for authorization`); + console.error('[mcp-proxy] Opening browser for MCP server authorization...'); + + openBrowser(url); + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + return this._codeVerifier || ''; + } + + invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void { + if (scope === 'all' || scope === 'tokens') { + this._tokens = undefined; + } + if (scope === 'all' || scope === 'client') { + this._clientInfo = undefined; + } + if (scope === 'all' || scope === 'verifier') { + this._codeVerifier = undefined; + } + logger.debug(`[mcp-proxy] Invalidated credentials: ${scope}`); + } + + /** + * Wait for the OAuth callback after browser redirect. + * Returns the authorization code from the callback. + * This is called externally by the bridge after auth() returns 'REDIRECT'. + */ + async waitForAuthorizationCode(): Promise { + if (!this.callbackWait) { + throw new Error('No active authorization flow — callback server not started'); + } + + try { + const result = await this.callbackWait; + logger.debug('[mcp-proxy] Received authorization callback'); + return result.code; + } finally { + this.callbackWait = undefined; + this.callbackClose = undefined; + } + } + + /** + * Clean up the callback server if still running (e.g., on shutdown). + */ + dispose(): void { + this.callbackClose?.(); + this.callbackWait = undefined; + this.callbackClose = undefined; + } +} + +/** + * Open a URL in the system default browser. + * Cross-platform: macOS (open), Windows (start), Linux (xdg-open). + */ +function openBrowser(url: string): void { + const platform = process.platform; + let command: string; + let args: string[]; + + if (platform === 'darwin') { + command = 'open'; + args = [url]; + } else if (platform === 'win32') { + command = 'cmd'; + args = ['/c', 'start', '', url]; + } else { + command = 'xdg-open'; + args = [url]; + } + + execFile(command, args, (error) => { + if (error) { + // Don't fail — user can manually copy the URL from stderr + console.error(`[mcp-proxy] Could not open browser automatically. Please open this URL:\n${url}`); + } + }); +} diff --git a/src/mcp/proxy-logger.ts b/src/mcp/proxy-logger.ts new file mode 100644 index 00000000..f13a378d --- /dev/null +++ b/src/mcp/proxy-logger.ts @@ -0,0 +1,33 @@ +/** + * Simple file logger for the MCP proxy. + * Writes to ~/.codemie/mcp-proxy.log — independent of the main logger. + * Enabled when CODEMIE_DEBUG=true or MCP_PROXY_DEBUG=true. + */ + +import { appendFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +const enabled = process.env.CODEMIE_DEBUG === 'true' + || process.env.CODEMIE_DEBUG === '1' + || process.env.MCP_PROXY_DEBUG === 'true' + || process.env.MCP_PROXY_DEBUG === '1'; + +const logDir = join(homedir(), '.codemie', 'logs'); +const logFile = join(logDir, 'mcp-proxy.log'); + +try { + mkdirSync(logDir, { recursive: true }); +} catch { + // ignore +} + +export function proxyLog(message: string): void { + if (!enabled) return; + const line = `[${new Date().toISOString()}] ${message}\n`; + try { + appendFileSync(logFile, line); + } catch { + // ignore — can't log if file write fails + } +} diff --git a/src/mcp/stdio-http-bridge.ts b/src/mcp/stdio-http-bridge.ts new file mode 100644 index 00000000..6241f2e1 --- /dev/null +++ b/src/mcp/stdio-http-bridge.ts @@ -0,0 +1,278 @@ +/** + * Stdio-to-HTTP MCP Bridge + * + * Pipes JSON-RPC messages between a StdioServerTransport (Claude Code side) + * and a StreamableHTTPClientTransport (real MCP server side). + * + * Lazy connect: the HTTP transport is created and started only when the first + * stdio message arrives. If the server requires OAuth, the auth flow runs during + * that first connection (blocking the first message until auth completes). + */ + +import { + StreamableHTTPClientTransport, + UnauthorizedError, +} from '@modelcontextprotocol/client'; +import { StdioServerTransport } from '@modelcontextprotocol/server'; +import type { JSONRPCMessage } from '@modelcontextprotocol/client'; +import { logger } from '../utils/logger.js'; +import { proxyLog } from './proxy-logger.js'; +import { McpOAuthProvider } from './auth/mcp-oauth-provider.js'; + +function log(msg: string): void { + logger.debug(msg); + proxyLog(msg); +} + +/** Serialize an error with all available details (message, cause, status, body, stack). */ +function errorDetail(error: unknown): string { + if (!(error instanceof Error)) return String(error); + const parts: string[] = [`${error.constructor.name}: ${error.message}`]; + // Capture any extra properties the SDK may attach (status, statusCode, body, response, etc.) + for (const key of ['status', 'statusCode', 'code', 'body', 'response', 'statusText']) { + const val = (error as unknown as Record)[key]; + if (val !== undefined) parts.push(` ${key}: ${JSON.stringify(val).slice(0, 500)}`); + } + if (error.cause) parts.push(` cause: ${errorDetail(error.cause)}`); + if (error.stack) parts.push(` stack: ${error.stack}`); + return parts.join('\n'); +} + +export interface BridgeOptions { + /** The real MCP server URL to connect to */ + serverUrl: string; +} + +export class StdioHttpBridge { + private stdioTransport: StdioServerTransport; + private httpTransport: StreamableHTTPClientTransport | null = null; + private oauthProvider: McpOAuthProvider; + private serverUrl: URL; + private connected = false; + private connecting = false; + private shuttingDown = false; + private pendingMessages: JSONRPCMessage[] = []; + + constructor(options: BridgeOptions) { + this.serverUrl = new URL(options.serverUrl); + this.oauthProvider = new McpOAuthProvider(); + this.stdioTransport = new StdioServerTransport(); + log(`[mcp-proxy] Bridge created for ${this.serverUrl}`); + } + + /** + * Start the bridge: begin listening on stdio immediately. + * HTTP connection is deferred until the first message arrives. + */ + async start(): Promise { + // Wire up stdio transport to handle incoming messages + this.stdioTransport.onmessage = (message: JSONRPCMessage) => { + this.handleStdioMessage(message); + }; + + this.stdioTransport.onclose = () => { + log('[mcp-proxy] Stdio transport closed'); + this.shutdown(); + }; + + this.stdioTransport.onerror = (error: Error) => { + log(`[mcp-proxy] Stdio transport error: ${error.message}`); + }; + + // Start listening on stdio + await this.stdioTransport.start(); + log('[mcp-proxy] Stdio transport started, waiting for messages'); + } + + /** + * Handle a message from Claude Code (stdio side). + * On the first message, lazily connect the HTTP transport. + * Drops messages if shutdown is in progress. + */ + private handleStdioMessage(message: JSONRPCMessage): void { + if (this.shuttingDown) return; + + log(`[mcp-proxy] Received stdio message: ${JSON.stringify(message).slice(0, 200)}`); + + if (this.connected && this.httpTransport) { + // Fast path: already connected, forward immediately + this.httpTransport.send(message).catch((error: unknown) => { + log(`[mcp-proxy] Error forwarding to HTTP:\n${errorDetail(error)}`); + this.shutdown(); + }); + return; + } + + // Queue message while connecting + this.pendingMessages.push(message); + log(`[mcp-proxy] Queued message (${this.pendingMessages.length} pending), connecting=${this.connecting}`); + + if (!this.connecting) { + this.connecting = true; + this.connectHttpTransport().catch((error: unknown) => { + // Suppress connection errors during shutdown — not a fatal failure + if (this.shuttingDown) { + log(`[mcp-proxy] Connection aborted during shutdown: ${errorDetail(error)}`); + return; + } + log(`[mcp-proxy] Failed to connect to MCP server:\n${errorDetail(error)}`); + process.exit(1); + }); + } + } + + /** + * Lazily create and connect the HTTP transport to the real MCP server. + * Handles OAuth authorization if the server returns 401. + */ + private async connectHttpTransport(): Promise { + log(`[mcp-proxy] Connecting to MCP server: ${this.serverUrl}`); + + // First attempt: connect WITHOUT auth provider. + // If the server doesn't require auth, this avoids the transport sending + // OAuth client metadata (client_name etc.) that the server may reject. + this.httpTransport = this.createHttpTransport(); + log('[mcp-proxy] HTTP transport created (no auth)'); + + try { + try { + log('[mcp-proxy] Starting HTTP transport...'); + await this.httpTransport.start(); + log('[mcp-proxy] HTTP transport started successfully (no auth needed)'); + } catch (error) { + log(`[mcp-proxy] HTTP transport start error:\n${errorDetail(error)}`); + if (error instanceof UnauthorizedError) { + log('[mcp-proxy] Server requires authorization, reconnecting with OAuth'); + + // Pre-start callback server so clientMetadata.redirect_uris is populated + // before the SDK calls registerClient() during the OAuth flow. + await this.oauthProvider.ensureCallbackServer(); + log('[mcp-proxy] Callback server pre-started'); + + // Recreate transport WITH auth provider + this.httpTransport = this.createHttpTransport(this.oauthProvider); + log('[mcp-proxy] HTTP transport recreated with auth provider'); + + // Start will trigger the OAuth flow via the provider + try { + await this.httpTransport.start(); + log('[mcp-proxy] HTTP transport started (may need browser auth)'); + } catch (authError) { + if (authError instanceof UnauthorizedError) { + log('[mcp-proxy] OAuth redirect initiated, waiting for browser auth'); + await this.handleOAuthFlow(); + } else { + throw authError; + } + } + } else { + throw error; + } + } + + this.connected = true; + log('[mcp-proxy] HTTP transport connected'); + + // Flush any queued messages + await this.flushPendingMessages(); + } finally { + this.connecting = false; + } + } + + /** + * Create an HTTP transport with common event handlers. + */ + private createHttpTransport(authProvider?: McpOAuthProvider): StreamableHTTPClientTransport { + const opts = authProvider ? { authProvider } : {}; + const transport = new StreamableHTTPClientTransport(this.serverUrl, opts); + + transport.onmessage = (message: JSONRPCMessage) => { + log(`[mcp-proxy] Received HTTP message: ${JSON.stringify(message).slice(0, 200)}`); + this.stdioTransport.send(message).catch((error: Error) => { + log(`[mcp-proxy] Error forwarding to stdio: ${error.message}`); + }); + }; + + transport.onclose = () => { + log('[mcp-proxy] HTTP transport closed'); + this.shutdown(); + }; + + transport.onerror = (error: Error) => { + log(`[mcp-proxy] HTTP transport error:\n${errorDetail(error)}`); + }; + + return transport; + } + + /** + * Handle the OAuth authorization code flow. + * 1. The provider's redirectToAuthorization() has already opened the browser + * 2. Wait for the callback with the authorization code + * 3. Call finishAuth() on the transport + * 4. Restart the transport + */ + private async handleOAuthFlow(): Promise { + // Wait for the user to complete browser authorization + log('[mcp-proxy] Waiting for authorization code from browser...'); + const code = await this.oauthProvider.waitForAuthorizationCode(); + log('[mcp-proxy] Authorization code received, exchanging for token'); + + // Exchange the code for tokens + await this.httpTransport!.finishAuth(code); + log('[mcp-proxy] Token exchange complete, reconnecting'); + + // Restart the transport — now with valid tokens + await this.httpTransport!.start(); + log('[mcp-proxy] Reconnected after OAuth'); + } + + /** + * Forward any messages that arrived while we were connecting/authenticating. + */ + private async flushPendingMessages(): Promise { + const messages = this.pendingMessages; + this.pendingMessages = []; + + for (const message of messages) { + try { + await this.httpTransport!.send(message); + } catch (error) { + log(`[mcp-proxy] Error flushing pending message:\n${errorDetail(error)}`); + } + } + + if (messages.length > 0) { + log(`[mcp-proxy] Flushed ${messages.length} pending message(s)`); + } + } + + /** + * Graceful shutdown: close both transports. Idempotent — safe to call multiple times. + */ + async shutdown(): Promise { + if (this.shuttingDown) return; + this.shuttingDown = true; + + log('[mcp-proxy] Shutting down bridge'); + this.oauthProvider.dispose(); + + try { + if (this.httpTransport) { + await this.httpTransport.terminateSession(); + await this.httpTransport.close(); + } + } catch (error) { + log(`[mcp-proxy] Error closing HTTP transport: ${(error as Error).message}`); + } + + try { + await this.stdioTransport.close(); + } catch (error) { + log(`[mcp-proxy] Error closing stdio transport: ${(error as Error).message}`); + } + + log('[mcp-proxy] Bridge shutdown complete'); + } +} diff --git a/src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts b/src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts index a7af9e63..218101de 100644 --- a/src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts +++ b/src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts @@ -4,7 +4,7 @@ * * Proxies the MCP OAuth authorization flow so that: * 1. All auth traffic is routed through the CodeMie proxy - * 2. `client_name` is replaced with "Codemie CLI" in dynamic client registration + * 2. `client_name` is replaced with "Claude Code" in dynamic client registration * * URL scheme: * - /mcp_auth?original= → Initial MCP connection @@ -868,7 +868,7 @@ class MCPAuthInterceptor implements ProxyInterceptor { // ─── Request Body Modification ─────────────────────────────────────────── /** - * Replace `client_name` with "Codemie CLI" in JSON request bodies. + * Replace `client_name` with "Claude Code" in JSON request bodies. * This targets the OAuth dynamic client registration (POST /register). */ private rewriteRequestBody(body: Buffer, headers: Record): Buffer { @@ -876,10 +876,10 @@ class MCPAuthInterceptor implements ProxyInterceptor { const parsed = JSON.parse(body.toString('utf-8')); if (typeof parsed === 'object' && parsed !== null && 'client_name' in parsed) { - parsed.client_name = 'Codemie CLI'; + parsed.client_name = 'Claude Code (stage_onehub_core)'; const newBody = Buffer.from(JSON.stringify(parsed), 'utf-8'); headers['content-length'] = String(newBody.length); - logger.debug(`[${this.name}] Replaced client_name with "Codemie CLI"`); + logger.debug(`[${this.name}] Replaced client_name with "Claude Code"`); return newBody; } } catch { From 0a5a411a9daec894901e368d2a6b48aad7cbefcf Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Fri, 10 Apr 2026 00:02:07 +0200 Subject: [PATCH 3/6] feat(proxy): add cookie jar and cleanup stdio-http bridge Add per-origin cookie jar to persist session cookies across MCP requests, pass transport explicitly to OAuth flow handler, and clean up comments. Also fix gitleaks allowlist to match .mcp.json.lock files. Generated with AI Co-Authored-By: codemie-ai --- .gitignore | 2 +- .gitleaks.toml | 2 +- bin/{mcp-proxy.js => codemie-mcp-proxy.js} | 0 package.json | 3 +- src/mcp/auth/mcp-oauth-provider.ts | 5 +- src/mcp/constants.ts | 7 + src/mcp/stdio-http-bridge.ts | 191 ++++++++++++------ .../sso/proxy/plugins/mcp-auth.plugin.ts | 11 +- 8 files changed, 151 insertions(+), 70 deletions(-) rename bin/{mcp-proxy.js => codemie-mcp-proxy.js} (100%) create mode 100644 src/mcp/constants.ts diff --git a/.gitignore b/.gitignore index a288658c..aed9ce80 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ dist/ .env.*.local # MCP config (contains API keys) -.mcp.json +.mcp.json* # IDE .vscode/ diff --git a/.gitleaks.toml b/.gitleaks.toml index 39f2b965..cb164306 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -13,5 +13,5 @@ paths = [ '''src/utils/__tests__/sanitize\.test\.ts$''', '''dist/''', '''\.idea/''', - '''\.mcp\.json$''' + '''\.mcp\.json''' ] diff --git a/bin/mcp-proxy.js b/bin/codemie-mcp-proxy.js similarity index 100% rename from bin/mcp-proxy.js rename to bin/codemie-mcp-proxy.js diff --git a/package.json b/package.json index 8ba72885..83880a24 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "codemie-claude": "./bin/codemie-claude.js", "codemie-claude-acp": "./bin/codemie-claude-acp.js", "codemie-gemini": "./bin/codemie-gemini.js", - "codemie-opencode": "./bin/codemie-opencode.js" + "codemie-opencode": "./bin/codemie-opencode.js", + "codemie-mcp-proxy": "./bin/codemie-mcp-proxy.js" }, "files": [ "dist", diff --git a/src/mcp/auth/mcp-oauth-provider.ts b/src/mcp/auth/mcp-oauth-provider.ts index d2d10872..dd1fce65 100644 --- a/src/mcp/auth/mcp-oauth-provider.ts +++ b/src/mcp/auth/mcp-oauth-provider.ts @@ -5,11 +5,12 @@ * OAuth authorization code flow. All state is memory-only (no persistent storage). * * Flow: 401 → resource metadata → auth server metadata → dynamic client registration - * (client_name = "Claude Code") → browser authorization → callback → token exchange. + * (client_name from MCP_CLIENT_NAME env var, default "CodeMie CLI") → browser authorization → callback → token exchange. */ import { execFile } from 'child_process'; import { logger } from '../../utils/logger.js'; +import { getMcpClientName } from '../constants.js'; import { startCallbackServer, type CallbackResult } from './callback-server.js'; import type { @@ -39,7 +40,7 @@ export class McpOAuthProvider implements OAuthClientProvider { get clientMetadata(): OAuthClientMetadata { return { - client_name: 'Claude Code (stage_onehub_core)', + client_name: getMcpClientName(), redirect_uris: this._redirectUrl ? [this._redirectUrl] : [], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], diff --git a/src/mcp/constants.ts b/src/mcp/constants.ts new file mode 100644 index 00000000..e1a89f92 --- /dev/null +++ b/src/mcp/constants.ts @@ -0,0 +1,7 @@ +/** Default client_name for MCP OAuth dynamic client registration. Overridable via MCP_CLIENT_NAME env var. */ +export const DEFAULT_MCP_CLIENT_NAME = 'CodeMie CLI'; + +/** Get the MCP client name from env var or default. */ +export function getMcpClientName(): string { + return process.env.MCP_CLIENT_NAME || DEFAULT_MCP_CLIENT_NAME; +} diff --git a/src/mcp/stdio-http-bridge.ts b/src/mcp/stdio-http-bridge.ts index 6241f2e1..de0eb94e 100644 --- a/src/mcp/stdio-http-bridge.ts +++ b/src/mcp/stdio-http-bridge.ts @@ -7,6 +7,10 @@ * Lazy connect: the HTTP transport is created and started only when the first * stdio message arrives. If the server requires OAuth, the auth flow runs during * that first connection (blocking the first message until auth completes). + * + * Cookie jar: Node's fetch doesn't persist cookies between requests. Some MCP + * auth gateways set session cookies during the OAuth flow that must be sent with + * subsequent requests. The bridge maintains a per-origin cookie jar automatically. */ import { @@ -28,8 +32,7 @@ function log(msg: string): void { function errorDetail(error: unknown): string { if (!(error instanceof Error)) return String(error); const parts: string[] = [`${error.constructor.name}: ${error.message}`]; - // Capture any extra properties the SDK may attach (status, statusCode, body, response, etc.) - for (const key of ['status', 'statusCode', 'code', 'body', 'response', 'statusText']) { + for (const key of ['status', 'statusCode', 'code', 'body', 'response', 'statusText', 'data']) { const val = (error as unknown as Record)[key]; if (val !== undefined) parts.push(` ${key}: ${JSON.stringify(val).slice(0, 500)}`); } @@ -38,6 +41,44 @@ function errorDetail(error: unknown): string { return parts.join('\n'); } +/** + * Minimal cookie jar: stores Set-Cookie values keyed by origin, sends them + * back on subsequent requests to the same origin. + */ +class CookieJar { + /** origin → Map */ + private cookies = new Map>(); + + /** Extract and store cookies from a response's Set-Cookie headers. */ + capture(requestUrl: string, response: Response): void { + const origin = new URL(requestUrl).origin; + // getSetCookie() returns individual Set-Cookie header values + const setCookies = response.headers.getSetCookie?.() ?? []; + if (setCookies.length === 0) return; + + let jar = this.cookies.get(origin); + if (!jar) { + jar = new Map(); + this.cookies.set(origin, jar); + } + for (const raw of setCookies) { + const name = raw.split('=')[0]?.trim(); + if (name) { + jar.set(name, raw.split(';')[0]!); // store "name=value" only + log(`[mcp-proxy] Cookie stored for ${origin}: ${name}=***`); + } + } + } + + /** Build a Cookie header value for the given request URL. */ + headerFor(requestUrl: string): string | undefined { + const origin = new URL(requestUrl).origin; + const jar = this.cookies.get(origin); + if (!jar || jar.size === 0) return undefined; + return [...jar.values()].join('; '); + } +} + export interface BridgeOptions { /** The real MCP server URL to connect to */ serverUrl: string; @@ -48,6 +89,7 @@ export class StdioHttpBridge { private httpTransport: StreamableHTTPClientTransport | null = null; private oauthProvider: McpOAuthProvider; private serverUrl: URL; + private cookieJar = new CookieJar(); private connected = false; private connecting = false; private shuttingDown = false; @@ -65,7 +107,6 @@ export class StdioHttpBridge { * HTTP connection is deferred until the first message arrives. */ async start(): Promise { - // Wire up stdio transport to handle incoming messages this.stdioTransport.onmessage = (message: JSONRPCMessage) => { this.handleStdioMessage(message); }; @@ -79,7 +120,6 @@ export class StdioHttpBridge { log(`[mcp-proxy] Stdio transport error: ${error.message}`); }; - // Start listening on stdio await this.stdioTransport.start(); log('[mcp-proxy] Stdio transport started, waiting for messages'); } @@ -87,7 +127,6 @@ export class StdioHttpBridge { /** * Handle a message from Claude Code (stdio side). * On the first message, lazily connect the HTTP transport. - * Drops messages if shutdown is in progress. */ private handleStdioMessage(message: JSONRPCMessage): void { if (this.shuttingDown) return; @@ -95,7 +134,6 @@ export class StdioHttpBridge { log(`[mcp-proxy] Received stdio message: ${JSON.stringify(message).slice(0, 200)}`); if (this.connected && this.httpTransport) { - // Fast path: already connected, forward immediately this.httpTransport.send(message).catch((error: unknown) => { log(`[mcp-proxy] Error forwarding to HTTP:\n${errorDetail(error)}`); this.shutdown(); @@ -103,14 +141,12 @@ export class StdioHttpBridge { return; } - // Queue message while connecting this.pendingMessages.push(message); log(`[mcp-proxy] Queued message (${this.pendingMessages.length} pending), connecting=${this.connecting}`); if (!this.connecting) { this.connecting = true; this.connectHttpTransport().catch((error: unknown) => { - // Suppress connection errors during shutdown — not a fatal failure if (this.shuttingDown) { log(`[mcp-proxy] Connection aborted during shutdown: ${errorDetail(error)}`); return; @@ -128,64 +164,100 @@ export class StdioHttpBridge { private async connectHttpTransport(): Promise { log(`[mcp-proxy] Connecting to MCP server: ${this.serverUrl}`); - // First attempt: connect WITHOUT auth provider. - // If the server doesn't require auth, this avoids the transport sending - // OAuth client metadata (client_name etc.) that the server may reject. - this.httpTransport = this.createHttpTransport(); - log('[mcp-proxy] HTTP transport created (no auth)'); + await this.oauthProvider.ensureCallbackServer(); + log('[mcp-proxy] Callback server pre-started'); + + this.httpTransport = this.createHttpTransport(this.oauthProvider); + log('[mcp-proxy] HTTP transport created with auth provider'); try { + log('[mcp-proxy] Starting HTTP transport...'); + await this.httpTransport.start(); + log('[mcp-proxy] HTTP transport started'); + + this.connected = true; + log('[mcp-proxy] HTTP transport connected'); + try { - log('[mcp-proxy] Starting HTTP transport...'); - await this.httpTransport.start(); - log('[mcp-proxy] HTTP transport started successfully (no auth needed)'); + await this.flushPendingMessages(); } catch (error) { - log(`[mcp-proxy] HTTP transport start error:\n${errorDetail(error)}`); if (error instanceof UnauthorizedError) { - log('[mcp-proxy] Server requires authorization, reconnecting with OAuth'); - - // Pre-start callback server so clientMetadata.redirect_uris is populated - // before the SDK calls registerClient() during the OAuth flow. - await this.oauthProvider.ensureCallbackServer(); - log('[mcp-proxy] Callback server pre-started'); - - // Recreate transport WITH auth provider - this.httpTransport = this.createHttpTransport(this.oauthProvider); - log('[mcp-proxy] HTTP transport recreated with auth provider'); - - // Start will trigger the OAuth flow via the provider - try { - await this.httpTransport.start(); - log('[mcp-proxy] HTTP transport started (may need browser auth)'); - } catch (authError) { - if (authError instanceof UnauthorizedError) { - log('[mcp-proxy] OAuth redirect initiated, waiting for browser auth'); - await this.handleOAuthFlow(); - } else { - throw authError; - } - } + log('[mcp-proxy] Auth required on first send, completing OAuth flow'); + await this.handleOAuthFlow(this.httpTransport); + log('[mcp-proxy] OAuth complete, retrying queued messages'); + await this.flushPendingMessages(); } else { throw error; } } + } catch (error) { + if (error instanceof UnauthorizedError) { + log('[mcp-proxy] Auth required on start, completing OAuth flow'); + await this.handleOAuthFlow(this.httpTransport!); - this.connected = true; - log('[mcp-proxy] HTTP transport connected'); + this.connected = true; + log('[mcp-proxy] HTTP transport connected after OAuth'); - // Flush any queued messages - await this.flushPendingMessages(); + await this.flushPendingMessages(); + } else { + throw error; + } } finally { this.connecting = false; } } /** - * Create an HTTP transport with common event handlers. + * Create an HTTP transport with cookie jar and logging. */ private createHttpTransport(authProvider?: McpOAuthProvider): StreamableHTTPClientTransport { - const opts = authProvider ? { authProvider } : {}; - const transport = new StreamableHTTPClientTransport(this.serverUrl, opts); + const jar = this.cookieJar; + + // Wrap fetch to: (1) inject cookies, (2) capture Set-Cookie, (3) log details + const cookieFetch: typeof fetch = async (input, init) => { + const reqUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url; + const method = init?.method ?? 'GET'; + log(`[mcp-proxy] HTTP ${method} ${reqUrl}`); + if (init?.body) log(`[mcp-proxy] Request body: ${String(init.body).slice(0, 300)}`); + + // Inject stored cookies into the request + const cookieHeader = jar.headerFor(reqUrl); + if (cookieHeader && init?.headers) { + const headers = init.headers instanceof Headers ? init.headers : new Headers(init.headers as Record); + headers.set('Cookie', cookieHeader); + init = { ...init, headers }; + log(`[mcp-proxy] Injected cookies for ${new URL(reqUrl).origin}`); + } + + // Log auth header presence (not value) + if (init?.headers instanceof Headers) { + log(`[mcp-proxy] Has Authorization: ${init.headers.has('Authorization')}`); + log(`[mcp-proxy] Request headers: ${[...init.headers.keys()].join(', ')}`); + } + + const response = await fetch(input, init); + + log(`[mcp-proxy] HTTP response: ${response.status} ${response.statusText}`); + const ct = response.headers.get('content-type'); + if (ct) log(`[mcp-proxy] Response content-type: ${ct}`); + + // Capture any Set-Cookie headers from the response + jar.capture(reqUrl, response); + + // Log error response bodies + if (!response.ok) { + const cloned = response.clone(); + const errorBody = await cloned.text().catch(() => '(unreadable)'); + log(`[mcp-proxy] Error response body: ${errorBody.slice(0, 500)}`); + } + + return response; + }; + + const transport = new StreamableHTTPClientTransport(this.serverUrl, { + fetch: cookieFetch, + ...(authProvider ? { authProvider } : {}), + }); transport.onmessage = (message: JSONRPCMessage) => { log(`[mcp-proxy] Received HTTP message: ${JSON.stringify(message).slice(0, 200)}`); @@ -208,28 +280,19 @@ export class StdioHttpBridge { /** * Handle the OAuth authorization code flow. - * 1. The provider's redirectToAuthorization() has already opened the browser - * 2. Wait for the callback with the authorization code - * 3. Call finishAuth() on the transport - * 4. Restart the transport */ - private async handleOAuthFlow(): Promise { - // Wait for the user to complete browser authorization + private async handleOAuthFlow(transport: StreamableHTTPClientTransport): Promise { log('[mcp-proxy] Waiting for authorization code from browser...'); const code = await this.oauthProvider.waitForAuthorizationCode(); log('[mcp-proxy] Authorization code received, exchanging for token'); - // Exchange the code for tokens - await this.httpTransport!.finishAuth(code); - log('[mcp-proxy] Token exchange complete, reconnecting'); - - // Restart the transport — now with valid tokens - await this.httpTransport!.start(); - log('[mcp-proxy] Reconnected after OAuth'); + await transport.finishAuth(code); + log('[mcp-proxy] Token exchange complete, transport ready'); } /** * Forward any messages that arrived while we were connecting/authenticating. + * UnauthorizedError is re-thrown so the caller can handle the OAuth flow. */ private async flushPendingMessages(): Promise { const messages = this.pendingMessages; @@ -239,6 +302,12 @@ export class StdioHttpBridge { try { await this.httpTransport!.send(message); } catch (error) { + if (error instanceof UnauthorizedError) { + const remaining = messages.slice(messages.indexOf(message)); + this.pendingMessages = remaining.concat(this.pendingMessages); + log(`[mcp-proxy] UnauthorizedError during flush, re-queued ${remaining.length} message(s)`); + throw error; + } log(`[mcp-proxy] Error flushing pending message:\n${errorDetail(error)}`); } } @@ -249,7 +318,7 @@ export class StdioHttpBridge { } /** - * Graceful shutdown: close both transports. Idempotent — safe to call multiple times. + * Graceful shutdown: close both transports. Idempotent. */ async shutdown(): Promise { if (this.shuttingDown) return; diff --git a/src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts b/src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts index 218101de..daf5bda2 100644 --- a/src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts +++ b/src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts @@ -4,7 +4,7 @@ * * Proxies the MCP OAuth authorization flow so that: * 1. All auth traffic is routed through the CodeMie proxy - * 2. `client_name` is replaced with "Claude Code" in dynamic client registration + * 2. `client_name` is replaced with MCP_CLIENT_NAME env var (default "CodeMie CLI") in dynamic client registration * * URL scheme: * - /mcp_auth?original= → Initial MCP connection @@ -33,6 +33,7 @@ import { ProxyPlugin, PluginContext, ProxyInterceptor } from './types.js'; import { ProxyContext } from '../proxy-types.js'; import { ProxyHTTPClient } from '../proxy-http-client.js'; import { logger } from '../../../../../utils/logger.js'; +import { getMcpClientName } from '../../../../../mcp/constants.js'; const gunzipAsync = promisify(gunzip); const inflateAsync = promisify(inflate); @@ -868,18 +869,20 @@ class MCPAuthInterceptor implements ProxyInterceptor { // ─── Request Body Modification ─────────────────────────────────────────── /** - * Replace `client_name` with "Claude Code" in JSON request bodies. + * Replace `client_name` in JSON request bodies. + * Uses MCP_CLIENT_NAME env var, defaults to "CodeMie CLI". * This targets the OAuth dynamic client registration (POST /register). */ private rewriteRequestBody(body: Buffer, headers: Record): Buffer { + const clientName = getMcpClientName(); try { const parsed = JSON.parse(body.toString('utf-8')); if (typeof parsed === 'object' && parsed !== null && 'client_name' in parsed) { - parsed.client_name = 'Claude Code (stage_onehub_core)'; + parsed.client_name = clientName; const newBody = Buffer.from(JSON.stringify(parsed), 'utf-8'); headers['content-length'] = String(newBody.length); - logger.debug(`[${this.name}] Replaced client_name with "Claude Code"`); + logger.debug(`[${this.name}] Replaced client_name with "${clientName}"`); return newBody; } } catch { From 4e2205377d80c48714639908b3e72abbf4ae88ab Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Fri, 10 Apr 2026 00:29:26 +0200 Subject: [PATCH 4/6] docs: add MCP proxy documentation across all guides Document the MCP authorization proxy feature in README, COMMANDS, ARCHITECTURE-PROXY, AUTHENTICATION, EXAMPLES, and internal guides (architecture, external-integrations). Generated with AI Co-Authored-By: codemie-ai --- .codemie/guides/architecture/architecture.md | 6 ++ .../integration/external-integrations.md | 64 ++++++++++++++ README.md | 6 ++ docs/ARCHITECTURE-PROXY.md | 84 +++++++++++++++++- docs/AUTHENTICATION.md | 75 ++++++++++++++++ docs/COMMANDS.md | 87 +++++++++++++++++++ docs/EXAMPLES.md | 29 +++++++ 7 files changed, 350 insertions(+), 1 deletion(-) diff --git a/.codemie/guides/architecture/architecture.md b/.codemie/guides/architecture/architecture.md index e96c70df..7a26a2f0 100644 --- a/.codemie/guides/architecture/architecture.md +++ b/.codemie/guides/architecture/architecture.md @@ -19,6 +19,7 @@ codemie-code/ │ ├── agents/ Agent system (registry + plugins) │ ├── providers/ LLM provider system │ ├── frameworks/ Framework integrations +│ ├── mcp/ MCP proxy bridge & OAuth │ ├── utils/ Shared utilities │ ├── env/ Environment management │ ├── workflows/ CI/CD templates @@ -48,6 +49,11 @@ src/ ├── frameworks/ Framework System │ ├── core/ Framework interfaces │ └── plugins/ Framework implementations (LangGraph) +├── mcp/ MCP Proxy System +│ ├── auth/ OAuth provider & callback server +│ ├── stdio-http-bridge.ts Stdio-to-HTTP bridge +│ ├── proxy-logger.ts File-based proxy logger +│ └── constants.ts MCP proxy constants └── utils/ Utilities Layer ├── errors.ts Error classes ├── logger.ts Logging utilities diff --git a/.codemie/guides/integration/external-integrations.md b/.codemie/guides/integration/external-integrations.md index a30b89bd..6a83309f 100644 --- a/.codemie/guides/integration/external-integrations.md +++ b/.codemie/guides/integration/external-integrations.md @@ -23,6 +23,7 @@ External service integration patterns for CodeMie Code: LangGraph orchestration, | Azure OpenAI | GPT via Azure | API Key + Endpoint | Azure credentials | | LiteLLM | 100+ providers proxy | Varies | Provider-specific | | OpenCode | Open-source AI assistant | SSO/API Key | Via CodeMie proxy | +| MCP Servers | Remote MCP tool servers | OAuth 2.0 (auto) | Via `codemie-mcp-proxy` | | Enterprise SSO | Corporate auth | SAML/OAuth | SSO base URL | --- @@ -654,6 +655,67 @@ export class ConfigLoader { --- +## MCP Server Integration + +### Overview + +The MCP (Model Context Protocol) proxy enables MCP clients to connect to remote MCP servers over HTTP, with automatic OAuth 2.0 authorization. It bridges stdio JSON-RPC to streamable HTTP transport. + +**Key Components:** + +| Component | File | Purpose | +|-----------|------|---------| +| Stdio-HTTP Bridge | `src/mcp/stdio-http-bridge.ts` | Bridges stdio JSON-RPC ↔ streamable HTTP | +| OAuth Provider | `src/mcp/auth/mcp-oauth-provider.ts` | Browser-based OAuth authorization code flow | +| Callback Server | `src/mcp/auth/callback-server.ts` | Ephemeral localhost server for OAuth callbacks | +| MCP Auth Plugin | `src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts` | SSO proxy plugin for URL rewriting and SSRF protection | + +### Architecture Patterns + +**Bridge Pattern (stdio ↔ HTTP):** +```typescript +// Source: src/mcp/stdio-http-bridge.ts +// StdioHttpBridge reads JSON-RPC messages from stdin, forwards them +// over streamable HTTP, and writes responses back to stdout. +const bridge = new StdioHttpBridge({ serverUrl: 'https://mcp-server.example.com/sse' }); +await bridge.start(); +``` + +**OAuth Client Pattern:** +```typescript +// Source: src/mcp/auth/mcp-oauth-provider.ts +// Implements OAuthClientProvider from @modelcontextprotocol/client +// Flow: 401 → metadata → dynamic registration → browser auth → callback → token +``` + +**Cookie Jar Pattern:** +```typescript +// Source: src/mcp/stdio-http-bridge.ts (CookieJar class) +// Per-origin cookie storage: captures Set-Cookie from responses, +// injects Cookie header on subsequent requests to the same origin. +``` + +### SSO Proxy Plugin + +When running through the CodeMie SSO proxy, the MCP Auth Plugin (priority 3) provides: + +- **URL rewriting**: `/mcp_auth?original=` for initial connections, `/mcp_relay///` for relayed requests +- **Client name override**: Replaces `client_name` in Dynamic Client Registration with `MCP_CLIENT_NAME` +- **SSRF protection**: Rejects private/loopback origins (hostname + DNS resolution) +- **Per-flow origin scoping**: Tags discovered origins with their root MCP server to prevent cross-flow confusion + +### Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `MCP_CLIENT_NAME` | `CodeMie CLI` | Client name for OAuth registration | +| `MCP_PROXY_DEBUG` | (unset) | Verbose proxy logging | +| `CODEMIE_PROXY_PORT` | (auto) | Fixed proxy port | + +For full architecture details, see [Proxy Architecture — MCP Auth Plugin](../../docs/ARCHITECTURE-PROXY.md#65-mcp-auth-plugin). + +--- + ## Troubleshooting | Issue | Cause | Solution | @@ -671,6 +733,8 @@ export class ConfigLoader { ## References +- **MCP Proxy**: `src/mcp/` +- **MCP Auth Plugin**: `src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts` - **Provider Plugins**: `src/providers/plugins/` - **Provider Core**: `src/providers/core/types.ts` - **Agent System**: `src/agents/codemie-code/` diff --git a/README.md b/README.md index db82945f..c147e433 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ CodeMie CLI is the all-in-one AI coding assistant for developers. - 🔄 **Multi-Provider Support** - OpenAI, Azure OpenAI, AWS Bedrock, LiteLLM, Ollama, Enterprise SSO, and JWT Bearer Auth. - 🚀 **Built-in Agent** - A powerful LangGraph-based assistant with file operations, command execution, and planning tools. - 🖥️ **Cross-Platform** - Full support for Windows, Linux, and macOS with platform-specific optimizations. +- 🔗 **MCP Proxy** - Connect to remote MCP servers with automatic OAuth authorization. - 🔐 **Enterprise Ready** - SSO and JWT authentication, audit logging, and role-based access. - ⚡ **Productivity Boost** - Code review, refactoring, test generation, and bug fixing. - 🎯 **Profile Management** - Manage work, personal, and team configurations separately. @@ -57,6 +58,9 @@ codemie-code "Analyze this codebase" # 6. Execute a single task and exit codemie --task "Generate unit tests" + +# 7. Connect to a remote MCP server (with automatic OAuth) +claude mcp add my-server -- codemie-mcp-proxy "https://mcp-server.example.com/sse" ``` **Prefer not to install globally?** Use npx with the full package name: @@ -268,6 +272,7 @@ codemie profile # Manage provider profiles codemie analytics # View usage analytics (sessions, tokens, costs, tools) codemie workflow # Manage CI/CD workflows codemie doctor # Health check and diagnostics +codemie mcp-proxy # Stdio-to-HTTP MCP proxy with OAuth ``` For a full command reference, see the [Commands Documentation](docs/COMMANDS.md). @@ -285,6 +290,7 @@ Comprehensive guides are available in the `docs/` directory: - **[Authentication](docs/AUTHENTICATION.md)** - SSO setup, token management, enterprise authentication - **[Examples](docs/EXAMPLES.md)** - Common workflows, multi-provider examples, CI/CD integration - **[Configuration Architecture](docs/ARCHITECTURE-CONFIGURATION.md)** - How configuration flows through the system from CLI to proxy plugins +- **[Proxy Architecture](docs/ARCHITECTURE-PROXY.md)** - Proxy plugin system, MCP authorization flow - **[Claude Code Plugin](src/agents/plugins/claude/plugin/README.md)** - Built-in commands, hooks system, and plugin architecture ## Contributing diff --git a/docs/ARCHITECTURE-PROXY.md b/docs/ARCHITECTURE-PROXY.md index a6bc9fef..5974dfdb 100644 --- a/docs/ARCHITECTURE-PROXY.md +++ b/docs/ARCHITECTURE-PROXY.md @@ -28,6 +28,7 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between AI coding agents and their target API endpoints. It enables: - **SSO Authentication**: Automatic cookie injection for enterprise SSO +- **MCP Authorization**: OAuth proxy for remote MCP servers with SSRF protection - **Header Management**: CodeMie-specific header injection for traceability - **Observability**: Detailed logging and metrics collection - **Metrics Sync**: Background sync of session metrics to CodeMie API @@ -60,6 +61,7 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A │ ┌────────────────────────────────────────────────────────────┐ │ │ │ Plugin System (Priority-Based) │ │ │ │ │ │ +│ │ [3] MCP Auth Plugin → MCP OAuth proxy & URL rewrite│ │ │ │ [10] SSO Auth Plugin → Inject cookies │ │ │ │ [20] Header Injection → Add X-CodeMie headers │ │ │ │ [50] Logging Plugin → Log requests/responses │ │ @@ -161,7 +163,8 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A - Retrieve plugin configurations **Plugin Priority Levels**: -- **0-10**: Authentication and security (SSO Auth: 10) +- **0-3**: MCP protocol handling (MCP Auth: 3) +- **4-10**: Authentication and security (SSO Auth: 10) - **11-50**: Header manipulation (Header Injection: 20) - **51-100**: Observability (Logging: 50, Metrics Sync: 100) - **101-500**: Business logic (rate limiting, caching) @@ -609,6 +612,85 @@ Proxy Stop - Check network connectivity to API - Enable debug logging +### 6.5 MCP Auth Plugin + +**Priority**: 3 (runs before all other plugins) +**File**: `src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts` + +**Purpose**: Proxy MCP OAuth authorization flows through the CodeMie proxy so that all auth traffic is routed centrally and `client_name` can be overridden via the `MCP_CLIENT_NAME` environment variable. + +#### 6.5.1 URL Scheme + +The plugin intercepts two URL patterns: + +| Route | Pattern | Purpose | +|-------|---------|---------| +| **Initial** | `/mcp_auth?original=` | First MCP connection — starts an OAuth flow | +| **Relay** | `/mcp_relay///` | Subsequent requests routed through proxy | + +- `root_b64`: Base64url-encoded root MCP server origin (for per-flow isolation) +- `relay_b64`: Base64url-encoded actual target origin (may differ when auth server is on a separate host) + +#### 6.5.2 Request Handling + +**`/mcp_auth` route:** +1. Extract `original` query parameter (the real MCP server URL) +2. Validate URL (SSRF check) +3. Forward request to the target MCP server +4. Buffer the JSON response and rewrite all discovered URLs to proxy relay URLs +5. Return the rewritten response to the MCP client + +**`/mcp_relay` route:** +1. Decode `root_b64` and `relay_b64` to recover target origin +2. Validate root-relay association (per-flow origin scoping) +3. Reconstruct the full target URL from relay origin + path + query +4. Forward request to the real target +5. Buffer JSON auth metadata responses and rewrite URLs; stream all other responses + +#### 6.5.3 Response URL Rewriting + +The plugin buffers JSON responses (auth metadata, client registration, etc.) and rewrites all absolute HTTP(S) URLs found in JSON values to proxy relay URLs. This ensures the MCP client routes all subsequent requests through the proxy. + +**Exceptions**: Token audience identifiers (e.g., `resource` field) are not rewritten — they are logical identifiers, not URLs to access. + +**Browser endpoints** (e.g., `authorization_endpoint`) are left as-is so the user's browser navigates directly to the auth server. + +#### 6.5.4 Security + +**SSRF Protection:** +- Private/loopback IP addresses are rejected (both literal hostname check and DNS resolution) +- Only `http:` and `https:` schemes are allowed + +**Per-Flow Origin Scoping:** +- Discovered origins (from auth metadata) are tagged with their root MCP server origin +- Relay requests validate that the relay origin is associated with the claimed root origin +- Prevents cross-flow origin confusion + +**Buffering Policy:** +- Only auth metadata responses are buffered (for URL rewriting) +- Post-auth MCP traffic streams through without buffering + +#### 6.5.5 Companion Components + +The MCP Auth Plugin works in conjunction with the stdio-to-HTTP bridge: + +| Component | File | Purpose | +|-----------|------|---------| +| Stdio-HTTP Bridge | `src/mcp/stdio-http-bridge.ts` | Bridges stdio JSON-RPC to streamable HTTP transport | +| OAuth Provider | `src/mcp/auth/mcp-oauth-provider.ts` | Implements `OAuthClientProvider` for browser-based OAuth flow | +| Callback Server | `src/mcp/auth/callback-server.ts` | Ephemeral localhost server for receiving OAuth callbacks | +| Proxy Logger | `src/mcp/proxy-logger.ts` | File-based logger for proxy operations | +| Constants | `src/mcp/constants.ts` | `MCP_CLIENT_NAME` default and accessor | + +#### 6.5.6 Configuration + +**Environment Variables:** +- `MCP_CLIENT_NAME`: Client name for OAuth Dynamic Client Registration (default: `CodeMie CLI`) +- `MCP_PROXY_DEBUG`: Enable verbose proxy logging +- `CODEMIE_PROXY_PORT`: Fixed proxy port (for stable MCP auth URLs across restarts) + +**Log Location**: `~/.codemie/logs/mcp-proxy.log` + --- ## 7. Quality Attributes diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 73e5b61d..16c5360a 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -109,6 +109,7 @@ AI/Run CodeMie SSO provides enterprise-grade features: - **Automatic Refresh**: Seamless token renewal without interruption - **Multi-Model Access**: Access to Claude, GPT, and other models through unified gateway - **Automatic Plugin Installation**: Claude Code plugin auto-installs for session tracking +- **MCP OAuth Proxy**: Automatic OAuth authorization for remote MCP servers - **Audit Logging**: Enterprise audit trails for security compliance - **Role-Based Access**: Model access based on organizational permissions @@ -283,3 +284,77 @@ codemie setup # Choose Bearer Authorization again # Or manually edit config cat ~/.codemie/codemie-cli.config.json ``` + +## MCP Server Authentication + +CodeMie provides a stdio-to-HTTP proxy that enables MCP clients (like Claude Code) to connect to OAuth-protected remote MCP servers. The proxy handles the full OAuth 2.0 authorization code flow transparently. + +### Setup + +Register the proxy as an MCP server in your Claude Code configuration: + +```bash +# Using the global binary +claude mcp add my-server -- codemie-mcp-proxy "https://mcp-server.example.com/sse" +``` + +Or configure `.mcp.json` directly: + +```json +{ + "mcpServers": { + "my-server": { + "type": "stdio", + "command": "codemie-mcp-proxy", + "args": ["https://mcp-server.example.com/sse"], + "env": { + "MCP_CLIENT_NAME": "Claude Code (my-server)" + } + } + } +} +``` + +### OAuth Flow + +When the remote MCP server requires authentication, the proxy handles it automatically: + +1. **401 Unauthorized** — the remote server rejects the initial request +2. **Metadata discovery** — fetch resource metadata and authorization server metadata +3. **Dynamic Client Registration** — register a client with `client_name` from `MCP_CLIENT_NAME` (default: `CodeMie CLI`) +4. **Browser authorization** — open the user's browser to the authorization endpoint +5. **Callback** — receive the authorization code via an ephemeral localhost HTTP server +6. **Token exchange** — exchange the code for access/refresh tokens +7. **Retry** — replay the original request with the Bearer token + +All tokens and client state are kept in-memory only — re-authorization is required each session. + +### SSO Integration + +When running through the CodeMie SSO proxy, the MCP Auth Plugin provides additional capabilities: + +- **URL rewriting**: Auth metadata URLs are rewritten to route through the proxy (`/mcp_auth` and `/mcp_relay` routes) +- **Client name override**: `client_name` in Dynamic Client Registration is replaced with the `MCP_CLIENT_NAME` value +- **SSRF protection**: Private/loopback origins are rejected before forwarding +- **Per-flow isolation**: Each MCP server flow is scoped by its root origin to prevent cross-flow confusion + +### Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `MCP_CLIENT_NAME` | `CodeMie CLI` | Client name for OAuth Dynamic Client Registration | +| `MCP_PROXY_DEBUG` | (unset) | Set to `true` for verbose proxy logging | +| `CODEMIE_PROXY_PORT` | (auto) | Fixed proxy port for stable auth callback URLs | + +Logs: `~/.codemie/logs/mcp-proxy.log` + +### Troubleshooting + +| Issue | Cause | Solution | +|-------|-------|----------| +| Browser doesn't open during auth | `open`/`xdg-open` not available | Copy the URL from logs and open manually | +| OAuth timeout after 2 minutes | User didn't complete browser authorization | Re-trigger the MCP connection and authorize faster | +| `401` persists after auth | Token expired or server rejected it | Check logs for token exchange errors | +| Connection refused | Remote MCP server unreachable | Verify the URL and network connectivity | + +For architecture details, see [Proxy Architecture — MCP Auth Plugin](./ARCHITECTURE-PROXY.md#65-mcp-auth-plugin). diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index b5453f25..4c18ac38 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -19,6 +19,7 @@ codemie update [agent] # Update installed agents 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 version # Show version information ``` @@ -490,6 +491,92 @@ codemie plugin disable For full documentation, see [Plugin System](./PLUGINS.md). +## MCP Proxy Command + +Run a stdio-to-HTTP bridge that connects MCP clients (like Claude Code) to remote MCP servers, handling OAuth 2.0 authorization automatically. + +```bash +codemie mcp-proxy +``` + +**Arguments:** +- `` — Remote MCP server URL (must be a valid HTTP/HTTPS URL) + +**Features:** +- Stdio-to-HTTP bridge (JSON-RPC over stdio ↔ streamable HTTP transport) +- Automatic OAuth 2.0 with Dynamic Client Registration +- Per-origin cookie jar for session persistence +- Browser-based authorization with ephemeral localhost callback server +- Graceful shutdown on SIGINT/SIGTERM + +### Registering with Claude Code + +Use `claude mcp add` to register the proxy as an MCP server: + +```bash +# Using the global binary (requires global install) +claude mcp add my-server -- codemie-mcp-proxy "https://mcp-server.example.com/sse" + +# Using node directly (works without global install) +claude mcp add my-server -- node /path/to/bin/codemie-mcp-proxy.js "https://mcp-server.example.com/sse" +``` + +Or configure `.mcp.json` directly: + +```json +{ + "mcpServers": { + "my-server": { + "type": "stdio", + "command": "codemie-mcp-proxy", + "args": ["https://mcp-server.example.com/sse"], + "env": { + "MCP_CLIENT_NAME": "Claude Code (my-server)" + } + } + } +} +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `MCP_CLIENT_NAME` | `CodeMie CLI` | Client name used in OAuth Dynamic Client Registration | +| `MCP_PROXY_DEBUG` | (unset) | Set to `true` to enable verbose proxy logging | +| `CODEMIE_DEBUG` | (unset) | Set to `true` to enable general debug logging | + +### OAuth Flow + +When the remote MCP server returns `401 Unauthorized`: + +1. Discover resource metadata and authorization server metadata +2. Register a client dynamically (`client_name` from `MCP_CLIENT_NAME`) +3. Open the user's browser for authorization +4. Receive the authorization code via ephemeral localhost callback server +5. Exchange the code for tokens +6. Retry the original request with the Bearer token + +All state is in-memory only — re-authorization is required each session. + +### Logs + +Proxy logs are written to `~/.codemie/logs/mcp-proxy.log`. Enable verbose logging with `MCP_PROXY_DEBUG=true`. + +### Troubleshooting + +| Issue | Cause | Solution | +|-------|-------|----------| +| `Invalid MCP server URL` | URL argument is malformed | Verify the URL is a valid HTTP/HTTPS URL | +| Browser doesn't open | System `open`/`xdg-open` not available | Open the URL printed in logs manually | +| OAuth timeout | User didn't complete browser auth in 2 minutes | Re-run the command and complete auth faster | +| `ECONNREFUSED` | Remote MCP server is unreachable | Check the URL and network connectivity | +| No tools appearing | OAuth flow not completed | Check `~/.codemie/logs/mcp-proxy.log` for errors | + +### Architecture + +For implementation details including the SSO proxy plugin, URL rewriting, and SSRF protection, see [Proxy Architecture](./ARCHITECTURE-PROXY.md#65-mcp-auth-plugin). + ## Detailed Command Reference ### `codemie setup` diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 3d9c000e..47ca6e04 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -146,6 +146,35 @@ codemie-claude \ --max-turns 30 ``` +## MCP Proxy Examples + +```bash +# Register an OAuth-protected MCP server with Claude Code +claude mcp add my-server -- codemie-mcp-proxy "https://mcp-server.example.com/sse" + +# With a custom OAuth client name +MCP_CLIENT_NAME="My Team Agent" claude mcp add team-tools -- codemie-mcp-proxy "https://tools.example.com/mcp" + +# Enable debug logging for troubleshooting +# In .mcp.json: +# { +# "mcpServers": { +# "my-server": { +# "type": "stdio", +# "command": "codemie-mcp-proxy", +# "args": ["https://mcp-server.example.com/sse"], +# "env": { +# "MCP_PROXY_DEBUG": "true", +# "MCP_CLIENT_NAME": "Claude Code (my-server)" +# } +# } +# } +# } + +# View proxy logs +tail -f ~/.codemie/logs/mcp-proxy.log +``` + ## Workflow Installation Examples ```bash From 2c86960ed70e658a3ec0584a6f6cfd7962c47879 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Fri, 10 Apr 2026 00:34:12 +0200 Subject: [PATCH 5/6] fix: add executable permission to codemie-mcp-proxy.js Generated with AI Co-Authored-By: codemie-ai --- bin/codemie-mcp-proxy.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 bin/codemie-mcp-proxy.js diff --git a/bin/codemie-mcp-proxy.js b/bin/codemie-mcp-proxy.js old mode 100644 new mode 100755 From dca6aff69b43d6bfb67bcd7a7a1cd66c46a79346 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Fri, 10 Apr 2026 11:08:28 +0200 Subject: [PATCH 6/6] feat(cli): add codemie mcp add command wrapping codemie-mcp-proxy Adds `codemie mcp add [--scope ] ` command that delegates to `claude mcp add [--scope] -- codemie-mcp-proxy `, eliminating the need to know the proxy binary name or argument order. Generated with AI Co-Authored-By: codemie-ai --- src/cli/commands/mcp/index.ts | 115 ++++++++++++++++++++++++++++++++++ src/cli/index.ts | 2 + src/utils/exec.ts | 13 +++- 3 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 src/cli/commands/mcp/index.ts diff --git a/src/cli/commands/mcp/index.ts b/src/cli/commands/mcp/index.ts new file mode 100644 index 00000000..dd1994c1 --- /dev/null +++ b/src/cli/commands/mcp/index.ts @@ -0,0 +1,115 @@ +import { Command } from 'commander'; +import os from 'os'; +import { exec } from '../../../utils/exec.js'; +import { getCommandPath } from '../../../utils/processes.js'; +import { resolveHomeDir } from '../../../utils/paths.js'; + +async function resolveClaudeCommand(): Promise<{ command: string; shell: boolean }> { + if (process.platform !== 'win32') { + const fullPath = resolveHomeDir('.local/bin/claude'); + try { + const result = await exec(fullPath, ['--version']); + if (result.code === 0) { + return { command: fullPath, shell: false }; + } + } catch { + // Fall back to PATH lookup below. + } + } + + const claudeCommand = await getCommandPath('claude'); + if (!claudeCommand) { + throw new Error('claude-not-found'); + } + + return { + command: claudeCommand, + shell: os.platform() === 'win32', + }; +} + +async function ensureProxyCommandExists(): Promise { + const proxyCommand = await getCommandPath('codemie-mcp-proxy'); + if (!proxyCommand) { + throw new Error('proxy-not-found'); + } +} + +function createMcpAddCommand(): Command { + const command = new Command('add'); + + command + .description('Register an MCP server via codemie-mcp-proxy') + .argument('', 'Name for the MCP server') + .argument('', 'MCP server URL') + .option('--scope ', 'Scope for the MCP server (e.g. project, user)') + .action(async (name: string, url: string, options: { scope?: string }) => { + // Validate URL early, before spawning claude + try { + new URL(url); + } catch { + console.error(`Invalid MCP server URL: ${url}`); + process.exit(1); + } + + // Reject names that look like flags to avoid corrupting the claude command + if (name.startsWith('-')) { + console.error(`Invalid server name: ${name}`); + process.exit(1); + } + + let claudeCommand: string; + let useShell: boolean; + try { + await ensureProxyCommandExists(); + ({ command: claudeCommand, shell: useShell } = await resolveClaudeCommand()); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message === 'proxy-not-found') { + console.error('codemie-mcp-proxy not found. Reinstall @codemieai/code to restore the MCP proxy binary.'); + process.exit(1); + } + + console.error('claude CLI not found. Install Claude Code: https://claude.ai/code'); + process.exit(1); + } + + const args: string[] = ['mcp', 'add']; + + if (options.scope) { + args.push('--scope', options.scope); + } + + args.push(name, '--', 'codemie-mcp-proxy', url); + + try { + const result = await exec(claudeCommand, args, { + interactive: true, + shell: useShell, + }); + process.exit(result.code); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('ENOENT')) { + console.error('claude CLI not found. Install Claude Code: https://claude.ai/code'); + process.exit(1); + } + + if (message.includes('terminated by signal')) { + process.exit(1); + } + + // exec rejects on non-zero exit in interactive mode — extract and propagate the code + const match = /code (\d+)/.exec(message); + process.exit(match ? parseInt(match[1], 10) : 1); + } + }); + + return command; +} + +export function createMcpCommand(): Command { + const mcp = new Command('mcp').description('Manage MCP servers'); + mcp.addCommand(createMcpAddCommand()); + return mcp; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 51ed5467..f39e4284 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -31,6 +31,7 @@ import { createOpencodeMetricsCommand } from './commands/opencode-metrics.js'; import { createTestMetricsCommand } from './commands/test-metrics.js'; import { createModelsCommand } from './commands/models.js'; import { createAssistantsCommand } from './commands/assistants/index.js'; +import { createMcpCommand } from './commands/mcp/index.js'; import { createMcpProxyCommand } from './commands/mcp-proxy.js'; import { FirstTimeExperience } from './first-time.js'; import { getDirname } from '../utils/paths.js'; @@ -75,6 +76,7 @@ program.addCommand(createPluginCommand()); program.addCommand(createOpencodeMetricsCommand()); program.addCommand(createTestMetricsCommand()); program.addCommand(createModelsCommand()); +program.addCommand(createMcpCommand()); program.addCommand(createMcpProxyCommand()); // Check for --task option before parsing commands diff --git a/src/utils/exec.ts b/src/utils/exec.ts index 561ef90f..6bad4258 100644 --- a/src/utils/exec.ts +++ b/src/utils/exec.ts @@ -20,6 +20,7 @@ export interface ExecResult { code: number; stdout: string; stderr: string; + signal?: NodeJS.Signals | null; } /** @@ -101,13 +102,19 @@ export async function exec( reject(new Error(`Failed to execute ${command}: ${error.message}`)); }); - child.on('close', (code) => { + child.on('close', (code, signal) => { cleanup(); + if (code === null) { + reject(new Error(`Command terminated by signal ${signal ?? 'unknown'}`)); + return; + } + const result = { - code: code || 0, + code, stdout: stdout.trim(), - stderr: stderr.trim() + stderr: stderr.trim(), + signal, }; // In interactive mode, reject on non-zero exit codes