Skip to content

Commit 705e63a

Browse files
steipeteVectorPeak
andcommitted
fix(mcp): reject non-object tool call arguments
Co-authored-by: VectorPeak <[email protected]>
1 parent a00e9fc commit 705e63a

2 files changed

Lines changed: 74 additions & 2 deletions

File tree

src/gateway/mcp-http.handlers.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Implements initialize, tools/list, tools/call, and notification handling.
33
import crypto from "node:crypto";
44
import { ContentBlockSchema, type ContentBlock } from "@modelcontextprotocol/sdk/types.js";
5+
import { isRecord } from "@openclaw/normalization-core/record-coerce";
56
import { runBeforeToolCallHook, type HookContext } from "../agents/agent-tools.before-tool-call.js";
67
import {
78
formatToolExecutionErrorMessage,
@@ -99,7 +100,11 @@ export async function handleMcpJsonRpc(params: {
99100
return jsonRpcResult(id, { tools: params.toolSchema });
100101
case "tools/call": {
101102
const toolName = typeof methodParams?.name === "string" ? methodParams.name.trim() : "";
102-
const toolArgs = (methodParams?.arguments ?? {}) as Record<string, unknown>;
103+
const rawToolArgs = methodParams?.arguments;
104+
if (rawToolArgs !== undefined && !isRecord(rawToolArgs)) {
105+
return jsonRpcError(id, -32602, "Invalid params: tools/call arguments must be an object");
106+
}
107+
const toolArgs = rawToolArgs ?? {};
103108
if (!toolName) {
104109
return jsonRpcResult(id, {
105110
content: [{ type: "text", text: "Tool not available: unknown" }],

src/gateway/mcp-http.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1153,21 +1153,88 @@ describe("mcp loopback server", () => {
11531153
});
11541154

11551155
it("executes tools for loopback callers", async () => {
1156-
const cronExecute = vi.fn(async () => ({
1156+
const cronExecute = vi.fn<MockGatewayTool["execute"]>(async () => ({
11571157
content: [{ type: "text", text: "CRON_EXECUTED" }],
11581158
}));
1159+
const args = { action: "status" };
11591160
mockScopedTools([makeMessageTool(), makeCronTool({ execute: cronExecute })]);
11601161
const { runtime } = await startLoopbackServerForTest();
11611162

11621163
const payload = await callMainSessionTool({
11631164
token: runtime?.ownerToken,
11641165
name: "cron",
1166+
args,
11651167
});
11661168

11671169
expect(cronExecute).toHaveBeenCalledTimes(1);
1170+
expect(getBeforeToolCallHookInput(0).params).toEqual(args);
1171+
expect(cronExecute.mock.calls[0]?.[1]).toEqual(args);
11681172
expectMcpResultText(payload, "CRON_EXECUTED");
11691173
});
11701174

1175+
it.each([
1176+
["null", null],
1177+
["array", []],
1178+
["string", "bad"],
1179+
])("rejects %s tool call arguments before hooks or execution", async (_label, badArguments) => {
1180+
const execute = vi.fn<MockGatewayTool["execute"]>(async () => ({
1181+
content: [{ type: "text", text: "EXECUTED" }],
1182+
}));
1183+
mockScopedTools([makeMessageTool({ execute })]);
1184+
const { runtime, port } = await startLoopbackServerForTest();
1185+
1186+
const response = await sendRaw({
1187+
port,
1188+
token: runtime.ownerToken,
1189+
headers: jsonHeaders(),
1190+
body: JSON.stringify({
1191+
jsonrpc: "2.0",
1192+
id: 1,
1193+
method: "tools/call",
1194+
params: { name: "message", arguments: badArguments },
1195+
}),
1196+
});
1197+
1198+
expect(response.status).toBe(200);
1199+
expect(await readMcpPayload(response)).toEqual({
1200+
jsonrpc: "2.0",
1201+
id: 1,
1202+
error: {
1203+
code: -32602,
1204+
message: "Invalid params: tools/call arguments must be an object",
1205+
},
1206+
});
1207+
expect(runBeforeToolCallHookMock).not.toHaveBeenCalled();
1208+
expect(execute).not.toHaveBeenCalled();
1209+
});
1210+
1211+
it("keeps omitted tool call arguments as an empty object", async () => {
1212+
const execute = vi.fn<MockGatewayTool["execute"]>(async () => ({
1213+
content: [{ type: "text", text: "EXECUTED" }],
1214+
}));
1215+
mockScopedTools([makeMessageTool({ execute })]);
1216+
const { runtime, port } = await startLoopbackServerForTest();
1217+
1218+
const response = await sendRaw({
1219+
port,
1220+
token: runtime.ownerToken,
1221+
headers: jsonHeaders(),
1222+
body: JSON.stringify({
1223+
jsonrpc: "2.0",
1224+
id: 1,
1225+
method: "tools/call",
1226+
params: { name: "message" },
1227+
}),
1228+
});
1229+
1230+
expect(response.status).toBe(200);
1231+
expectMcpResultText(await readMcpPayload(response), "EXECUTED");
1232+
expect(runBeforeToolCallHookMock).toHaveBeenCalledTimes(1);
1233+
expect(getBeforeToolCallHookInput(0).params).toEqual({});
1234+
expect(execute).toHaveBeenCalledTimes(1);
1235+
expect(execute.mock.calls[0]?.[1]).toEqual({});
1236+
});
1237+
11711238
it("preserves valid MCP content blocks returned by loopback tools", async () => {
11721239
const content = [
11731240
{ type: "text", text: "caption", annotations: { audience: ["user"] } },

0 commit comments

Comments
 (0)