Set up an MCP server for your application
AI · Aug 2026 · 19 min read
Write the server once and every client can use it — Claude Code, your own agent, the Messages API. Tools, resources, transports, config scopes, and the limits that bite in production.
MCP is the wire protocol between an AI client and your tools. The point of it is that the integration is written once: the same server answers Claude Code on your laptop, an agent you deploy, and a Messages API call, without a line of glue per client.
If you have read the custom tools tutorial, you have already built something MCP-shaped — an in-process server, living inside one Python process. That is the right tool when the agent and the tools ship together. This is the other case.
which kind of server you want In-process (SDK) Standalone server Lives in Your agent's process Its own process Reachable by That one agent Any MCP client Set up with create_sdk_mcp_server mcp package + a transport Reach for it when Tools ship with the agent Several clients, or a separate team owns it
Step 1: the server
A server is a Python module with decorated functions. The type hints become the input schema and the docstring becomes the description the model reads, so both are load-bearing rather than documentation.
# uv add "mcp[cli]" (the cli extra gives you mcp dev / run / install)
from mcp.server import MCPServer
mcp = MCPServer('orders')
@mcp.tool()
async def lookup_order(order_id: str) -> str:
"""Look up an order by id.
Call this whenever the user mentions an order number, asks about
delivery status, or references a past purchase.
Args:
order_id: The order reference, e.g. ORD-10432
"""
order = await db.orders.find(order_id)
if order is None:
return f'No order {order_id}.' # a result, not an exception
return order.as_summary()
if __name__ == '__main__':
mcp.run(transport='stdio') # or 'sse' | 'streamable-http'
The docstring is the trigger: "Look up an order by id" tells the model what the tool does. The second paragraph tells it *when to reach for it*, and that is what actually moves the call rate. Write the situations, not the job description.
What the schema actually gets: The generated schema carries the parameter's name and type, but not the Args: lines — those stay part of the tool description rather than becoming per-field descriptions. So put anything the model must not misread about a parameter, such as its format or unit, in the parameter *name* or the main description.
Return errors as strings rather than raising. An exception crossing the transport is an infrastructure failure the client reports as a broken tool; a returned sentence is something the model reads and routes around.
Step 2: the two capabilities everyone skips
MCP servers expose three things, and most only ever ship the first. Tools are functions the model calls. Resources are data the client can read. Prompts are templates the user invokes.
@mcp.resource('orders://recent')
async def recent_orders() -> str:
"""The 50 most recent orders, as CSV."""
return await db.orders.recent_csv(limit=50)
# templated - the client fills in the segment
@mcp.resource('orders://customer/{customer_id}')
async def customer_orders(customer_id: str) -> str:
"""Every order for one customer."""
return await db.orders.for_customer(customer_id)
The distinction is about who decides. A tool is called by the model when it judges the moment right; a resource is attached by the client or the user, deliberately, before anything runs. Reference data that should be *available* rather than *fetched* belongs in a resource — it costs no tool call and no decision.
Step 3: connect it to Claude Code
For a local server the transport is stdio: the client launches your process and talks over its standard input and output. Note the double dash — it separates Claude's own flags from the command that runs your server.
claude mcp add orders -- uv run /path/to/orders_server.py
# ^^
# everything after -- is passed through untouched
# with an environment variable for the server process:
claude mcp add --env DB_URL=postgres://... orders \
-- uv run /path/to/orders_server.py
claude mcp list # ✔ Connected | ! Needs authentication | ✘ Failed
Remote servers use HTTP instead, and that is the form you want for anything shared across a team or deployed centrally.
claude mcp add --transport http orders https://mcp.internal/mcp
# with a static auth header (-H is short for --header)
claude mcp add --transport http secure-api https://api.example.com/mcp \
-H "Authorization: Bearer ${API_TOKEN}"
# some services still expose SSE only
claude mcp add --transport sse asana https://mcp.asana.com/sse
# servers behind OAuth: add, then authenticate interactively
/mcp
Reserved names: workspace, claude-in-chrome, computer-use, Claude Preview, and Claude Browser belong to built-in servers. claude mcp add rejects them outright, and a config file that defines one is skipped at load with a warning — which looks exactly like a server that silently fails to appear.
Step 4: pick the right scope
Where the configuration is written decides who gets the server, and the default is the narrowest option. This is the flag people set once and then wonder why a teammate cannot see anything.
scopes --scope Stored in Who gets it local (default) ~/.claude.json You, in this project only project .mcp.json in the repo Everyone who checks out the repo user ~/.claude.json You, in every project
For anything the team should share, use project and commit the file. That turns the integration into part of the repository rather than something each engineer rediscovers from a wiki page.
Project scope needs approving first: A project-scoped server does not connect the moment you add it. claude mcp list shows it as ⏸ Pending approval (run claude to approve) until you start a session and accept it — a local-scoped server, by contrast, shows ✔ Connected straight away. This is a deliberate guard against a checked-in config launching a process you did not read, and it is the single most confusing part of the flow the first time.
{
"mcpServers": {
"orders": {
"command": "uv",
"args": ["run", "${CLAUDE_PROJECT_DIR:-.}/tools/orders_server.py"],
"env": { "DB_URL": "${DB_URL}" },
"timeout": 600000
},
"internal-api": {
"type": "streamable-http",
"url": "https://mcp.internal/mcp"
}
}
}
streamable-http there is an accepted alias for http — the MCP specification uses that name, so a config copied straight out of a server's own documentation works without editing.
Two things bite here: ${VAR} expands against the *server's* environment, not Claude Code's — in a project-scoped file give it a default, as in ${CLAUDE_PROJECT_DIR:-.}, or the path resolves to nothing. And commit the variable reference, never the value: .mcp.json is in the repository.
Step 5: connect it from your own application
Claude Code is one client. The reason to write a standalone server is that the others cost nothing extra. From the Agent SDK, point mcp_servers at the same config and address the tools by their full name.
from claude_agent_sdk import query, ClaudeAgentOptions
options = ClaudeAgentOptions(
mcp_servers='.mcp.json', # or a dict, or a Path
allowed_tools=['mcp__orders__lookup_order'],
)
async for message in query(
prompt='What happened with ORD-10432?', options=options,
):
...
# naming again: mcp__<key in mcp_servers>__<tool name>
And from the Messages API directly, the MCP connector opens the connection server-side. Two parameters are required together — declaring the server without a matching toolset entry is rejected.
client.beta.messages.create(
model='claude-opus-5', max_tokens=4096,
betas=['mcp-client-2025-11-20'],
mcp_servers=[{
'type': 'url', 'name': 'orders',
'url': 'https://mcp.internal/mcp',
}],
tools=[{'type': 'mcp_toolset', 'mcp_server_name': 'orders'}],
messages=[...],
)
# mcp_server_name must match a name in mcp_servers, and every
# declared server needs exactly one toolset entry.
Step 6: authentication, and where the secret goes
The rule is the same one that governs the whole design: the credential belongs to the server process, not to the model's context. Your handler holds the token; the model sees a tool name and a text result.
Local stdio server — pass the secret in env, referenced as ${VAR} so the value never lands in the repository. Remote server, static token — a header via -H or the headers field. Fine for machine-to-machine, provided the token is scoped narrowly. Remote server, per-user identity — OAuth, added with claude mcp add and completed interactively with /mcp. Never in the prompt. Prompts persist in session transcripts and replay into later context; a key placed there is recoverable long after you have forgotten it.
Scope the credential to what the tools actually need. An MCP server is a piece of software that will be asked to do things by a model that can be influenced by whatever it just read, so the blast radius of that token is a design decision, not an implementation detail.
Step 7: the operational limits
Everything so far works on your laptop with a fast local database. These are the constraints that turn up once a real server is in front of real data, and knowing them beforehand saves an afternoon of confused debugging.
limits worth knowing before production Limit Default Lever Tool output warning 10,000 tokens fixed Tool output cap 25,000 tokens MAX_MCP_OUTPUT_TOKENS Per-call wall clock per-server timeout timeout field, MCP_TOOL_TIMEOUT Idle with no response 5 min HTTP · 30 min stdio CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT Moves to background after 2 min CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS Server startup — MCP_TIMEOUT
The output cap is the one that shapes your API design. A tool returning a 40,000-token table gets truncated, and the model then reasons about a fragment without necessarily knowing it is a fragment. Paginate, summarise, or return a resource reference — but decide, rather than letting the truncation decide for you.
Long calls background themselves: A main-conversation tool call still running after two minutes moves to a background task rather than blocking the session, and the result arrives as a notification. Good behaviour to know about — it means a slow tool degrades the experience instead of freezing it, and it does not apply to subagent calls.
Pitfalls
A server that fails to start shows as ✘ Failed to connect in claude mcp list, not as an error at add time — claude mcp add only confirms the config was written. Stdio servers must keep stdout clean. Anything you print for debugging goes into the protocol stream and corrupts it; log to stderr or a file. Tool names collide across servers. The mcp__server__tool prefix disambiguates, which is another reason the server key is worth choosing deliberately. Returning structured data as a JSON string is usually better than prose the model has to parse — but keep it small, per the output cap above.
Start with one tool, wired into one client, doing one thing you currently copy and paste. That is enough to shake out the transport, the config scope, and the credential path — all the parts that are annoying to debug later — while the surface is still small enough to reason about.
Versions this was run against: Every snippet here was executed end to end: mcp 1.29.0 on Python 3.12, driven over real stdio by an MCP client, and wired up with Claude Code 2.1.220. The tool call, the error-as-result path, the static resource, and the templated resource were each verified against a live server.
References
Takeaways
Write the server once and every MCP client can use it — that is the whole reason to choose standalone over in-process. Type hints become the schema and the docstring becomes the trigger; write when to call the tool, not what it does. Scope decides who gets it: local is the default, project commits .mcp.json to the repo, user follows you everywhere. Design around the 25,000-token output cap before it truncates a result the model then reasons about as if complete.
All notes · Shehzad Aslam