Every argument that reaches an MCP tool handler was chosen by a language model that may have just read attacker-controlled text, so a tool handler must be written like an internet-facing API endpoint. The MCP specification puts this duty on the server: servers MUST validate all tool inputs, implement proper access controls, rate limit tool invocations and sanitise tool outputs. This guide is for the developers who write servers: how to design a narrow tool surface, validate arguments, confine file access to roots, run processes without a shell, handle outbound requests and return safe errors. It assumes you already know the protocol basics covered in MCP Security Best Practices Explained, and goes deeper into the code.
Key takeaways
- The model is not a trusted caller. Prompt injection lets a third party steer tool arguments, so validate every argument in the handler as if it came from an anonymous HTTP request.
- Narrow tools beat clever validation. A
read_project_filetool with a constrained path is easier to secure than arun_commandtool with a blocklist. - Canonicalise, then check, then open the checked object. Path scoping needs resolution of
..and symbolic links, a separator-aware containment check and protection against the file being swapped between check and use. - No shell, ever, for model-supplied values. Pass an argument array to a fixed executable, allow-list the values and end option parsing with
--where the command supports it. - Errors and outputs are a data channel too. Return validation failures as tool execution errors with
isError: true, keep paths and secrets out of them, and never echo credentials back to the model.
Why the tool handler is the trust boundary
An MCP tool is model-controlled: the specification describes tools as something the language model can discover and invoke automatically. Client confirmation prompts are only a SHOULD in the specification. The arguments in a tools/call request are therefore shaped by everything in the model's context: the user's prompt, retrieved documents, web pages, issue comments and the outputs of other tools. Indirect prompt injection (MITRE ATLAS AML.T0051 LLM Prompt Injection, with ATLAS AML.T0053 AI Agent Tool Invocation describing the follow-on abuse of tools) turns any of those inputs into a way to choose your arguments.
Three consequences follow for server authors:
- Client-side checks do not protect you: a malicious or misconfigured client can send any JSON it likes.
- Annotations are hints, not controls: the specification says clients MUST consider tool annotations such as
readOnlyHintanddestructiveHintuntrusted unless they come from trusted servers. Declaring a tool read-only does not make it read-only; your handler code does. - Your handler runs with your server's privileges: a local stdio server runs as the user, with their files and keys; a remote server holds service credentials. A handler bug reaches whatever that identity can reach.
OWASP's LLM Top 10 2025 names the two failure modes that meet here: LLM06 Excessive Agency (tools with more functionality, permission or autonomy than the task needs) and LLM05 Improper Output Handling (model output passed to a downstream system without validation). A tool handler that forwards a model-chosen string to a shell, a file API or a SQL query is LLM05 in its purest form.
Designing a narrow tool surface
The safest tool is one whose arguments cannot express anything dangerous.
- One task per tool: prefer
list_open_issues(repo, label)overgithub_api(method, path, body). Generic pass-through tools turn the model into an unrestricted API client with your token. - Identifiers, not locations: where possible, accept an identifier the server maps to a resource (a ticket number, a document ID, a report name from an enum) rather than a raw path, URL or query. CWE-22 calls this enforcement by conversion: map fixed input values to actual filenames and reject everything else.
- Split read and write: separate tools let clients confirm, and operators disable, the destructive ones.
The inputSchema is the first, cheap layer. The specification requires it to be a valid JSON Schema object (JSON Schema 2020-12 when no $schema is given) and recommends {"type": "object", "additionalProperties": false} for tools with no parameters. Use the same strictness everywhere.
| JSON Schema keyword | Use in a tool schema | What it prevents |
|---|---|---|
additionalProperties: false | On every object | Smuggled parameters a handler might later read by accident |
required | List every argument the handler needs | Handlers falling back to unsafe defaults when a field is missing |
enum | Modes, report names, sort fields, environments | Free text where a fixed set of values is enough |
pattern | Identifiers such as ^[A-Z]{2,10}-[0-9]{1,6}$ | Metacharacters, whitespace and path separators in IDs |
maxLength, maxItems, maximum | Every string, array and number | Resource exhaustion and oversized payloads to downstream systems |
format (for example uri, date) | Only as documentation | Nothing on its own: many validators treat format as an annotation unless format assertion is switched on |
Declare an outputSchema where the result is structured. The specification says that if one is provided, servers MUST return structuredContent that conforms to it, which forces you to decide exactly which fields leave the server.
Validating arguments inside the handler
Schema validation tells you the shape is right. It cannot tell you that the ticket belongs to this user, that the date range is sane or that the branch name does not begin with a hyphen. Validate again in the handler, with these rules:
- Validate on the server, always: run your own schema validator on the received arguments. Do not assume the SDK or client did it.
- Decode and canonicalise before checking: URL-decode, Unicode-normalise and resolve paths first, then apply the allow-list to the canonical value. Checking the raw string and then using a decoded one is how double-encoding bypasses happen.
- Allow-list, do not blocklist: describe what is valid. A list of forbidden characters always misses one (a newline, a NUL byte, a Unicode lookalike).
- Authorise the object, not just the tool: a remote server that accepts
document_idmust check that the authenticated user can read that document. This is OWASP API1:2023 Broken Object Level Authorization applied to MCP. - Fail closed with a useful message: return a tool execution error (
isError: true) such as "label must be one of: bug, docs, security", so the model can correct itself, as the specification intends.
See the Input Validation and Output Encoding Guide for the general discipline; the rest of this guide covers the sinks that matter most in MCP servers.
Path scoping: confining file access to roots
File tools are the most common MCP server type and the most common source of handler bugs. The protocol offers roots: clients that declare the roots capability expose a list of file:// URIs, servers request them with roots/list, and clients send notifications/roots/list_changed when they change. The specification says servers SHOULD respect root boundaries and validate all paths against the provided roots. Roots are a statement of intent from the client; enforcement is entirely your code.
The reference filesystem server in the modelcontextprotocol/servers repository is a useful worked example. Its path validation, read for this guide in November 2026, follows this order:
- Reject NUL bytes, then normalise and resolve the requested path to an absolute path.
- Check containment against each allowed directory with a separator-aware comparison: the path must equal the directory or start with the directory plus a path separator, so a sibling such as
/data/project-evildoes not match/data/project. - Resolve the real path (following symbolic links) and check containment again, refusing a symlink whose target is outside the allowed directories.
- For a file that does not exist yet, resolve and check the parent directory instead.
- When writing an existing file, refuse anything that is not a regular file, open it with
O_NOFOLLOWon POSIX and confirm the opened handle is the same device and inode that was checked, so a swap between check and open is caught. - On Windows, refuse alternate data stream names (a colon anywhere but the drive prefix), reserved device names such as
CON,NULandCOM1, and components ending in a dot or space, because each names something other than what the containment check saw.
| Path pitfall | Example input | Defence |
|---|---|---|
| Dot-dot traversal | docs/../../.ssh/id_ed25519 | Resolve to an absolute canonical path before the containment check |
| Prefix match without separator | /srv/app-secrets/key against root /srv/app | Compare against root plus separator, or use a path-aware API such as Python Path.is_relative_to() on resolved paths |
| Symbolic link escape | A link inside the root pointing to /etc | Check the real path after link resolution (CWE-59) |
| Check-then-use race | Directory swapped for a link after validation | Open with no-follow flags, verify the handle, operate on the handle (CWE-367) |
| Absolute path injection | /home/user/.aws/credentials | Join only after validation; never let an absolute argument replace the base |
| Windows name aliasing | report.txt::$DATA, NUL.txt, notes.txt. | Refuse colons after the drive, device names and trailing dots or spaces |
Two design rules sit above the code. Keep sensitive material out of the roots: a root at the user's home directory includes .ssh, .aws and browser profiles, which ATT&CK T1552.001 Credentials In Files describes as a standard collection target. If roots change at runtime, re-read them on notifications/roots/list_changed and treat an empty root list as "no access", not "full access". The reference server denies access when no allowed directories exist. See the Path Traversal Guide for the wider vulnerability class.
Running processes without a shell
Many MCP servers wrap a command-line tool: git, a linter, a cloud CLI, a database client. The OWASP OS Command Injection Defense Cheat Sheet gives the order of preference: avoid calling OS commands where a library can do the job; where you cannot, separate the command from its data with a structured API and validate both the command (against an allow-list) and its arguments. It also notes that every command injection is an argument injection.
| Language | Use | Avoid for model-supplied values | Platform note |
|---|---|---|---|
| Python | subprocess.run(["git", "log", "--", path]) (shell is off by default) | shell=True, os.system, string commands | Python docs: on Windows, .bat and .cmd files may run in a system shell regardless of arguments |
| Node.js | execFile or spawn with an argument array (shell option default false) | exec, execSync, any call with shell: true | Node docs: .bat and .cmd files cannot be launched with execFile; avoid wrapping them for model input |
| Java | ProcessBuilder with the command and each argument separate | Runtime.exec(String), which splits on whitespace | OWASP notes Runtime.exec(String) is deprecated since Java 18 |
Avoiding the shell stops ;, | and $() from being interpreted, but argument injection remains. A branch name of --upload-pack=... or a URL argument that begins with a hyphen is passed straight to the program as an option. Defend in three layers:
- Fixed executable, fixed options: hard-code the binary path and every flag. The model supplies values only.
- End option parsing: place
--before positional values where the program supports it, and reject values starting with-where it does not. - Constrain the environment: pass an explicit minimal environment, set a working directory inside the root, apply a timeout and cap output size.
A general "run command" tool belongs only in a sandbox with no credentials and restricted network, with a human approving each call. The Command Injection Guide covers the vulnerability class in depth.
Other sinks: URLs, queries and templates
- Outbound requests: a
fetch_urlor webhook tool is an SSRF primitive. The MCP security best practices page lists the ranges clients should block during OAuth discovery (10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,169.254.0.0/16,fc00::/7,fe80::/10) and the same list suits tool handlers. It also warns against hand-written IP parsing because of octal, hex and IPv4-mapped IPv6 encodings, recommends validating every redirect hop, and recommends an egress proxy for server-side deployments. See the SSRF Prevention Guide. - Database queries: parameterise values, allow-list identifiers such as column names, and give any query tool a read-only role scoped to the views it needs.
- Templates, code and API paths: never pass model output to
eval, template compilation or a deserialiser, and build downstream API paths only from validated identifiers.
Outputs, errors and rate limits
Tool results flow back into the model's context and often onto the user's screen. The specification requires servers to sanitise tool outputs. In practice that means:
- Return only what the task needs: select fields explicitly; do not serialise whole database rows or API responses that contain tokens, internal IDs or personal data.
- Label untrusted content: text fetched from the web, issues or email is attacker-influenced. Return it as data (for example in
structuredContentfields) and avoid wording that reads as instructions from the server. The MCP Tool Poisoning Detection guide covers the related risk in tool descriptions. - Safe error text: distinguish protocol errors (JSON-RPC errors such as
-32602for an unknown tool or malformed request) from tool execution errors (isError: true). Put actionable, non-sensitive detail in the second; never include stack traces, absolute host paths, connection strings or tokens. CWE-22 itself advises keeping path details out of error messages. - Rate limits and quotas: the specification says servers MUST rate limit tool invocations. Limit per session and per user, and set separate, lower limits for expensive or destructive tools.
Where identity fits
- Remote servers: the specification says MCP servers MUST NOT accept tokens that were not explicitly issued for the MCP server (no token passthrough), and MUST NOT use sessions for authentication. Validate the token audience on every request and call downstream APIs with credentials issued to your server, scoped per tool where the downstream system allows it. See the MCP Authorisation Specification Guide.
- Local servers: prefer stdio so only the launching client can talk to the server. If you expose HTTP locally, the best practices page says to require an authorisation token or use a Unix domain socket or other restricted IPC. Read credentials from a secrets store or an environment variable set at launch, never from a file inside a root the model can read. See the Local MCP Server Security Guide.
- Least privilege for the server's own credentials: a server that only reads issues should hold a token that can only read issues. Static, broad tokens in MCP configuration files are a recurring finding; see the MCP Static Token Audit.
How to implement: a phased approach
- Inventory tools and sinks (week 1): list every tool, its arguments and every sink each argument reaches (file system, process, network, database, template). Mark tools that change state.
- Tighten schemas: add
additionalProperties: false,required,enum,patternand length limits; addoutputSchemafor structured results. - Centralise sink wrappers: write one path validator, one process runner and one outbound HTTP client, each enforcing the rules above, and make handlers call only those. Enforce this with a lint rule.
- Test hostile inputs: add unit tests for every row of the path pitfall table, for leading hyphens, newlines and NUL bytes, and for private IP and redirect cases. Fuzz tool arguments in CI. The MCP Security Testing Guide describes test harnesses and the MCP Security Scanners Guide covers automated checks.
- Pilot behind a flag: release the hardened handlers to an internal ring first and monitor validation rejections. Widen an allow-list deliberately when a legitimate use is rejected; never remove the check.
- Roll back safely: keep the previous release available, but roll back by disabling the affected tool (servers can change the tool list and send
notifications/tools/list_changed) rather than by shipping the unvalidated handler again.
Decision guidance
- Local stdio server for one developer: roots plus the path wrapper and no-shell rule are the minimum; sandbox the process if it runs any external command.
- Remote multi-user server: add object-level authorisation on every identifier, per-user rate limits, audience-checked tokens and an egress proxy. Expect a security review before each new tool.
- Wrapping an existing CLI: prefer its library or API; otherwise expose a few fixed subcommands.
Detection and measurement
The client SHOULD log tool usage, but the server is the only place that sees what each handler actually did. Log per call: tool name, caller identity, session, a hash or redacted form of the arguments, the resolved resource (canonical path, destination host, query identifier), the validation outcome and duration. Do not log raw secrets or full file contents; see the Sensitive Data in Logs Guide and the AI Agent Audit Log Schema Template.
Alert on containment failures, process arguments starting with -, outbound requests to blocked ranges and bursts of validation errors from one session, a common sign of injected instructions.
- Tools with strict schemas: percentage of tools whose input schemas set
additionalProperties: falseand bound every string (target 100%). - Sink wrapper coverage: percentage of file, process and network calls in handler code routed through the shared wrappers (target 100%, enforced by lint).
- Hostile input test pass rate: share of the path, argument and SSRF test cases passing in CI on every release.
- Validation rejection rate: rejections per thousand calls per tool, trended; sudden rises warrant review.
Framework mapping
| Control area | NIST SP 800-53 Rev 5 | NIST CSF 2.0 | Threat reference |
|---|---|---|---|
| Argument validation and allow-lists | SI-10 Information Input Validation | PR.PS-06 | OWASP LLM05 Improper Output Handling |
| Output and error sanitisation | SI-15 Information Output Filtering; SI-11 Error Handling | PR.DS-02 | OWASP LLM02 Sensitive Information Disclosure |
| Narrow tools and scoped credentials | AC-6 Least Privilege | PR.AA-05 | OWASP LLM06 Excessive Agency; ATLAS AML.T0053 |
| Process sandboxing | SC-39 Process Isolation | PR.PS-01 | ATT&CK T1059 Command and Scripting Interpreter |
| Hostile input testing | SA-11 Developer Testing and Evaluation | PR.PS-06 | CWE-22, CWE-78 |
| Tool call audit records | AU-3 Content of Audit Records | PR.PS-04 | ATLAS AML.T0051 (investigation of injected calls) |
Common failure patterns
- String-prefix containment: checking
path.startsWith(root)without a trailing separator, which lets a sibling directory with a longer name pass. - Validating before link resolution: the normalised path is inside the root but a symbolic link inside it points elsewhere, and the handler opens the link.
- Trusting values from configuration as well as arguments: paths read from a project file or manifest are just as attacker-controlled as tool arguments. In the Smithery.ai MCP Hosting Breach 2025, an unvalidated build path value of
..exposed a configuration file holding a platform token. - Shell for convenience:
exec(`git log ${branch}`)written for a quick prototype and shipped. The fix is an argument array, not better escaping. - Verbose errors: returning exception text with absolute paths, SQL fragments or connection strings, which hands the model (and anyone steering it) a map of the host.
Practitioner checklist
- Every tool does one task and accepts identifiers or constrained values rather than raw paths, URLs or commands where possible.
- Every input schema sets
additionalProperties: false,requiredand length or range limits; enums replace free text where the set is known. - Handlers re-validate arguments server-side and authorise each object against the caller.
- All file access goes through one wrapper that resolves real paths, checks containment with a separator and guards against check-then-use races.
- No handler invokes a shell; processes run from fixed executables with argument arrays,
--, timeouts and a minimal environment. - Outbound requests block private, loopback and link-local ranges, validate redirects and go through an egress proxy on servers.
- Tool execution errors use
isError: truewith actionable, non-sensitive messages; outputs return only needed fields. - Remote servers validate token audience, never pass client tokens downstream, and rate limit per user and per tool.
- CI runs hostile-input tests for paths, arguments and SSRF on every release.
- Tool calls are logged with caller, resolved resource and validation outcome, without secrets.
Standards and references
- Model Context Protocol: Specification 2025-11-25, Tools
- Model Context Protocol: Specification 2025-11-25, Roots
- Model Context Protocol: Security Best Practices
- Model Context Protocol: Reference filesystem server source
- OWASP: OS Command Injection Defense Cheat Sheet
- MITRE: CWE-22 Improper Limitation of a Pathname to a Restricted Directory
- Python Software Foundation: subprocess, Security Considerations
- Node.js: Child process documentation
Related NHI Mgmt Group resources: MCP Security Guide · Remote MCP Server Hardening Guide · MCP Server Security Checklist · Top 10 MCP Security Risks