AI agent security

How to Secure an MCP Server or AI Agent Tool Calls

An MCP tool is invoked over stdio or Streamable HTTP, not by a request hitting one of your routes, so a local server or a path your proxy does not front is invisible to proxies and WAFs. The control has to run inside the handler, one check per specific operation with a hardcoded label.

6 min read
In short: An MCP tool is invoked over stdio or Streamable HTTP, not by a request hitting one of your routes, so a local server or a path your proxy does not front is invisible to proxies and WAFs. The control has to run inside the handler, one check per specific operation with a hardcoded label.

How do you secure an MCP server or AI agent tool calls?

You enforce inside the tool handler, because there's no HTTP request to inspect. Run one check per specific operation, with a hardcoded label, covering a budget, injection detection on inputs and on outputs fed back to the model, and local sensitive-information detection.

Nearly every security tool assumes a request that it can see. MCP breaks that assumption, and that's the whole problem.

Agentic systems don't have a front door

A client invokes an MCP tool over stdio or Streamable HTTP, so no request arrives at one of your routes. A local stdio server, or a remote server that your proxy doesn't front, is invisible to perimeter tooling: proxies, WAFs, and HTTP middleware alike.

That's awkward, because the tool call is where the risk is. It's where the agent reads a record, calls an API, or moves money, on input that it doesn't fully control.

A gateway in front of your MCP servers helps for traffic that routes through it, and that's a real control at fleet scale. It doesn't cover a tool invoked locally over stdio, a background job, or a direct API call, which is why vendors in that category also ship libraries that run inside the handler.

Enforce inside the handler

The check runs in the handler, immediately before the tool does its work:

import {
launchArcjet,
tokenBucket,
detectPromptInjection,
localDetectSensitiveInfo,
} from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const budget = tokenBucket({
refillRate: 60,
intervalSeconds: 60,
maxTokens: 120,
});
const injection = detectPromptInjection();
const pii = localDetectSensitiveInfo({ deny: ["EMAIL", "PHONE_NUMBER"] });
// Inside one specific tool
const decision = await arcjet.guard({
label: "tools.get-customer",
actor: session.userId,
correlationId: sessionId,
rules: [
budget({ key: session.userId, requested: 1 }),
injection(args.query),
pii(args.query),
],
});
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) {
// Return an error the client can act on
throw new Error(`Denied: ${decision.reason}`);
}

Use one check per specific operation, with a hardcoded label. A direct Guard call fails open (allow with error codes), so the sample denies when hasFailedOpen() is true rather than letting a timeout execute the tool. Vercel AI SDK and LangChain wrappers fail closed unless you opt into continuing on error. Avoid the generic-dispatcher pattern: building label: `tools.${name}` inside a handleToolCall router breaks grep and produces messy groupings in reporting. Labels are validated server-side as slugs, meaning lowercase letters, digits, dash, and dot only, starting and ending with a letter or digit.

What to enforce

ControlWhy it belongs at the tool
A budget

Repeated tool calls can drain a quota even when each call is individually legitimate, and the entrypoint sees only one invocation

Injection detection on inputs

Tool arguments are model-generated, which means they are downstream of whatever the model has read

Injection detection on outputs

In a normal API, JSON is data. In an agent workflow, JSON is context, so tool outputs are defended the same way as tool inputs

Local sensitive-info detection

Personally identifiable information (PII) moving through a tool is detected without leaving your environment

The output case is the one most often skipped, and it's the one that makes indirect injection possible. Arcjet's approach to it is covered in how we defend MCP tool outputs from prompt injection.

Make it operable

Pass metadata alongside the label, using server-controlled values such as the authenticated user identifier and a request identifier, and never user-authored free text.

Return different errors depending on which rule denied, rather than a generic failure. "Rate limited, retry in 12 seconds" and "input flagged as prompt injection" are different errors, and the caller must be able to tell them apart. An agent client that can't tell them apart retries the call that it must not retry.

The label and metadata surface in the Console, so a security team can see which tool was called, by whom, and why it was stopped, without reading application logs.

Get identity right in an MCP server

actor must come from your authenticated server-side session, never from a tool argument. A policy can depend on the actor, so a model that sets its own actor value selects its own policy scope, which defeats the control precisely when it matters.

For a stdio MCP server with no user context, there might be no per-user identity available at all. Be explicit about that rather than inventing one: key budgets on a stable deployment or instance identifier that you control, and be clear in your own documentation that the limit is per instance rather than per user. A limit keyed on something that the caller supplies isn't a limit.

Verify it fires

Don't try to reach a guard with curl. There's no HTTP surface to hit.

Invoke the tool through an MCP client or inspector, then confirm the decision landed with arcjet guards list. If nothing appears, the usual causes are a guard call that was never awaited, an empty rules array, or a client that failed before reaching the handler at all.

An empty rule set still reaches Arcjet and returns an allow decision carrying a warning to record that nothing was submitted. It isn't treated as a failure, so a guard with no rules looks like it's working while checking nothing.

Where Arcjet fits

MCP security divides cleanly into two positions. A gateway sits in front of your MCP servers, giving you a catalog of sanctioned servers, identity-aware policy, and audit logging across an estate. Runlayer, Kong, and MintMCP are examples.

Arcjet sits inside the tool handler instead. It reaches tools invoked locally over stdio, background jobs, and direct API calls, none of which traverse a gateway, and it has the arguments and the authenticated actor available at decision time.

They aren't mutually exclusive, and a large organization plausibly wants both: a gateway to govern which servers exist, and in-code enforcement inside the tools that move money or read customer data. How the layers divide is set out in the AI agent security platform category map.

Learn more: Arcjet Guards ยท How we defend MCP tool outputs from prompt injection

Frequently asked questions

How do I secure an MCP server?

Enforce inside the tool handler, because there is no HTTP request to inspect. Use one check per specific operation with a hardcoded label, running a budget, injection detection on inputs and on outputs fed back to the model, and local sensitive-information detection.

Why can a WAF or proxy not protect MCP tool calls?

An MCP tool is invoked by a client over stdio or Streamable HTTP rather than by a request hitting one of your routes. A local stdio server, or a path your proxy does not front, gives perimeter tooling nothing to inspect. A gateway helps for traffic that routes through it and misses tools invoked locally.

Should I check tool outputs as well as tool inputs?

Yes. In a normal API, JSON returned from a service is data. In an agent workflow it becomes context, so anything in it can function as an instruction. Tool output re-entering model context is the main indirect injection path.

Why does my guard call not appear in the Console?

The usual causes are a guard call that was never awaited, an empty rules array, or a client that failed before reaching the handler. An empty rule set still returns an allow decision with a warning, so a guard with no rules looks like it is working while checking nothing.

AI runtime security in your code

Protect your AI agent workflows with Arcjet

Get allow, deny, and redact on agent actions before the side effect.