Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +503 to +509

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Invalid-value test misses schema regressions

The assertions accept any tool error for a non-string workspaceId. They still pass when schema validation is bypassed and the value fails later during workspace handling, so a validation regression could go unnoticed. Assert that the response specifically reports invalid workspace_id input.

Artifacts

HTTP probe source for non-string workspace IDs

  • The executed probe authenticates, sends four non-string IDs to the read endpoint, and applies the test's exact two assertions.

HTTP responses with string schema validation enabled

  • The command and captured responses show HTTP 200 OK validation errors for all four inputs, with both original assertions passing.

HTTP responses with string schema validation bypassed

  • The command and captured responses show HTTP 200 OK non-validation errors for the same inputs, yet both original assertions still pass.

View artifacts

T-Rex Ran code and verified through T-Rex

}

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<string, unknown> } }>;
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,
Expand Down
34 changes: 33 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>).arguments;
if (!args || typeof args !== "object" || Array.isArray(args)) return true;
const values = args as Record<string, unknown>;
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<string, unknown> {
return {
ip: requestIp(req, config.logging.trustProxy),
Expand Down Expand Up @@ -946,6 +967,17 @@ export function createServer(
method: req.method,
});

if (!normalizeCachedWorkspaceArgument(req.body)) {
const body = req.body as Record<string, unknown>;
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) {
Expand Down