Skip to content

Designing High-Quality MCP Servers

An MCP server is not just an API wrapper.

The easy version exposes every backend endpoint as a separate tool. The useful version starts from the outcome the user wants, then gives the model a curated set of tools that are clear, safe, and difficult to misuse.

The distinction matters because a REST API and an agent-facing tool interface have different consumers. They may use the same backend services, but they should not automatically have the same public surface.

MCP started from a practical problem: people kept copying context from external systems into the model by hand.

flowchart LR
    A["David and Justin notice repeated context copying"]
    B["Small internal team builds MCP"]
    C["MCP goes viral at Anthropic hack week"]
    D["Anthropic open sources MCP"]
    E["Coding tools adopt MCP"]
    F["Major AI companies and tools adopt MCP"]

    A --> B --> C --> D --> E --> F

The design goal was bigger than reducing copy-paste. It was about model agency: letting models fetch context and take actions through a standardized open interface.

Without a standard, every integration becomes a custom negotiation:

  1. Find the right client or platform team.
  2. Align on a private interface.
  3. Build the connector.
  4. Repeat the same work for the next client.

That does not scale when agents need access to many systems.

MCP improves the shape of the ecosystem because servers can expose capabilities once, and compatible clients can consume them without bespoke partnership work.

A good MCP server has three users:

UserWhat they need
End userThe task should complete correctly and safely
Client developerThe server should be easy to connect, test, and debug
ModelTools should be understandable, scoped, and hard to misuse

Most weak MCP servers optimize only for the API owner. They mirror backend endpoints but do not help the model decide what to call.

REST is not the problem. REST APIs are effective interfaces for developers and services that can read documentation, write deterministic code, maintain state, and explicitly compose several operations.

An agent consumes an interface differently.

REST API consumerAgent tool consumer
A developer studies documentation before writing codeA model often receives tool names, descriptions, and schemas in its working context
The chosen endpoint is written into deterministic codeTool selection is inferred from the current prompt and context
Application code owns sequencing and stateThe agent loop may need to infer the next call from each result
Complex request objects can be constructed deliberatelyOpaque or weakly typed arguments invite guessing
Failures can be handled with custom application logicThe model needs an actionable error that explains how to recover
The application can discard irrelevant response fieldsUnbounded responses consume context and distract the model
Permissions can be built into the calling serviceTools may be mixed with capabilities from several servers in one agent session

The MCP specification defines how a client can list and call tools. Each tool has a name, description, input schema, and optional output schema and annotations. The protocol does not require a host to place every definition into one model prompt, but many agent runtimes make some or all of this metadata available to the model so it can select a tool.

This means the tool definition is not passive API documentation. It participates in the agent’s runtime decision.

Suppose an existing commerce API exposes:

GET /users?email=...
GET /users/{id}/orders
GET /orders/{id}/shipping

A mechanical conversion produces three MCP tools:

@mcp.tool
async def get_user_by_email(email: str) -> dict: ...
@mcp.tool
async def list_orders_v2(user_id: str, query: dict) -> list: ...
@mcp.tool
async def get_shipping_details(order_id: str) -> dict: ...

These tools may faithfully represent the REST API, but the user did not ask the agent to operate the API. The user asked:

Where is my latest order?

The endpoint-shaped interface forces the model to discover and reproduce an algorithm that the application already knows:

  1. Find the user by email.
  2. Extract the internal user ID.
  3. List the user’s orders.
  4. Decide what counts as the latest relevant order.
  5. Extract its order ID.
  6. Fetch shipping details.
  7. Convert the backend response into a useful answer.

This creates several avoidable failure points:

  • The model may choose the wrong user lookup tool.
  • It may pass an email where an internal ID is required.
  • It must guess the structure of query.
  • It may sort orders incorrectly or include cancelled orders unexpectedly.
  • It may call the shipping tool with the wrong identifier.
  • Every intermediate result occupies context.
  • Sequential decisions add tool round trips and often additional model inference.
  • Authorization and error handling are spread across several calls.

The model has become application glue for a sequence that never required model reasoning.

flowchart LR
    U["User asks where the latest order is"]

    subgraph R["REST-shaped MCP surface"]
        A["Agent"]
        T1["get_user_by_email"]
        T2["list_orders_v2"]
        T3["get_shipping_details"]
        A --> T1 --> A
        A --> T2 --> A
        A --> T3 --> A
    end

    subgraph O["Outcome-shaped MCP surface"]
        B["Agent"]
        T4["track_latest_order"]
        S["Server performs known API sequence"]
        B --> T4 --> S
    end

    U --> A
    U --> B

