Security Testing

Securing MCP Endpoints: Threats and Test Cases (2026)

Rishi GauravUpdated Aug 20, 20268 min read

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

Share:
Securing MCP Endpoints: Threats and Test Cases (2026) — Total Shift Left

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

ThreatWhat it looks likePrimary control
Tool poisoningInstruction-shaped text in a tool name, description or schemaReview and lint tool metadata; pin server versions
Indirect prompt injectionHostile content inside data a tool returnsLabel returned content as data; never re-emit it as instruction
Confused deputyThe server acts with its own privileges instead of the caller'sAuthorize every call against the caller's identity
Token passthroughThe client's token is forwarded verbatim upstreamToken exchange for a scoped, server-issued credential
Session hijackingA session id is accepted from a different identityBind sessions to the authenticated principal
DNS rebindingA web page drives a localhost MCP serverValidate Origin; bind to loopback only
Excessive agencyA tool can do far more than the use case needsNarrow tool scope; require confirmation for destructive actions
Supply-chain driftA server updates and silently changes its toolsSnapshot 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 Free

Design 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_id is safer than run_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.

QuestionIf the answer is unclearControl
Whose authority does each tool act with?Stop and fix the designPer-call authorization against the caller
What is the worst thing the most powerful tool can do?Narrow itTool scope reduction
Which tools are destructive or irreversible?Split them outExplicit confirmation
Where does the upstream credential come from?Do not shipToken exchange, never passthrough
Can any tool return attacker-influenced content?Assume yesContent labelling, no chaining on fetched data
Who can add or change a tool?Lock it downReviewed snapshot, pinned versions
Over HTTP: who can reach the endpoint?Assume the internetAuth, origin validation, network policy
What is logged when a tool runs?Add itAudit 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

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.

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.