DEV Community

Royal Simpson Pinto
Royal Simpson Pinto

Posted on

Scoping MCP tool access per client, and auditing every call

Most MCP servers I have seen expose every tool they know about to every client that connects. That is fine on your laptop. It stops being fine the moment the same server is meant to sit between a company's real accounts (Shopify, an analytics platform, a Postgres database) and several different AI clients, where one of them is a read-only analyst assistant and another is an ops bot that is allowed to write.

The protocol gives you tools/list and tools/call. It does not tell you who is allowed to see what, or who is allowed to do what. If you wire the server up naively, tools/list hands back the full menu, and the model on the other end will happily try to call the write tool because it can see it. Bridgekit is my answer to that: a scoped MCP server where every client key carries its own permission boundary, writes are gated separately from reads, and every call, allowed or denied, lands in an append-only audit log.

The core idea: scope lives with the key, not the tool

Clients are configured as a JSON secret. Each key maps to a name, the exact list of tools it may use, and whether it may write:

{
  "bk_live_demo123": {
    "name": "growth-os",
    "tools": ["shopify_orders", "triplewhale_metrics", "db_query"],
    "allowWrite": false
  }
}
Enter fullscreen mode Exit fullscreen mode

Callers present that key as Authorization: Bearer <key> or as an x-bridgekit-key header. Every request must resolve to a known client before anything else happens. If the key is missing or unknown, the request never reaches the tool layer; it comes back as a JSON-RPC error with a 401.

The important part is that the scope is a property of the caller, not a global setting on the server. Two clients hitting the same /mcp endpoint see two different worlds.

How it works

Transport is MCP over Streamable HTTP: clients POST JSON-RPC 2.0 to /mcp, and the server implements initialize, tools/list, tools/call, and ping. The whole thing runs as a single Cloudflare Worker.

The first place scope shows up is discovery. tools/list does not return the catalog; it returns the intersection of what exists, what this client is scoped for, and (for write tools) whether the client can write at all:

const visible = TOOLS.filter(
  (t) =>
    caller.config.tools.includes(t.name) &&
    (!t.write || caller.config.allowWrite),
).map((t) => ({
  name: t.name,
  description: t.description,
  inputSchema: t.inputSchema,
}));
Enter fullscreen mode Exit fullscreen mode

A read-only client literally never sees the write tool exist. That matters, because a tool the model cannot see is a tool the model will not try to call.

The second place is enforcement, on tools/call. Listing filtering is a convenience; it is not security on its own, because a client could still name a tool directly. So the call path re-checks everything from scratch and records the decision either way:

if (!caller.config.tools.includes(name)) {
  await audit(env, caller, {
    tool: name,
    decision: "denied",
    reason: "not in client scope",
  });
  return rpcOk(id, toolError(`tool "${name}" not allowed for this client`));
}
if (tool.write && !caller.config.allowWrite) {
  await audit(env, caller, {
    tool: name,
    decision: "denied",
    reason: "write scope required",
    args,
  });
  return rpcOk(id, toolError(`tool "${name}" is a write action; client lacks write scope`));
}
Enter fullscreen mode Exit fullscreen mode

There are four tools in the current build: shopify_orders, triplewhale_metrics, and db_query are reads, and shopify_tag_order is the one write. db_query reads from an allowlisted set of Postgres tables rather than accepting arbitrary SQL, so scope narrows again inside the tool itself.

One deliberate design choice: a denied write does not blow up as a transport error. It comes back as an MCP tool result with isError: true. That follows the protocol convention where tool-level failures are readable results, not connection faults, so the model on the other end can actually read "you lack write scope" and react, rather than seeing an opaque crash.

The audit log

Every branch above calls audit() before returning. The allowed path logs after the tool runs; the denied paths log the reason they were rejected. Entries carry the client name, a non-reversible short label of the key (first eight characters, an ellipsis, the last two, so raw keys never hit the log), the tool, the decision, an optional reason, and truncated arguments. They are written to a bk_audit table over PostgREST.

Two details I care about. First, logging failures are swallowed. If the audit sink is down, the tool call still returns; observability should never take down the actual product, and the failure surfaces in the Worker logs instead. Second, arguments are truncated before storage (capped at 2000 characters), so a giant payload cannot bloat a row, and the code path is written to keep secrets out of the log.

One honest limitation

Scopes are coarse. A client either has a tool or it does not, and it either may write or it may not. There is no row-level or field-level policy, no rate limit per client, and no per-tool write approval; write access is a single boolean for the whole client. For the Shopify and analytics workflows this was built around, tool-level plus read/write separation covers the real cases. But if you needed "this client may tag orders under $500 only," that logic does not exist yet; you would push it into the connector by hand. The boundary Bridgekit enforces is which tool and which direction, not which values.

There is also a demo-shaped edge: the audit log is pruned to the newest rows to stay bounded, so it is a live trail, not long-term retention. In a real deployment you would drop the prune and point it at durable storage.

Closing

The thing I wanted was boring and specific: give an AI client real tools without giving it the keys, and be able to answer "who called what, and did we allow it" after the fact. Scope lives on the key, discovery and enforcement both respect it, and nothing runs without leaving a record. That is the whole product.

Code and the full tool list are here: https://github.com/AgentPostmortem/Bridgekit

Top comments (0)