The agent-facing tool can instead represent the user’s outcome:

from typing import Literal
@mcp.tool(annotations={"readOnlyHint": True})
async def track_latest_order(
email: str,
include_cancelled: bool = False,
detail: Literal["basic", "verbose"] = "basic",
) -> OrderStatus:
"""Return the status of a user's latest relevant order."""
user = await api.get_user_by_email(email)
orders = await api.list_orders(user.id)
latest = select_latest_order(orders, include_cancelled)
shipping = await api.get_shipping_details(latest.id)
return normalize_order_status(latest, shipping, detail)

The exact decorator and type-to-schema conversion depend on the MCP SDK being used. The design principle does not: the server owns the deterministic sequence and exposes a typed task contract to the agent.

The REST API remains useful inside the server. It simply stops being the agent’s user interface.

This produces a cleaner division of responsibility:

  • The agent understands the user’s language and chooses the appropriate capability.
  • The MCP tool represents the capability in terms the agent can recognize.
  • The server performs deterministic workflow composition, validation, retries, pagination, and normalization.
  • The backend APIs remain focused on service and data boundaries.

The outcome tool also gives the server one place to enforce what “latest” means, how cancelled orders are treated, which fields may be returned, and how upstream failures are translated.

Deterministic Work Belongs In Deterministic Code

Section titled “Deterministic Work Belongs In Deterministic Code”

A useful test is to ask whether the sequence can be written down before the request runs.

flowchart TD
    A["A task requires several operations"] --> B{"Can the sequence and branch rules be defined in advance?"}
    B -->|"Yes"| C["Compose the operations inside the server"]
    B -->|"No"| D{"Does the next action depend on ambiguous intent or new evidence?"}
    D -->|"Yes"| E["Let the agent choose the next capability"]
    D -->|"Missing user information"| F["Use elicitation or ask for clarification"]
    C --> G["Expose one outcome-oriented tool"]
    E --> H["Expose a small set of well-scoped tools"]
DecisionBetter owner
Call these three APIs in this known orderServer code
Apply documented sorting and filtering rulesServer code
Follow pagination until a fixed stopping conditionServer code
Retry a transient upstream failureServer code
Enforce authorization and data boundariesServer, gateway, and host controls
Determine which of several workflows matches an ambiguous requestAgent
Revise a research plan based on newly discovered evidenceAgent
Ask what the user means by “best”Agent or MCP elicitation
Approve a destructive or sensitive operationUser through the host’s approval flow

Using an LLM for a known sequence makes the workflow less deterministic without adding useful intelligence.

A practical design heuristic is one tool per recognizable agent task, not one tool per backend operation.

An agent story is a bounded objective the model can identify from the user’s request:

  • Track the latest order.
  • Prepare a renewal brief.
  • Find unresolved finance exceptions.
  • Draft a customer follow-up.
  • Compare deployment failures across two releases.

This does not mean one giant tool should run an entire product. A tool is too broad when its name no longer predicts its behavior, its schema contains unrelated modes, or one permission grant unlocks many unrelated side effects.

The goal is task-level cohesion: enough composition to remove known plumbing, but not so much that the tool becomes an unbounded command interpreter.

Low-level tools are not always wrong. They can be useful when:

  • a coding agent is explicitly exploring an unfamiliar API
  • the correct sequence genuinely changes based on intermediate evidence
  • an administrator needs diagnostic access to individual operations
  • a planning agent is intentionally composing a small, stable set of primitives
  • the server is a development or debugging tool rather than an end-user product

Even then, keep the surface deliberate. Consider putting low-level or administrative operations in a separate toolset or server so ordinary users and agents do not receive capabilities they do not need.

The mistake is not exposing a primitive. The mistake is assuming endpoint parity is automatically a good agent experience.

Start with the prompts users will actually ask:

  • “Find the contract renewal risks for this account.”
  • “Summarize open finance exceptions this week.”
  • “Create a draft follow-up email for this sales opportunity.”
  • “Book the best flight to Atlanta.”

Then design tools around the model’s decision path.

flowchart TD
    A["User workflow"] --> B["Likely user prompts"]
    B --> C["Model decisions needed"]
    C --> D["Deterministic work the server should own"]
    D --> E["Curated tool set"]
    E --> F["Input and output schemas"]
    F --> G["Safety and review points"]
    G --> H["Evaluation prompts"]

