Repository navigation
fix(runtime): support subpaths and preserve target routing in MCP proxy #627
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<ApiGatewayEnvironment>) { | ||
|
|
@@ -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<Response> { | ||
| 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<ApiGatewayEnvironment>) { | |
| } | ||
| }); | ||
|
|
||
| driver.all("/mcp/proxy/:serverId", async (c) => { | ||
| const handleRuntimeMcpProxy = async (c: Context<ApiGatewayEnvironment>) => { | ||
| await cleanupDriverInstances(c.env); | ||
|
|
||
| let grant: Awaited<ReturnType<typeof requireDriverAuthorizationGrant>>; | ||
|
|
@@ -797,7 +843,7 @@ export function registerDriverRoute(app: Hono<ApiGatewayEnvironment>) { | |
| let serverId: McpServerId; | ||
|
|
||
| try { | ||
| serverId = toPlatformId<McpServerId>(c.req.param("serverId"), "MCP server ID"); | ||
| serverId = toPlatformId<McpServerId>(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<ApiGatewayEnvironment>) { | |
| ); | ||
| } | ||
|
|
||
| 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<ReturnType<typeof resolveRuntimeMcpProxyTarget>>; | ||
| let toolCallId: string | null; | ||
|
|
||
|
|
@@ -847,7 +899,7 @@ export function registerDriverRoute(app: Hono<ApiGatewayEnvironment>) { | |
| } | ||
|
|
||
| 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<ApiGatewayEnvironment>) { | |
| const details = toRuntimeMcpProxyPublicErrorDetails(proxyError); | ||
| return Response.json(runtimeMcpProxyErrorBody(details), { status: details.status }); | ||
| } | ||
| }); | ||
| }; | ||
|
|
||
| driver.all("/mcp/proxy/:serverId", handleRuntimeMcpProxy); | ||
| driver.all("/mcp/proxy/:serverId/*", handleRuntimeMcpProxy); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Keep credential-bearing requests within admitted MCP transport targets This wildcard accepts every HTTP method and arbitrary descendants, while |
||
|
|
||
| app.route(getRuntimeDriverRoutePrefix(), driver); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ApiGatewayEnvironment>(); | ||
| registerDriverRoute(app); | ||
| return { app, bindings }; | ||
| } | ||
|
|
||
| // 快速签发合法的 MCP 代理授权凭证 | ||
| async function createTestGrant(bindings: ReturnType<typeof createPublicHttpTestBindings>) { | ||
| return createRuntimeActionToken(bindings, { | ||
| action: "mcp_proxy", | ||
| driverInstanceId: parsePlatformId<DriverInstanceId>("01J0000000000000000000000D", "driver ID"), | ||
| expiresAt: Date.now() + 60_000, | ||
| resourceId: parsePlatformId<McpServerId>(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<McpServerId>(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<McpServerId>(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(); | ||
| } | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Preserve the downstream endpoint's trailing slash
subPath.replace(/^\/+|\/+$/g, "")removes a significant trailing slash as well as the join separator. With upstream/base, a request to/api/driver/mcp/proxy/<id>/messages/is forwarded to/base/messages, although/base/messages/and/base/messagescan be different endpoints. A local upstream accepting only/base/messages/returns 200 directly and 404 through this PR. Normalize the base/suffix join without removing the validated suffix's trailing slash, including a suffix of/, and add coverage for that case.