Claude · 23 · Part 11 · Hands-On Platform

MCP Lab: Give Claude a Tool of Your Own

The Model Context Protocol lets Claude Code call tools that live outside it: a database, a ticket system, or, here, a 68-line Node script that reports which bookshelf features are passing. This lab extends the server from Chapter 12 with a second tool, tests it over the wire without Claude, registers it with claude mcp add, and then watches Claude use it, and be refused it, in real sessions.

2
Read-only tools
5
Protocol messages tested
2
Real sessions
Information current as of: 2026-10-31. Real sessions used Claude Code 2.1.316 with the haiku model on a copy of the bookshelf project; the server needs Node.js (v24 here). Chapter 12 wrote the first version of this server.
01
The Idea

A Plug Between Claude and Everything Else

Anthropic describes the Model Context Protocol as an open standard for AI-tool integrations, and suggests connecting a server when you catch yourself copying data into the chat from another tool (Source 1). Claude Code is the client. A server advertises a list of tools, each with a name, a description and an input schema, and Claude calls them with JSON.

One tool call, end to end
Claude CodeMCP clientasks, then calls feature-servernode processstarted by Claude your datafeature_list.jsonread-only requestanswerread stdio: one JSON-RPC message per line, in and out initialize, then tools/list, then tools/call

Where the server runs, and how Claude reaches it, is the transport:

TransportStatus in the docsUse
httpRecommendedRemote servers
stdioSupportedA local process Claude starts, as in this lab
sseDeprecatedRemote servers that only offer SSE
wsSupportedConfigured only through .mcp.json or add-json
02
Build Along

The Server, and Testing It Without Claude

The whole server is one file with no SDK: it reads a line from stdin, parses JSON-RPC, and answers on stdout. Version 0.2.0 adds list_features, which takes an optional status and, on bad input, returns a tool-level error instead of crashing.

mcp/feature-server.mjs (0.2.0)
#!/usr/bin/env node // Minimal MCP server over stdio (newline-delimited JSON-RPC 2.0). No SDK. // Exposes two read-only tools backed by feature_list.json: feature_status and list_features. import { readFileSync } from 'node:fs'; import { createInterface } from 'node:readline'; const PROTOCOL = '2025-11-25'; const FILE = process.env.FEATURE_FILE ?? 'feature_list.json'; const tools = [ { name: 'feature_status', description: 'Report how many bookshelf features pass and which one is next.', inputSchema: { type: 'object', properties: {}, additionalProperties: false }, }, { name: 'list_features', description: 'List bookshelf features, optionally only those that are passing or failing.', inputSchema: { type: 'object', properties: { status: { type: 'string', enum: ['passing', 'failing'] } }, additionalProperties: false, }, }, ]; const features = () => JSON.parse(readFileSync(FILE, 'utf8')).features; function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); } function ok(id, result) { send({ jsonrpc: '2.0', id, result }); } function fail(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); } const rl = createInterface({ input: process.stdin }); rl.on('line', (line) => { if (!line.trim()) return; let msg; try { msg = JSON.parse(line); } catch { return fail(null, -32700, 'Parse error'); } if (msg.id === undefined) return; // notifications need no reply switch (msg.method) { case 'initialize': return ok(msg.id, { protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: 'bookshelf-features', version: '0.2.0' }, }); case 'tools/list': return ok(msg.id, { tools }); case 'tools/call': { const name = msg.params?.name; if (name === 'feature_status') { const list = features(); const passing = list.filter((f) => f.status === 'passing').length; const next = list.find((f) => f.status !== 'passing'); return ok(msg.id, { content: [{ type: 'text', text: `${passing}/${list.length} passing; next: ${next ? next.name : 'none'}` }], isError: false }); } if (name === 'list_features') { const want = msg.params?.arguments?.status; if (want !== undefined && want !== 'passing' && want !== 'failing') { return ok(msg.id, { content: [{ type: 'text', text: `status must be "passing" or "failing", got ${JSON.stringify(want)}` }], isError: true }); } const rows = features().filter((f) => !want || f.status === want).map((f) => `#${f.id} ${f.name} (${f.status})`); return ok(msg.id, { content: [{ type: 'text', text: rows.join('\n') || 'none' }], isError: false }); } return fail(msg.id, -32602, 'Unknown tool: ' + name); } default: return fail(msg.id, -32601, 'Method not found: ' + msg.method); } });

