To give an AI agent access to tools securely, never give the agent the keys. The model proposes a tool call; your code checks that specific call against a default-deny policy, fetches a short-lived token bound to one resource and the smallest scope, makes the call and logs it. Treat third-party tool servers, including MCP servers, as dependencies that can turn hostile: pin exact versions, fingerprint their tool definitions when you approve them, re-approve on any change, and run them with no access to your credentials and a narrow network allowlist.
Key takeaways
- The agent never holds a credential. A broker or gateway holds keys and hands tool code a narrow, short-lived token per call.
- Prefer tokens bound to one resource (RFC 8707 resource indicators) over shared API keys. The MCP spec has required this for HTTP servers since its June 2025 revision.
- Authorize each call, with argument constraints, not the agent as a whole. Writes and irreversible actions need a policy check or a person.
- Third-party MCP servers are code you run with your credentials. Pin them, fingerprint their tool definitions and treat a definition change as a security event.
- Contain tool processes: no home directory, read-only filesystem, egress through an allowlisting proxy.
- Log every call with the agent, user, tool, definition fingerprint, decision and token scope, so "what did it do?" is one query.
The rule: the agent never holds the key
Anything in the model's context window can come back out. A prompt injection in a ticket, web page or tool result can ask the model to print a token, append it to a URL or pass it as an argument. Anything in the agent process's environment can be read by a tool the agent runs. So the design rule is simple: credentials live with a broker, and the code that executes a tool fetches what it needs at call time.
In code, the model only ever sees tool names, schemas and trimmed results:
async def call_tool(ctx, tool: str, args: dict):
decision = authorize(ctx.agent_id, tool, args) # default deny, see below
if decision == "approval":
await approvals.wait_for_decision(ctx, tool, args)
spec = TOOLS[tool]
token = await broker.get_token(
agent=ctx.agent_id,
user=ctx.user_id, # None for autonomous work
resource=spec.resource, # one API or MCP server
scopes=spec.scopes, # the smallest scope this tool needs
)
result = await spec.handler(args, token=token) # token never enters a prompt
audit.log(ctx, tool, args, decision, spec, result)
return result.for_model() # no headers, no secretsThe broker can be your cloud's agent identity service, a secrets manager behind a small service, or an LLM gateway that brokers tool credentials. The product matters less than the property: no long-lived secret exists anywhere the model or its tools can read it. Our guide to AI agent identity on AWS, Azure and GCP covers the per-cloud options for the agent's own principal and for acting on behalf of a user.
Brokered credentials and short-lived, audience-bound tokens
A shared API key is the worst credential to give an agent. It is long-lived, it works against everything the key's owner can reach, and when it leaks there is no way to tell the agent's use from an attacker's.
An OAuth access token bound to one resource is the opposite. RFC 8707 (Resource Indicators for OAuth 2.0) lets the client name the resource it wants a token for, so the authorization server issues a token whose audience is that one server. A token stolen from the Jira tool is useless against the GitHub tool, and it expires in minutes.
What the MCP spec requires
For MCP servers reached over HTTP, the specification has required this model since its 2025-06-18 revision, and the 2026-07-28 revision keeps and tightens it:
- The MCP server is an OAuth 2.1 resource server and must publish OAuth 2.0 Protected Resource Metadata (RFC 9728). Clients use it to discover the authorization server.
- Clients must send the
resourceparameter, naming the MCP server's canonical URI, in both the authorization request and the token request, whether or not the authorization server supports it. - Servers must validate that a token was issued specifically for them, and must not accept or transit any other tokens. An MCP server that calls an upstream API uses a separate token issued for that API. Token passthrough is banned.
- PKCE with
S256is required, and clients must refuse to proceed if the authorization server does not advertise PKCE support.
The 2026-07-28 revision adds defenses against mix-up attacks. Clients must record the expected issuer from validated authorization server metadata and check the iss parameter (RFC 9207) before redeeming an authorization code; authorization servers should include it, and the spec expects to make that a must in a future revision. Metadata whose issuer does not match the URL it was fetched from must be ignored. Client credentials are bound to the authorization server that issued them. Dynamic Client Registration is deprecated in favor of Client ID Metadata Documents. The same revision also made the protocol stateless, dropping the initialization handshake, which we cover alongside the basics in our MCP guide.
On the server side, audience validation is a few lines you must not skip:
import jwt # PyJWT
MCP_RESOURCE = "https://mcp.tickets.internal.example.com"
ISSUER = "https://auth.internal.example.com"
def verify_access_token(token: str, signing_key) -> dict:
# Rejects tokens minted for any other resource, even by the same issuer.
return jwt.decode(
token,
signing_key,
algorithms=["RS256"],
audience=MCP_RESOURCE,
issuer=ISSUER,
options={"require": ["exp", "aud", "iss", "sub"]},
)When that server needs to call a downstream API on the user's behalf, it exchanges tokens instead of forwarding the one it received. With RFC 8693 token exchange:
POST /oauth2/token HTTP/1.1
Host: auth.internal.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<token the MCP server received>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&resource=https://api.tickets.internal.example.com
&scope=tickets:readLocal servers are different
The same specification says servers on the stdio transport should not follow the OAuth flow and should take credentials from the environment. That makes the launch environment your security boundary. Give each local server exactly one narrow credential, injected at launch from a secrets manager rather than from a dotfile, and never launch local servers from a shell that has your cloud and source-control credentials loaded.
Authorize every call, not the agent
Granting "the agent can use Jira" is not authorization. The model will eventually be steered into a call you did not intend, so each call is checked on its own merits: which tool, with which arguments, for which agent, on whose behalf. Default deny.
Keep the policy in a reviewed file, per agent:
# tool-policy/support-triage.yaml
agent: support-triage
default: deny
rules:
- tool: search_tickets
allow: true
args:
limit: { max: 50 }
- tool: add_label
allow: true
args:
label: { in: [billing, technical, account, spam] }
- tool: issue_refund
allow: approval # a person approves each call
args:
amount_usd: { max: 100 }
- tool: send_email
allow: falseAnd enforce it in the executor, outside the model:
import re
import yaml
class Denied(Exception):
pass
POLICY = yaml.safe_load(open("tool-policy/support-triage.yaml"))
def authorize(agent: str, tool: str, args: dict) -> str:
rule = next((r for r in POLICY["rules"] if r["tool"] == tool), None)
if rule is None or rule["allow"] is False:
raise Denied(f"{agent}: {tool} is not allowed")
for name, c in rule.get("args", {}).items():
value = args.get(name)
if "in" in c and value not in c["in"]:
raise Denied(f"{tool}.{name}={value!r} is not in the allowlist")
if "max" in c and not (isinstance(value, (int, float)) and value <= c["max"]):
raise Denied(f"{tool}.{name}={value!r} exceeds {c['max']}")
if "pattern" in c and not re.fullmatch(c["pattern"], str(value)):
raise Denied(f"{tool}.{name} does not match the required pattern")
return "approval" if rule["allow"] == "approval" else "allow"A policy engine such as Open Policy Agent does the same job at larger scale. Three practices matter more than the engine:
- Split reads from writes. Reads can be broad within a tenant. Writes are narrow and individually justified.
- Approve the right things. The MCP spec says there should always be a human in the loop with the ability to deny tool invocations. In practice, put people on writes, money and outbound messages, and show them the exact arguments. Approving every read trains people to click yes.
- Log denials and read them. A denied call is either a policy that is too tight or an attempt to do something the agent should not. Both deserve a look.
Third-party MCP servers: vet, pin, re-approve on change
A third-party MCP server is code you run with your credentials, or a remote service you let talk to your model. Treat it like any dependency with access to production, because that is what it is.
Vet before you install
- Publisher and source. Who maintains it, and does the published package match the repository? Watch for look-alike names of official servers.
- Capabilities. Read every tool description the model will see. Any tool that reads arbitrary paths, fetches arbitrary URLs or runs commands needs a hard look and probably argument constraints.
- Credentials and network. What does it need, and where does it connect? A formatting tool that needs outbound internet is a question.
- Advisories. Check the repository's security advisories and the CVE databases. Several popular servers had serious bugs this summer, covered below.
Pin what you run
Never launch a server by name alone. npx -y some-server installs whatever is newest every time it starts.
# npm package, exact version
npx -y @example/jira-mcp@1.4.2
# Python package, exact version (uvx's @ syntax takes exact versions only)
uvx example-jira-mcp@0.9.1
uvx --from 'example-jira-mcp==0.9.1' example-jira-mcp
# container image, pinned by digest
docker pull ghcr.io/example/jira-mcp@sha256:<digest>Update deliberately, through review, like any other dependency bump.
Fingerprint tool definitions
Pinning the package does nothing for a remote server, and nothing for a server that changes its own behavior at run time. What the model actually consumes is the tool list: names, descriptions and schemas. Fingerprint it when a person approves the server, and compare on every session and on every notifications/tools/list_changed.
import hashlib
import json
def tool_fingerprint(tools: list[dict]) -> str:
"""Hash what the model reads: names, titles, descriptions, schemas, annotations."""
keep = ("name", "title", "description", "inputSchema", "outputSchema", "annotations")
canonical = [{k: t.get(k) for k in keep} for t in sorted(tools, key=lambda t: t["name"])]
blob = json.dumps(canonical, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(blob.encode()).hexdigest()
async def check_server(session, server: str, approved: dict[str, str]) -> None:
result = await session.list_tools()
current = tool_fingerprint([t.model_dump() for t in result.tools])
if current != approved.get(server):
disable_server(server) # nothing from it reaches the model
alert_security(server, approved.get(server), current)Do the same for prompts if the server provides them. Re-listing also gives you a check against the specification itself: the 2026-07-28 revision says a server's tools/list result may change over time but must not vary per connection or as a side effect of other requests on the connection. A server whose tools change after it has been used is either broken or hostile, and the right response to both is the same.
What 2026 taught us: Deadbugz and the summer's MCP CVEs
Deadbugz. On 12 August, Pillar Security disclosed an active campaign it named Deadbugz. A GitHub account, zellkernel, opened 23 pull requests against unrelated AI, MCP and developer-tool projects in 74 minutes on the evening of 10 August (21:52 to 23:07 UTC). Seventeen added a remote MCP server to the project's configuration, and four referenced a hidden local Python file. The server, productivity-suite, offered two harmless tools, format_text and summarize. It kept a per-client counter of tool calls. After the third call, its tools/list and prompts/get responses changed, and the new metadata directed the agent to look for SSH keys, AWS credentials, shell history and Kubernetes configuration, and to hide that from the user. At the time of Pillar's review, none of the pull requests had been merged through GitHub's merge mechanism.
Every control in this post would have blunted it. Review of configuration changes would have questioned a pull request that adds an MCP server to a project that never had one. A definition fingerprint would have disabled the server on the first change. A container with no home directory would have had no SSH keys or AWS credentials to find. An egress allowlist would have stopped the data leaving.
The CVEs. Three MCP-related CVEs published in August, all fixed by their maintainers, show the other side: honest servers with dangerous bugs.
| CVE | Server | Flaw | Fixed |
|---|---|---|---|
| CVE-2026-73498 | mcp-atlassian | Path traversal in confluence_upload_attachment: read any file the server can read, including environment variables holding the Confluence API token, and upload it as an attachment | 0.22.0 |
| CVE-2026-67357 | ArcadeDB's built-in MCP server | get_server_settings leaked the cluster token in cleartext, enough to act as root | 26.7.3 |
| CVE-2026-19956 | facebook-ads-mcp-server 0.1.0 | Server-side request forgery in fetch_pagination_url | Patched in the repository |
The pattern is the one to design for: a tool server holds a credential, and a bug or an injected instruction turns the server into a way to read it. Earlier incidents follow the same shape. Invariant Labs described tool poisoning and the "rug pull" in April 2025. JFrog's CVE-2025-6514 in mcp-remote (July 2025, fixed in 0.1.16) let a malicious server's authorization endpoint inject commands on the client. In September 2025 Koi Security found postmark-mcp copying every email it sent to an outside address from version 1.0.16. And LiteLLM's MCP test endpoints were exploitable for command execution this spring, which we covered in our post on AI gateways.
Contain the tool process (network, filesystem)
Assume a tool server will eventually misbehave, and decide in advance what it can reach. For local servers, a container gives you most of it:
# an internal network has no route out; only the egress proxy joins both networks
docker network create --internal mcp-egress
docker run --rm -i \
--read-only --tmpfs /tmp \
--cap-drop ALL --security-opt no-new-privileges \
--user 65534:65534 \
--memory 512m --pids-limit 128 \
--network mcp-egress \
-e HTTPS_PROXY=http://egress-proxy:3128 \
-e JIRA_TOKEN \
ghcr.io/example/jira-mcp@sha256:<digest>No home directory is mounted, so there is no ~/.ssh, ~/.aws or shell history to read. If the server needs project files, mount that one directory read-only. The only credential it gets is the one variable passed in.
The proxy enforces where the server may connect. With Squid, an allowlist is a few lines:
acl tool_destinations dstdomain .atlassian.net
http_access allow tool_destinations
http_access deny allThe MCP specification's own security guidance recommends egress proxies against server-side request forgery for the same reason. For remote servers you do not run, you cannot contain the process, so the fingerprint, the token's audience and scope, and the per-call policy are your whole defense.
Log every call in a form you can audit
When something goes wrong, you need to answer, in one query: what did the agent do, for whom, with which definition of the tool, and who allowed it? Log one structured record per call:
{
"ts": "2026-09-09T14:02:11Z",
"run_id": "run_8f2c",
"agent_id": "support-triage",
"agent_version": "2026.09.02-1",
"user_id": "u_1842",
"server": "jira-mcp",
"server_version": "1.4.2",
"tool": "add_label",
"tool_call_id": "call_51a",
"tool_def_sha256": "3b9e...",
"args": { "ticket": "SUP-4411", "label": "billing" },
"decision": "allow",
"approver": null,
"token_resource": "https://mcp.tickets.internal.example.com",
"token_scopes": ["tickets:write"],
"result": "ok",
"duration_ms": 412
}Redact secrets and personal data from args before writing, and keep the log out of the agent's own reach. If you trace with OpenTelemetry, the same fields belong on the execute_tool span, which the GenAI conventions define with gen_ai.tool.name and gen_ai.tool.call.id.
What goes wrong in production
- One personal access token for every server. It starts as a quick setup and ends with every MCP server holding admin access to the whole organization. One credential per server, scoped to what its tools do.
- Unpinned launches.
npx -yoruvxwithout a version means whatever was published last night runs this morning with your credentials. - Approval fatigue. Prompting a person for every call teaches them to click yes. Approve writes, money and outbound messages, and show the exact arguments.
- Tool definitions nobody watches. The package is pinned, but the remote server's tool descriptions change and the model starts reading new instructions. Fingerprint and compare.
- Token passthrough. An MCP server forwards the client's token to an upstream API because it was easier than an exchange. The spec forbids it, and it turns the server into a confused deputy.
- Local servers launched from a loaded shell. The server inherits cloud credentials, SSH agents and source-control tokens it was never meant to see.
- Headless runs that skip the prompt. A client that asks before using a new server in an interactive session may behave differently in CI or a scripted run. Check what happens when nobody is there to click.
- Logs that store secrets. Full tool arguments, including tokens and personal data, written to a log store with broad read access.
For the wider threat model behind these controls, read our guide to securing AI agents in production.
Frequently Asked Questions
How do you give AI agents access to tools securely?
Route every tool call through code you control: check the specific call against a default-deny policy, fetch a short-lived token bound to one resource and the smallest scope, make the call, and log it. The model proposes actions but never holds credentials. Treat third-party tool servers as dependencies: pin versions, fingerprint their tool definitions and contain their network and filesystem access.
Should an AI agent ever hold API keys?
No. Anything in the model's context can be exfiltrated by a prompt injection, and anything in the agent's environment can be read by a tool it runs. Keep keys in a broker or secrets manager, have tool code fetch a narrow, short-lived credential for each call, and keep long-lived keys out of prompts, logs and agent processes.
How does MCP authorization work in the 2026 spec?
For HTTP transports, the MCP server is an OAuth 2.1 resource server that publishes Protected Resource Metadata, and the client must request tokens with a resource parameter naming that server. The server must accept only tokens issued for it and must never pass a client's token through to another service. The 2026-07-28 revision adds issuer validation against mix-up attacks, binds client credentials to the issuing authorization server and deprecates Dynamic Client Registration in favor of Client ID Metadata Documents.
What is a tool definition change (rug pull) attack?
A tool server presents harmless tool names and descriptions when you approve it, then changes them later so the model reads new, hostile instructions. The Deadbugz campaign disclosed in August 2026 used a server that changed its tool list after three tool calls. The defense is to fingerprint tool definitions at approval, re-check them every session and disable the server until a person re-approves any change.
How do you vet a third-party MCP server before installing it?
Check who publishes it, whether the source matches the published package, what credentials and network access it needs, which tools it exposes and whether any of them can read files or fetch URLs. Search its advisories, read the tool descriptions the model will see, and pin an exact version or image digest. Then run it in a container with no home directory mounted and an egress allowlist.
Do local MCP servers need OAuth?
No. The MCP specification says servers using the stdio transport should not follow the OAuth authorization flow and should take credentials from the environment instead. That makes the environment the security boundary: give each local server only the one narrow credential it needs, injected at launch from a secrets manager, and run it with no access to your home directory.
Need help putting agents into production?
Tool access is where an agent stops being a demo and starts holding real power over real systems. Our senior engineers design the brokers, policies, containment and audit trails that keep that power narrow, and we stay on the pager for what we build. If we built it, we're on the pager.
