The tool,
not the prompt.
A filter on what the model reads is a control over an unbounded space. The list of functions the model is allowed to call is finite, written by you, and enforced in code you already own.
Somewhere in your repo is a JSON object with a name, a description, and an input_schema, and below it a handler that takes what the model put in those fields and does something real — runs a query, writes a file, calls an internal endpoint with your service credentials. The schema got the attention, because that is what the model reads. The handler got a happy path, because the demo worked and the feature shipped.
Where the string actually lands.
Prompt injection is delivery, not the vulnerability. A hidden instruction in a scraped page, a support ticket, or another tool's output can push a model toward something you would never authorise — and that decision stays inert until code acts on it. The acting has an address. With the Anthropic SDK the model returns a tool_use block and, per the tool-use documentation, "your code executes the operation and sends back a tool_result". OpenAI's function-calling guide draws the same line: the model chooses the function and produces the arguments, and your application code must execute it. Both providers hand you a JSON object and stop there. Everything after the stop is a function you wrote.
MCP relocates that seam across a process boundary without altering its nature. The client sends tools/call, your server runs the handler, and the current specification — revision 2026-07-28 — puts the obligation on the server side in four lines: validate all tool inputs, implement proper access controls, rate limit tool invocations, sanitize tool outputs. Whether this class of bug reaches shipped code is settled. Microsoft patched its own Semantic Kernel this year for an internal file-download helper that had been tagged as a model-callable tool and never untagged (CVE-2026-25592), which made an unvalidated file write reachable by anything the model chose to say.
Four rules for the code behind the call.
Write these four into the definition itself, where they hold regardless of what the model was told.
- 01
Enumerate what is callable, including what you inherited
The
toolsarray you pass to the API is the whole attack surface at this layer, and it is longer than the functions you wrote. MCP servers you connect, framework defaults, and starter code all add entries. Print the array your process actually sends — names and descriptions — and read it as the list of what a hostile string can reach. - 02
Constrain it in the schema, then again in the handler
Both providers ship a strict mode that makes a tool call conform to your JSON Schema rather than approximate it. Turn it on, then validate again in the handler. A schema constrains a caller that is playing along; the handler is what stands between a hostile string and your interpreter, filesystem, or database.
- 03
Withhold the arguments you already know
OpenAI's function-calling guide states it plainly: don't make the model fill arguments you already know. The user ID, the tenant, the role — these belong in the handler's closure, read from the session you already verified. A
delete_invoice(invoice_id, user_id)that trusts its second argument has handed authorisation to a text generator.delete_invoice(invoice_id), scoped to the caller, cannot be talked across a tenant boundary. - 04
Treat an annotation as a claim, a scope as a control
A tool that advertises itself as read-only is metadata a server wrote about itself, and MCP is blunt about it: clients "MUST consider tool annotations to be untrusted unless they come from trusted servers". Put the enforcement in the credential. The same specification requires a server to validate that access tokens were issued specifically for it, and forbids it accepting or transiting any others — a scope your identity provider issued outranks a hint your dependency shipped.
What this looks like at the keyboard.
If the array from the first rule surprised you, this is the order to work through it.
Print the array you actually send
In Node or Python, log the payload immediately before the API call —
console.log(JSON.stringify(tools, null, 2))orprint(json.dumps(tools, indent=2)). Against an MCP server,tools/listreturns the same set over the wire. Count the entries, then account for each one.Grep the handlers for sinks
Run
grep -rnE "eval\(|exec\(|child_process|subprocess|os\.system"over the files your tools resolve to, then repeat forpath.join, raw SQL string concatenation, and outboundfetch. Every hit is where an argument stops being data.Turn on strict schemas
Add
strict: trueto every tool definition. On OpenAI that also requiresadditionalProperties: falseand every property listed as required, which is the point — the schema stops being a suggestion. Anthropic's equivalent flag carries the same guarantee.Give each server its own scoped token
For every MCP server you connect, issue a credential bound to that server and scoped to what it needs, per the authorization spec. Then write one test that calls a tool with a deliberately out-of-scope argument and asserts a 403.
Everything above is written for tools you own, or a codebase you have been asked in writing to review. OOPUO's public-surface work touches only the surfaces and tool lists named in advance, with a disclosure path agreed before anything is opened — and with your permission, AI-assisted review runs against those approved surfaces and nothing else. Reading your own tools array needs permission from nobody but you.
Bring the list of what your agent can call.
Two controls sit at this seam, and only one of them terminates. A filter on the model's input has to anticipate every phrasing that has worked and every phrasing nobody has tried yet. The tools array has a length you can print. That is the shape of application hardening: naming every function the model can reach, and closing what was never meant to be reachable. Send the array and where the handlers run.