CCD-F.3 · D8 + D2 + D7 · ProcessCCD-F D8 · 10.6%22 min build

Custom MCP Tool Server.

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.

Loading your plan…
Mental modelArchitecture chooses the flow. Deterministic controls enforce policy. Structured state preserves truth.
Loop mascot illustrating Custom MCP Tool Server.
Share
01 · System & parts

What this system is, and its 4 parts

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.

4Components
D8Primary domain
6Build steps
7Decision traps
3Concept links
Stack · Python (mcp SDK) or TypeScript (@modelcontextprotocol/sdk)Needs · Tool schemas - JSON-RPC basics - Claude Code config
Custom MCP Tool Server component architecture.
4 components. Each owns one concept.
01

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.

ConfigurationFastMCP('server-name') in Python, or new McpServer({name, version}) in TypeScript, with @mcp.tool() / server.registerTool() per capability.
Concept: mcp →
02

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.

Configurationmcp.run(transport='stdio') for local dev; mcp.run(transport='streamable-http') behind an authenticated reverse proxy for shared use.
Concept: mcp →
03

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 →
04

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.

Configurationif 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 →
02 · Problem framing

The problem

What the user 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
03 · Data flow

One run, traced end to end

Custom MCP Tool Server sequence diagram.
Custom MCP Tool Server end-to-end flow.
04 · Build

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.

Concept: mcp →
Python
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)
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.

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.

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.

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.

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.

Concept: mcp →
05 · Right call, wrong call

7 decisions the exam turns into distractors

01 · Transport for local dev
Looks right

Streamable HTTP on localhost for a single-user dev loop

Actually right

stdio, launched as a subprocess via .mcp.json

DEC-01
02 · Transport for shared/remote use
Looks right

stdio, expecting multiple clients to share one process's stdin/stdout

Actually right

Streamable HTTP behind authentication

DEC-02
03 · Where input validation lives
Looks right

trust the JSON schema as sufficient enforcement

Actually right

inside every tool handler, re-checked server-side

DEC-03
04 · Action vs read-only content
Looks right

model every capability as a tool, including static reference content

Actually right

tools for actions, resources for read-only content

DEC-04
05 · Schema trusted as enforcement
Looks right

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

Actually right

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

CCDF-08
06 · stdio server exposed for shared use
Looks right

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

Actually right

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

CCDF-09
07 · Everything modeled as a tool
Looks right

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

Actually right

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

CCDF-10
Custom MCP Tool Server failure map.
06 · Budget

Cost & latency

~50-150ms round tripMCP tool-call overhead

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

auto-offloaded above ~100K tokensLarge MCP tool output

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

07 · Ship checklist

Check every gate before release

0/6 checked
  • tool-calling →
  • mcp →
  • guardrails-and-safety-controls →
  • mcp →
08 · Practice

3 exam-pattern questions

Work through one question at a time, check the architecture, then move through the set.

Question 1 of 3Check the architecture

You want a tool available to Claude Code locally and to a production agent hitting the Messages API. Do you write two tool definitions?

09 · 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.

Help someone build it

Share this scenario.

One share is one less team repeating the same architecture mistake.

Last reviewed ·