A tool should make three things clear after a failure: what it understood, what effects are known to have happened, and what the caller can safely do next. Reject invalid requests before performing work when possible, represent partial completion explicitly, and preserve uncertainty when the outcome cannot be confirmed.
“An error occurred” is too little information for an agent choosing its next action. “Nothing changed” is worse if the tool cannot prove it.
This guide develops an application-level result contract for a batch labeling tool. The schema and eight scenarios were checked in a local, in-memory fixture. No real service was modified and no MCP interoperability test was performed.
Separate a malformed request from a failed operation
There are different failure boundaries:
- The protocol message cannot be understood or names an unknown operation.
- The operation is recognized, but its arguments violate the contract.
- Arguments are valid, but the caller lacks authority.
- Work starts and only part of it completes.
- The connection fails before the caller learns whether the work completed.
An integration needs to map those situations to its actual protocol and SDK.
For example, the MCP 2026-07-28 tools specification distinguishes protocol errors from tool execution errors, which can use isError: true in a tool result. Input-validation and business-logic failures can be tool execution errors. Do not assume every invalid argument must be a JSON-RPC error.
The mapping also depends on implementation. The MCP TypeScript SDK's error guide documents how its tool handlers turn thrown exceptions into tool-error results. That behavior should not be generalized to every SDK or to resource and prompt handlers.
Make the input contract narrow enough to validate
Our synthetic tool applies one label to a list of projects. A request looks like:
{
"project_ids": ["p1", "p2"],
"label": "reviewed"
}
Its input schema requires a nonempty list of unique known project identifiers and a nonempty label of at most 30 characters. Unknown properties are rejected. The fixture permits only p1 and p2; a real service would normally validate resource identity against its own data.
{
"type": "object",
"additionalProperties": false,
"properties": {
"project_ids": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {"type": "string", "enum": ["p1", "p2"]}
},
"label": {"type": "string", "minLength": 1, "maxLength": 30}
},
"required": ["project_ids", "label"]
}
JSON Schema does not make properties required merely because they appear under properties; it also permits additional properties by default. The object reference explains why the explicit required and additionalProperties rules matter here.
A valid shape is only the first check. The service must still verify authorization for the requested projects and validate domain rules. A string that fits the schema does not grant access to the resource it names.
Describe effects with a result the caller can interpret
For this example, the application uses four statuses:
| Status | Meaning | Appropriate next decision |
|---|---|---|
succeeded |
All requested effects are confirmed | Report completion with the affected identifiers |
rejected |
The operation did not start | Correct input or resolve authorization |
partial |
Some effects are confirmed and some remain unresolved | Inspect the per-item state before proceeding |
unknown |
The result of the attempted operation is unconfirmed | Reconcile state before assuming failure or success |
These are our application terms, not standardized MCP status values. The tool adapter must translate them into the protocol result while keeping the same meaning in human-readable content.
A partial result is explicit:
{
"status": "partial",
"completed": ["p1"],
"unresolved": ["p2"],
"error": "UPSTREAM_REJECTED"
}
The caller can tell that labeling p1 is a confirmed effect. It must not report the entire operation as rolled back or blindly restart the whole batch.
“Unresolved” deliberately does not mean “definitely failed.” A richer service might expose per-item states such as not started, rejected, and outcome unknown. Choose the detail that callers actually need, and document whether the batch is atomic.
Do not turn missing acknowledgment into a false claim
The most revealing fixture scenario applies the first label and then loses the acknowledgment. The synthetic backend contains {"p1": "x"}, but the caller cannot confirm that effect from the failed exchange.
The result is:
{
"status": "unknown",
"completed": [],
"unresolved": ["p1", "p2"],
"error": "OUTCOME_UNCONFIRMED"
}
An empty completed list means “no confirmed effects in this result,” not “no effects occurred.” That distinction belongs in the contract and the model-facing explanation.
For a durable integration, provide a way to inspect the operation or affected resources using an authorized identifier. Return that identifier when it genuinely exists. Do not fabricate an operation handle after losing the information needed to locate it.
The details of retry budgets and deduplication are separate design work. The result contract must first give the caller enough evidence to choose whether a retry is appropriate at all.
Validate semantics as well as schema
The output schema in the fixture requires status, completed, unresolved, and error, with defined types and no extra properties. Both schemas passed JSON Schema Draft 2020-12 schema checks using jsonschema 4.25.1.
The executed scenarios were:
- A valid two-project update.
- Missing label, unexpected property, empty project list, and wrong argument type.
- An authorization rejection before any write.
- A failure on the second project after the first completed.
- A lost acknowledgment after the first write.
All eight returned results matching the output schema and their expected status. Additional assertions checked that rejected requests left the in-memory store unchanged, the partial case recorded the first effect, and the unknown case did not claim no change.
A schema alone would not establish those properties. A result could say succeeded with an empty list and still match a loose shape. Check relationships between fields and compare results with the actual state your test can observe.
These are injected faults in a synchronous fixture. They do not exercise network races, concurrent updates, process crashes, durable recovery, or a real authorization system.
Give useful errors without leaking the environment
A model-facing error should identify the rejected field or operation, explain a safe correction, and provide an appropriate diagnostic reference. It should not expose a stack trace, credential, database connection string, or private record simply because the backend included it in an exception.
For example: “No labels were applied. The label must contain 1–30 characters” is actionable when input validation prevented all work. “The outcome is unconfirmed; inspect operation X before continuing” is appropriate when X is a real, accessible operation record.
Use stable machine-readable codes for programmatic handling and concise text for explanation. Keep internal diagnostics in an access-controlled location and ensure the public message does not contradict the structured result.
Finally, test how the calling application presents each state. If it discards isError, hides partial results, or turns every failure into “try again,” a careful server contract still produces an unsafe workflow.
Execution note: the local fixture ran October 28, 2026 with Python 3.14.2 and jsonschema 4.25.1. MCP references use revision 2026-07-28; protocol conformance and SDK behavior were reviewed documentarily, not tested against a running server.