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.
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.
# 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"
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.
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.
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.
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 }
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 FreeDesign choices that remove whole classes of finding
Testing catches what you built; these choices mean there is less to catch:
- 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.
- 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.
The 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.
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.
Related articles
How to Test MCP Servers | MCP vs REST APIs for Testers | OWASP API Security Top 10 | Authentication and Authorization Testing | Enterprise API Security Testing
Ready to shift left with your API testing?
Try our no-code API test automation platform free.