The tool surface should make the right model behavior obvious.

Avoid blindly exposing backend endpoints:

Backend endpointWeak MCP tool
GET /customers/{id}get_customer
GET /invoices?customer_id=...list_invoices
GET /tickets?customer_id=...list_tickets
POST /notescreate_note

This may be technically correct, but it forces the model to reconstruct product intent from low-level primitives.

Prefer tools that match user intent:

WorkflowBetter tool
Account reviewanalyze_account_risk
Renewal preparationprepare_renewal_brief
Finance exception triagesummarize_open_exceptions
Sales follow-updraft_follow_up_email

These tools can still call the same backend endpoints internally. The difference is that the MCP boundary exposes a useful task, not database plumbing.

The tool schema is part of the model’s operating context. A valid schema is not automatically a good schema.

This signature hides the real contract:

async def track_latest_order(email: str, config: dict) -> str: ...

The model must discover or guess:

  • which keys are supported
  • which values are valid
  • which keys are required together
  • what defaults apply
  • whether unknown keys are ignored or rejected

The missing contract often gets copied into a long system prompt. Now the same interface is documented in two places. When the server changes but the prompt does not, the model receives conflicting instructions.

Prefer explicit top-level arguments:

async def track_latest_order(
email: str,
include_cancelled: bool = False,
detail: Literal["basic", "verbose"] = "basic",
) -> OrderStatus: ...

The resulting agent-facing schema should be similarly explicit:

{
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "Email address belonging to the customer"
},
"include_cancelled": {
"type": "boolean",
"default": false
},
"detail": {
"type": "string",
"enum": ["basic", "verbose"],
"default": "basic"
}
},
"required": ["email"],
"additionalProperties": false
}

Good agent-facing schemas generally use:

  • explicit top-level fields
  • familiar domain language
  • enums for small closed sets
  • formats and bounds where they are meaningful
  • defaults that represent the safest common behavior
  • required fields only when the tool truly cannot proceed without them
  • additionalProperties: false when arbitrary fields are not supported

Avoid exposing every backend flag simply because it exists. Each option increases the number of combinations the model can attempt and the server must test.

The model usually does not begin with the same product knowledge as the server author. Names, descriptions, schemas, examples, and errors teach it how the capability works.

A useful tool definition answers two different questions:

  1. Selection: When should the model use this tool instead of another one?
  2. Execution: What arguments should it send, and what will come back?

For example:

async def track_latest_order(
email: str,
include_cancelled: bool = False,
) -> OrderStatus:
"""
Locate a customer's latest relevant order and return its current status.
Use when the user asks where an order is, whether it has shipped, or when
the latest order should arrive. Do not use to list order history.
Example:
User: "Where is the last order for bob@example.com?"
Call: track_latest_order(email="bob@example.com")
Returns the order ID, lifecycle status, carrier, tracking URL, and estimated
delivery date when those fields are available.
"""

The MCP tool type has a description field rather than a dedicated universal example field. Depending on the SDK, examples can be preserved in the description, represented through supported JSON Schema keywords, or maintained in server documentation and evaluation cases.

Useful metadata includes:

  • a distinct verb-and-object name
  • what the tool does
  • when to use it
  • when not to use it if a nearby tool could be confused with it
  • descriptions for non-obvious parameters
  • valid values and units
  • a concise example for difficult calls
  • the expected result shape
  • important side effects and permission requirements

Do not turn every description into a manual. Tool definitions consume attention and, in many clients, model context. Include information that changes selection, argument construction, recovery, or safety.

Current MCP tools can define an optional outputSchema and return JSON in structuredContent. This gives the server and client a testable output contract instead of requiring the model to parse an arbitrary prose response.

For the order tool, a result might contain:

{
"order_id": "ORD-4821",
"status": "in_transit",
"carrier": "Example Express",
"estimated_delivery": "2026-07-19",
"tracking_url": "https://shipping.example/track/ORD-4821"
}

Structured output helps with:

  • client-side validation
  • reliable downstream automation
  • smaller and more predictable responses
  • consistent rendering in the host UI
  • easier regression testing

Return only what the agent needs for the task. Raw API payloads often contain internal IDs, duplicated objects, pagination metadata, and fields the user is not authorized to see.

An error is also context. Compare:

BadRequest

