Skip to content

Commit 0e700c9

Browse files
fix(mcp-bridge): harden argument handling for routed tools (#349)
## Summary Harden argument handling in the MCP bridge for the routed tools: browser, cloud, comms and ml. These tools share a cartridge and are told apart by a routing key derived from the tool name. After this change the tool name alone decides the route, and arguments that a routed tool's `inputSchema` does not declare are refused with `-32602`. ## 📌 New pins Head SHA: **f01e55a72ab4a3ec8f2edbdbefff9b29200db8cb**. This PR adds or changes no action, lockfile or container pins. ## Changes - `mcp-bridge/lib/dispatcher.js`: - New `ROUTED_TOOLS` table (tool name → cartridge plus routing key). It replaces four `switch` arms. The routing key is written after the caller's arguments. - New `validateRoutedArgs` and `declaredArgs`. The hardening gate refuses arguments that a routed tool's `inputSchema` does not declare. - The gate also refuses non-object `arguments`. - JSDoc added to `dispatchTool`, `hardeningGate` and the new helpers. - Scope: non-routed tools are unchanged. `coord_send` reads `sender_role`, which no schema declares, so applying the check to every tool would break it. - `mcp-bridge/tests/routing_args_test.js` (new, 16 tests). Each routed tool still reaches its cartridge with the right key. An argument cannot change the routing key. Undeclared arguments are refused. Non-routed handling is unchanged. `fetch` is stubbed for this file only and restored afterwards, because bun shares one process across files. - `.github/workflows/e2e.yml` (node, deno and bun unit lines) and `package.json` `test` now include the new file. ## RSR Quality Checklist ### Required - [x] Tests pass. node `--test`: 68/68 across the four bridge test files. `bun test`: 68/68. `deno test`: 68/68. - [ ] Code is formatted: no formatter is configured for `mcp-bridge/` JS. The code follows the surrounding style by hand. - [ ] Linter is clean: no JS linter runs on `mcp-bridge/` locally. CI scanners will report on this PR. - [x] No banned language patterns. Plain ESM JS, as in the existing bridge. Nothing new in TS, Python or Go. The `npm test` script line already existed and only gained a file name. - [x] No `unsafe` blocks: there is no Rust or Zig in this change. - [x] No banned functions. - [x] SPDX headers: the new test file carries `MPL-2.0`. The modified files keep theirs. - [x] No secrets, credentials or `.env` files. ### As Applicable - [ ] `.machine_readable/*` not updated: project state and integrations are unchanged. - [ ] Documentation not updated: the advertised tool schemas are unchanged. Only calls that already violated those schemas are now refused. - [ ] `TOPOLOGY.md` not updated: architecture is unchanged. - [ ] CHANGELOG / release notes: to follow with the patch release that ships this fix. - [ ] New dependencies: none. - [ ] ABI/FFI: not touched. ## Testing - `node --test mcp-bridge/tests/{routing_args,dispatch,http_transport,path_claims}_test.js`: 68 pass, 0 fail. - `bun test` (same files): 68 pass, 0 fail. `deno test --allow-read --allow-env --allow-run --allow-net` (same files): 68 passed. - Control: `routing_args_test.js` against `origin/main`'s `dispatcher.js` gives 8 pass and 8 fail. Every routing and undeclared-argument test fails there; the well-formed-call and non-routed tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019j8She9eTFx54r6aL6sCHP Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9180ee6 commit 0e700c9

4 files changed

Lines changed: 170 additions & 24 deletions

File tree

‎.github/workflows/e2e.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,16 +210,16 @@ jobs:
210210
matrix:
211211
include:
212212
- runtime: node
213-
unit: node --test mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js
213+
unit: node --test mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js mcp-bridge/tests/routing_args_test.js
214214
boot: node mcp-bridge/main.js
215215
- runtime: deno
216-
unit: deno test --allow-read --allow-env --allow-run --allow-net mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js
216+
unit: deno test --allow-read --allow-env --allow-run --allow-net mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js mcp-bridge/tests/routing_args_test.js
217217
# Scoped perms matching main.js's shebang, NOT -A: the boot smoke
218218
# must exercise the exact permission set a real install uses, or it
219219
# would mask a missing-grant bug that scoped users would hit.
220220
boot: deno run --allow-net --allow-env --allow-read mcp-bridge/main.js
221221
- runtime: bun
222-
unit: bun test mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js
222+
unit: bun test mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js mcp-bridge/tests/routing_args_test.js
223223
boot: bun mcp-bridge/main.js
224224

225225
steps:

‎mcp-bridge/lib/dispatcher.js‎

Lines changed: 81 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,64 @@ function rpcError(id, code, message) {
5353
return { jsonrpc: "2.0", id, error: { code, message: sanitizeErrorMessage(message) } };
5454
}
5555

56+
// Tools that share one cartridge and are told apart by a routing key
57+
// derived from the tool name. The bridge sets the key, never the caller:
58+
// dispatch writes it last, and the gate refuses any argument the tool's
59+
// inputSchema does not declare.
60+
const ROUTED_TOOLS = new Map([
61+
...["verpex", "cloudflare", "vercel"].map((p) => [`boj_cloud_${p}`, { cartridge: "cloud-mcp", key: "provider", value: p }]),
62+
...["gmail", "calendar"].map((p) => [`boj_comms_${p}`, { cartridge: "comms-mcp", key: "provider", value: p }]),
63+
["boj_ml_huggingface", { cartridge: "ml-mcp", key: "provider", value: "huggingface" }],
64+
...["navigate", "click", "type", "read_page", "screenshot", "tabs", "execute_js"].map((a) => [`boj_browser_${a}`, { cartridge: "browser-mcp", key: "action", value: a }]),
65+
]);
66+
67+
let declaredArgsCache = null;
68+
69+
/**
70+
* Return the argument names a tool's inputSchema declares, or null when
71+
* the tool is not in the full tool list.
72+
*
73+
* @param {string} toolName
74+
* @returns {Set<string>|null}
75+
*/
76+
function declaredArgs(toolName) {
77+
if (!declaredArgsCache) {
78+
declaredArgsCache = new Map(
79+
buildToolList("full").map((t) => [t.name, new Set(Object.keys(t.inputSchema?.properties ?? {}))]),
80+
);
81+
}
82+
return declaredArgsCache.get(toolName) ?? null;
83+
}
84+
85+
/**
86+
* Check a routed tool's arguments against its inputSchema. Other tools
87+
* are not checked here.
88+
*
89+
* @param {string} toolName
90+
* @param {Record<string, unknown>} args
91+
* @returns {string|null} error message, or null when every argument is declared
92+
*/
93+
function validateRoutedArgs(toolName, args) {
94+
const route = ROUTED_TOOLS.get(toolName);
95+
if (!route) return null;
96+
const allowed = declaredArgs(toolName);
97+
if (!allowed) return "Unknown tool";
98+
for (const name of Object.keys(args)) {
99+
if (name === route.key || !allowed.has(name)) {
100+
return `Unexpected argument '${name}' for ${toolName}`;
101+
}
102+
}
103+
return null;
104+
}
105+
106+
/**
107+
* Pre-dispatch checks for a tools/call: rate limit, tool-name shape,
108+
* argument size, injection scan, and per-tool argument validation.
109+
*
110+
* @param {string} toolName
111+
* @param {Record<string, unknown>} args
112+
* @returns {{code: number, message: string}|null} a JSON-RPC error, or null to proceed
113+
*/
56114
function hardeningGate(toolName, args) {
57115
if (!rateLimitAllow()) {
58116
return { code: -32000, message: "Rate limit exceeded. Max " + RATE_LIMIT + " tool calls per minute." };
@@ -72,6 +130,14 @@ function hardeningGate(toolName, args) {
72130
warn("Injection warning", { tool: toolName, confidence: injectionLevel });
73131
}
74132

133+
if (args === null || typeof args !== "object" || Array.isArray(args)) {
134+
return { code: -32602, message: "Tool arguments must be an object" };
135+
}
136+
const routedError = validateRoutedArgs(toolName, args);
137+
if (routedError) {
138+
return { code: -32602, message: routedError };
139+
}
140+
75141
let validationError = null;
76142
if (toolName === "boj_cartridge_info" || toolName === "boj_cartridge_invoke") {
77143
validationError = validateRequiredStrings(args, ["name"]);
@@ -103,7 +169,22 @@ function hardeningGate(toolName, args) {
103169
return null;
104170
}
105171

172+
/**
173+
* Dispatch a gated tools/call to its handler.
174+
*
175+
* Routed tools (see ROUTED_TOOLS) go to their shared cartridge with the
176+
* routing key written after the caller's arguments, so the tool name
177+
* alone decides the route. Returns null for an unknown tool.
178+
*
179+
* @param {string} toolName
180+
* @param {Record<string, unknown>} args
181+
* @returns {Promise<object|null>}
182+
*/
106183
async function dispatchTool(toolName, args) {
184+
const route = ROUTED_TOOLS.get(toolName);
185+
if (route) {
186+
return invokeCartridge(route.cartridge, { ...args, [route.key]: route.value });
187+
}
107188
switch (toolName) {
108189
case "boj_health":
109190
return fetchHealth();
@@ -116,26 +197,6 @@ async function dispatchTool(toolName, args) {
116197
case "boj_cartridge_invoke":
117198
return invokeCartridge(args.name, args.params);
118199

119-
case "boj_cloud_verpex":
120-
case "boj_cloud_cloudflare":
121-
case "boj_cloud_vercel":
122-
return invokeCartridge("cloud-mcp", { provider: toolName.replace("boj_cloud_", ""), ...args });
123-
124-
case "boj_comms_gmail":
125-
case "boj_comms_calendar":
126-
return invokeCartridge("comms-mcp", { provider: toolName.replace("boj_comms_", ""), ...args });
127-
128-
case "boj_ml_huggingface":
129-
return invokeCartridge("ml-mcp", { provider: "huggingface", ...args });
130-
131-
case "boj_browser_navigate":
132-
case "boj_browser_click":
133-
case "boj_browser_type":
134-
case "boj_browser_read_page":
135-
case "boj_browser_screenshot":
136-
case "boj_browser_tabs":
137-
case "boj_browser_execute_js":
138-
return invokeCartridge("browser-mcp", { action: toolName.replace("boj_browser_", ""), ...args });
139200

140201
case "boj_github_list_repos":
141202
case "boj_github_get_repo":
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
//
4+
// BoJ Server — routed-tool argument tests
5+
//
6+
// The browser / cloud / comms / ml tools are routed to a shared
7+
// cartridge with a routing key (`action` or `provider`) derived from
8+
// the tool name. These tests pin two properties:
9+
// 1. arguments outside a routed tool's inputSchema are refused, and
10+
// 2. the routing key the cartridge receives always matches the tool
11+
// that was called.
12+
// `fetch` is stubbed so no backend is needed.
13+
//
14+
// Run: node --test mcp-bridge/tests/routing_args_test.js
15+
16+
import { after, before, test } from "node:test";
17+
import assert from "node:assert/strict";
18+
19+
import { dispatchMcpMessage } from "../lib/dispatcher.js";
20+
21+
// Some runners (bun) share one process across test files, so the stub is
22+
// installed for this file only and the real fetch is put back afterwards.
23+
const posted = [];
24+
const realFetch = globalThis.fetch;
25+
before(() => {
26+
globalThis.fetch = async (url, init) => {
27+
posted.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : null });
28+
return { ok: true, status: 200, json: async () => ({ ok: true }) };
29+
};
30+
});
31+
after(() => {
32+
globalThis.fetch = realFetch;
33+
});
34+
35+
let nextId = 1;
36+
/** Issue one tools/call and return the JSON-RPC response plus what was posted. */
37+
async function call(name, args) {
38+
posted.length = 0;
39+
const res = await dispatchMcpMessage({
40+
jsonrpc: "2.0",
41+
id: nextId++,
42+
method: "tools/call",
43+
params: { name, arguments: args },
44+
});
45+
return { res, sent: [...posted] };
46+
}
47+
48+
const ROUTED = [
49+
["boj_browser_read_page", {}, "action", "read_page"],
50+
["boj_browser_screenshot", {}, "action", "screenshot"],
51+
["boj_browser_navigate", { url: "https://example.org/" }, "action", "navigate"],
52+
["boj_cloud_verpex", { operation: "list" }, "provider", "verpex"],
53+
["boj_cloud_vercel", { operation: "list" }, "provider", "vercel"],
54+
["boj_comms_calendar", { operation: "list" }, "provider", "calendar"],
55+
["boj_ml_huggingface", { operation: "list" }, "provider", "huggingface"],
56+
];
57+
58+
for (const [tool, args, key, expected] of ROUTED) {
59+
test(`${tool}: a well-formed call reaches the cartridge with ${key}=${expected}`, async () => {
60+
const { res, sent } = await call(tool, args);
61+
assert.equal(res.error, undefined, JSON.stringify(res.error));
62+
assert.equal(sent.length, 1);
63+
assert.equal(sent[0].body[key], expected);
64+
});
65+
66+
test(`${tool}: an argument named '${key}' cannot change the routing`, async () => {
67+
const { res, sent } = await call(tool, { ...args, [key]: "something-else" });
68+
for (const s of sent) assert.equal(s.body[key], expected, "routing key was overridden");
69+
assert.ok(res.error, "expected the call to be refused");
70+
assert.equal(res.error.code, -32602);
71+
});
72+
}
73+
74+
test("routed tools refuse arguments outside their inputSchema", async () => {
75+
const { res, sent } = await call("boj_browser_screenshot", { not_in_schema: 1 });
76+
assert.ok(res.error, "expected the call to be refused");
77+
assert.equal(res.error.code, -32602);
78+
assert.equal(sent.length, 0);
79+
});
80+
81+
test("non-routed tools keep their existing argument handling", async () => {
82+
// coord_send reads `sender_role`, which no inputSchema declares.
83+
const { res } = await call("coord_send", { message: "hi", target: "peer", sender_role: "worker" });
84+
assert.equal(res.error, undefined, JSON.stringify(res.error));
85+
});

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
},
2121
"scripts": {
2222
"start": "deno run -A mcp-bridge/main.js",
23-
"test": "node --test mcp-bridge/tests/dispatch_test.js"
23+
"test": "node --test mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/routing_args_test.js"
2424
},
2525
"engines": {
2626
"node": ">=18.0.0"

0 commit comments

Comments
 (0)