MCP Security: Checking Real Actions Beyond Tool Responses

In this article · 12 sections
An AI assistant asks a tool to publish a report. The tool replies, “Done.” What do we actually know? We know that a component returned those words. We do not yet know where the report went, whether its contents matched the approved draft, or whether anything else happened along the way.
That distinction is easy to miss when a conversation is the main interface to a system. The response feels like the action. In software, however, a message about an operation and the operation itself are different objects with different sources of evidence.
This article develops that idea through a fictional reporting workflow. The examples are self-contained teaching code, not a complete MCP implementation or a claim that a particular security system has been validated.
Begin with the boundary you are trying to protect
The Model Context Protocol gives AI applications a structured way to discover and invoke tools. A tool can query a service, read a resource or request a change. The MCP tools specification describes tool names, input schemas, results and behavioral annotations.
Those interfaces do not make every connected server trustworthy. The specification explicitly treats annotations from untrusted servers as untrusted. A description saying that a tool is read-only is a statement about behavior, not an operating-system restriction preventing a write.
For a design review, start by drawing the components that can read data, change state and send requests. Then identify which of those components you trust to enforce each permission. If an untrusted component can perform an external action directly, checking its final sentence cannot undo the action it already performed.
Separate intent, request, observation and outcome
Consider four different records in the report workflow. Intent records what the user approved: publish a specific draft to a particular workspace. The request is the structured operation eventually sent to the destination. Observation records what a trusted component actually saw. Outcome records what is known about the destination's state.
These records should be related, but they are not interchangeable. A request can match the intended destination and still fail to arrive. A successful HTTP response can acknowledge a job without proving that the job completed. A tool can describe the correct workspace while sending a request to a different one.
Keeping these distinctions explicit prevents an application from displaying “published” when the strongest evidence available is merely “request accepted.” It also gives an engineer a clearer place to investigate a disagreement between the chat transcript and the external system.
Put enforcement where the action must pass
Suppose a third-party tool can prepare a report but cannot directly publish it. A separate trusted service accepts a constrained publication request, checks it and performs the operation using its own narrowly scoped authority. That creates an opportunity to enforce the decision before the external action occurs.
This arrangement only helps if the tool cannot bypass the service. If it also possesses the same destination credential or unrestricted network access to a privileged endpoint, the checked route may be optional in practice. A diagram with a gate in the middle is insufficient; the surrounding permission and network configuration must make the gate unavoidable for the protected action.
This is an application of ordinary least-authority design, not a special property granted by MCP. The official security guidance discusses authorization boundaries and related integration risks. The application still has to implement and test its own controls.
Approve concrete arguments rather than a tool name
“Allow publish_report” leaves important questions unanswered. Which workspace? Which document? Which revision? Are attachments included? Can the server add another recipient or substitute a different destination after approval?
A useful approval record binds to the relevant operation and arguments. It should be created by the trusted side of the application, not accepted as a claim supplied by the tool. When the request changes materially, the previous approval should not silently authorize the new operation.
The UI also needs a readable summary of those arguments. A cryptographic digest can help identify exact bytes internally, but a user cannot reasonably inspect a hash and infer what document it represents. Human-readable review and machine-checkable identity solve complementary problems.
A small example of an argument check
The following Python code checks a deliberately narrow request shape. It permits a fictional report publication only when the destination and exact body digest match the approval. Nothing is sent over a network.
from dataclasses import dataclass
from hashlib import sha256
@dataclass(frozen=True)
class Approval:
workspace: str
body_digest: str
def digest(body):
return sha256(body.encode("utf-8")).hexdigest()
def check_publication(request, approval):
expected_fields = {"operation", "workspace", "body"}
if set(request) != expected_fields:
return False, "unexpected request shape"
if not all(isinstance(request[key], str) for key in expected_fields):
return False, "fields must be strings"
if request["operation"] != "publish_report":
return False, "unsupported operation"
if request["workspace"] != approval.workspace:
return False, "destination differs from approval"
if digest(request["body"]) != approval.body_digest:
return False, "content differs from approval"
return True, "request matches approval"
approved_body = "Quarterly test report: no customer data."
approval = Approval("demo-workspace", digest(approved_body))
request = {"operation": "publish_report", "workspace": "demo-workspace",
"body": approved_body}
assert check_publication(request, approval)[0]
assert not check_publication({**request, "workspace": "other"}, approval)[0]
assert not check_publication({**request, "body": "different"}, approval)[0]
assert not check_publication({**request, "extra": "surprise"}, approval)[0]
The extra-field test matters. Validating a few recognized fields while silently forwarding everything else can leave meaningful parts of a request outside the review. The example intentionally uses exact content matching; a real product needs to decide which transformations are permitted and represent them explicitly.
Understand what the example does not establish
The checker assumes that its approval object is authentic and that the request it checks is the request eventually executed. It does not authenticate a user, prevent replay, constrain a process, handle concurrent calls or prove anything about a remote service.
It also assumes that the downstream system interprets the fields as expected. If another component adds headers, rewrites a destination or transforms the body after the check, the checked object and the executed request can diverge. Validate at the correct point in the pipeline and keep the transformation rules visible.
This is why production security should not be inferred from a short validator or a passing unit test. Small examples teach a boundary. They do not replace the surrounding system that preserves that boundary under errors and concurrency.
Observe state through an independent authority
If a tool writes a local report, asking that same tool whether it wrote the correct report is not an independent check. A compromised component may control both the original response and the follow-up response.
A stronger design obtains evidence through a trusted filesystem view, a controlled storage service or another appropriately authorized observation path. Even then, the scope matters. A content check might establish the bytes of one object without establishing that no additional object was created elsewhere.
For network operations, do not assume that reading the outgoing request proves the final remote outcome. Request inspection, transport acknowledgement and destination-state verification answer different questions. Define which question your system can answer and expose uncertainty for the others.
Repeated operations require a separate policy
An approved request may arrive twice because a client retried after a timeout. If the first attempt completed but its acknowledgement was lost, repeating the operation can create an unwanted duplicate.
A durable idempotency mechanism can bind repeated attempts to one operation identifier. The details depend on the destination and operation. An application must also decide what to do when it cannot determine whether the first attempt reached the remote service.
Do not convert that ambiguity into a false success or a confident failure. A status such as “outcome unknown; reconciliation required” can be more accurate. Preventing a second admission and guaranteeing exactly one external effect are different claims, especially when the destination does not participate in the same transaction.
Treat tool output as data in the next model step
Effect enforcement and prompt-injection resistance are related but different concerns. A tool response can include text that looks like a new instruction, even if the tool's own operation was read-only. The host must preserve the distinction between instructions from an authorized user and content returned from an external source.
OWASP's MCP security guidance discusses tool-related risks and the need for controls beyond trusting labels. In an application, I would keep sensitive authority on the trusted side, constrain the operations available to the model and avoid treating a retrieved sentence as permission to expand the task.
The final assistant response should also be grounded in the operation's recorded status. If the publication service reports an unresolved outcome, the model should not turn that into “your report is now live” merely to make the conversation feel complete.
Evaluate useful work as well as blocked actions
A guard that blocks every request can refuse all unwanted actions and still be unusable. A meaningful evaluation needs ordinary allowed operations, disallowed variations and ambiguous cases. Record why a request was refused and whether an allowed task completed.
For this fictional workflow, useful cases include a valid report, a changed workspace, modified content, an unexpected field, a repeated operation identifier, an unavailable destination and a timeout after the destination accepted the request. These cases probe different boundaries; they should not be collapsed into one undifferentiated “attack blocked” count.
State the denominator for any reported result. A check over selected request shapes is not evidence about every possible server or every possible external action. Keep false refusals, unsupported cases and unknown outcomes visible alongside successful enforcement.
An exercise that connects the pieces
Extend the teaching example with an operation identifier and an in-memory record of previously accepted identifiers. Then explain why that record would be lost on restart and what additional storage semantics a durable version would require.
Next, place a harmless transformation after the checker that changes the report body. Observe that the checker can pass while the eventual object no longer matches the approved digest. Move the check to the point where the final request is fixed, and document what can still happen outside that boundary.
The central lesson is to ask where evidence comes from and where authority is exercised. A reassuring tool response is useful interface feedback. A reliable system needs an additional account of which action was permitted, which request crossed the boundary and what outcome is actually known.
Sources and further reading
- MCP tools specification, 2026-07-28: tool schemas, results and trust considerations for annotations.
- MCP security best practices: authorization and integration boundaries.
- OWASP MCP Security Cheat Sheet: application-level security considerations.
The report workflow, code and exercises are illustrative. They do not disclose an unpublished research method or report experimental performance figures.