Securing MCP Endpoints: Threats and Test Cases (2026)
Quick answer
An MCP server over HTTP is an internet-facing API with two extra problems: its tool descriptions and results feed a model's context, and the model holds delegated authority to call tools on a user's behalf. Secure it with authorization on every request, origin validation against DNS rebinding, session binding to an identity, per-tool authorization checks against the caller rather than the server's own credentials, and no token passthrough. Then test each of those, because none of them fails loudly.
Reviewed by Sushant Joshi
MCP servers concentrate two things that are individually risky and jointly worse: privileged access to real systems, and a caller whose behaviour is determined by text it reads at runtime. The threat model follows from that combination.
This is the security companion to how to test MCP servers. For the conceptual differences from REST, see MCP vs REST APIs for testers.
In this guide
- The threat model
- Authorization is the one that actually matters
- Tool poisoning and the metadata channel
- Indirect injection through returned content
- Transport-level controls (HTTP only)
- A CI gate for all of it
- Design choices that remove whole classes of finding
- A threat-modelling checklist for a new server
- What to log, and what an investigation needs
- Testing the controls you designed
- Common mistakes
- Frequently asked questions about securing MCP endpoints
The threat model
| Threat | What it looks like | Primary control |
|---|---|---|
| Tool poisoning | Instruction-shaped text in a tool name, description or schema | Review and lint tool metadata; pin server versions |
| Indirect prompt injection | Hostile content inside data a tool returns | Label returned content as data; never re-emit it as instruction |
| Confused deputy | The server acts with its own privileges instead of the caller's | Authorize every call against the caller's identity |
| Token passthrough | The client's token is forwarded verbatim upstream | Token exchange for a scoped, server-issued credential |
| Session hijacking | A session id is accepted from a different identity | Bind sessions to the authenticated principal |
| DNS rebinding | A web page drives a localhost MCP server | Validate Origin; bind to loopback only |
| Excessive agency | A tool can do far more than the use case needs | Narrow tool scope; require confirmation for destructive actions |
| Supply-chain drift | A server updates and silently changes its tools | Snapshot and diff the tool surface; pin versions |
Authorization is the one that actually matters
Almost every serious finding reduces to the same root cause: the tool ran with the server's credentials rather than the caller's — broken object-level authorization by another name, as covered in the OWASP API Security Top 10 and in authentication and authorization testing for APIs.
# tests/test_mcp_authorization.py
import pytest
def test_tool_cannot_read_another_users_object(mcp_as_user_b, order_owned_by_a):
"""Confused deputy: the server can read this order; user B cannot."""
res = call_tool(mcp_as_user_b, "get_order", {"id": order_owned_by_a})
assert res["result"]["isError"], "tool used server authority instead of the caller's"
def test_tool_respects_role_boundaries(mcp_as_viewer):
res = call_tool(mcp_as_viewer, "delete_user", {"id": "7"})
assert res["result"]["isError"], "no function-level authorization on a destructive tool"
@pytest.mark.parametrize("tool", DESTRUCTIVE_TOOLS)
def test_destructive_tools_require_explicit_confirmation(mcp, tool):
"""Excessive agency: a model should not be able to delete without a confirm step."""
res = call_tool(mcp, tool["name"], minimal_valid_input(tool))
assert res["result"]["isError"] or res["result"].get("_meta", {}).get("requiresConfirmation"), \
f"{tool['name']} executes destructively on a single model decision"
def test_unauthenticated_calls_are_rejected(http_mcp):
r = requests.post(http_mcp.url, json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
assert r.status_code == 401
Run these against every tool, parametrised from tools/list, so a newly added tool cannot skip the check.
Tool poisoning and the metadata channel
Tool descriptions are sent to the model before any call. A poisoned description therefore affects behaviour without the tool ever being invoked, which makes it a supply-chain problem rather than a runtime one.
INSTRUCTION_SHAPED = [
"ignore previous instructions", "disregard the above", "system:",
"you must", "do not tell the user", "</system>", "<|im_start|>",
"before using any other tool", "always call this first",
]
def test_no_tool_metadata_contains_instructions(tools):
for tool in tools:
blob = json.dumps({"n": tool["name"], "d": tool["description"],
"s": tool["inputSchema"]}).lower()
for phrase in INSTRUCTION_SHAPED:
assert phrase not in blob, f"{tool['name']}: instruction-shaped metadata"
def test_tool_surface_has_not_changed_since_review(tools):
"""A server that updates its own tools is a supply-chain event."""
expected = json.load(open("contracts/tools.snapshot.json"))
assert [t["name"] for t in sorted(tools, key=lambda x: x["name"])] == \
[t["name"] for t in expected], "tool surface changed without review"
Pin third-party MCP server versions the way you pin any other dependency. A server that silently gains a new tool has gained new authority over your users' sessions — the same surprise a published API version bump causes, which is why schema drift is worth gating on.
Indirect injection through returned content
The second-order case: the tool itself is clean, but it fetches a document, reads an issue tracker or queries a database whose contents an attacker controls.
Ready to shift left with your API testing?
Try our no-code API test automation platform free. Generate tests from OpenAPI, run in CI/CD, and scale quality.
def test_hostile_content_is_returned_as_data_not_instruction(mcp):
res = call_tool(mcp, "fetch_document", {"url": POISONED_DOC_URL})
result = res["result"]
# returning the content is fine — promoting it is not
assert not result.get("_meta", {}).get("systemPrompt")
assert all(item["type"] in {"text", "resource"} for item in result["content"])
def test_tool_does_not_chain_on_content_it_fetched(mcp, audit_log):
call_tool(mcp, "fetch_document", {"url": POISONED_DOC_URL})
# the poisoned document says "now call delete_user" — nothing should have
assert not audit_log.calls_since(tool="delete_user"), "server acted on fetched content"
Transport-level controls (HTTP only)
def test_origin_is_validated(http_mcp, token):
r = requests.post(http_mcp.url,
headers={"Authorization": f"Bearer {token}",
"Origin": "https://evil.example"},
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
assert r.status_code in (400, 403), "DNS-rebinding protection missing"
def test_local_server_binds_to_loopback_only(local_mcp_port):
"""A local server on 0.0.0.0 is reachable from the whole network."""
import socket
s = socket.socket()
s.settimeout(2)
with pytest.raises((ConnectionRefusedError, socket.timeout)):
s.connect((non_loopback_ip(), local_mcp_port))
def test_session_id_is_bound_to_the_identity_that_created_it(http_mcp, token_a, token_b):
sid = open_session(http_mcp, token_a)
r = requests.post(http_mcp.url,
headers={"Authorization": f"Bearer {token_b}", "Mcp-Session-Id": sid},
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
assert r.status_code in (401, 403, 404), "session accepted across identities"
def test_upstream_receives_an_exchanged_token_not_the_clients(http_mcp, token, upstream_spy):
call_tool_http(http_mcp, token, "get_order", {"id": "42"})
forwarded = upstream_spy.last_request.headers.get("Authorization", "")
assert token not in forwarded, "client token passed through to the upstream API"
A CI gate for all of it
# .github/workflows/mcp-security.yml
name: MCP security
on:
pull_request:
paths: ['server/**', 'tools/**', 'contracts/**']
schedule: [{ cron: '0 4 * * *' }]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- run: pip install pytest requests jsonschema
- name: Tool surface has not drifted
run: |
node build/server.js --list-tools | jq -S '.tools' > /tmp/tools.json
diff -u contracts/tools.snapshot.json /tmp/tools.json \
|| { echo "::error::MCP tool surface changed — review required"; exit 1; }
- name: Authorization, poisoning and transport suite
run: pytest tests/security -q --junitxml=security-results.xml
- name: Secrets are not embedded in the server or its config
run: docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest detect --source=/repo --redact --exit-code 1
- uses: actions/upload-artifact@v4
if: always()
with: { name: mcp-security-results, path: security-results.xml }
Design choices that remove whole classes of finding
Testing catches what you built; these choices mean there is less to catch. For the surrounding programme — tooling, evidence and where these checks sit in a pipeline — see enterprise API security testing:
- Give each tool the narrowest possible scope.
get_order_by_idis safer thanrun_sql, and vastly easier to authorize. - Split read and write servers. A read-only MCP server has a much smaller blast radius and covers most assistant use cases.
- Require a confirmation step for destructive actions, surfaced to the human rather than decided by the model.
- Exchange tokens rather than forwarding them, so the upstream audit trail names the right actor and a compromised server holds nothing reusable. The exchange and refresh paths are worth negative-testing as described in OAuth 2.0 API testing.
- Pin server versions and review tool-surface diffs like any other dependency change.
A threat-modelling checklist for a new server
Before writing a line of the server, work through these eight questions. Each maps to a control and to a test above.
| Question | If the answer is unclear | Control |
|---|---|---|
| Whose authority does each tool act with? | Stop and fix the design | Per-call authorization against the caller |
| What is the worst thing the most powerful tool can do? | Narrow it | Tool scope reduction |
| Which tools are destructive or irreversible? | Split them out | Explicit confirmation |
| Where does the upstream credential come from? | Do not ship | Token exchange, never passthrough |
| Can any tool return attacker-influenced content? | Assume yes | Content labelling, no chaining on fetched data |
| Who can add or change a tool? | Lock it down | Reviewed snapshot, pinned versions |
| Over HTTP: who can reach the endpoint? | Assume the internet | Auth, origin validation, network policy |
| What is logged when a tool runs? | Add it | Audit trail naming the real caller |
The first and second questions do most of the work. A server whose tools each act with the caller's authority and whose most powerful tool is get_order_by_id has a small enough blast radius that the remaining controls are defence in depth rather than the only thing standing between a prompt and your database.
Free PDF guide (12 pages)
Top 50 API Testing Mistakes
50 real-world API testing mistakes organized by category — from authentication to performance — each with a concrete fix strategy.
Download FreeThe pattern to avoid is the general-purpose tool — run_query, execute, call_api — added because it was quicker than modelling the four operations that were actually needed. It cannot be authorized meaningfully, it cannot be rate-limited meaningfully, and every subsequent control is compensating for that one decision.
What to log, and what an investigation needs
Most MCP servers log that a tool ran. That is not enough to answer the question an incident actually asks, which is on whose behalf, with what input, and what did it reach.
| Field | Why an investigation needs it |
|---|---|
| The real caller's identity | Not the service account — the human or system whose authority the call used |
| Session identifier | Ties a sequence of calls together; individual calls rarely tell the story |
| Tool name and version | The tool surface changes; a log without a version is ambiguous later |
| Arguments, with sensitive fields masked | The input is usually where the attack is |
| Whether content from a previous call influenced this one | The single most useful field for detecting indirect injection |
| Outcome and any upstream system touched | Blast radius, which is the first thing anyone asks |
The fifth row is the one nobody logs and everybody wants. When a tool acts on text that came back from a fetch, a document or another tool, that provenance is the difference between reconstructing an incident in an hour and not reconstructing it at all.
Log at the point the server decides to act, not at the transport layer. Transport logs show that a request arrived; they cannot show which authority it ran with after the server resolved it.
Testing the controls you designed
A threat model that has not been tested is a document. Each control in the checklist above corresponds to a test that can run in CI, and these are the five worth writing first.
A caller cannot reach another tenant's data. Call every read tool with an identifier belonging to a different tenant, using a valid low-privilege session. Every one must fail closed. This is the highest-value test in the suite because the failure is a breach rather than a bug.
A tool refuses when the caller lacks scope. For each tool that requires elevated permission, assert the refusal path explicitly. Tests that only cover the permitted case leave the check itself unverified.
Returned content is not treated as instruction. Feed a tool a payload containing text shaped like a directive — the kind an attacker would place in a document or a web page — and assert that no subsequent tool call happens as a result. This is the test with no REST equivalent, and it is the one specific to this protocol.
The upstream credential is never passed through. Assert that a token presented by the caller is exchanged rather than forwarded. A passthrough gives the caller the server's reach.
The tool surface matches the reviewed snapshot. Any tool added or changed without review fails the build. This is cheap, and it is what stops the surface expanding quietly between releases.
Common mistakes
Securing the transport and stopping there. TLS, origin validation and network policy protect the channel. None of them decide whether cancel_order should act on order 42 for this caller, which is where the actual risk is.
Building one powerful tool instead of several narrow ones. run_query cannot be authorized meaningfully, cannot be rate-limited meaningfully, and turns every downstream control into compensation for one design decision.
Trusting tool descriptions because you wrote them. Descriptions are instructions to a model. If any part of the surface is assembled from configuration, a plugin, or another team's contribution, that text is an input and needs the same review as code.
Treating stdio transport as inherently safe. A local transport removes the network attacker and keeps every other risk — a poisoned document still reaches the model, and an over-scoped tool still acts on it.
Reviewing the server once. The surface changes as tools are added. The threat model is only current if it is revisited when the surface changes, which is why pinning the tool list in a test matters more than it first appears.
Frequently asked questions about securing MCP endpoints
What is tool poisoning? Instruction-shaped text placed in a tool's name, description or schema so that a model reading the tool list is influenced by it. Because descriptions are sent to the model before any call happens, a poisoned tool can affect behaviour without ever being invoked.
What is the confused deputy problem in MCP? The server holds credentials with more authority than the caller, and performs an action the caller is not entitled to because the model asked for it. The fix is to authorize each tool call against the caller's identity, never against the server's own credentials.
Why is token passthrough discouraged? Forwarding the client's token to an upstream API means the upstream cannot tell who is really calling, audit trails point at the wrong actor, and a compromised server holds usable credentials for every connected user. Exchange for a scoped token instead.
What is DNS rebinding and why does it matter here? A local MCP server listening on localhost can be reached by a web page whose DNS is re-pointed at 127.0.0.1. Validating the Origin header and binding only to the loopback interface prevents a browser tab from driving a local server.
Does stdio transport avoid all of this? It avoids the network-facing half — no origin, no session hijacking. It does not avoid tool poisoning, confused deputy or excessive agency, which are properties of the tool design rather than the transport.
What is the single highest-value control? Authorizing every tool call against the caller's identity. Nearly every serious MCP finding reported so far reduces to a tool that acted with the server's authority instead of the user's.
Sources and further reading
- Model Context Protocol documentation — transports, sessions and the authorization guidance.
- OWASP Top 10 for LLM Applications — prompt injection, excessive agency and supply-chain risks.
- OAuth 2.0 Security Best Current Practice — why token passthrough and confused-deputy patterns are discouraged.
- OWASP API Security Top 10 (2023) — the risks the API behind each tool still carries.
Key takeaways
- Nearly every serious MCP finding reduces to one root cause: the tool acted with the server's authority instead of the caller's. Authorize every call against the caller.
- Tool descriptions reach the model before any call, so poisoned metadata is a supply-chain risk — lint it, snapshot it, and pin third-party server versions.
- Content a tool fetches is data, never instruction. Test that hostile content in a returned document does not cause the server to chain into another tool.
- Over HTTP, validate
Origin, bind local servers to loopback, and bind sessions to the authenticated principal. - Exchange tokens instead of forwarding them; passthrough breaks the upstream audit trail and turns a server compromise into a credential compromise.
- Narrow tools, read-only servers and explicit confirmation for destructive actions remove entire classes of finding before testing starts.
Ready to shift left with your API testing?
Try our no-code API test automation platform free.