import type { H3Event } from "h3"; import { defineEventHandler, setResponseStatus, setResponseHeader, getMethod, getRequestHeader, } from "h3"; import { getConfiguredAppBasePath } from "../server/app-base-path.js"; import { isLoopbackRequest } from "../server/auth.js"; import { getH3App } from "../server/framework-request-handler.js"; import { readBody } from "../server/h3-helpers.js"; import { createMCPServerForRequest, verifyAuth, getAccessTokens, resolveOrgIdFromDomain, buildLinkArtifacts, type MCPConfig, type MCPCallerIdentity, type MCPRequestMeta, } from "./build-server.js"; import { buildMcpOAuthChallenge, getMcpOAuthAudiences, getMcpOAuthIssuer, getMcpOAuthProtectedResourceMetadataUrl, getMcpOAuthResource, } from "./oauth-route.js"; import { MCP_PUBLIC_ROUTE_PREFIX, MCP_ROUTE_PREFIXES, joinMcpRoute, } from "./route-paths.js"; // Re-export the shared MCP server builder + types so the stdio transport and // any (future) external importer of `@agent-native/core/mcp` keep resolving // against `./server.js` exactly as before this refactor. export { createMCPServerForRequest, verifyAuth, getAccessTokens, resolveOrgIdFromDomain, buildLinkArtifacts, }; export type { MCPConfig, MCPCallerIdentity, MCPRequestMeta }; // --------------------------------------------------------------------------- // Runtime detection — Node fast-path vs. web-standard fallback // --------------------------------------------------------------------------- /** * Resolve the underlying Node `http` req/res pair if (and only if) we're * running on a real Node HTTP server (local dev, `node` Nitro preset). On the * web-standard runtime (Nitro 3 / Netlify web runtime, Cloudflare, Deno, Bun) * BOTH of these are undefined — that's the signal to take the web fallback * instead of returning 501. */ function getNodeReqRes(event: H3Event): { nodeReq: any | undefined; nodeRes: any | undefined; } { const e = event as any; const nodeReq = e.node?.req ?? e.req?.runtime?.node?.req; const nodeRes = e.node?.res ?? e.req?.runtime?.node?.res; return { nodeReq, nodeRes }; } function shouldUseNodeFastPath(event: H3Event): boolean { if (process.env.AGENT_NATIVE_MCP_NODE_FAST_PATH !== "1") return false; const { nodeReq, nodeRes } = getNodeReqRes(event); return Boolean(nodeReq && nodeRes); } /** * Derive the request origin + the markdown deep-link target from the inbound * headers. Identical logic for both the Node and web paths so the absolute * deep-link URLs in tool results are computed the same way regardless of * runtime. */ function deriveRequestMeta(event: H3Event): MCPRequestMeta { const forwardedProto = getRequestHeader(event, "x-forwarded-proto"); const host = getRequestHeader(event, "x-forwarded-host") || getRequestHeader(event, "host"); const proto = forwardedProto?.split(",")[0]?.trim() || (host && /^(localhost|127\.0\.0\.1)(:|$)/.test(host) ? "http" : "https"); const origin = host ? `${proto}://${host}` : undefined; const targetHeader = getRequestHeader( event, "x-agent-native-open-target", )?.toLowerCase(); const target = targetHeader === "desktop" || targetHeader === "terminal" || targetHeader === "browser" ? (targetHeader as MCPRequestMeta["target"]) : undefined; const clientName = getRequestHeader(event, "user-agent")?.trim() || undefined; const clientHint = getRequestHeader(event, "x-agent-native-mcp-client")?.trim() || undefined; const fullCatalogHeader = getRequestHeader( event, "x-agent-native-mcp-full-catalog", )?.toLowerCase(); const fullCatalog = fullCatalogHeader === "1" || fullCatalogHeader === "true" || fullCatalogHeader === "yes"; const inlineAppsHeader = getRequestHeader( event, "x-agent-native-mcp-inline-apps", )?.toLowerCase(); const inlineAppsRequested = inlineAppsHeader === "1" || inlineAppsHeader === "true" || inlineAppsHeader === "yes"; const basePath = getConfiguredAppBasePath(); return { origin, ...(basePath ? { basePath } : {}), target, clientName, clientHint, ...(fullCatalog ? { fullCatalog } : {}), ...(inlineAppsRequested ? { inlineMcpApps: true } : {}), }; } function isLoopbackOrigin(origin: string | undefined): boolean { if (!origin) return false; try { const hostname = new URL(origin).hostname; return ( hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" || hostname.startsWith("127.") ); } catch { return false; } } /** * Reconstruct a Web Standard `Request` for the web-standard MCP transport. * * On the web runtime h3 v2 exposes the real web `Request` as `event.req`; we * prefer it (its `method` / `headers` are exactly what the client sent). But * the framework middleware rewrites `event.req.url` when it strips a mount * prefix, and the transport reads `req.method` + `req.headers` (never the * body — we pass that via `parsedBody`), so we always synthesize a clean * `Request` with the verified method + a fresh `Headers` copy. The URL is * cosmetic for the SDK (it only does `new URL(req.url)` for `requestInfo`), * so a best-effort absolute URL derived from the inbound host is sufficient * and never throws. */ function buildWebRequest(event: H3Event, method: string): Request { const src = (event as any).req as Request | undefined; const headers = new Headers(); if (src?.headers && typeof src.headers.forEach === "function") { src.headers.forEach((value, key) => headers.set(key, value)); } else { const rawHeaders = (event as any).node?.req?.headers as | Record | undefined; if (rawHeaders) { for (const [key, value] of Object.entries(rawHeaders)) { if (value == null) continue; headers.set(key, Array.isArray(value) ? value.join(", ") : value); } } } // The SDK requires Accept + Content-Type to advertise both JSON and SSE on // a POST. Real MCP clients (Claude Code, `agent-native connect`) always // send these; we never inject/alter them — if they're absent the SDK // returns its spec-mandated 406/415, identical to the Node path. const host = headers.get("x-forwarded-host") || headers.get("host") || "localhost"; const forwardedProto = headers.get("x-forwarded-proto"); const proto = forwardedProto?.split(",")[0]?.trim() || (/^(localhost|127\.0\.0\.1)(:|$)/.test(host) ? "http" : "https"); const basePath = getConfiguredAppBasePath(); const url = `${proto}://${host}${basePath}${MCP_PUBLIC_ROUTE_PREFIX}`; // No body here on purpose: the JSON-RPC payload is forwarded via the // transport's `parsedBody` option (the same mechanism the Node transport // uses), so the request stream is never read twice. return new Request(url, { method, headers }); } /** * Build an actionable JSON body for the 401 response. OAuth-capable clients * follow the `WWW-Authenticate` header automatically, but the JSON body is what * a human or a coding agent reads when a tool call comes back unauthorized — so * spell out the exact remediation: the `agent-native connect ` command and * the authorize/metadata URL. Keeping the legacy `error: "Unauthorized"` field * means existing clients that only check that field still work. */ function buildUnauthorizedBody(event: H3Event): { error: string; message: string; authenticate: { command?: string; firstTimeCommand?: string; authorizeUrl?: string; resourceMetadataUrl?: string; mcpUrl?: string; }; } { const issuer = getMcpOAuthIssuer(event); const mcpUrl = getMcpOAuthResource(event); const resourceMetadataUrl = getMcpOAuthProtectedResourceMetadataUrl(event); const command = issuer ? `npx -y @agent-native/core@latest reconnect ${issuer}` : undefined; const firstTimeCommand = issuer ? `npx @agent-native/core@latest connect ${issuer}` : undefined; const authorizeUrl = issuer ? `${issuer}${MCP_PUBLIC_ROUTE_PREFIX}/oauth/authorize` : undefined; const message = command ? `Authentication required. Run \`${command}\` to re-authenticate this ` + `MCP connector without reinstalling it (or, in a Claude Code host, ` + `run /mcp and choose Authenticate), then retry. For first-time ` + `setup, run \`${firstTimeCommand}\`.` : "Authentication required. Authenticate the MCP connector in your host, " + "then retry."; return { error: "Unauthorized", message, authenticate: { ...(command ? { command } : {}), ...(firstTimeCommand ? { firstTimeCommand } : {}), ...(authorizeUrl ? { authorizeUrl } : {}), ...(resourceMetadataUrl ? { resourceMetadataUrl } : {}), ...(mcpUrl ? { mcpUrl } : {}), }, }; } // --------------------------------------------------------------------------- // handleMcpRequest — runtime-agnostic MCP request handler // --------------------------------------------------------------------------- /** * Handle a single `{routePrefix}/mcp` request on either runtime. * * - **Default path:** build the SAME MCP `Server` * from the SAME config + identity, drive it through the SDK's * `WebStandardStreamableHTTPServerTransport` (which the Node transport is * itself just a thin wrapper around), and return the resulting Web * `Response` as a normal h3 return value. This is used for Nitro local dev * too; the direct Node writer can otherwise race h3 and double-write. * - **Opt-in Node fast-path:** set `AGENT_NATIVE_MCP_NODE_FAST_PATH=1` to * delegate directly to the SDK's `StreamableHTTPServerTransport`. * * Auth, the `runWithRequestContext` identity wrap, the deep-link `_meta` / * markdown append, `requestMeta` origin/target derivation and the stateless * semantics are IDENTICAL on both paths because both build the same server * via `createMCPServerForRequest` and both transports funnel into the same * `WebStandardStreamableHTTPServerTransport.handleRequest(webRequest, { * parsedBody })` with the same options. * * Returns: * - `undefined` when the request targets a sub-route (so management/status * routes mounted under `/_agent-native/mcp/*` handle it themselves) — the * h3 mount falls through to the next handler. * - a Web `Response` (web fallback) or a string/object (Node path / * auth-error path) otherwise. The Node path also sets `_handled` so h3 * doesn't double-write. */ export async function handleMcpRequest( event: H3Event, config: MCPConfig, ): Promise< Response | string | { error: string } | Record | undefined > { const pathname = event.url?.pathname || "/"; const subpath = pathname.replace(/^\/+/, "").replace(/\/+$/, ""); if (subpath) { // Let management/status routes mounted under /_agent-native/mcp/* handle // their own requests instead of treating them as MCP protocol traffic. return undefined; } const method = getMethod(event); // Auth check — extracts the caller's identity from the JWT (`sub`), or, on // the static-token / dev-open path, from the forwarded // `X-Agent-Native-Owner-Email` hint the stdio proxy sends (the // `agent-native mcp install` flow). Without this the install flow would run // every tool unscoped (userEmail === undefined). const authHeader = getRequestHeader(event, "authorization"); const ownerEmailHeader = getRequestHeader( event, "x-agent-native-owner-email", ); // Gate header-only dev-open on the REAL socket peer, never a parsed // `Host` header (client-controlled — an attacker could send // `Host: localhost`). A deployed app missing A2A_SECRET / ACCESS_TOKEN // must fail closed rather than trust a spoofable owner-email header that // `fullSurface` would otherwise escalate to the full mutating surface. const requestMeta = deriveRequestMeta(event); const hasLocalOwnerHint = Boolean(ownerEmailHeader?.trim()); const authResult = await verifyAuth(authHeader, ownerEmailHeader, { // A bare localhost URL is still a protected MCP resource. This lets // OAuth-native hosts (Kiro, Claude Code, etc.) receive the standard 401 // challenge and open browser approval instead of silently getting the // sparse anonymous dev surface. The stdio proxy remains zero-config for // local installs because it forwards an owner hint; an explicit opt-in is // available for local diagnostics. allowDevOpen: isLoopbackRequest(event) && isLoopbackOrigin(requestMeta.origin) && (hasLocalOwnerHint || process.env.AGENT_NATIVE_MCP_DEV_OPEN === "1"), resourceUrl: getMcpOAuthAudiences(event), }); if (!authResult.authed) { setResponseStatus(event, 401); setResponseHeader(event, "WWW-Authenticate", buildMcpOAuthChallenge(event)); return buildUnauthorizedBody(event); } // Stateless mode: only POST is meaningful. A stateless, per-request transport // on serverless cannot keep the standalone GET SSE stream (server->client // channel) alive across invocations — once the function returns and freezes, // that stream dies and the client reports "session expired" / "not // connected". The spec lets a server that offers no GET stream answer 405, so // the client falls back to plain POST request/response. Reject GET here // instead of letting the SDK open a doomed stream. if (method === "DELETE") { setResponseStatus(event, 204); return ""; } if (method !== "POST") { setResponseStatus(event, 405); return { error: "Method not allowed" }; } // Read body for POST (GET has no body). Read it via the h3 helper exactly // once; both transports accept it as a pre-parsed body so the request // stream is never consumed twice. const body = method === "POST" ? await readBody(event) : undefined; // Optional diagnostics for host capability negotiation. Keep disabled by // default because initialize payloads can include client-specific metadata. if (process.env.MCP_DEBUG_INIT && body) { const msgs = Array.isArray(body) ? body : [body]; const init = msgs.find( (m): m is { params?: { capabilities?: unknown; clientInfo?: unknown } } => typeof m === "object" && m !== null && (m as { method?: unknown }).method === "initialize", ); if (init) { console.error( "[MCP_DEBUG_INIT] clientInfo=", JSON.stringify(init.params?.clientInfo), "capabilities=", JSON.stringify(init.params?.capabilities), ); } } // Per-request stateless transport + server. Both runtimes build the SAME // server from the SAME config + verified identity + request meta, so // tools/list, tools/call, and the deep-link `_meta` are identical. A // connected real caller (connect-minted token / `mcp install` / // ACCESS_TOKEN / production) gets the full action surface even in local // dev; unauthenticated dev probes stay sparse. See `external-agents` skill. const server = await createMCPServerForRequest(config, authResult.identity, { ...requestMeta, fullSurface: authResult.fullSurface === true, inlineMcpApps: requestMeta.inlineMcpApps === true && authResult.identity?.firstPartyMcp === true ? true : undefined, // When the caller minted their token with --full-catalog (catalog_scope: // "full" JWT claim), bypass the connector-catalog tier filter. ...(authResult.fullCatalog === true ? { fullCatalog: true } : {}), }); if (shouldUseNodeFastPath(event)) { const { nodeReq, nodeRes } = getNodeReqRes(event); // ---- Opt-in Node fast-path --------------------------------------------- const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js"); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // stateless // Return JSON request/response instead of SSE. A stateless serverless // instance can freeze right after returning a streaming Response, before // the deferred SSE result event is flushed — the client then never gets // the tools/call result and reports "session expired". JSON mode awaits // the result inside the request lifecycle and returns it as one body. enableJsonResponse: true, }); await server.connect(transport); try { // The SDK transport writes directly to the Node response. Node-only by // construction; we only reach here when real Node req/res exist. await transport.handleRequest(nodeReq, nodeRes, body); } catch (err: any) { // The SDK transport writes directly to the Node response. If the socket // is already closed/ended (client disconnected, or the host stream // layer also flushed), Node throws ERR_STREAM_WRITE_AFTER_END *after* // the MCP payload was already delivered correctly. Swallow that benign // post-flush write so an external agent disconnecting mid-stream can // never take down the server process; rethrow anything else. if (err?.code !== "ERR_STREAM_WRITE_AFTER_END") throw err; if (process.env.DEBUG) console.log( "[mcp] ignored post-flush ERR_STREAM_WRITE_AFTER_END (client disconnected)", ); } // Prevent H3 from double-writing the response (event as any)._handled = true; return undefined; } // ---- Web-standard response path (Nitro local dev, Netlify web runtime, CF, // Deno, Bun) --------------------------------------------------------------- // // `StreamableHTTPServerTransport` is itself just a thin wrapper that // converts the Node req/res to a web Request/Response and delegates to // `WebStandardStreamableHTTPServerTransport.handleRequest(webRequest, { // parsedBody })`. Using the web transport directly with the SAME options + // the same pre-read `parsedBody` produces byte-identical protocol output // (including the deep-link `_meta` built inside createMCPServerForRequest), // and works on every web runtime because it returns a Web `Response` // (JSON for request/response, or an SSE `ReadableStream` body which h3 // streams natively). const { WebStandardStreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"); const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, // stateless — same as the Node path // JSON request/response (not SSE) — see the Node fast-path note above. // This is the serverless-safe framing: the result is computed and returned // within the request, never pushed onto a stream after the instance froze. enableJsonResponse: true, }); await server.connect(transport); const webRequest = buildWebRequest(event, method); // `parsedBody: undefined` would make the SDK try to read `req.json()`; our // synthesized request has no body, so only pass the option for POST (where // we actually have a parsed body). For GET the transport reads no body. const response = await transport.handleRequest( webRequest, method === "POST" ? { parsedBody: body } : undefined, ); return response; } // --------------------------------------------------------------------------- // mountMCP — register MCP Streamable HTTP endpoint on H3/Nitro // --------------------------------------------------------------------------- /** * Mount an MCP remote server on an H3/Nitro app. * * Endpoints: `/mcp` (public) and `/_agent-native/mcp` (compatibility). * A custom route prefix only mounts that custom endpoint. * * Uses stateless Streamable HTTP transport — no in-memory sessions, JSON * request/response (no SSE), and no standalone GET stream, so it survives * serverless instances that freeze between invocations (SSE framing there * drops the result and clients report "session expired"). Runtime-agnostic: a real Node * server uses the SDK's Node transport; the web-standard runtime (Nitro 3 / * Netlify web runtime, Cloudflare, Deno, Bun) uses the SDK's web-standard * transport. Both build the same server and produce identical JSON-RPC * output. * * Auth: Bearer token matching ACCESS_TOKEN/ACCESS_TOKENS or JWT via A2A_SECRET. * No auth required when neither is configured (dev mode). */ export function mountMCP( nitroApp: any, config: MCPConfig, routePrefix = "/_agent-native", ): void { const routePaths = routePrefix === "/_agent-native" ? [...MCP_ROUTE_PREFIXES] : [joinMcpRoute(routePrefix, "/mcp")]; const handler = defineEventHandler(async (event) => { return handleMcpRequest(event as H3Event, config); }); for (const routePath of routePaths) { getH3App(nitroApp).use(routePath, handler); } if (process.env.DEBUG) console.log( `[mcp] Mounted MCP server at ${routePaths.join(" and ")} (${Object.keys(config.actions).length} tools${config.askAgent ? " + ask-agent" : ""})`, ); }