with:

No customer was found for bob@example.com. Check the spelling or ask the user
for the email address used during checkout. Do not retry the same value.

The second message explains what failed, what information is needed, and whether retrying is useful.

The MCP tools specification distinguishes:

  • Protocol errors for malformed JSON-RPC requests, unknown tools, and server-level failures
  • Tool execution errors returned with isError: true for validation, upstream API, and business-logic failures

Tool execution errors should be actionable because clients can provide them to the model for self-correction.

Good execution errors state:

  • what could not be completed
  • which input or business condition caused it
  • which values or format are accepted
  • whether the same call may be retried
  • whether the model should ask the user for information
  • whether the operation partially changed state

Do not leak secrets, internal stack traces, private records, or authorization details while trying to be helpful.

Use Tool Annotations, But Do Not Trust Them As Enforcement

Section titled “Use Tool Annotations, But Do Not Trust Them As Enforcement”

MCP tool annotations provide a small risk vocabulary:

AnnotationMeaning
readOnlyHintThe tool claims it does not modify its environment
destructiveHintA non-read-only tool may perform destructive updates
idempotentHintRepeating the call with the same arguments should not create additional effects
openWorldHintThe tool may interact with external or open-ended entities

These hints can help a trusted client decide how to present or approve a call. For example, it might treat a read-only lookup differently from deleting a repository.

They are not security controls:

  • An untrusted server can publish an incorrect annotation.
  • readOnlyHint: true does not prevent the implementation from writing data.
  • An annotation does not grant or restrict authorization.
  • An annotation does not protect the model from prompt injection in tool results.
  • Clients differ in how they use the hints.

The current specification therefore requires clients to treat annotations as untrusted unless they come from a trusted server.

Use deterministic controls for guarantees:

  • authorization scopes
  • server-side access checks
  • separate read-only deployments or toolsets
  • network restrictions
  • sandboxing
  • user confirmation for sensitive actions
  • audit logging

Annotations describe intended behavior. Runtime policy must enforce allowed behavior.

MCP clients discover tools through tools/list. The server returns names, descriptions, input schemas, and other metadata. How a host presents those tools to its model is an implementation choice.

In many agent runtimes, a larger catalog has two costs:

  1. Context cost: more definitions compete with the user’s request, conversation, retrieved content, and working state.
  2. Selection cost: overlapping names and descriptions create more opportunities to choose the wrong capability.

There is no universal MCP limit such as “50 tools.” The practical limit depends on the model, host, definition size, similarity between tools, task complexity, and any search or filtering performed by the runtime.

Measure the tool surface instead of relying on a fixed number.

Useful curation strategies include:

  • expose only the toolsets required for the current product or role
  • separate ordinary user tools from administrative tools
  • offer a read-only mode that removes mutating tools rather than merely describing them
  • allowlist individual tools for sensitive workflows
  • split unrelated domains into separate servers or namespaces
  • use server or host-supported tool search and progressive disclosure where available
  • remove legacy aliases after a deliberate compatibility period
  • keep descriptions concise enough to remain useful at catalog scale

GitHub’s MCP server is a useful production example. It supports selecting toolsets or individual tools and states that enabling only what is needed can improve tool choice and reduce context size. That is an implementation pattern, not a requirement of the MCP protocol.

Generating MCP tools from REST or OpenAPI definitions can accelerate a prototype. It proves connectivity, authentication, and basic request mapping.

It does not finish the agent interface.

flowchart LR
    A["REST or OpenAPI source"] --> B["Generate initial MCP tools"]
    B --> C["Test representative user prompts"]
    C --> D["Group calls into agent stories"]
    D --> E["Compose deterministic operations"]
    E --> F["Flatten and constrain schemas"]
    F --> G["Document results and recovery"]
    G --> H["Annotate and enforce safety"]
    H --> I["Remove irrelevant tools"]
    I --> J["Evaluate and ship curated surface"]

Treat generated tools as scaffolding:

  1. Start with representative user requests.
  2. Observe which generated endpoints are required to complete them.
  3. Move known multi-call sequences into server-side workflow functions.
  4. Rename tools around user intent.
  5. Replace opaque request bodies with explicit agent-facing arguments.
  6. Normalize and bound responses.
  7. Add actionable errors and appropriate annotations.
  8. Enforce authorization independently of the model and annotations.
  9. Delete tools the target agent does not need.
  10. Run evaluation prompts before treating the server as production-ready.

