Wrap the server once. Every tool after it is policy-checked, credential-injected, and audited.

Open almost any MCP server and you will find the same line near the top of a handler. A personal access token, sitting in a closure, ready to be handed to whatever tool call comes in next. It works on the first try, which is exactly the problem. The thing that makes it easy to ship is the thing that makes it dangerous.

Here is a GitHub-issues server written the way a lot of them actually get written.

const token = process.env.GITHUB_TOKEN;   // full-scope PAT, lives in the closure

server.tool("create_issue", schema, async (args) => {
  log(`create_issue ${token} ${JSON.stringify(args)}`);   // token and args, straight to disk
  return await githubClient(token).issues.create(args);
});

server.tool("delete_repo", schema, async (args) => {
  return await githubClient(token).repos.delete(args);    // runs for anyone who can reach the server
});

Three things are wrong here and none of them are exotic.

The token is a full-scope credential, so every tool on the server can do everything the token can do. create_issue and delete_repo hold identical power, because they hold the same secret. There is no policy layer deciding that one of those is routine and the other should never happen without a human. And the only record of any of it is a log line that writes the token and the full arguments to disk, so your audit trail is also your worst leak.

What that actually looks like when it runs

This is the log the insecure server produces. It is the real fixture from the example repo.

{"ts":"…","tool":"create_issue","token":"ghp_EXAMPLE…","args":{"title":"Prod is down","body":"Customer X reports 500s since 08:50"}}
{"ts":"…","tool":"delete_repo","token":"ghp_EXAMPLE…","args":{"name":"acme/really-important-repo"}}

Read it back. The token is in the log, so anyone who can read the log has the credential. The issue body is in the log, so a customer report is now sitting in plaintext next to it. And the repo deletion already happened. No check, no approval, no way to have said no. The log is not a record of a decision. It is a record of a thing that was allowed to happen because nothing was ever asked.

The fix is three lines

The same server, with @agentvalet/mcp-broker wrapped around it once.

import { broker } from "@agentvalet/mcp-broker";

broker(server, {
  policy: "file:./policy.yaml",
  secrets: "env:",
  audit: "jsonl:./audit.log",
});

server.tool("create_issue", schema, async (args, ctx) => {
  return await githubClient(ctx.credential.token).issues.create(args);
});

You import broker, you call it once before you register your tools, and inside the handler you read ctx.credential.token instead of a module-level secret. Every tool you register after that call runs through the same pipeline: resolve the caller identity, evaluate the policy, hold for approval if the policy says so, resolve a narrow credential for that one call, run the handler, then audit it. You did not restructure a single tool. You wrapped the server.

The policy is a small file, deliberately boring.

version: 1
defaults:
  effect: deny
rules:
  - tool: create_issue
    effect: allow
    credential: github_issues_rw
  - tool: delete_repo
    effect: require_approval
    credential: github_admin

Deny by default. create_issue is allowed. delete_repo needs a human. Anything you did not write a rule for does not run.

The same run, now audited properly

Here is the after log. Same client, same three calls, plus one call for a tool nobody ever wrote a rule for.

{"resource":{"tool":"create_issue"},"argsFingerprint":"sha256:3431…","decision":{"effect":"allow"},"outcome":"allowed","warnings":["credential_no_expiry_static_secret"]}
{"resource":{"tool":"export_secrets"},"argsFingerprint":"sha256:ad91…","decision":{"effect":"deny"},"outcome":"denied"}
{"resource":{"tool":"delete_repo"},"argsFingerprint":"sha256:2ca1…","decision":{"effect":"require_approval"},"outcome":"approval_timeout"}

Four things changed, and each of them maps to one of the problems from before.

There is no token anywhere in the log. The handler gets a resolved credential at call time and never holds a standing secret. create_issue ran, and the credential_no_expiry_static_secret warning is the broker pointing out that a local env: secret is static, which is your nudge toward a real short-lived credential later.

There are no raw arguments in the log, only a sha256 fingerprint. You can still correlate calls, spot a repeat, prove two requests carried the same payload, all without the payload itself ever touching disk.

export_secrets was denied by default. Nobody wrote a rule for it, so it did not run, and the attempt is on the record. That is the deny-by-default posture doing its job on a tool the author never even thought about.

delete_repo was held for a human. With no approval it timed out and was denied, logged as approval_timeout. The repo is still there. The dangerous action became a decision instead of an event.

Why this is a library and not a gateway

Every other tool in this space is a gateway. You stand up a proxy or a runtime in front of your MCP servers, a platform team owns it, and it governs traffic on the way through. That model is fine when a platform team exists and a gateway is going to get deployed.

For most MCP servers, neither of those is true. The server is one file, written by one person, shipped on its own. A gateway will never sit in front of it. A library can live inside it. That is the whole idea here. Governance that an individual server author adopts with one dependency and one function call, not infrastructure that an org has to stand up first.

Local mode is genuinely free, and honest about where it stops

The example above uses no account and no network. Policy from a YAML file, secrets from the environment, audit to a local JSONL file. For one developer and one server, that is a complete and useful setup, and it stays free.

It is also deliberately built to stop scaling at exactly the point where a team needs more. The YAML format has no environments, no inheritance, no templating, and no record of who changed a rule. The moment you want approval routing, versioned rollout, a central audit store, or a real short-lived credential minted per call instead of a static secret, those are the hosted plane. The local policy you write evaluates with byte-identical semantics to the hosted one, because it is literally the same evaluator. You are not migrating off a toy when you upgrade. You are keeping the same rules and moving where they run.

Try it on your own server

If you maintain an MCP server, the whole change is one install and three lines.

npm install @agentvalet/mcp-broker

Wrap your server, write a five-line policy, point audit at a file, and read ctx.credential in your handlers. It is MIT licensed, has zero runtime dependencies, and the before-and-after example with both audit logs is in the repo so you can see the full diff before you touch your own code. The token comes out of your handler, the dangerous tools start asking first, and your logs stop being the thing you are most afraid of leaking.