From a59a06e9a1fc05061dd2eea5ee1731e56abdeb5b Mon Sep 17 00:00:00 2001 From: JunkaiWang-TheoPhy <1181100960@qq.com> Date: Sun, 27 Sep 2026 17:31:51 +0800 Subject: [PATCH 1/2] Preserve cached workspace calls across the snake_case migration ChatGPT can retain the former workspaceId argument after schemas migrate to workspace_id. Normalize only that alias at the authenticated HTTP edge and reject contradictory handles before tool validation, while keeping the advertised schema canonical. Constraint: Preserve OAuth and workspace containment boundaries Confidence: high Scope-risk: narrow Tested: HTTP regression fails with original server and passes with compatibility adapter; all 26 server tests; TypeScript typecheck Not-tested: Live ChatGPT cached session and packaged installation Related: https://github.com/Waishnav/devspace/issues/373 --- src/server.test.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 25 +++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/server.test.ts b/src/server.test.ts index 59bcc5d37..1bcd9d6af 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -470,6 +470,61 @@ 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 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..d42d8b7a6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -207,6 +207,26 @@ function sendJsonRpcError( }); } +// 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 +966,11 @@ export function createServer( method: req.method, }); + if (!normalizeCachedWorkspaceArgument(req.body)) { + sendJsonRpcError(res, 400, -32602, "Conflicting workspaceId and workspace_id arguments"); + return; + } + try { await mcpNodeHandler(req, res, req.body); } catch (error) { From 6c5fc8fa86ece2b2d63440b4c1548ba9e2b45575 Mon Sep 17 00:00:00 2001 From: JunkaiWang-TheoPhy <1181100960@qq.com> Date: Sun, 27 Sep 2026 17:38:24 +0800 Subject: [PATCH 2/2] Keep compatibility errors correlated with their JSON-RPC requests Alias conflicts must remain recognizable to MCP clients: retain string, numeric and null request IDs and accept notifications without sending JSON-RPC responses. Existing generic error responses retain their default null ID. Confidence: high Scope-risk: narrow Tested: Original conflict handler fails string and numeric ID plus notification regressions; all 31 server tests pass after repair; TypeScript typecheck Not-tested: Live ChatGPT error presentation --- src/server.test.ts | 31 +++++++++++++++++++++++++++++++ src/server.ts | 11 +++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index 1bcd9d6af..bedc1bfd0 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -525,6 +525,37 @@ test("HTTP cached workspaceId arguments preserve canonical schemas and validatio 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 d42d8b7a6..5dbac7315 100644 --- a/src/server.ts +++ b/src/server.ts @@ -199,11 +199,12 @@ 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, }); } @@ -967,7 +968,13 @@ export function createServer( }); if (!normalizeCachedWorkspaceArgument(req.body)) { - sendJsonRpcError(res, 400, -32602, "Conflicting workspaceId and workspace_id arguments"); + 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; }