Skip to main content

MCP security

How to Secure MCP Servers and Tool Calls

Decide where MCP tool authorization belongs, which calls should wait for human approval, and what to record when a call runs.

An MCP server can accept a valid token and still receive a tool request it should reject. Consider a support agent that can read tickets and send replies. Both calls come from the same application, but a reply crosses a customer-facing boundary. The authorization decision needs the tool, the ticket, and the proposed recipient.

Start with three questions. Who can connect to the server? Which tools and resources may that identity access? What does a specific call need before it can run? Each answer belongs at a different point in the request path.

Check the connection, then check the action

For HTTP transports, the MCP authorization specification describes how a client obtains a token for an MCP server. When using that flow, validate the token for the intended resource. The MCP server should reject a token issued for another service and use an appropriate credential for the downstream API. The protocol's security guidance explains the risks of forwarding client tokens and of a proxy acting as a confused deputy.

Token validation establishes the connection boundary. At dispatch, also check the operation and its arguments against the principal's entitlements. In the support example, a caller entitled to read cases in one queue should have that boundary enforced by the server or ticket system. A client-side tool filter improves what the model sees, but a server or resource check still has to handle requests that reach it.

HTTP MCP: token exchange and the separate action check
  1. Client → MCP serverDiscover the protected resourceUse protected resource metadata to locate an authorization server. Discovery is not permission to run a tool.
  2. Client ↔ Authorization serverObtain a resource-bound tokenComplete the applicable OAuth flow with resource and scope. The authorization server returns the token to the client.
  3. Client → MCP serverPresent the access tokenThe MCP server validates the token, including its intended audience. It must not accept a token meant for another service.
  4. MCP server → Downstream resourceAuthorize this action, then dispatchCheck principal, case and recipient. Use the downstream service’s appropriate credentials; do not pass through the client’s token.

Simplified HTTP flow. Registration, consent and lifecycle messages are omitted. Local stdio servers use a process and credential boundary instead.

For local servers launched over standard input and output, look at the process boundary. Identify who can configure the command, what credentials it inherits, which files it can read, and where it can connect. HTTP authorization does little for a local process that starts with a broadly privileged environment.

Trust boundaries in the support workflow
  1. Agent hostProposes a tool call using the task and ticket context.
  2. Tool dispatchCheck the case, operation and recipient. Hold sensitive writes for review.
  3. Ticket serviceEnforce case access and record the downstream result.

Give each tool the smallest useful authority

Treat tool names and descriptions as interface documentation. Check what the service actually does before assigning a low-risk policy. A tool described as a search endpoint might return private case notes or attachments. Even where the call changes nothing, its response may contain data the agent should never see.

A support deployment could start with this policy:

Risk to checkControl at dispatchEvidence to collect
Reading a case outside the assigned queueRequire access to the specific case in the ticket service or MCP serverCase ID, caller, and access decision
Sending a customer reply without reviewHold the proposed recipient and message at the approval gateApproval record and downstream write ID
Sending an attachment to a different recipientDeny unless policy explicitly authorizes that destinationDeny record and fixture log showing zero writes

Model-visible results can contain instructions written by someone else. A customer can type anything into a ticket. Treat that content as data and keep it out of the authority decision. Apply the same destination and identity checks when a tool request follows a retrieved document as when it comes directly from the original task. Vercel's engineering note on secure agents explains the design implications of putting untrusted text into a model with tools.

Make approval specific enough to audit

A prompt that says “Allow messaging?” leaves a reviewer guessing. Show the account, ticket, destination, and change the agent proposes. Recheck authorization at dispatch. If a recipient or message changes after review, evaluate the new request. Decide up front what happens on timeout and how you will prevent a retried request from sending twice.

Keep enough evidence to answer an incident investigator's next question. A useful record ties together the principal, tool and resource, request identifier, decision and policy version, reviewer outcome when applicable, and downstream result when available. Capture a redacted representation or digest where full arguments would expose sensitive content. Store credentials and message bodies outside routine decision logs.

Check the policy decision and the downstream result independently in a test workspace. For a blocked send, verify that the ticket system received no write. For an approved send, correlate the decision with the destination's record. An allow decision alone cannot tell an investigator whether the remote operation succeeded.

Try an approval-binding example

The dangerous interval is between showing a reviewer a proposed call and executing it. A ticket can be reassigned, a recipient can change, or an agent can revise the message. Bind the review to an immutable representation of the action, then recheck current resource access before dispatch. A digest identifies what was reviewed; it does not establish who reviewed it or whether that reviewer had authority.

Save this as approval_binding.py and run python3 approval_binding.py. It uses only the standard library and makes no network calls. The fixed IDs and message are harmless fixture data.

import hashlib
import json


def fingerprint(call):
    # A deliberately restricted example: string fields, no floats.
    encoded = json.dumps(call, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(encoded.encode("utf-8")).hexdigest()


def decide(call, approved_digest=None):
    if call["tenant"] != "fixture-team":
        return "deny"
    if call["recipient"] != "customer@example.invalid":
        return "deny"
    return "allow" if fingerprint(call) == approved_digest else "review"


proposal = {
    "tenant": "fixture-team",
    "tool": "send_reply",
    "case_id": "case-fixture-1",
    "recipient": "customer@example.invalid",
    "message": "Your test request is ready.",
}
assert decide(proposal) == "review"
approved_digest = fingerprint(proposal)
assert decide(proposal, approved_digest) == "allow"
changed_message = {**proposal, "message": "A different reply."}
assert decide(changed_message, approved_digest) == "review"
changed_recipient = {**proposal, "recipient": "other@example.invalid"}
assert decide(changed_recipient, approved_digest) == "deny"
print("Approval-binding model: four assertions passed; no call dispatched.")

This is an executable explanation of argument binding, not a production authorization service. A real implementation needs schema validation, authenticated principal and reviewer identities, policy and resource versions, expiry, a replay-resistant approval store, and atomic consumption. It must also define canonicalization for its supported types. Do not log full message bodies just to compare requests.

Once dispatch begins, a timeout creates a different problem: the destination might have committed the write before the response was lost. Approval binding cannot prevent a duplicate retry on its own. Use a destination-supported idempotency mechanism where available, preserve the original request identifier, and reconcile the destination before retrying an ambiguous mutation. Track not started, confirmed outcome, and unknown outcome separately. That distinction is more useful to an operator than reducing all three to “failed.”

Where Sernixa fits

Sernixa's MCP registry guide separates server discovery, organizational inventory, and individual call authorization. Its SDK integration guide describes plan-bound decisions and the dispatch owner for each supported path. The SDK distinguishes gateway-owned execution from caller-owned dispatch; application code must respect that distinction to avoid executing an already-forwarded call again.

For teams evaluating centralized tool governance, this makes the integration question concrete: which component receives the actual call, binds it to the approved plan, and records dispatch? An advisory MCP connection alone does not intercept other tool calls. Route the intended action through the configured execution boundary and qualify it with the same fixture cases above. Correlate Sernixa's decision with the destination's observation before claiming that an operation ran or was prevented.