diff --git a/src/server.test.ts b/src/server.test.ts index 59bcc5d37..bedc1bfd0 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -470,6 +470,92 @@ test("open_workspace scopes checkout reuse to OpenAI session metadata", async (t assert.ok(Array.isArray(structuredContent(unscoped).agents_files)); }); +test("HTTP cached workspaceId arguments preserve canonical schemas and validation", async (t) => { + const { root, localBaseUrl, accessToken } = await httpServerFixture(t, "devspace-cached-arguments-test-"); + await writeFile(join(root, "note.txt"), "cached argument regression\n"); + const opened = await postModernMcp(localBaseUrl, accessToken, "tools/call", { + name: "open_workspace", arguments: { path: root }, + }); + const workspaceId = (await opened.json()).result.structuredContent.workspace_id as string; + assert.equal(typeof workspaceId, "string"); + + for (const argumentsValue of [ + { workspaceId, path: "note.txt" }, + { workspaceId, workspace_id: workspaceId, path: "note.txt" }, + { workspace_id: workspaceId, path: "note.txt" }, + ]) { + const response = await postModernMcp(localBaseUrl, accessToken, "tools/call", { + name: "read", arguments: argumentsValue, + }); + const body = await response.json(); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.equal(body.error, undefined, JSON.stringify(body)); + assert.notEqual(body.result?.isError, true, JSON.stringify(body)); + assert.match(JSON.stringify(body.result), /cached argument regression/); + } + + const conflict = await postModernMcp(localBaseUrl, accessToken, "tools/call", { + name: "read", arguments: { workspaceId, workspace_id: "different", path: "note.txt" }, + }); + assert.equal(conflict.status, 400); + assert.equal((await conflict.json()).error.code, -32602); + + for (const workspaceId of [null, 42, {}, []]) { + const invalid = await postModernMcp(localBaseUrl, accessToken, "tools/call", { + name: "read", arguments: { workspaceId, path: "note.txt" }, + }); + const body = await invalid.json(); + assert.ok(body.error || body.result?.isError, JSON.stringify(body)); + assert.notEqual(invalid.status, 500); + } + + const unauthorized = await postModernMcp(localBaseUrl, undefined, "tools/call", { + name: "read", arguments: { workspaceId, workspace_id: "different", path: "note.txt" }, + }); + assert.equal(unauthorized.status, 401); + const outside = await postModernMcp(localBaseUrl, accessToken, "tools/call", { + name: "read", arguments: { workspaceId, path: "../outside.txt" }, + }); + assert.equal((await outside.json()).result.isError, true); + + const listed = await postModernMcp(localBaseUrl, accessToken, "tools/list", {}); + const tools = (await listed.json()).result.tools as Array<{ name: string; inputSchema: { properties?: Record } }>; + const read = tools.find((tool) => tool.name === "read"); + assert.ok(read?.inputSchema.properties?.workspace_id); + assert.equal(read?.inputSchema.properties?.workspaceId, undefined); +}); + +test("HTTP conflicting workspace aliases preserve request IDs and notification semantics", async (t) => { + const { localBaseUrl, accessToken } = await httpServerFixture(t, "devspace-cached-id-test-"); + for (const id of ["cached-request", 37, null, undefined]) { + await t.test(id === undefined ? "notification" : `request ID ${JSON.stringify(id)}`, async () => { + const response = await fetch(`${localBaseUrl}/mcp`, { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "mcp-method": "tools/call", + "mcp-name": "read", + "mcp-protocol-version": "2026-07-28", + }, + body: JSON.stringify({ + jsonrpc: "2.0", ...(id === undefined ? {} : { id }), method: "tools/call", + params: { name: "read", arguments: { workspaceId: "old", workspace_id: "new", path: "note.txt" } }, + }), + }); + if (id === undefined) { + assert.equal(response.status, 202); + assert.equal(await response.text(), ""); + } else { + assert.equal(response.status, 400); + const body = await response.json(); + assert.equal(body.error.code, -32602); + assert.equal(body.id, id); + } + }); + } +}); + test("HTTP endpoint serves modern MCP and stateless legacy clients", async (t) => { const { root, localBaseUrl, accessToken } = await httpServerFixture( t, diff --git a/src/server.ts b/src/server.ts index 0a2792f3f..5dbac7315 100644 --- a/src/server.ts +++ b/src/server.ts @@ -199,14 +199,35 @@ function sendJsonRpcError( status: number, code: number, message: string, + id: string | number | null = null, ): void { res.status(status).json({ jsonrpc: "2.0", error: { code, message }, - id: null, + id, }); } +// ChatGPT may retain the pre-snake_case workspace argument in cached tool calls. +// Normalize only that known alias at the HTTP edge; the public schema remains canonical. +function normalizeCachedWorkspaceArgument(body: unknown): boolean { + if (!body || typeof body !== "object" || Array.isArray(body)) return true; + const request = body as Record; + if (request.method !== "tools/call") return true; + const params = request.params; + if (!params || typeof params !== "object" || Array.isArray(params)) return true; + const args = (params as Record).arguments; + if (!args || typeof args !== "object" || Array.isArray(args)) return true; + const values = args as Record; + if (!Object.hasOwn(values, "workspaceId")) return true; + if (Object.hasOwn(values, "workspace_id") && values.workspace_id !== values.workspaceId) { + return false; + } + if (!Object.hasOwn(values, "workspace_id")) values.workspace_id = values.workspaceId; + delete values.workspaceId; + return true; +} + function requestLogFields(req: Request, config: ServerConfig): Record { return { ip: requestIp(req, config.logging.trustProxy), @@ -946,6 +967,17 @@ export function createServer( method: req.method, }); + if (!normalizeCachedWorkspaceArgument(req.body)) { + const body = req.body as Record; + if (!Object.hasOwn(body, "id")) { + res.status(202).end(); + return; + } + const id = typeof body.id === "string" || typeof body.id === "number" ? body.id : null; + sendJsonRpcError(res, 400, -32602, "Conflicting workspaceId and workspace_id arguments", id); + return; + } + try { await mcpNodeHandler(req, res, req.body); } catch (error) {