The generated mapping is a pipe to the backend. A production MCP server is a product interface for an agent.

Traditional API tests verify that an endpoint returns the expected response for known input. Agent-facing tools also need selection and recovery tests.

Create a small evaluation set from realistic user language, including clear requests, ambiguous requests, malformed details, and requests that should not call a tool.

Measure:

QuestionExample signal
Did the model select the correct tool?Selection accuracy across prompt variants
Did it construct valid arguments?Schema-validation success rate
Did it complete the task efficiently?Number of model turns and tool calls
Did it recover from expected failures?Successful correction after actionable errors
Did it avoid irrelevant tools?Wrong-tool and unnecessary-call rate
Did it respect safety boundaries?Blocked unauthorized or destructive attempts
Is the result useful to the next step?Output-schema validity and task completion
Is the catalog reasonably sized?Serialized tool-definition size plus selection performance

Test semantically similar tools together. A tool can work perfectly in isolation and still fail in production because its name and description collide with another tool.

Good tools have:

  • clear names with verbs and domain nouns
  • descriptions that say when to use the tool
  • narrow, explicit input schemas
  • constrained values and safe defaults
  • bounded result sizes
  • predictable structured outputs
  • actionable error messages
  • accurate behavioral annotations
  • deterministic authorization and policy checks

Poor tools have:

  • vague names like execute, fetch, or run_query
  • huge optional parameter bags
  • opaque config or filters objects
  • backend-specific jargon
  • unbounded raw API results
  • side effects hidden behind generic operations
  • unclear permission requirements
  • descriptions copied directly from REST documentation

Some user requests are underspecified.

If the user says “book the best flight,” the server may need to ask whether “best” means cheapest, fastest, fewest stops, or preferred airline. MCP elicitation exists for this class of interaction: the server can ask the user for missing information instead of guessing.

Use elicitation when:

  • the missing detail materially changes the action
  • a default would be risky
  • the server needs user preference or consent
  • the model should not invent the missing field

Elicitation should not replace good defaults for ordinary, low-risk choices. Use it when the user really needs to make the decision.

Before calling an MCP server production-ready, check:

  • Does each tool map to a recognizable user or agent task?
  • Are known API sequences implemented in deterministic server code?
  • Can the model infer when to use each tool from its name and description?
  • Are similar tools distinguishable?
  • Are arguments explicit, typed, constrained, and documented?
  • Are destructive actions separated from read-only actions?
  • Are annotations accurate while real controls remain independently enforced?
  • Are result sizes bounded and sensitive fields removed?
  • Does structured output conform to an output schema where one is declared?
  • Are errors written for the model to recover from safely?
  • Is authentication clear to the client developer?
  • Can unnecessary tools be disabled by role, workflow, or deployment?
  • Can the server be tested in MCP Inspector?
  • Are high-risk calls logged for audit?
  • Have representative prompts been evaluated against the complete tool catalog?

Much of the early MCP ecosystem was developer-tool heavy. There is room for high-quality vertical servers in:

  • sales
  • finance
  • legal
  • education
  • healthcare operations
  • customer support
  • procurement
  • internal knowledge management

The strongest vertical servers will probably not be the broadest API wrappers. They will understand the domain workflow well enough to expose the right model-facing actions, decisions, and safeguards.

There is also room for infrastructure around MCP server development:

  • server hosting
  • testing harnesses
  • eval suites
  • deployment tooling
  • registry and discovery workflows
  • security scanning
  • observability and audit logs
  • generated server scaffolding followed by interface curation

As models improve at coding and tool use, teams will still need reliable ways to test whether generated integrations are understandable, efficient, and safe for agents.

  • A REST API and an MCP server can use the same backend without exposing the same interface.
  • REST endpoints describe backend operations; agent tools should describe recognizable outcomes.
  • Keep known sequencing, validation, retries, pagination, and normalization in deterministic server code.
  • Let the agent decide where intent, planning, or new evidence creates genuine ambiguity.
  • Treat names, descriptions, schemas, examples, outputs, and errors as runtime context for the model.
  • Flatten opaque arguments, constrain choices, and return bounded structured results.
  • Use annotations as hints, never as substitutes for authorization or runtime policy.
  • Curate the tools exposed to each agent instead of relying on a universal tool-count limit.
  • REST-to-MCP generation is useful scaffolding, but production interfaces require refactoring and evaluation.

Diagram viewer