An MCP tool is not merely a function with a description. It is a capability contract crossing four trust boundaries:
user intent -> model selection -> MCP request -> business effect
Each boundary can fail independently. A model may select the wrong tool, produce schema-valid but semantically wrong arguments, call an operation the principal cannot perform, or misread an otherwise correct result. Production design must make each failure observable and containable.
This guide uses the 2026-07-28 MCP protocol model. If your client or server still negotiates an older revision, keep a compatibility matrix and test that profile separately rather than mixing assumptions.
Key Takeaways
- Design four contracts: selection, input, output, and effect.
- Expose business intent, not generic infrastructure primitives.
- Keep identity, tenant, ownership, prices, and policy out of model arguments.
- Use
outputSchemawithstructuredContentfor machine-consumable results. - Distinguish tool errors from protocol errors and
input_requiredfrom completion. - Treat Tool Annotations as hints, never authorization.
- Use opaque, integrity-protected handles for state that spans requests.
- Bound result size, pagination, retries, wall time, and side effects.
- Evaluate descriptions with real task distributions, including abstention cases.
The 2026-07-28 Protocol Context
The current protocol is stateless at the protocol-session layer:
- clients call
server/discoverto learn supported versions and capabilities; - every request carries required protocol and client metadata in
_meta; - the former
initialize/initializedsession handshake is not the current model; - result-bearing methods use
resultType, normallycompleteorinput_required; - multi-round requests carry explicit
inputResponsesand opaquerequestState; - long-lived change delivery uses explicit subscriptions rather than an implicit session stream.
A maintained SDK should construct and validate protocol metadata. Tool handlers should focus on the business contract, but authors still need this model to avoid storing security state in a connection or assuming that one successful handshake authorizes later calls.
Four Contracts for Every Tool
1. Selection Contract
The name and description help a model decide whether to call the tool.
Prefer names that express one business action:
| Weak | Better | Why |
|---|---|---|
process |
invoice_list_overdue |
Names the resource and operation |
get_data |
customer_get_profile |
Makes scope explicit |
manage_order |
order_preview_refund |
Separates preview from commit |
http_request |
Domain-specific read tools | Avoids arbitrary network authority |
A useful description states:
- what the tool does;
- when to use it;
- when not to use it;
- authoritative input meaning;
- output shape and limits;
- whether it reads, previews, or commits.
Do not put credentials, hidden policy, or long tutorials in the description. Descriptions are untrusted server-provided text and also consume model context.
2. Input Contract
Use the narrowest supported JSON Schema:
- explicit
required; additionalProperties: false;- enums for stable business values;
- bounded strings, arrays, and numbers;
- separate fields with one meaning each;
- cursor pagination instead of unbounded limits;
- identifiers users actually know, not internal database keys.
Schema validation proves shape, not intent. A valid order_reference may belong to another tenant. A valid amount may be stale. A valid path may escape an allowed directory after symlink resolution.
Never accept these as model-controlled arguments:
principal_id, tenant_id, roles, is_admin, approved,
authoritative_price, credential, policy_decision
Derive them from authenticated server context and trusted systems of record.
3. Output Contract
MCP supports both:
content: human/model-consumable content blocks;structuredContent: a machine-readable JSON value.
When a tool declares outputSchema, structuredContent must conform to it. Typed SDK helpers can infer and validate this contract. Keep important fields stable:
{
"items": [
{
"invoice_reference": "INV-42",
"currency": "USD",
"amount_minor": 1299
}
],
"next_cursor": ""
}
Use integer minor units for money, explicit units and time zones, stable enums, and opaque cursors. Include source or freshness metadata when the caller needs to judge evidence.
Do not dump an entire database row, webpage, log stream, or exception into the result. Minimize fields, cap bytes and item counts, redact secrets and personal data, and return references for large artifacts.
4. Effect Contract
Define whether the tool is:
- a pure read;
- an externally observable read;
- a preview;
- an additive write;
- an idempotent update;
- a destructive or irreversible action.
For writes, document and enforce preconditions, idempotency key scope, conflict behavior, confirmation, and ambiguous-timeout recovery. A description that says “asks for confirmation” is not enforcement. Bind approval to a digest of canonical arguments and resource version in trusted code.
Annotations Are Hints
readOnlyHint, destructiveHint, idempotentHint, and openWorldHint can help a host label or rank tools. They are not verified claims. A malicious or buggy server can annotate a destructive tool as read-only.
The server must enforce actual behavior. The host should combine annotations with its own allowlist, trust policy, user consent, and observed tool identity.
Errors and Multi-Round Results
Keep three outcomes separate:
| Outcome | Representation | Example |
|---|---|---|
| Protocol failure | JSON-RPC / MCP error | Unknown method, malformed protocol request |
| Tool execution failure | Tool result with isError |
Invalid business state, downstream unavailable |
| More input required | resultType: "input_required" |
User choice, credentials, confirmation |
A recoverable tool error should expose a stable code, whether retry may help, and a safe next action. Do not expose stack traces, SQL, credentials, internal hostnames, or resource-existence details that violate authorization.
The 2026-07-28 multi-round trip result (MRTR) flow is:
tools/call
-> resultType: input_required
inputRequests: {server-assigned IDs ...}
requestState: opaque state
-> client fulfills requests
-> tools/call with inputResponses + same requestState
-> resultType: complete
Treat requestState as attacker-controlled on receipt. Authenticated deployments should bind it to the principal, tenant, operation, expiry, and original canonical arguments. Unauthenticated servers must encrypt, sign, and verify it. Never serialize raw credentials or unrestricted internal state into the handle.
Runnable Go SDK Example
The official Go SDK v1.8.0 supports the 2026-07-28 specification and requires Go 1.25 or newer. Its generic AddTool helper derives input and output schemas, validates input, populates structuredContent, and turns handler errors into tool errors.
This local stdio example scopes invoices to a trusted tenant captured by the server process. A remote server should obtain the same principal from authenticated request middleware, never from ListInvoicesInput.
package main
import (
"context"
"errors"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type ListInvoicesInput struct {
Status string `json:"status" jsonschema:"invoice status: open or overdue"`
Limit int `json:"limit" jsonschema:"maximum number of invoices to return"`
}
type Invoice struct {
Reference string `json:"invoice_reference"`
Currency string `json:"currency"`
AmountMinor int64 `json:"amount_minor"`
}
type ListInvoicesOutput struct {
Items []Invoice `json:"items"`
NextCursor string `json:"next_cursor"`
}
type storedInvoice struct {
TenantID string
Status string
Invoice
}
type InvoiceService struct {
tenantID string
invoices []storedInvoice
}
func (s *InvoiceService) ListInvoices(
ctx context.Context,
request *mcp.CallToolRequest,
input ListInvoicesInput,
) (*mcp.CallToolResult, ListInvoicesOutput, error) {
if err := ctx.Err(); err != nil {
return nil, ListInvoicesOutput{}, err
}
if input.Status != "open" && input.Status != "overdue" {
return nil, ListInvoicesOutput{}, errors.New("invalid_status")
}
if input.Limit < 1 || input.Limit > 50 {
return nil, ListInvoicesOutput{}, errors.New("invalid_limit")
}
output := ListInvoicesOutput{Items: []Invoice{}}
for _, invoice := range s.invoices {
if invoice.TenantID != s.tenantID || invoice.Status != input.Status {
continue
}
output.Items = append(output.Items, invoice.Invoice)
if len(output.Items) == input.Limit {
break
}
}
return nil, output, nil
}
func boolPointer(value bool) *bool {
return &value
}
func main() {
service := &InvoiceService{
tenantID: "tenant-a",
invoices: []storedInvoice{{
TenantID: "tenant-a",
Status: "overdue",
Invoice: Invoice{
Reference: "INV-42",
Currency: "USD",
AmountMinor: 1299,
},
}},
}
server := mcp.NewServer(
&mcp.Implementation{Name: "invoice-server", Version: "v1.0.0"},
nil,
)
mcp.AddTool(server, &mcp.Tool{
Name: "invoice_list",
Description: "List open or overdue invoices visible to the authenticated " +
"tenant. Use for invoice lookup, not for payment or reminders. " +
"Returns at most 50 invoice summaries.",
Annotations: &mcp.ToolAnnotations{
ReadOnlyHint: true,
OpenWorldHint: boolPointer(false),
},
}, service.ListInvoices)
if err := server.Run(
context.Background(),
&mcp.StdioTransport{},
); err != nil {
log.Fatal(err)
}
}
The typed helper validates JSON shape, but the handler still enforces the status vocabulary and limit. More importantly, tenant identity is absent from model input. In a real repository query, include tenant scope in the database predicate and test that no cross-tenant reference changes the result.
State and Handles
Stateless protocol requests do not mean the business has no state. Store durable state in an authoritative system and return scoped handles:
handle -> principal + tenant + operation + resource version + expiry
Use a server-side record or an authenticated encrypted token. Handles should be opaque, unguessable, revocable when needed, and rejected when principal, purpose, version, or expiry no longer matches.
Do not rely on:
- process-local maps in a horizontally scaled deployment;
- an HTTP connection as user identity;
- client-supplied session IDs as authorization;
- replayable approval tokens with no operation digest;
- model memory as workflow state.
Tool Discovery and Context Budget
There is no universal maximum tool count. Measure:
- serialized schema tokens;
- wrong-tool and no-tool rates;
- argument accuracy;
- first-token and task latency;
- task success and recovery;
- tool-specific policy violations.
Expose the smallest relevant set after deterministic routing and policy filtering. Discovery does not grant authority: an installed server or listed tool is not automatically trusted.
Testing Strategy
Contract Tests
- minimal, typical, maximal, and malformed input;
- unknown fields, enum variants, Unicode, and boundary values;
- output conformance and stable error codes;
- cancellation, timeout, duplicate request, and idempotency;
- authorization with another tenant's identifier;
- result-size and pagination limits.
Protocol Tests
server/discoverand negotiated protocol version;- required per-request
_meta; - tools list and call round trips;
resultTypecomplete andinput_required;inputResponses/requestStateintegrity;- compatibility profiles supported by the deployment;
- transport disconnect and retry behavior.
Use the official conformance suite and MCP Inspector where they fit, but keep business authorization tests in your own suite.
Model-in-the-Loop Tests
Create realistic utterances for:
- positive selection;
- choosing between similar tools;
- abstaining when no tool applies;
- asking for missing information;
- preserving IDs, dates, currencies, and units;
- malicious text in tool results;
- claims of success after a tool error.
Run the same corpus when descriptions, schemas, model versions, routing, or tool catalogs change. Report confidence intervals and slice results by task type rather than publishing one aggregate score.
Production Checklist
- [ ] One narrow business action per tool.
- [ ] Description states use, non-use, output, and side-effect boundaries.
- [ ] Input and output schemas are versioned and reviewed.
- [ ] Trusted identity and policy are absent from model arguments.
- [ ] Object-level authorization occurs before data or effects are returned.
- [ ] Results are bounded, redacted, and provenance-aware.
- [ ] Errors, completion, and input-required states are distinct.
- [ ] Write operations have confirmation and idempotency semantics.
- [ ] State handles are opaque, scoped, integrity-protected, and expiring.
- [ ] Annotations match behavior but are never relied on as enforcement.
- [ ] Deterministic, protocol, model, and adversarial tests run in CI.
- [ ] Traces record versions and decisions without secrets.
Conclusion
Good MCP Tool design reduces ambiguity for the model while preserving deterministic authority in the server. The name and description guide selection, schemas define machine contracts, structured results support composition, and explicit result types make recovery possible. None of those replace authentication, object authorization, idempotency, or audit.
For the host-side execution loop, read the LLM tool-calling architecture guide. For transport behavior, continue with the Go MCP Streamable HTTP guide.