Connect the GitHub MCP server to a triage agent and it can read your issues and merge your pull requests, because MCP access is decided one server at a time. This is the case for deciding it one tool at a time, and for everything that has to be true around that decision.
Dan FlanaganOctober 10, 202613 min read
One rule of access control has held everywhere I have applied it, from payment APIs to building networks. Give out the smallest piece of access that does the job, give it to something with a name, and write down every time it is used.
MCP got popular before any of that was in place. This post walks through what goes wrong when access is decided per server, what the usual workaround costs, and how Statio handles each piece. The figures play on their own once they scroll into view; click a phase to jump to it, or pause one to read.
The merge problem
Here is the setup most teams start with. You want an agent that reads new issues, sorts them and leaves a comment. You add the GitHub MCP server to its config, paste in a token, and it works.
It also works for merge_pull_request, push_files and delete_file. An MCP server registers all of its tools at once, the client config has one entry per server, and that entry carries one credential. There is no line in mcp.json that says this tool and not that one. The agent you wanted triages issues. The agent you got can merge to main.
That matters more than it looks, because issues are written by strangers. Anyone who can open an issue on your repository can put text in front of your agent. If that text talks the agent into a merge, the merge runs on your token.
triage-bot on github
triage-bot asks for open issues. The token in its config covers the whole github server, so the call goes straight through.
triage-bot asks for open issues. The token in its config covers the whole github server, so the call goes straight through.
triage-bot asks for open issues. The token in its config covers the whole github server, so the call goes straight through.
triage-bot asks for open issues. The token in its config covers the whole github server, so the call goes straight through. 12 open issues come back.
It opens issue #412. A stranger wrote that issue.
It opens issue #412. A stranger wrote that issue.
It opens issue #412. A stranger wrote that issue.
It opens issue #412. A stranger wrote that issue, and its last line tells the agent to merge PR #418.
The agent does what the issue says. merge_pull_request is on the same server and the same token, so nothing is in the way.
The agent does what the issue says. merge_pull_request is on the same server and the same token, so nothing is in the way.
The agent does what the issue says. merge_pull_request is on the same server and the same token, so nothing is in the way. PR #418 is merged into main.
The agent does what the issue says. merge_pull_request is on the same server and the same token, so nothing is in the way. PR #418 is merged into main.
Same agent, same server, now through Statio. The grant on github covers list_issues.
Same agent, same server, now through Statio. The grant on github covers list_issues.
Same agent, same server, now through Statio. The grant on github covers list_issues.
Same agent, same server, now through Statio. The grant on github covers list_issues. 12 open issues come back.
Same issue, same injected line. Reading it is allowed; the agent is meant to read issues.
Same issue, same injected line. Reading it is allowed; the agent is meant to read issues.
Same issue, same injected line. Reading it is allowed; the agent is meant to read issues.
Same issue, same injected line. Reading it is allowed; the agent is meant to read issues.
The agent tries the merge. merge_pull_request is denied for triage-bot.
The agent tries the merge. merge_pull_request is denied for triage-bot.
The agent tries the merge. merge_pull_request is denied for triage-bot, so the gateway answers 403 before it fetches a credential: “You don't have permission to call tool 'merge_pull_request'”. GitHub never sees the call.
The agent tries the merge. merge_pull_request is denied for triage-bot, so the gateway answers 403 before it fetches a credential: “You don't have permission to call tool 'merge_pull_request'”. GitHub never sees the call.
The agent tries the merge. merge_pull_request is denied for triage-bot, so the gateway answers 403 before it fetches a credential: “You don't have permission to call tool 'merge_pull_request'”. GitHub never sees the call.
One switch per server
A decision per tool
The same three calls run against both models. Tool names are from the official GitHub MCP server and the 403 message is the one the Statio app shows; triage-bot is an illustrative name.
With per-tool access you grant the server, and the read tools ride on that grant. Then you deny the one tool that should never run. The deny wins over the server grant, and the refused call never reaches GitHub: the gateway answers 403 before it fetches a credential.
GitHub gives you some of this already. A fine-grained token can leave out write access to code, if you mint one per agent and know which permission each tool needs. The server's --read-only and --toolsets flags trim the tool list, but they are set when the server starts, so they apply to everyone connected to it. All of that is also GitHub's alone. Slack, Stripe and your internal API each have their own scheme, or none, and none of them know which agent is calling.
Why access stops at the server
This is not a bug in any particular client. MCP describes how a client finds tools and calls them. Authorization, where a server has any, happens when the connection is made, and a connected client gets the server's whole tool list. Plenty of servers never got that far: they read an API key from an environment variable and trust whoever launched them.
So by default everything lines up on the server: the config entry, the credential, the token. Granting less than a whole server means putting something in the middle that reads every call. That is what a gateway is.
Splitting servers is the workaround
The fix teams reach for first is more servers. If you cannot grant part of github, run one copy with --read-only and another with write tools, and hand them out separately. It works, and I have done it. It also multiplies everything you operate, and the multiplication is easy to underestimate because each step looks small.
Split the server, or grant the tool
To operate
Split servers
One server, per-tool grants
Servers to deploy and patch
8
2
Copies of the API key
8
2
Client config entries
50
25
Move one tool between groups
redeploy 2 servers, re-sync ~12 machines
edit 1 grant, checked on the next call
A deliberately simple model: one split server per group per environment, each with its own copy of the upstream key, and one client entry per person per environment. Real groups overlap, which makes the split column worse, not better.
The row that matters most is the last one. Access changes constantly: someone moves teams, an agent needs one more tool, a tool turns out to be dangerous. With split servers each of those is a deploy and a config push. With per-tool grants it is a row in a table, and the gateway checks it on the next call.
Big servers, small servers
There is a separate argument about how big an MCP server should be. The case for small ones is real: a server with five well described tools is easy to reason about, and models choose better from a short list. The case for big ones is real too, because APIs are big. GitHub's own server lists more than 90 tools with every toolset on, and a server that covers ten of them sends you back to writing glue the day you need an eleventh.
Per-tool access takes most of the heat out of that argument. Run one big server and hand each agent a small slice of it. The slice is also what the agent sees, which matters more than it sounds: every tool definition a client loads (name, description, input schema) lands in the model's context before anyone has typed a word.
tools/list on github (40 tools)
Every tool
≈ 12,000
alex@acme
≈ 12,000
triage-bot is granted 4 of 40 tools, so that is all it is shown: 10,800 fewer tokens in context before it does anything.
triage-bot is granted 4 of 40 tools, so that is all it is shown: 10,800 fewer tokens in context before it does anything.
ci-agent is granted 9 of 40 tools, so that is all it is shown: 9,300 fewer tokens in context before it does anything.
ci-agent is granted 9 of 40 tools, so that is all it is shown: 9,300 fewer tokens in context before it does anything.
review-agent is granted 14 of 40 tools, so that is all it is shown: 7,800 fewer tokens in context before it does anything.
review-agent is granted 14 of 40 tools, so that is all it is shown: 7,800 fewer tokens in context before it does anything.
alex@acme is granted 22 of 40 tools, so that is all it is shown: 5,400 fewer tokens in context before it does anything.
alex@acme is granted 22 of 40 tools, so that is all it is shown: 5,400 fewer tokens in context before it does anything.
The check cannot be resolved, so triage-bot is shown nothing. An agent does not act on grants nobody could read.
The check cannot be resolved, so triage-bot is shown nothing. An agent does not act on grants nobody could read.
The check cannot be resolved, so alex@acme is shown every tool. Each call is still checked, and refused if the check still cannot be resolved.
The check cannot be resolved, so alex@acme is shown every tool. Each call is still checked, and refused if the check still cannot be resolved.
The check cannot be resolved, so alex@acme is shown every tool. Each call is still checked, and refused if the check still cannot be resolved.
What each caller is shown
Permission check unreachable
Token counts are estimates at about 300 tokens per tool definition, the same rough figure behind the mcp-gen estimates, not measurements. Names are illustrative.
Statio filters tools/list per caller, so an agent is only shown the tools it can call. That saves context, and it saves turns. An agent that can see a tool will try it, get refused, and spend a round trip working out why.
The checkbox shows the one place the two kinds of caller are treated differently. If the gateway cannot resolve the permission check while building the list, a person keeps the tool and an agent loses it. An outage in the permission service should not empty every developer's tool list, and an agent should not act on grants nobody could read. Either way, the call itself is checked again and refused if the check still cannot be resolved.
If the API really is huge, mcp-gen can generate a server with three generic tools in place of one per endpoint, so the list stays the same size however many endpoints sit behind it.
Agents need their own names
Most agents today run on a person's credentials. It is the path of least resistance, since the engineer who set the agent up already had a key. The trouble shows up the first time something goes wrong. In the logs, three agents and their owner are one identity, and you cannot stop one of them without stopping all of them.
Stop one agent
alex@acmesigns in as alexsearch_codesearch_codeactive
Three agents and alex, all calling tools. The agents run on alex's key, so to the server they are all alex.
Three agents and alex, all calling tools. The agents run on alex's key, so to the server they are all alex.
triage-bot misbehaves. The only switch is the key.
triage-bot misbehaves. The only switch is the key: rotate it and all three agents stop, and alex with them. They were one principal all along.
triage-bot misbehaves. The only switch is the key: rotate it and all three agents stop, and alex with them. They were one principal all along.
Same three agents, each with its own identity in Statio. alex signs in as alex.
Same three agents, each with its own identity in Statio. alex signs in as alex.
triage-bot misbehaves. Suspend it.
triage-bot misbehaves. Suspend it, and only triage-bot stops. It gets no new tokens; everyone else keeps working.
triage-bot misbehaves. Suspend it, and only triage-bot stops. It gets no new tokens; everyone else keeps working.
triage-bot misbehaves. Suspend it, and only triage-bot stops. It gets no new tokens; everyone else keeps working.
On alex's key
Each agent its own
Watch what stopping one agent does in each model. Agent names are illustrative; active and suspended are the app's own states.
In Statio an agent is its own principal, with its own credentials, status and expiry. Suspend one and it stops getting tokens, and agent tokens last five minutes, so it does not get far on the one it has. You can give an agent an expiry date. And an agent whose owner leaves the organization stops getting tokens until someone else attests to it.
Where the key lives
Open ~/.cursor/mcp.json on a typical developer laptop and you will find live secrets in plain text: a Stripe key, a GitHub token, a Slack bot token. The same three are on every other laptop on the team.
A laptop goes missing
alex's mcp.json holds three live keys in plain text.
alex's mcp.json holds three live keys in plain text.
The laptop is gone. Whoever has it has the file, and the file is all they need.
The laptop is gone. Whoever has it has the file, and the file is all they need.
They use the keys from their own machine. Every vendor sees a valid key, so every call works.
They use the keys from their own machine. Every vendor sees a valid key, so every call works.
They use the keys from their own machine. Every vendor sees a valid key, so every call works.
You rotate three keys at three vendors, then go looking for every other laptop and CI job that holds the same copies.
You rotate three keys at three vendors, then go looking for every other laptop and CI job that holds the same copies.
With Statio the file holds one Statio token. The upstream keys are encrypted in Statio and added at call time.
With Statio the file holds one Statio token. The upstream keys are encrypted in Statio and added at call time.
The laptop is gone, and the token with it. The token is bound to that laptop's device key.
The laptop is gone, and the token with it. The token is bound to that laptop's device key.
Each call needs a fresh proof signed by the device key, valid 60 seconds and usable once. A copied token cannot call a tool on its own.
Each call needs a fresh proof signed by the device key, valid 60 seconds and usable once. A copied token cannot call a tool on its own.
Each call needs a fresh proof signed by the device key, valid 60 seconds and usable once. A copied token cannot call a tool on its own.
Revoke the device in the dashboard and every session bound to it ends. Nothing upstream needs rotating.
Revoke the device in the dashboard and every session bound to it ends. Nothing upstream needs rotating.
Revoke the device in the dashboard and every session bound to it ends. Nothing upstream needs rotating.
Keys in mcp.json
With Statio
Key prefixes are the real formats; the values are elided.
With Statio the laptop holds one Statio token. Upstream keys are encrypted in Statio with AES-256-GCM, the encryption key is kept outside the database, and the gateway adds the right credential to the request at call time. No API returns a stored credential to anyone, including the person who added it. The agent only ever gets the upstream's response.
One call, every check
Here is everything that happens to a single tool call, in order. The figure runs a normal call first, then breaks the call at each step in turn so you can see where it stops.
One tool call through the gateway
1The agent calls a toolpass
2Who is calling?pass
3Which organization?pass
4Device proof, for peoplepass
5Server access, then tool accesspass
6Arguments scannedpass
7The credential is addedpass
8The server calls GitHubpass
9Response checkedpass
10Activity records it
Step 1 of 10
The agent calls a tool
triage-bot sends tools/call for list_issues to its one Statio endpoint.
Step 2 of 10
Who is calling?
A short-lived token, five minutes for agents, signed RS256 and checked against published keys.
Step 3 of 10
Which organization?
The call has to belong to the organization that owns the server. Tenancy first, then authority.
Step 4 of 10
Device proof, for people
A person’s token is bound to their laptop. Each call carries a proof signed by the device key, valid 60 seconds and usable once.
Step 5 of 10
Server access, then tool access
The grant on the server covers its tools; a deny on one tool overrides it. Nothing is granted by default, admins included.
Step 6 of 10
Arguments scanned
Shell and SQL injection, path traversal, requests aimed at internal addresses, oversized inputs. On by default; an organization can switch it to monitor, not off.
Step 7 of 10 · held in the vault
The credential is added
The gateway fetches the GitHub token from encrypted storage (AES-256-GCM) and adds it to the outgoing request. The caller’s own auth headers are stripped first.
Step 8 of 10
The server calls GitHub
A hosted server has no direct route out. Its traffic goes through the egress proxy, which checks the destination against that server’s allowlist.
Step 9 of 10
Response checked
Injected instructions are flagged and secrets that appear in a response are masked before the agent sees them.
Step 10 of 10
Activity records it
Who called which tool on which server, when, how long it took and the status code. Refused calls are recorded too.
allowed · list_issues on github · 200
Step 1 of 10
The agent calls a tool
triage-bot sends tools/call for list_issues to its one Statio endpoint.
Step 2 of 10
Who is calling?
A short-lived token, five minutes for agents, signed RS256 and checked against published keys.
Refused before anything else runs: the token is not valid. The agent has to get a new one, and a suspended agent cannot.
Step 10 of 10
Activity records it
Who called which tool on which server, when, how long it took and the status code. Refused calls are recorded too.
Refused before anything else runs: the token is not valid. The agent has to get a new one, and a suspended agent cannot.
Step 1 of 10
The agent calls a tool
triage-bot sends tools/call for list_issues to its one Statio endpoint.
Step 2 of 10
Who is calling?
A short-lived token, five minutes for agents, signed RS256 and checked against published keys.
Step 3 of 10
Which organization?
The call has to belong to the organization that owns the server. Tenancy first, then authority.
Step 4 of 10
Device proof, for people
A person’s token is bound to their laptop. Each call carries a proof signed by the device key, valid 60 seconds and usable once.
Refused: a proof is usable once. A token and proof copied off the machine do not get a second call.
Step 10 of 10
Activity records it
Who called which tool on which server, when, how long it took and the status code. Refused calls are recorded too.
Refused: a proof is usable once. A token and proof copied off the machine do not get a second call.
Step 1 of 10
The agent calls a tool
triage-bot sends tools/call for list_issues to its one Statio endpoint.
Step 2 of 10
Who is calling?
A short-lived token, five minutes for agents, signed RS256 and checked against published keys.
Step 3 of 10
Which organization?
The call has to belong to the organization that owns the server. Tenancy first, then authority.
Step 4 of 10
Device proof, for people
A person’s token is bound to their laptop. Each call carries a proof signed by the device key, valid 60 seconds and usable once.
Step 5 of 10
Server access, then tool access
The grant on the server covers its tools; a deny on one tool overrides it. Nothing is granted by default, admins included.
403 · You don't have permission to call tool 'merge_pull_request'. No credential was fetched and GitHub never saw the request.
Step 10 of 10
Activity records it
Who called which tool on which server, when, how long it took and the status code. Refused calls are recorded too.
403 · You don't have permission to call tool 'merge_pull_request'. No credential was fetched and GitHub never saw the request.
Step 1 of 10
The agent calls a tool
triage-bot sends tools/call for list_issues to its one Statio endpoint.
Step 2 of 10
Who is calling?
A short-lived token, five minutes for agents, signed RS256 and checked against published keys.
Step 3 of 10
Which organization?
The call has to belong to the organization that owns the server. Tenancy first, then authority.
Step 4 of 10
Device proof, for people
A person’s token is bound to their laptop. Each call carries a proof signed by the device key, valid 60 seconds and usable once.
Step 5 of 10
Server access, then tool access
The grant on the server covers its tools; a deny on one tool overrides it. Nothing is granted by default, admins included.
Step 6 of 10
Arguments scanned
Shell and SQL injection, path traversal, requests aimed at internal addresses, oversized inputs. On by default; an organization can switch it to monitor, not off.
Request blocked by security policy. The API behind the server is never touched.
Step 10 of 10
Activity records it
Who called which tool on which server, when, how long it took and the status code. Refused calls are recorded too.
Request blocked by security policy. The API behind the server is never touched.
Step 1 of 10
The agent calls a tool
triage-bot sends tools/call for list_issues to its one Statio endpoint.
Step 2 of 10
Who is calling?
A short-lived token, five minutes for agents, signed RS256 and checked against published keys.
Step 3 of 10
Which organization?
The call has to belong to the organization that owns the server. Tenancy first, then authority.
Step 4 of 10
Device proof, for people
A person’s token is bound to their laptop. Each call carries a proof signed by the device key, valid 60 seconds and usable once.
Step 5 of 10
Server access, then tool access
The grant on the server covers its tools; a deny on one tool overrides it. Nothing is granted by default, admins included.
Step 6 of 10
Arguments scanned
Shell and SQL injection, path traversal, requests aimed at internal addresses, oversized inputs. On by default; an organization can switch it to monitor, not off.
Step 7 of 10 · held in the vault
The credential is added
The gateway fetches the GitHub token from encrypted storage (AES-256-GCM) and adds it to the outgoing request. The caller’s own auth headers are stripped first.
Step 8 of 10
The server calls GitHub
A hosted server has no direct route out. Its traffic goes through the egress proxy, which checks the destination against that server’s allowlist.
Step 9 of 10
Response checked
Injected instructions are flagged and secrets that appear in a response are masked before the agent sees them.
The call completes, but the key in the response reaches the agent as ghp_•••• . The agent gets the data, not the secret.
Step 10 of 10
Activity records it
Who called which tool on which server, when, how long it took and the status code. Refused calls are recorded too.
The call completes, but the key in the response reaches the agent as ghp_•••• . The agent gets the data, not the secret.
Step 10 of 10
Activity records it
Who called which tool on which server, when, how long it took and the status code. Refused calls are recorded too.
allowed · list_issues on github · 200
The agent calls a tool. triage-bot sends tools/call for list_issues to its one Statio endpoint.
Who is calling? A short-lived token, five minutes for agents, signed RS256 and checked against published keys.
Which organization? The call has to belong to the organization that owns the server. Tenancy first, then authority.
Device proof, for people. A person’s token is bound to their laptop. Each call carries a proof signed by the device key, valid 60 seconds and usable once.
Server access, then tool access. The grant on the server covers its tools; a deny on one tool overrides it. Nothing is granted by default, admins included.
Arguments scanned. Shell and SQL injection, path traversal, requests aimed at internal addresses, oversized inputs. On by default; an organization can switch it to monitor, not off.
The credential is added. The gateway fetches the GitHub token from encrypted storage (AES-256-GCM) and adds it to the outgoing request. The caller’s own auth headers are stripped first.
The server calls GitHub. A hosted server has no direct route out. Its traffic goes through the egress proxy, which checks the destination against that server’s allowlist.
Response checked. Injected instructions are flagged and secrets that appear in a response are masked before the agent sees them.
All ten checks passed. The agent gets its issues, and the call is on the record.
The agent calls a tool. triage-bot sends tools/call for list_issues to its one Statio endpoint.
Refused here. Refused before anything else runs: the token is not valid. The agent has to get a new one, and a suspended agent cannot.
The call stopped at who is calling?. The refusal is recorded like any other call.
The agent calls a tool. triage-bot sends tools/call for list_issues to its one Statio endpoint.
Who is calling? A short-lived token, five minutes for agents, signed RS256 and checked against published keys.
Which organization? The call has to belong to the organization that owns the server. Tenancy first, then authority.
Refused here. Refused: a proof is usable once. A token and proof copied off the machine do not get a second call.
The call stopped at device proof, for people. The refusal is recorded like any other call.
The agent calls a tool. triage-bot sends tools/call for list_issues to its one Statio endpoint.
Who is calling? A short-lived token, five minutes for agents, signed RS256 and checked against published keys.
Which organization? The call has to belong to the organization that owns the server. Tenancy first, then authority.
Device proof, for people. A person’s token is bound to their laptop. Each call carries a proof signed by the device key, valid 60 seconds and usable once.
Refused here. 403 · You don't have permission to call tool 'merge_pull_request'. No credential was fetched and GitHub never saw the request.
The call stopped at server access, then tool access. The refusal is recorded like any other call.
The agent calls a tool. triage-bot sends tools/call for list_issues to its one Statio endpoint.
Who is calling? A short-lived token, five minutes for agents, signed RS256 and checked against published keys.
Which organization? The call has to belong to the organization that owns the server. Tenancy first, then authority.
Device proof, for people. A person’s token is bound to their laptop. Each call carries a proof signed by the device key, valid 60 seconds and usable once.
Server access, then tool access. The grant on the server covers its tools; a deny on one tool overrides it. Nothing is granted by default, admins included.
Refused here. Request blocked by security policy. The API behind the server is never touched.
The call stopped at arguments scanned. The refusal is recorded like any other call.
The agent calls a tool. triage-bot sends tools/call for list_issues to its one Statio endpoint.
Who is calling? A short-lived token, five minutes for agents, signed RS256 and checked against published keys.
Which organization? The call has to belong to the organization that owns the server. Tenancy first, then authority.
Device proof, for people. A person’s token is bound to their laptop. Each call carries a proof signed by the device key, valid 60 seconds and usable once.
Server access, then tool access. The grant on the server covers its tools; a deny on one tool overrides it. Nothing is granted by default, admins included.
Arguments scanned. Shell and SQL injection, path traversal, requests aimed at internal addresses, oversized inputs. On by default; an organization can switch it to monitor, not off.
The credential is added. The gateway fetches the GitHub token from encrypted storage (AES-256-GCM) and adds it to the outgoing request. The caller’s own auth headers are stripped first.
The server calls GitHub. A hosted server has no direct route out. Its traffic goes through the egress proxy, which checks the destination against that server’s allowlist.
Caught here, call continues. The call completes, but the key in the response reaches the agent as ghp_•••• . The agent gets the data, not the secret.
The call completes with the secret masked, and the call is recorded like any other.
All ten checks passed. The agent gets its issues, and the call is on the record.
The order matches /security. Text in the result line that reads like a message is the product's own.
Two things about the order are on purpose. Authority is checked before any credential is fetched, so a refused call never touches the vault or the upstream API. And refused calls are recorded like allowed ones, because the calls you refused are the ones you will want to read later.
When the server itself is the problem
Everything so far assumes the MCP server is honest. Suppose it is not: a dependency gets hijacked, or an image you pulled was poisoned. This is the case I worried about most, because a server legitimately sees the data it fetches, and no amount of credential hygiene changes that.
So the useful question is where an attacker who owns the process can go next. The figure walks through four starting points.
Where does the attacker land?
Identity and deviceNot the layer doing the work here.The token is bound to the device key and every call needs a fresh, single-use proof. Revoke the device and its sessions end.Agent tokens last five minutes. Suspend the agent and it gets no new ones; nobody else is affected.Not the layer doing the work here.
Per-tool accessNot the layer doing the work here.If the agent is fooled anyway, it can only call the tools it was granted. merge_pull_request is still denied.For those minutes it holds exactly that agent’s tools.It still only receives the calls callers were allowed to make.It still only receives the calls callers were allowed to make.
Call scanningNot the layer doing the work here.Responses are checked for text posing as instructions before the agent reads them.Not the layer doing the work here.
Credential vaultNot the layer doing the work here.No upstream key is on the disk. The only secret there is one revocable Statio token.It never holds stored keys. A credential is added per call, for that call.It never holds stored keys. A credential is added per call, for that call.
Egress proxyNot the layer doing the work here.It has no direct route out. Every destination goes through the proxy and its allowlist, and blocked attempts are logged with host, port and reason.It has no direct route out. Every destination goes through the proxy and its allowlist, and blocked attempts are logged with host, port and reason.
Activity and auditNot the layer doing the work here.Every call it makes because of the injection is recorded under its own name.You can see which calls were made with it.The refused exfiltration attempt is a log line you can read.The refused exfiltration attempt is a log line you can read.
Starting point: injected text in a tool response.
Starting point: injected text in a tool response.
Starting from injected text in a tool response, an attacker lands inside the agent’s existing grants, and no further.
Starting point: a stolen laptop.
Starting point: a stolen laptop.
Starting from a stolen laptop, an attacker lands nowhere you cannot switch off from the dashboard.
Starting point: a leaked agent token.
Starting point: a leaked agent token.
Starting from a leaked agent token, an attacker lands on one agent’s tools, for minutes.
Starting point: a compromised MCP server.
Starting point: a compromised MCP server.
Starting from a compromised MCP server, an attacker lands inside its own container, with nowhere to send what it sees.
Starting from a compromised MCP server, an attacker lands inside its own container, with nowhere to send what it sees.
Each starting point lights up the layers that do the work. Every mechanism listed is on /security.
For a compromised server, the damage stays inside its own container, and egress control is what makes that true. Hosted servers have no direct route to the internet. Outbound traffic goes through a proxy outside the container, which checks every destination against that server's allowlist. A check inside the server would be useless here, since an attacker who controls the process controls every check inside it.
Hosted server github, compromised
The process tries api.github.com:443.
The process tries api.github.com:443.
The process tries api.github.com:443. Allowed: on the allowlist.
The process tries paste.example.net:443.
The process tries paste.example.net:443.
The process tries paste.example.net:443. Blocked: not on the allowlist. The attempt is logged with host, port and reason.
The process tries 169.254.169.254:80.
The process tries 169.254.169.254:80.
The process tries 169.254.169.254:80. Blocked: not on the allowlist, and not port 443. The attempt is logged with host, port and reason.
The process tries 203.0.113.9:4444.
The process tries 203.0.113.9:4444.
The process tries 203.0.113.9:4444. Blocked: no direct route; the proxy is the only way out. The attempt is logged with host, port and reason.
The process tries 203.0.113.9:4444. Blocked: no direct route; the proxy is the only way out. The attempt is logged with host, port and reason.
203.0.113.9 and paste.example.net are documentation addresses. Only port 443 is open by default, and a bare wildcard is refused.
Blocked attempts are logged with the host, the port and the reason. On most days that log tells you about a destination you forgot to add. On a bad day it is the evidence.
The other half is not running a bad image in the first place. Images are scanned with Grype before they can run, a critical finding blocks promotion, promotion is always a person's decision, and rolling back is one click.
v15.1servingbuildingblocked: critical CVEnot builtrolled backcandidaterolled back
v15 pulls in a dependency with a critical CVE. The image is built.
v15 pulls in a dependency with a critical CVE. The image is built.
Images are scanned with Grype before they can run.
Images are scanned with Grype before they can run. One critical finding.
v15 cannot be promoted, and v14 keeps serving. Fix the dependency and deploy again.
v15 cannot be promoted, and v14 keeps serving. Fix the dependency and deploy again.
The dependency is bumped and v15.1 is built.
The dependency is bumped and v15.1 is built.
Scanned again.
Scanned again. Clean.
Promotion is still a person's decision, and it needs the approve permission, which deploy rights alone do not include.
Promotion is still a person's decision, and it needs the approve permission, which deploy rights alone do not include.
v15.1 is live. If it misbehaves, the previous version is one click away.
v15.1 is live. If it misbehaves, the previous version is one click away.
Rolled back. v14 is serving again.
Rolled back. v14 is serving again.
Rolled back. v14 is serving again.
v15
v15.1
The first release is held on a critical finding; the fixed one goes through. Promote needs the approve permission, which deploy rights do not include.
Who else is doing this
Statio is not the first gateway in front of MCP, and I would be suspicious of anyone who said theirs was. Several companies sell one. Uber built its own: an internal gateway in front of more than 800 MCP servers and 5,000 tools, with per-tool authorization for people, services and agents. Their write-up names the hard part well:
“building the connective tissue: the discovery, the security, the reliability that makes agents trustworthy enough to act on behalf of real users.”Uber Engineering, Designing Uber's MCP Gateway
Uber has a platform team to build that. Most companies running agents do not. Statio is my attempt to make the same shape something a team can sign up for in an afternoon: per-tool grants, agents with their own identity, keys in a vault, egress control on hosted servers and a record of every call. The desktop app configures nine clients (Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, Cline, Continue, Zed and Amazon Q) and, on Team and up, finds the MCP servers people installed on their own.
Pricing is flat and never per seat. Free covers three servers and 10,000 calls a month, a solo developer is $19, and a team is $149. Overage is off unless you turn it on. The details are on the pricing page.
Try it
Create an account, add a server, deny one tool and watch the call fail. It takes a few minutes, and the free plan does not ask for a card. If you run a security review, /security is written for you, and I answer email at [email protected].
Deny merge_pull_request before an agent finds it. Three servers and 10,000 calls a month, free.