From 18de54f51812abd25f03d366e2161414bbee2f0f Mon Sep 17 00:00:00 2001 From: xsvm <2479667226@qq.com> Date: Fri, 11 Sep 2026 17:34:46 +0800 Subject: [PATCH] fix(runtime): support subpaths and preserve target routing in MCP proxy Fixes an issue where the runtime MCP proxy router strictly matched /mcp/proxy/:serverId without a wildcard, causing requests with subpaths (e.g. /messages, /sse) to fail with 404. Also ensures toUpstreamProxyUrl safely appends the subpath onto the resolved upstream URL instead of truncating it. --- .../src/adapters/http/routes/driver-route.ts | 67 +++++- apps/api/tests/driver-mcp-proxy-route.test.ts | 206 ++++++++++++++++++ 2 files changed, 267 insertions(+), 6 deletions(-) create mode 100644 apps/api/tests/driver-mcp-proxy-route.test.ts diff --git a/apps/api/src/adapters/http/routes/driver-route.ts b/apps/api/src/adapters/http/routes/driver-route.ts index 54b7c5dd..839b5f8f 100644 --- a/apps/api/src/adapters/http/routes/driver-route.ts +++ b/apps/api/src/adapters/http/routes/driver-route.ts @@ -64,6 +64,8 @@ const HOP_BY_HOP_HEADERS = new Set([ const LLM_PROXY_GRANT_HEADERS = new Set(["authorization", "x-api-key", "x-goog-api-key"]); const LLM_PROXY_PATH_MARKER = "/llm/proxy/"; const LLM_PROXY_UNSAFE_PATH_ENCODING = /%(?:25|2f|5c)/iu; +const MCP_PROXY_PATH_MARKER = "/mcp/proxy/"; +const MCP_PROXY_UNSAFE_PATH_ENCODING = /%(?:25|2f|5c)/iu; const OPENAI_IMAGE_API_PATHS = new Set(["/images/edits", "/images/generations"]); async function requireDriverActionGrant(c: Context) { @@ -121,10 +123,15 @@ function copyProxyResponseHeaders(headers: Headers): Headers { return nextHeaders; } -function toUpstreamProxyUrl(request: Request, upstreamUrl: string): string { +function toUpstreamProxyUrl(request: Request, upstreamUrl: string, subPath = ""): string { const target = new URL(upstreamUrl); const incoming = new URL(request.url); + const trimmedSubPath = subPath.replace(/^\/+|\/+$/g, ""); + if (trimmedSubPath.length > 0) { + target.pathname = target.pathname.replace(/\/+$/, "") + `/${trimmedSubPath}`; + } + for (const [key, value] of incoming.searchParams) { if (key !== "grant") { target.searchParams.append(key, value); @@ -240,6 +247,44 @@ function extractLlmProxySubPath(pathname: string): string | null { return subPath; } +function extractMcpProxySubPath(pathname: string): string | null { + const markerIndex = pathname.indexOf(MCP_PROXY_PATH_MARKER); + + if (markerIndex === -1) { + return null; + } + + const rest = pathname.slice(markerIndex + MCP_PROXY_PATH_MARKER.length); + const slashIndex = rest.indexOf("/"); + const subPath = slashIndex === -1 ? "" : rest.slice(slashIndex); + + if (subPath.includes("\\") || MCP_PROXY_UNSAFE_PATH_ENCODING.test(subPath)) { + return null; + } + + for (const segment of subPath.split("/")) { + let decoded: string; + + try { + decoded = decodeURIComponent(segment); + } catch { + return null; + } + + if ( + decoded === "." || + decoded === ".." || + decoded.includes("/") || + decoded.includes("\\") || + decoded.includes("%") + ) { + return null; + } + } + + return subPath; +} + function resolveGrantedLlmProxyModelId( method: string, subPath: string, @@ -453,6 +498,7 @@ async function proxyRuntimeMcpRequest( upstreamAccessToken: string; url: string; }, + subPath: string, ): Promise { const init: RequestInit = { headers: copyProxyRequestHeaders( @@ -467,7 +513,7 @@ async function proxyRuntimeMcpRequest( init.body = request.body; } - const response = await fetch(toUpstreamProxyUrl(request, input.url), init); + const response = await fetch(toUpstreamProxyUrl(request, input.url, subPath), init); return new Response(response.body, { headers: copyProxyResponseHeaders(response.headers), @@ -780,7 +826,7 @@ export function registerDriverRoute(app: Hono) { } }); - driver.all("/mcp/proxy/:serverId", async (c) => { + const handleRuntimeMcpProxy = async (c: Context) => { await cleanupDriverInstances(c.env); let grant: Awaited>; @@ -797,7 +843,7 @@ export function registerDriverRoute(app: Hono) { let serverId: McpServerId; try { - serverId = toPlatformId(c.req.param("serverId"), "MCP server ID"); + serverId = toPlatformId(c.req.param("serverId") ?? "", "MCP server ID"); } catch (error) { const response = driverPlatformIdErrorResponse(error); if (response !== null) { @@ -820,6 +866,12 @@ export function registerDriverRoute(app: Hono) { ); } + const subPath = extractMcpProxySubPath(new URL(c.req.url).pathname); + + if (subPath === null) { + return Response.json({ error: "MCP proxy path is invalid." }, { status: 400 }); + } + let target: Awaited>; let toolCallId: string | null; @@ -847,7 +899,7 @@ export function registerDriverRoute(app: Hono) { } try { - return await proxyRuntimeMcpRequest(c.req.raw, target); + return await proxyRuntimeMcpRequest(c.req.raw, target, subPath); } catch { const proxyError = createRuntimeMcpProxyError({ code: "mcp_upstream_unavailable", @@ -857,7 +909,10 @@ export function registerDriverRoute(app: Hono) { const details = toRuntimeMcpProxyPublicErrorDetails(proxyError); return Response.json(runtimeMcpProxyErrorBody(details), { status: details.status }); } - }); + }; + + driver.all("/mcp/proxy/:serverId", handleRuntimeMcpProxy); + driver.all("/mcp/proxy/:serverId/*", handleRuntimeMcpProxy); app.route(getRuntimeDriverRoutePrefix(), driver); } diff --git a/apps/api/tests/driver-mcp-proxy-route.test.ts b/apps/api/tests/driver-mcp-proxy-route.test.ts new file mode 100644 index 00000000..5bbc703b --- /dev/null +++ b/apps/api/tests/driver-mcp-proxy-route.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, spyOn, test } from "bun:test"; + +import { parsePlatformId } from "@mosoo/id"; +import type { DriverInstanceId, McpServerId } from "@mosoo/id"; +import { Hono } from "hono"; + +import { registerDriverRoute } from "../src/adapters/http/routes/driver-route"; +import { createRuntimeActionToken } from "../src/modules/runtime/infrastructure/runtime-boot-token"; +import type { ApiGatewayEnvironment } from "../src/platform/cloudflare/worker-types"; +import { + createPublicHttpContractDatabase, + createPublicHttpTestBindings, + createTestExecutionContext, +} from "./helpers/public-api-http-test-fixture"; + +const SERVER_ID = "01J0000000000000000000000S"; + +// 初始化测试环境与路由实例 +async function setupTestApp() { + const database = await createPublicHttpContractDatabase(); + const bindings = createPublicHttpTestBindings(database); + const app = new Hono(); + registerDriverRoute(app); + return { app, bindings }; +} + +// 快速签发合法的 MCP 代理授权凭证 +async function createTestGrant(bindings: ReturnType) { + return createRuntimeActionToken(bindings, { + action: "mcp_proxy", + driverInstanceId: parsePlatformId("01J0000000000000000000000D", "driver ID"), + expiresAt: Date.now() + 60_000, + resourceId: parsePlatformId(SERVER_ID, "server ID"), + }); +} + +// 拦截并记录发往 upstream 的 HTTP 请求 +function captureFetch(mockResponse: Response) { + const captured: Array<{ body: string | null; headers: Headers; method: string; url: string }> = + []; + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + captured.push({ + body: request.method === "GET" ? null : await request.text(), + headers: request.headers, + method: request.method, + url: request.url, + }); + return mockResponse.clone(); + }) as typeof fetch; + + return { + captured, + restore: () => { + globalThis.fetch = originalFetch; + }, + }; +} + +describe("driver MCP proxy route", () => { + test("matches base route and rejects missing authorization grant with 401", async () => { + const { app, bindings } = await setupTestApp(); + + const response = await app.request( + `https://api.example.com/api/driver/mcp/proxy/${SERVER_ID}`, + { method: "POST" }, + bindings, + createTestExecutionContext(), + ); + + expect(response.status).toBe(401); + }); + + test("matches subpath route and reaches handler instead of 404", async () => { + const { app, bindings } = await setupTestApp(); + + const response = await app.request( + `https://api.example.com/api/driver/mcp/proxy/${SERVER_ID}/messages`, + { method: "POST" }, + bindings, + createTestExecutionContext(), + ); + + expect(response.status).toBe(401); + }); + + test("rejects path traversal attempts with 400 when authorized", async () => { + const { app, bindings } = await setupTestApp(); + const grant = await createTestGrant(bindings); + const headers = { Authorization: `Bearer ${grant}` }; + + for (const badSubpath of ["/v1/%zz/messages", "/v1/%5csecret"]) { + const response = await app.request( + `https://api.example.com/api/driver/mcp/proxy/${SERVER_ID}${badSubpath}`, + { headers, method: "POST" }, + bindings, + createTestExecutionContext(), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "MCP proxy path is invalid." }); + } + }); + + test("successfully proxies requests with subpath and query parameters end-to-end", async () => { + const { app, bindings } = await setupTestApp(); + const grant = await createTestGrant(bindings); + const { captured, restore } = captureFetch( + new Response(JSON.stringify({ result: "mcp-tool-executed" }), { + headers: { "content-type": "application/json" }, + status: 200, + }), + ); + + const mcpProxyService = + await import("../src/modules/runtime/application/runtime-mcp-proxy.service"); + const targetSpy = spyOn(mcpProxyService, "resolveRuntimeMcpProxyTarget").mockResolvedValue({ + delegationToken: null, + serverId: parsePlatformId(SERVER_ID, "server id"), + upstreamAccessToken: "upstream-secret-token", + url: "https://mcp.upstream.org/base", + }); + + try { + const response = await app.request( + `https://api.example.com/api/driver/mcp/proxy/${SERVER_ID}/messages?apiVersion=2026&grant=internal-grant`, + { + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/call" }), + headers: { + Authorization: `Bearer ${grant}`, + "Content-Type": "application/json", + }, + method: "POST", + }, + bindings, + createTestExecutionContext(), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ result: "mcp-tool-executed" }); + + expect(captured).toHaveLength(1); + const upstream = captured[0]; + expect(upstream?.url).toBe("https://mcp.upstream.org/base/messages?apiVersion=2026"); + expect(upstream?.headers.get("authorization")).toBe("Bearer upstream-secret-token"); + expect(upstream?.body).toBe(JSON.stringify({ jsonrpc: "2.0", method: "tools/call" })); + } finally { + targetSpy.mockRestore(); + restore(); + } + }); + + test("handles upstream trailing slashes cleanly for base route and subpaths", async () => { + const { app, bindings } = await setupTestApp(); + const grant = await createTestGrant(bindings); + const { captured, restore } = captureFetch( + new Response(JSON.stringify({ ok: true }), { + headers: { "content-type": "application/json" }, + status: 200, + }), + ); + + const mcpProxyService = + await import("../src/modules/runtime/application/runtime-mcp-proxy.service"); + const targetSpy = spyOn(mcpProxyService, "resolveRuntimeMcpProxyTarget").mockResolvedValue({ + delegationToken: null, + serverId: parsePlatformId(SERVER_ID, "server id"), + upstreamAccessToken: "upstream-secret-token", + url: "https://mcp.upstream.org/base/", + }); + + try { + const baseResponse = await app.request( + `https://api.example.com/api/driver/mcp/proxy/${SERVER_ID}?tag=stable`, + { + headers: { Authorization: `Bearer ${grant}` }, + method: "POST", + }, + bindings, + createTestExecutionContext(), + ); + + expect(baseResponse.status).toBe(200); + expect(captured[0]?.url).toBe("https://mcp.upstream.org/base/?tag=stable"); + + const subpathResponse = await app.request( + `https://api.example.com/api/driver/mcp/proxy/${SERVER_ID}/sse`, + { + headers: { Authorization: `Bearer ${grant}` }, + method: "GET", + }, + bindings, + createTestExecutionContext(), + ); + + expect(subpathResponse.status).toBe(200); + expect(captured[1]?.url).toBe("https://mcp.upstream.org/base/sse"); + expect(captured[1]?.body).toBeNull(); + } finally { + targetSpy.mockRestore(); + restore(); + } + }); +});