CCD-F.3 · CCDVF-D8 + CCDVF-D2 + CCDVF-D7 · Process

Custom MCP Tool Server.

MCP (Model Context Protocol) is a standard way to expose tools and data to an LLM client, so instead of hard-coding tool schemas into every app that needs them, you write one server once and any MCP-aware client, Claude Code, Claude Desktop, or an agent wired through the Messages API MCP connector, can discover and call it. This scenario is that server: a small process that exposes a handful of tools and one read-only resource over the MCP protocol, run locally over stdio during development and swapped to a remote Streamable HTTP endpoint when other people or agents need to share it.

22 min build·4 components·3 concepts

A standalone MCP server built with the official SDK (FastMCP in Python, McpServer in TypeScript) that exposes tools (actions) and resources (read-only content) over JSON-RPC. Local development uses the stdio transport wired into Claude Code via .mcp.json; shared/remote use switches to Streamable HTTP and wires into the Messages API's beta MCP connector (mcp_servers + mcp_toolset). Every handler validates model-supplied input server-side, the schema is a hint to the model, not a guarantee.

D8 - Tools & MCPs
SourceDeveloper-cert production pattern
What do the colours mean?
Green
Official Anthropic doc or API contract
Yellow
Partial doc / inferred
Orange
Community-derived
Red
Disputed / changes frequently
Stack
Python (mcp SDK) or TypeScript (@modelcontextprotocol/sdk)
Needs
Tool schemas - JSON-RPC basics - Claude Code config
Exam
CCD-F D8 - Tools & MCPs (10.6%). 10.6% CCDVF-D8 · 33.1% CCDVF-D2 · 8.1% CCDVF-D7.
Loop the mascot - painterly hero illustration for the Custom MCP Tool Server scenario.
End-to-end flowCCD-F D8 - Tools & MCPs (10.6%)
01 · Problem framing

The problem

What the customer needs

  1. Expose internal tools once and reuse them across Claude Code, Claude Desktop, and a custom agent, without rewriting the schema three times.
  2. Let a client discover available tools and resources dynamically, without a hardcoded list baked into the app.
  3. Keep tool execution scoped and validated, since the server is invoked from model-generated input.

Why naive approaches fail

  1. 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.
  2. Trusting tool_use.input at 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.
  3. 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.
Definition of done
  • 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
02 · Architecture

The system

03 · Component detail

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.

Concept: mcp

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.

Concept: mcp

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"}].

Concept: mcp

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.

Concept: guardrails-and-safety-controls
04 · One concrete run

Data flow

05 · Build it

6 steps to production

01

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.

Scaffold the server and register a tool
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)
↪ Concept: mcp
02

Register a read-only resource

Resources expose content (not actions) the client can read directly, discoverable via resources/list and fetched via resources/read.

Register a read-only resource
@mcp.resource("config://refund-policy")
def refund_policy() -> str:
    """Current refund policy document, read-only."""
    return open("policy/refund-policy.md").read()
↪ Concept: mcp
03

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.

Validate input server-side before acting
@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)
↪ Concept: guardrails-and-safety-controls
04

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.

Run over stdio for local development
if __name__ == "__main__":
    mcp.run(transport="stdio")
↪ Concept: mcp
05

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.

Wire the stdio server into Claude Code
# .mcp.json (project root)
# {
#   "mcpServers": {
#     "internal-tools": {
#       "command": "python",
#       "args": ["server.py"]
#     }
#   }
# }
pass
↪ Concept: mcp
06

Switch 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.

Switch to Streamable HTTP and wire the Messages API 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"}],
)
↪ Concept: mcp
06 · Configuration decisions

The four decisions

DecisionRight answerWrong answerWhy
Transport for local devstdio, launched as a subprocess via .mcp.jsonStreamable HTTP on localhost for a single-user dev loopstdio needs no network config and is what Claude Code/Desktop expect for locally-run servers.
Transport for shared/remote useStreamable HTTP behind authenticationstdio, expecting multiple clients to share one process's stdin/stdoutstdio is a one-to-one process pipe. Shared or multi-client access needs a network transport.
Where input validation livesinside every tool handler, re-checked server-sidetrust the JSON schema as sufficient enforcementThe schema shapes what the model tries to send; it is not a runtime guarantee about what your handler receives.
Action vs read-only contenttools for actions, resources for read-only contentmodel every capability as a tool, including static reference contentResources are discoverable and fetchable without an action semantic, and keep the tool list focused on things that actually do something.
07 · Failure modes

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.

Schema trusted as enforcement

Handler calls billing.refund(order_id, amount) directly on the input schema's declared fields.

CCDF-08
✅ Fix

Re-validate amount against the policy cap and order_id shape inside the handler before calling billing.

stdio server exposed for shared use

Team tries to point multiple developers' Claude Code instances at one running stdio process.

CCDF-09
✅ Fix

Switch to Streamable HTTP for anything beyond a single local subprocess.

Everything modeled as a tool

A static reference document (refund policy) is exposed as a get_refund_policy tool.

CCDF-10
✅ Fix

Expose static, read-only content as a resource; reserve tools for actions with real behavior.

08 · Budget

Cost & latency

MCP tool-call overhead
~50-150ms round trip

Local stdio is near-instant; remote HTTP adds normal network latency plus your handler's own work.

Large MCP tool output
auto-offloaded above ~100K tokens

Oversized results are written to a file in the sandbox with a truncated preview; no extra handling required in the tool itself.

09 · Ship gates

Ship checklist

Two passes. Build-time gates verify the code; run-time gates verify the system in production.

Build-time

  1. Tools registered with clear, when-to-use descriptionstool-calling
  2. At least one resource for read-only reference contentmcp
  3. Every handler re-validates input server-sideguardrails-and-safety-controls
  4. stdio transport wired into .mcp.json for local devmcp
  5. Streamable HTTP transport behind auth for remote/shared use
  6. 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
10 · Question patterns

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?
No. Write one MCP server exposing the tool once. Run it over stdio for local Claude Code use (wired via .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?
Server-side validation. The JSON schema on the tool definition is a hint to the model, not an enforcement mechanism - the handler must re-check 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?
stdio is a one-to-one process pipe between the launching client and the subprocess. Multiple clients need Streamable HTTP, a network transport that supports concurrent connections.
11 · FAQ

Frequently asked

What's the actual difference between an MCP tool and a Messages API custom tool?
A Messages API custom tool is defined per-request in your app code and executed by your app. An MCP tool is defined once in a standalone server and discovered/called over the MCP protocol by any connected client, Claude Code, Claude Desktop, or an agent using the Messages API MCP connector, without redefining the schema in each consumer.
When should I use a resource instead of a tool?
Use a resource for static or slowly-changing read-only content the client can fetch directly (a policy doc, a config file, a schema reference). Use a tool for anything that performs an action or does dynamic work, even a read like lookup_order that queries a live database.
Do I need the MCP connector beta header for every use of an MCP server?
Only when wiring a remote MCP server into a direct Messages API request via 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?
For a Messages API MCP connector, pass an 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.
Last reviewed: 2026-07-12·Refresh cadence: 90 days
CCD-F.3 · CCDVF-D8 · Tools & MCPs

Custom MCP Tool Server, complete.

You've covered the full ten-section breakdown for this primitive, definition, mechanics, code, false positives, comparison, decision tree, exam patterns, and FAQ. One technical primitive down on the path to CCA-F.

More platforms →