Most Model Context Protocol (MCP) security incidents don't start with a sophisticated model failure. They start with a configuration choice that seemed reasonable during a demo: no authentication because it was internal-only at the time, a tool granted broad access because scoping it felt like premature optimization, a container running with default permissions because nobody circled back before it shipped. OWASP's dedicated MCP Top 10 lists token mismanagement and secret exposure as its first category for exactly this reason (OWASP Foundation, 2025), and independent research into MCP RCE vulnerabilities and tool-poisoning attacks has repeatedly traced real exploitation paths back to these same, ordinary config gaps rather than to novel model jailbreaks.
This post walks through three specific files that determine whether an MCP deployment is defensible: the server manifest and auth config, the tool-call handler, and the deployment/container spec. Bad and good versions of each, with the reasoning for what changed.
File 1: the server manifest and authorization config
The manifest is where scope, trust, and auth get decided, or don't. The most common failure is treating it as internal documentation instead of an enforced security boundary.
# BAD: bad-mcp-manifest.yaml# No auth, unscoped tool access, plaintext secret, no approval gatingname: finance-ops-servertransport: httpauth: none # "it's just for internal use"tools: - name: query_database description: "Run a query against the finance database" scopes: ["*"] # unscoped: full read/write access - name: send_wire_transfer description: "Initiate a wire transfer" requires_approval: false # destructive action, no human gatesecrets: db_password: "p@ssw0rd123" # hardcoded, in the config file# GOOD: good-mcp-manifest.yaml# OAuth 2.1 resource server, scoped tools, human approval on destructive ops,# secrets resolved from a vault reference, not stored in configname: finance-ops-servertransport: httpauth: type: oauth2.1 mode: resource_server # MCP server validates tokens; does not issue them audience: "finance-ops-server" # RFC 8707 resource indicator, binds tokens to this server authorization_server: "https://auth.internal.example.com" token_validation: require_audience_match: true reject_unbound_tokens: truetools: - name: query_database description: "Run a read-only query against the finance database" scopes: ["finance:read"] # least privilege, not "*" description_hash: "sha256:4f2c...a91" # pinned; a silent edit breaks the hash - name: send_wire_transfer description: "Initiate a wire transfer" scopes: ["finance:write", "finance:transfer"] risk_tier: destructive requires_approval: true # explicit human gate, enforced by the clientsecrets: db_password: from: vault path: "secret/finance-ops/db_password" # resolved at runtime, never written to diskFour changes carry the actual risk reduction. The auth block makes the server an OAuth 2.1 resource server that validates a token's audience before accepting it, exactly what the MCP specification's 2025-06-18 authorization revision requires via RFC 8707 resource indicators, so a token minted for a different MCP server can't be replayed against this one. Tool scopes go from ["*"] to named, least-privilege scopes. The wire transfer tool, a genuinely destructive action, is explicitly tagged and gated behind human approval rather than left to whatever the client happens to do by default, matching the MCP specification's own expectation that a human stay in the loop for actions that write, delete, or move money. And the description hash addresses a documented attack class: MCP tool descriptions are read by the model as trusted context every time it decides which tool to call, which makes an unpinned description an editable attack surface, the mechanism behind real "rug pull" and tool-poisoning exploits researchers have already demonstrated against production MCP servers (Invariant Labs, 2026).
File 2: the tool-call handler
The manifest declares intent. The handler is where that intent is actually enforced, or silently isn't.
# BAD: handler executes whatever arguments the model produced,# no validation, no allow-listing, direct shell executiondef handle_tool_call(tool_name, arguments): if tool_name == "run_report": # arguments["query"] comes straight from the model's tool call result = subprocess.run( arguments["query"], shell=True, capture_output=True ) return result.stdout# GOOD: validates and allow-lists before executing anything,# no shell interpolation, scoped to a fixed set of safe operationsALLOWED_REPORTS = {"monthly_revenue", "quarterly_headcount", "open_invoices"}def handle_tool_call(tool_name, arguments, caller_scopes): if tool_name != "run_report": raise ToolNotFoundError(tool_name) if "finance:read" not in caller_scopes: raise PermissionDeniedError(tool_name) report_id = arguments.get("report_id") if report_id not in ALLOWED_REPORTS: raise InvalidArgumentError(f"Unknown report_id: {report_id}") # Fixed, parameterized lookup. No user- or model-controlled string # ever reaches a shell or a raw SQL statement. return REPORT_REGISTRY[report_id].run()The bad version has two independent failures stacked on top of each other: it trusts model-generated input as if it were developer-written code, and it passes that input to shell=True, which turns any successful prompt injection or tool-hijack into arbitrary command execution. The fix isn't "sanitize the string harder." It's removing the class of input entirely, the model selects from a fixed, pre-approved set of operations rather than supplying a string that gets executed, and the scope check happens against the caller's actual granted permissions rather than being assumed from the fact that the call reached the handler at all.
File 3: the deployment spec
Even a correctly scoped, correctly validated MCP server should assume it will eventually be compromised, through a dependency vulnerability, a supply-chain issue in an SDK, or a flaw in the protocol implementation itself, and be deployed so that compromise doesn't spread.
# BAD: docker-compose.yml# Root user, host network, unrestricted egress, no resource limitsservices: mcp-server: image: finance-mcp:latest network_mode: host # server can reach anything the host can privileged: true # runs as root, effectively environment: - ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxx # secret baked into the compose file# GOOD: docker-compose.yml# Non-root, isolated network, explicit egress allow-list, resource capsservices: mcp-server: image: finance-mcp:1.4.2 # pinned version, not :latest user: "10001:10001" # non-root read_only: true security_opt: - no-new-privileges:true networks: - mcp-isolated # dedicated network, not host deploy: resources: limits: cpus: "1.0" memory: 512M environment: - ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic_api_key # mounted secret, not inline secrets: - anthropic_api_keynetworks: mcp-isolated: driver: bridge internal: true # no direct route to the public internetsecrets: anthropic_api_key: external: trueContainer isolation is the layer that limits blast radius when the first two layers fail anyway, and it's the layer 2026's MCP RCE research made most relevant: an architectural vulnerability in MCP's official SDKs, disclosed by researchers earlier this year, enabled arbitrary remote code execution across a supply chain touching more than 150 million downloads and up to 200,000 server instances (Tom's Hardware, 2026). A pinned, non-root, network-isolated container doesn't prevent that class of SDK vulnerability. It determines whether exploiting it gets an attacker a foothold in one disposable container or a path to the host and everything else reachable from it.
A short checklist
- 1Authenticate every MCP server as an OAuth 2.1 resource server, and validate token audience so a credential minted for one server can't be replayed against another.
- 2Scope tools to named permissions, never
["*"], and tag destructive operations for mandatory human approval rather than relying on client-side defaults. - 3Pin tool descriptions. A tool description is trusted input to the model; treat an unreviewed change to it the same way you'd treat an unreviewed change to a permissions file.
- 4Never let model-generated arguments reach a shell, raw SQL, or an unbounded file path. Validate against a fixed set of allowed operations before executing anything.
- 5Deploy MCP servers like any other high-privilege service: pinned images, non-root, isolated networks, resource limits, and secrets from a mount or vault, never inline in a compose file or manifest.
None of these fixes require exotic tooling. They require treating an MCP server's configuration with the same scrutiny a team would already apply to a service holding production database credentials, because functionally, that's what it is. Chronoloq's LLM Shield is built to catch the gap when that scrutiny doesn't happen: it covers RAG, A2A, and MCP-based architectures specifically, flagging unscoped tool permissions, missing approval gates, and exposed secrets before they ship rather than after a research team finds them.