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

**Sub-marker:** CCD-F.3
**Domains:** CCDVF-D8 · Tools & MCPs, CCDVF-D2 · Applications & Integration, CCDVF-D7 · Security & Safety
**Exam weight:** CCD-F D8 - Tools & MCPs (10.6%)
**Build time:** 22 minutes
**Source:** 🟡 Developer-cert production pattern
**Canonical:** https://claudearchitectcertification.com/scenarios/custom-mcp-tool-server
**Last reviewed:** 2026-07-12

## In plain English

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.

## Exam impact

Tools & MCPs (D8) is a dedicated 10.6% domain distinct from generic tool calling - it specifically tests whether you understand MCP as a protocol with its own transports, handshake, and client config, not just another way to write a tool schema. D2 (Applications & Integration) covers wiring the server into a Messages API request; D7 (Security & Safety) covers input validation and least-privilege scoping.

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

## Concepts in play

- 🟢 **Model Context Protocol** (`mcp`), Server, transports, and client wiring
- 🟡 **Tool calling** (`tool-calling`), Tool schema + description design
- 🟡 **Guardrails** (`guardrails-and-safety-controls`), Server-side input validation

## Components

### 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`

## Build steps

### 1. 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.

**Python:**

```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)
```

**TypeScript:**

```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({ name: "internal-tools", version: "1.0.0" });

// Method name varies by SDK version (tool() vs registerTool()) - check your
// installed package's exports before pinning to one signature.
server.registerTool(
  "lookup_order",
  {
    title: "Lookup Order",
    description:
      "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.",
    inputSchema: { order_id: z.string() },
  },
  async ({ order_id }) => {
    const order = await db.orders.get(order_id);
    if (!order) return { content: [{ type: "text", text: `order_id ${order_id} not found` }] };
    return { content: [{ type: "text", text: JSON.stringify(order) }] };
  },
);
```

Concept: `mcp`

### 2. Register a read-only resource

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

**Python:**

```python
@mcp.resource("config://refund-policy")
def refund_policy() -> str:
    """Current refund policy document, read-only."""
    return open("policy/refund-policy.md").read()
```

**TypeScript:**

```typescript
server.registerResource(
  "refund-policy",
  "config://refund-policy",
  { title: "Refund Policy", description: "Current refund policy document, read-only." },
  async (uri) => ({
    contents: [{ uri: uri.href, text: readFileSync("policy/refund-policy.md", "utf8") }],
  }),
);
```

Concept: `mcp`

### 3. 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.

**Python:**

```python
@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)
```

**TypeScript:**

```typescript
server.registerTool(
  "process_refund",
  { title: "Process Refund", description: "Process a refund for order_id up to the policy cap.",
    inputSchema: { order_id: z.string().min(1), amount: z.number() } },
  async ({ order_id, amount }) => {
    if (amount <= 0 || amount > POLICY_CAP) {
      return { content: [{ type: "text", text: `amount must be between 0 and ${POLICY_CAP}` }], isError: true };
    }
    const result = await billing.refund(order_id, amount);
    return { content: [{ type: "text", text: JSON.stringify(result) }] };
  },
);
```

Concept: `guardrails-and-safety-controls`

### 4. 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.

**Python:**

```python
if __name__ == "__main__":
    mcp.run(transport="stdio")
```

**TypeScript:**

```typescript
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const transport = new StdioServerTransport();
await server.connect(transport);
```

Concept: `mcp`

### 5. 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.

**Python:**

```python
# .mcp.json (project root)
# {
#   "mcpServers": {
#     "internal-tools": {
#       "command": "python",
#       "args": ["server.py"]
#     }
#   }
# }
pass
```

**TypeScript:**

```typescript
// .mcp.json (project root)
// {
//   "mcpServers": {
//     "internal-tools": {
//       "command": "node",
//       "args": ["server.js"]
//     }
//   }
// }
export {};
```

Concept: `mcp`

### 6. 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.

**Python:**

```python
# 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"}],
)
```

**TypeScript:**

```typescript
// server: server.connect(new StreamableHTTPServerTransport(...))

const response = await 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: process.env.INTERNAL_TOOLS_TOKEN,
  }],
  tools: [{ type: "mcp_toolset", mcp_server_name: "internal-tools" }],
  messages: [{ role: "user", content: "Look up order 42" }],
});
```

Concept: `mcp`

## Decision matrix

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

## Failure modes

| Anti-pattern | Failure | Fix |
|---|---|---|
| CCDF-08 · Schema trusted as enforcement | 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. |
| CCDF-09 · stdio server exposed for shared use | Team tries to point multiple developers' Claude Code instances at one running stdio process. | Switch to Streamable HTTP for anything beyond a single local subprocess. |
| CCDF-10 · Everything modeled as a tool | 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. |

## Implementation checklist

- [ ] 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

## Cost &amp; 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.

## Domain weights

- **CCDVF-D8 · Tools & MCPs (10.6%):** MCP Server + Transport + Client Wiring
- **CCDVF-D2 · Applications & Integration (33.1%):** MCP connector wiring into a Messages API request
- **CCDVF-D7 · Security & Safety (8.1%):** Input Validator

## Practice questions

### Q1. 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).

### Q2. 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.

### Q3. 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.

## FAQ

### Q1. 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.

### Q2. 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.

### Q3. 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.

### Q4. 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.

## Production readiness

- [ ] 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

---

**Source:** https://claudearchitectcertification.com/scenarios/custom-mcp-tool-server
**Last reviewed:** 2026-07-12

**Evidence tiers**, 🟢 official Anthropic doc · 🟡 partial doc / inferred · 🟠 community-derived · 🔴 disputed.