Three design decisions worth copying:

  • Read-only. Both tools only read a file. A first server should not be able to change anything.
  • A schema that says what is valid. status is an enum of passing or failing, and the server checks it again anyway, because a client's schema is advice.
  • Two kinds of error. A request the protocol cannot understand (an unknown tool) is a JSON-RPC error. A request that is valid but wrong (a bad status) is a normal result with isError: true, so Claude can read the message and correct itself.

Test it before Claude ever sees it. This script pipes five messages into the server, the same ones a client sends: initialise, list tools, a good call, a call with a bad argument and a call to a tool that does not exist. No model is involved.

Actual output of mcp/test_protocol.sh · executed 2026-10-31 · no Claude calls
{"id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"bookshelf-features","version":"0.2.0"}}} {"id":2,"result":{"tools":["feature_status","list_features"]}} {"id":3,"result":{"content":[{"type":"text","text":"#6 pagination for list (failing)\n#7 delete book (failing)"}],"isError":false}} {"id":4,"result":{"content":[{"type":"text","text":"status must be \"passing\" or \"failing\", got \"bogus\""}],"isError":true}} {"id":5,"error":{"code":-32602,"message":"Unknown tool: drop_everything"}}

Row 1 is the handshake, row 2 lists both tools, row 3 returns the two failing features, row 4 is the tool-level error (isError: true) and row 5 is the protocol-level error (code -32602). Debugging a server this way is faster and free; only move to Claude when this passes.

03
Registering

claude mcp add, Scopes and .mcp.json

Register a local server with claude mcp add. Everything before the -- belongs to Claude's own options; everything after is the server command, passed through unchanged (Source 1):

claude mcp add --transport stdio --scope project bookshelf-features -- node mcp/feature-server.mjs
Actual output · executed 2026-10-31
Added stdio MCP server bookshelf-features with command: node mcp/feature-server.mjs to project config File modified: <project>/.mcp.json

The --scope flag decides where the entry is stored and who gets it:

ScopeLoads inShared?Stored in
local (default)This project, for youNo~/.claude.json
projectThis projectYes, through git.mcp.json at the project root
userAll your projectsNo~/.claude.json

Project scope wrote this file, which you can commit. ${VAR} and ${VAR:-default} expansion works in command, args, env, url and headers:

.mcp.json (written by claude mcp add)
{ "mcpServers": { "bookshelf-features": { "type": "stdio", "command": "node", "args": [ "mcp/feature-server.mjs" ], "env": {} } } }

Check it:

Actual output of claude mcp get bookshelf-features · executed 2026-10-31
bookshelf-features: Scope: Project config (shared via .mcp.json) Status: ⏸ Pending approval (run `claude` to approve) Type: stdio Command: node Args: mcp/feature-server.mjs Environment: To remove this server, run: claude mcp remove bookshelf-features -s project

The status is Pending approval, and that is a security feature: a project-scoped server comes from a file in the repository, so in an interactive session Claude Code asks before it runs. claude mcp reset-project-choices clears earlier answers. The docs add a sharp edge: in non-interactive runs (claude -p and the Agent SDK) Claude Code cannot show the prompt and loads project servers without asking. I did not test that behaviour; the runs below pass the config explicitly with --strict-mcp-config, which does not exercise the approval flow.

Actual output of claude mcp list, filtered to this lab's server · executed 2026-10-31
Checking MCP server health… bookshelf-features: node mcp/feature-server.mjs - ⏸ Pending approval (run `claude` to approve) (output filtered: the machine's other connectors are not shown)
claude mcp list prints everything. On my machine it listed dozens of work and personal connectors, with their URLs. I filtered the output to the one line that belongs to this lab before saving it. Do the same before you paste that command's output into an issue, a chat or a tutorial.
04
Real Sessions

Claude Uses the Tool, and Is Refused It

Tool names follow mcp__<server>__<tool>, and the same name is what permission rules, skills' allowed-tools, subagent tools and hook matchers use (Source 1). The first session allows exactly one of the two tools on the command line:

Real run MCP-01: prompt, every step, and the answer (Checked)
Prompt as sent
Which bookshelf features are failing? Get the answer from the MCP server's tool, not from reading files.
Who did what, in order · executed 2026-10-31
#WhoEventContent (shortened)
1Main sessionToolSearch call{"query": "select:mcp__bookshelf-features__feature_status,mcp__bookshelf-features__list_features", "max_results": 2}
2Main sessionTool result[{"type":"tool_reference","tool_name":"mcp__bookshelf-features__feature_status"},{"type":"tool_reference","tool_name":"mcp__bookshelf-features__list_features"}]
3Main sessionmcp__bookshelf-features__list_features call{"status": "failing"}
4Main sessionTool result[{"type":"text","text":"#6 pagination for list (failing)\n#7 delete book (failing)"}]
5Main sessionmcp__bookshelf-features__feature_status call{}
6Main sessionTool resultPermission to use mcp__bookshelf-features__feature_status has been denied because Claude Code is running in don't ask mode. IMPORTANT: You *may* attempt to accomplish this action using other tools tha
7Main sessionTextTwo bookshelf features are failing, according to the MCP server's `list_features` tool: - **#6: pagination for list** - **#7: delete book**…
Final answer shown to you · 4 turns, 4.3 s, estimated $0.003, models claude-haiku-5-5
Two bookshelf features are failing, according to the MCP server's `list_features` tool: - **#6: pagination for list** - **#7: delete book** I also tried `feature_status` to get the pass count and the next feature, but it was denied in this session's permission mode. I didn't try to get around that. If you approve that tool, I can run it and report the summary.
Author's check: Checked. The result matches feature_list.json: #6 and #7 are the two failing entries. Claude's statement that feature_status was denied matches the log. The explicit --mcp-config meant this run did not test the project-approval flow.
  • Tool search came first. MCP tools are not all loaded up front; Claude searched for them by exact name with ToolSearch, which ran although it was not on my allow list. Tool search is on by default in the documentation.
  • The answer is right. list_features with status: failing returned #6 and #7, which are the two failing entries in feature_list.json. I checked the file.
  • Permissions are per tool. Allowing list_features did not allow its sibling feature_status: Claude tried it, was refused, said so, and did not try to get around it.

The second session allows no MCP tool at all:

Real run MCP-02: prompt, every step, and the answer (Checked)
Prompt as sent
Which bookshelf features are failing? Get the answer from the MCP server's tool, not from reading files.
Who did what, in order · executed 2026-10-31
#WhoEventContent (shortened)
1Main sessionToolSearch call{"query": "select:mcp__bookshelf-features__feature_status,mcp__bookshelf-features__list_features", "max_results": 2}
2Main sessionTool result[{"type":"tool_reference","tool_name":"mcp__bookshelf-features__feature_status"},{"type":"tool_reference","tool_name":"mcp__bookshelf-features__list_features"}]
3Main sessionmcp__bookshelf-features__list_features call{"status": "failing"}
4Main sessionTool resultPermission to use mcp__bookshelf-features__list_features has been denied because Claude Code is running in don't ask mode. IMPORTANT: You *may* attempt to accomplish this action using other tools that
5Main sessionmcp__bookshelf-features__feature_status call{}
6Main sessionTool resultPermission to use mcp__bookshelf-features__feature_status has been denied because Claude Code is running in don't ask mode. IMPORTANT: You *may* attempt to accomplish this action using other tools tha
7Main sessionTextI couldn't get the answer. Both bookshelf MCP tools were denied: `mcp__bookshelf-features__list_features` (filtered to failing) and `mcp__bo…
Final answer shown to you · 4 turns, 4.7 s, estimated $0.001, models claude-haiku-5-5
I couldn't get the answer. Both bookshelf MCP tools were denied: `mcp__bookshelf-features__list_features` (filtered to failing) and `mcp__bookshelf-features__feature_status`. The session is in "don't ask" mode, which blocks them. I haven't read files or run anything else to get the answer, since you asked for it from the MCP server. Two ways forward: - Approve the `mcp__bookshelf-features__*` tools for this session, or switch out of "don't ask" mode, and I'll call `list_features` with `status: failing` and `feature_status`. - If you'd rather I use a different source, such as the repo's feature list file, tell me and I'll read that instead.
Author's check: Checked. The log shows both MCP calls denied and no file read afterwards; Claude's account matches. Wildcard permission rules were not tried.

Both calls were denied by the session's dontAsk mode, and Claude reported that instead of falling back to reading the file, because I had told it to use the tool. The documentation excerpt I read did not include the syntax for wildcard permission rules over a whole server, so I used exact tool names; check the permissions page before relying on a pattern.

05
Design and Safety

Writing Servers You Can Trust

ConcernWhat the docs sayWhat to do
Output sizeOutput above 10,000 tokens triggers a warning; the default limit is 25,000 (MAX_MCP_OUTPUT_TOKENS raises it)Return summaries and filters, not whole tables
Slow toolsMCP_TIMEOUT for start-up, MCP_TOOL_TIMEOUT per call; calls over two minutes move to a background taskKeep tools fast or paginate
Untrusted serversVerify you trust a server; ones that fetch outside content can expose you to prompt injectionPrefer your own or vetted servers; read what they do
Repository-supplied serversProject servers need approval interactively; a cloned repo's blanket approval is ignored in an untrusted folderRead .mcp.json in any repo you did not write
CredentialsSome credential variables read as empty in a remote server's URL and headers, so a project file cannot leak themPass secrets under names you choose
DatabasesThe page's own PostgreSQL example uses a read-only database userGive a server the least privilege it needs

Two connections to earlier chapters. A hook can guard MCP tools by matching their names, for example mcp__bookshelf-features__.*. And a plugin can bundle this server so that installing the plugin starts it for you; the tool names then gain a plugin prefix.

Not covered here. I did not run HTTP servers or OAuth sign-in (a claude -p session cannot complete an OAuth flow, per the docs), MCP resources and prompts, running Claude Code itself as a server with claude mcp serve, or enterprise allowlists. The last part of the MCP page, about 54,700 of its 154,700 characters, was not in the text I read.
06
Summary

Key Takeaways, Exercises and Sources

∑ MCP: what to remember
  • A server is a process that answers JSON-RPC. You can test every message by hand before Claude is involved.
  • Start read-only and validate arguments in the server, not only in the schema.
  • Use both error levels: protocol errors for the impossible, isError: true for the correctable.
  • Scope decides sharing: project scope writes .mcp.json and needs approval; user and local scopes stay on your machine.
  • Permissions are per tool, and the name is mcp__server__tool.
Exercises

Exercise 1 Beginner

Run mcp/test_protocol.sh against your own copy of feature_list.json. Change a status, run it again and watch the answer change. Then send the server a line of invalid JSON and read what it returns.

Learning outcome: speak the protocol by hand.

Exercise 2 Intermediate

Add a third tool, feature_detail, that takes a feature id and returns that feature. Validate the argument, return isError: true for an unknown id, extend the test script, and only then register and use it.

Learning outcome: extend a server test-first.

Exercise 3 Advanced

Write a PreToolUse hook with a matcher for your server's tools that logs every call's arguments to a file. Run a session, then compare the log with what the transcript shows Claude sent.

Learning outcome: observe and audit tool calls.

Sources (read on 2026-10-31; paraphrased)
  1. Anthropic, Connect Claude Code to tools via MCP — transports, claude mcp commands, scopes and .mcp.json, environment expansion, project approval, tool naming, /mcp, output limits and timeouts, security notes. The first roughly 100,000 of 154,700 characters were read; resources, prompts, claude mcp serve and managed-MCP details were not.

Executed 2026-10-31: the protocol test (five messages, no Claude calls), claude mcp add, get and list, and two short sessions, MCP-01 and MCP-02. The list output is filtered to this lab's server. Not run: HTTP and OAuth servers, resources, prompts, claude mcp serve, wildcard permission rules. The server and scripts are in the examples repository under mcp/.