Project overview · local teaching tool
An MCP server you can watch — every request in, every response out, over either transport, with the protocol visible on the wire.
The premise
A Model Context Protocol server with a live inspector beside it. The inspector shows every MCP message in both directions — the actual envelope, not a summary — along with which transport carried it and how long the handler took.
It is the companion to the Anthropic Chat Lab, and the two are built to run at the same time. The chat lab shows you the Anthropic API boundary. This shows you the MCP boundary — a second, independent protocol with its own handshake, its own envelope and its own idea of what a tool is.
Run both and the full picture is on screen at once: Claude asks the chat lab for a tool, the chat lab asks this server, this server answers, and the answer travels back. Two logs, two protocols, one request.
This server never calls Anthropic and holds no API key. An MCP server does not talk to a model — it exposes capabilities and waits to be called. The model sits on the far side of the client. "Where does the key live?" is the question that tells you which component talks to the model, and in an MCP setup the answer is never the MCP server.
Node · Express · vanilla TypeScript · no frontend framework
| Package | Version | Role |
|---|---|---|
| @modelcontextprotocol/server | 2.4.0 | McpServer, registerTool, and the stdio and HTTP serving entry points |
| @modelcontextprotocol/client | 2.4.0 | The client half, used by the two try- scripts to call this server over both transports |
| @modelcontextprotocol/node | 2.1.2 | toNodeHandler — adapts the web-standard MCP handler onto Express |
| express | 5.3.0 | Hosts the MCP endpoint, the inspector and its event stream |
| zod | 4.6.7 | Tool input schemas. MCP takes Zod objects directly |
| dotenv | 18.0.10 | Loads an optional .env at startup — port only, no secrets |
| Package | Version | Role |
|---|---|---|
| typescript | 7.0.2 | Two configs — one per half of the app |
| tsx | 4.23.15 | Runs TypeScript directly, restarts on save. The normal way to use the app |
| @types/node | 26.6.6 | Server-side type definitions |
| @types/express | 5.0.6 | Route and middleware types |
| Node.js | 20 or newer required; developed on 24.4.1 |
| Module system | "type": "module" — ESM throughout, which means .js extensions on imports of .ts files |
| Frontend | Hand-written TypeScript compiled to ES modules. No React, no bundler, no CSS framework |
| Models | None. This process never calls one |
MCP's TypeScript SDK moved from a single @modelcontextprotocol/sdk package to separate /server and /client packages at 2.x. Most tutorials still show the 1.x shape, and several of its APIs no longer exist. The notes in this project's files record the differences where they bite; the short version is that HTTP serving is now createMcpHandler() returning a web-standard handler, not a transport object you wire up by hand.
The page is the teaching surface. A framework would put its own abstraction between the reader and the JSON on the wire, which is the one thing the app exists to show. That reasoning carries over from the chat lab unchanged — and matters more here, because the MCP envelope is the subject rather than the plumbing.
Built and working, except where noted
| Primitive | What it is | Why it is in the lab |
|---|---|---|
| Tools | Functions a model can call | The familiar one — and the one that looks most like what the chat lab already does |
| Resources | Readable data, addressed by URI | Has no Anthropic-API equivalent. Closer to a file read than a tool call |
| Prompts | Parameterised templates the server offers | The server suggesting how it should be used, rather than waiting to be told |
| Tool | What it teaches |
|---|---|
| echo | The round trip, with nothing else in the way |
| roll_dice | Non-determinism — proof the server really ran, rather than the model guessing |
| inspect_request | Returns the MCP envelope it was called with. The protocol looking at itself |
| slow_task | Progress notifications: a server speaking before it has an answer |
| Address | What it shows |
|---|---|
| lab://server-info | Generated at read time, not stored — read it twice and the uptime has moved |
| lab://notes/protocol | Backed by a file. The client names an address, never a path, so there is no traversal to defend against |
| lab://dice/{sides} | A template. The parameter arrives as an unvalidated string, which is where the handler earns its keep |
The two prompts cover the primitive's whole range: try_the_tools returns a single message, and explain_exchange returns three — user, assistant, user — where the middle turn is a worked example the server wrote. That fabricated turn is why prompts return messages rather than text, and it is the same mechanism as prompt injection, differing only in who is doing it.
A two-pane page at localhost:3100: the traffic log on the left, what the server exposes on the right. Every exchange appears as it happens — the request arriving, each handler running and returning with its timing, the response going back.
Rows carry a transport tag, which the chat lab's activity log has no equivalent of. With a stdio client and an HTTP client both connected, filtering by transport leaves one conversation alone on screen. The filters run in the browser and never reach the server, so unticking a level hides rows rather than stopping them being recorded — tick it again and the history is still there.
The surface panel shows the same JSON Schema a client receives from tools/list, produced by the same conversion, so it cannot flatter the server by describing something other than what is advertised.
The same server is served two ways. stdio is how Claude Desktop launches a local server — a child process talking over its own standard input and output. Streamable HTTP is how a remote server works, and is what the chat lab will connect to.
Both work now. npm run try-stdio and npm run try-http run the same function over each — one shared session.ts, called with two different clients, so any difference in output is a difference in the transport rather than in the script. Run both and diff them: after normalising the dice rolls and the uptime, the output is identical line for line.
Sampling and elicitation — where the server calls back into the client — are not planned. They need client-side support that would complicate the chat lab integration substantially. Their absence is a decision, not an oversight.
The configuration the two projects are built for
Each lab is useful alone. Together they show the whole path a tool call travels, with both boundaries on screen at once.
| Where | Command | Opens |
|---|---|---|
| anthropic-mcp-lab | npm run dev | localhost:3100 — the inspector |
| anthropic-chat-lab | npm run dev | localhost:3000 — the chat window |
In the chat lab, open Settings → Tools, scroll to Connect an MCP server, and connect to http://localhost:3100/mcp. Its four tools appear in the Tools tab badged mcp, ticked and sent exactly like the local ones.
Ask the chat lab to roll some dice, with both activity logs visible. The same exchange appears twice, from two sides:
| In the chat lab | In this inspector |
|---|---|
| A request to Anthropic carrying the tool definitions | nothing — this server is not involved yet |
stop_reason: "tool_use" asking for roll_dice | nothing — the model has only asked |
A tool row tagged mcp | POST /mcp arriving, the handler running, the response leaving |
A second request carrying the tool_result | nothing — this server's part is over |
Claude is never told where a tool runs. It receives one tools array and answers with one tool_use block; the chat lab's own tools, Anthropic's web_search, and these MCP tools are indistinguishable in the request. Which machine executed the function is the chat lab's problem alone — and watching both logs is what makes that concrete rather than a claim.
The chat lab's tool loop was not changed to accommodate MCP. A remote tool is adapted into the same shape a local one has and goes in the same list. Three things made that possible, and they are the interesting part of the diff: the tool schema type was widened to name only the two operations the app actually performs on a schema; the JSON Schema from the remote server is carried verbatim rather than converted to Zod; and a remote tool whose name is already taken is dropped rather than allowed to shadow a local one.
There is deliberately no JSON-Schema-to-Zod converter. JSON Schema is the larger language, and a converter that handles most of it fails silently — as a tool that mysteriously rejects valid arguments.
5 phases · phase 1 complete
Built in phases from a written plan, the same way the chat lab was. Each phase ends somewhere runnable: phase 2 finishes with a server the MCP Inspector can list tools from, before any of the UI exists.
The two TypeScript configs, the three CLI scripts, the wire contract in shared/types.ts, and the conventions the rest of the build is held to. npm run audit-comments went in first, before any source file, so the documentation rule is enforced from the beginning rather than retrofitted at the end.
tsc fails with TS18003 when an include pattern matches zero files, so the client config broke the typecheck while public/ was still empty — on a project where nothing was actually wrong. This page claimed the typecheck passed before anyone had run it. Both are fixed; the claim is now measured.McpRequestContext. A createServer(hooks) signature therefore does not fit, and the compiler says so. Had the parameter been typed loosely enough to accept it, every server would have been built with a request context in place of its hooks and the inspector would have been silently inert.callTool takes (params, options) — not the (params, schema, options) shape of the lower-level request(). And resource contents are a text-or-blob union, so contents[0].text does not compile.@modelcontextprotocol/sdk 1.x is superseded by the /server and /client 2.x packages. Code written against the old single package does not port across by changing the import.moduleResolution: NodeNext is load-bearing here, not stylistic — it is what resolves the packages' subpath exports to their type definitions.What the code enforces on itself
Each file opens with a comment saying what it does and why it exists; npm run audit-comments checks none were missed, and fails the check if a header is missing, does not name its file, or is short enough to be a label rather than an explanation.
This project's copy of that script fixes two things about the chat lab's. It audits shared/, which the original never looked at — leaving the wire contract as the one file exempt from the rule. And it drops a stray fragment from a regex that quietly ate two characters from any comment line beginning * ex…. Both fixes are documented in the script's own header, because an unexplained difference between two near-identical files is worse than either version.
Tools follow three levels: types.ts imports nothing, definitions/ imports types, registry.ts imports the definitions. The chat lab learned this the hard way — an earlier version put the interface in the registry, creating an import cycle that worked with one tool and threw Cannot access 'currentTime' before initialization with two. Same layout here, for the same reason.
tsconfig sets "types": [] so server globals cannot be referenced from frontend code — and so the inspector cannot import from server/ at all.textContent, never innerHTML. This matters more here than in the chat lab, where the only untrusted text was model output.Verified 13 November 2026
All five phases are complete and every figure above is measured. The server runs over both transports, the inspector shows each exchange live, and the chat lab borrows these tools over MCP. What it is not is production software — it has no authentication, binds to localhost, and executes tools for anyone who can reach it. It is built to be read and watched, not deployed.