The problem
What the customer needs
- Expose internal tools once and reuse them across Claude Code, Claude Desktop, and a custom agent, without rewriting the schema three times.
- Let a client discover available tools and resources dynamically, without a hardcoded list baked into the app.
- Keep tool execution scoped and validated, since the server is invoked from model-generated input.
Why naive approaches fail
- Duplicating the same tool schema as a Messages API custom tool in three different apps means schema drift: one app's version diverges from another's.
- Trusting
tool_use.inputat face value inside the handler means the JSON schema is a hint, not an enforcement mechanism - a malformed or adversarial input reaches your handler unvalidated. - Running a stdio server exactly as written for a shared/remote use case doesn't work: stdio is a local process pipe, not a network protocol.
- Server responds correctly to
tools/list,tools/call,resources/list,resources/read - Every tool handler validates input server-side before acting on it
- Local dev works over stdio via Claude Code's
.mcp.json - Remote/shared deployment works over Streamable HTTP with authenticated access
The system
What each part does
4 components, each owns a concept. Click any card to drill into the underlying primitive.
MCP Server
tools/list - tools/call - resources/list
The process that implements the MCP protocol: it registers tools and resources, handles the initialize handshake, and responds to tools/call and resources/read requests from a connected client.
Configuration
FastMCP('server-name') in Python, or new McpServer({name, version}) in TypeScript, with @mcp.tool() / server.registerTool() per capability.
Transport
stdio (local) - Streamable HTTP (remote)
stdio pipes JSON-RPC over the process's stdin/stdout - the default for a server launched by Claude Code or Claude Desktop as a subprocess. Streamable HTTP exposes the same server as a network endpoint for shared or remote use.
Configuration
mcp.run(transport='stdio') for local dev; mcp.run(transport='streamable-http') behind an authenticated reverse proxy for shared use.
Client Wiring
.mcp.json - MCP connector
For local stdio servers: a .mcp.json entry with command + args. For remote HTTP servers consumed from the Messages API: the beta MCP connector's mcp_servers + mcp_toolset fields.
Configuration
.mcp.json: {"mcpServers": {"my-tools": {"command": "python", "args": ["server.py"]}}}. Or Messages API: mcp_servers=[{"type":"url","url":...,"name":"my-tools"}].
Input Validator
server-side re-check
Every tool handler re-validates its input before acting, treating the JSON schema as a hint to the model rather than a guarantee about what actually arrives.
Configuration
if not isinstance(order_id, str) or not order_id: raise ValueError('invalid order_id') - before any lookup or side-effecting call.
Data flow
6 steps to production
Scaffold the server and register a tool
Define the tool with a clear docstring/description (the client-side model reads this to decide when to call it) and a typed signature the SDK turns into a JSON schema.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("internal-tools")
@mcp.tool()
def lookup_order(order_id: str) -> str:
"""Look up an order by order_id. Use this when asked about order
status or contents. Returns a not_found message if the id is unknown."""
order = db.orders.get(order_id)
if order is None:
return f"order_id {order_id} not found"
return json.dumps(order)Register a read-only resource
Resources expose content (not actions) the client can read directly, discoverable via resources/list and fetched via resources/read.
@mcp.resource("config://refund-policy")
def refund_policy() -> str:
"""Current refund policy document, read-only."""
return open("policy/refund-policy.md").read()Validate input server-side before acting
The client-supplied JSON schema shapes what the model tries to send, it does not guarantee what actually arrives. Re-validate inside the handler before any side-effecting action.
@mcp.tool()
def process_refund(order_id: str, amount: float) -> str:
"""Process a refund for order_id up to the policy cap."""
if not order_id or not isinstance(order_id, str):
raise ValueError("invalid order_id")
if not (0 < amount <= POLICY_CAP):
raise ValueError(f"amount must be between 0 and {POLICY_CAP}")
return billing.refund(order_id, amount)Run over stdio for local development
stdio is the default transport for a server launched as a subprocess by Claude Code or Claude Desktop - no network config needed for local dev.
if __name__ == "__main__":
mcp.run(transport="stdio")Wire the stdio server into Claude Code
A .mcp.json entry in the project root tells Claude Code how to launch the server as a subprocess and speak MCP over its stdin/stdout.
# .mcp.json (project root)
# {
# "mcpServers": {
# "internal-tools": {
# "command": "python",
# "args": ["server.py"]
# }
# }
# }
passSwitch to Streamable HTTP and wire the Messages API MCP connector
For remote or shared use, run the same server over Streamable HTTP behind auth, then connect it to a Messages API request via the beta MCP connector.
# server: mcp.run(transport="streamable-http")
response = client.beta.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
betas=["mcp-client-2025-11-20"],
mcp_servers=[{
"type": "url",
"url": "https://internal-tools.example.com/mcp",
"name": "internal-tools",
"authorization_token": os.environ["INTERNAL_TOOLS_TOKEN"],
}],
tools=[{"type": "mcp_toolset", "mcp_server_name": "internal-tools"}],
messages=[{"role": "user", "content": "Look up order 42"}],
)The four decisions
| Decision | Right answer | Wrong answer | Why |
|---|---|---|---|
| Transport for local dev | stdio, launched as a subprocess via .mcp.json | Streamable HTTP on localhost for a single-user dev loop | stdio needs no network config and is what Claude Code/Desktop expect for locally-run servers. |
| Transport for shared/remote use | Streamable HTTP behind authentication | stdio, expecting multiple clients to share one process's stdin/stdout | stdio is a one-to-one process pipe. Shared or multi-client access needs a network transport. |
| Where input validation lives | inside every tool handler, re-checked server-side | trust the JSON schema as sufficient enforcement | The schema shapes what the model tries to send; it is not a runtime guarantee about what your handler receives. |
| Action vs read-only content | tools for actions, resources for read-only content | model every capability as a tool, including static reference content | Resources are discoverable and fetchable without an action semantic, and keep the tool list focused on things that actually do something. |
Where it breaks
3 failure pairs. Each maps to an exam-style question - the naive move on the left, the disciplined fix on the right.
Handler calls billing.refund(order_id, amount) directly on the input schema's declared fields.
Re-validate amount against the policy cap and order_id shape inside the handler before calling billing.
Team tries to point multiple developers' Claude Code instances at one running stdio process.
CCDF-09Switch to Streamable HTTP for anything beyond a single local subprocess.
A static reference document (refund policy) is exposed as a get_refund_policy tool.
Expose static, read-only content as a resource; reserve tools for actions with real behavior.
Cost & latency
Local stdio is near-instant; remote HTTP adds normal network latency plus your handler's own work.
Oversized results are written to a file in the sandbox with a truncated preview; no extra handling required in the tool itself.
Ship checklist
Two passes. Build-time gates verify the code; run-time gates verify the system in production.
Build-time
- Tools registered with clear, when-to-use descriptions↗ tool-calling
- At least one resource for read-only reference content↗ mcp
- Every handler re-validates input server-side↗ guardrails-and-safety-controls
- stdio transport wired into .mcp.json for local dev↗ mcp
- Streamable HTTP transport behind auth for remote/shared use
- MCP connector beta header (mcp-client-2025-11-20) present when wired into the Messages API
Run-time
- Every tool handler has server-side input validation, not just a JSON schema
- Resource endpoints tested via resources/list and resources/read
- .mcp.json checked into the repo for reproducible local dev
- Remote HTTP transport runs behind authentication, never open
- Tool descriptions reviewed for when-to-call clarity, same bar as Messages API custom tools
Five exam-pattern questions
You want a tool available to Claude Code locally and to a production agent hitting the Messages API. Do you write two tool definitions?
.mcp.json) and over Streamable HTTP for the production agent (wired via the Messages API MCP connector's mcp_servers + mcp_toolset).Your `process_refund` MCP tool trusts the `amount` field from `tool_use.input` and calls `billing.refund()` directly. What's missing?
amount against the policy cap before acting, exactly as it would for a custom Messages API tool.A teammate tries to share one running stdio MCP server across three developers' Claude Code sessions. It doesn't work reliably. Why?
Frequently asked
What's the actual difference between an MCP tool and a Messages API custom tool?
When should I use a resource instead of a tool?
lookup_order that queries a live database.Do I need the MCP connector beta header for every use of an MCP server?
mcp_servers/mcp_toolset (mcp-client-2025-11-20). Claude Code and Claude Desktop connect to MCP servers through their own configuration (.mcp.json / claude_desktop_config.json) and don't require you to pass that beta header yourself.How do I authenticate a remote MCP server?
authorization_token on the server entry. For Managed Agents sessions, credentials live in a vault (mcp_oauth for OAuth with auto-refresh, or static_bearer) attached via vault_ids rather than embedded on the agent config.