The code behind the notes. One small MCP server that exposes all three primitives — Tools (do), Resources (know), Prompts (reuse) — plus the Host/Client that discovers and invokes them. Every file maps to one chapter of
../../notes.
Runs offline, with no API key. A real host would let an LLM choose the tool; here the
client stands in for the model deterministically, so the whole lifecycle —
tools/list → tools/call → result → resources/read → prompts/get — is reproducible on
any machine.
cd "Lecture 14/code/Basic Code"
npm install
npm run client # host + client: the full lifecycle, printed step by step
npm run server # the server alone, listening on stdio (waits for a client)
npm run typecheck # tsc --noEmitnpm run client prints:
[task-mcp-server] v1.0.0 listening on stdio
1. tools/list -> create_task, list_tasks, complete_task
2. tools/call create_task -> Task #1 created: Record MCP Video (PENDING)
3. tools/call complete_task (unknown title) -> isError=true No pending task titled "No such task". Call list_tasks to read the exact titles, then retry.
tools/call complete_task (exact title) -> Task completed: Record MCP Video (COMPLETED)
4. resources/list -> company://policies/return-policy, company://policies/refund-policy
resources/read company://policies/return-policy -> 1 content block(s)
5. prompts/list -> review_pull_request
prompts/get review_pull_request -> {"type":"text","text":"You are reviewing pull request #42. ..."}
Basic Code/
├── .env # server name / version / transport (gitignored; see .env.example)
├── .env.example
├── package.json
├── package-lock.json
├── tsconfig.json
├── src/
│ ├── index.ts # Chapter 04 — STDIO transport binding (the process entry)
│ ├── config/config.ts # Chapter 02/04/06 — server identity, transport, URI scheme
│ ├── server/server.ts # Chapter 02 — createServer(): the "standard door"
│ ├── services/taskService.ts # Chapter 05/07 — the execution layer (business logic)
│ ├── tools/tools.ts # Chapter 05 — Tools: name + description + inputSchema
│ ├── resources/resources.ts # Chapter 06 — Resources: addressable context by URI
│ ├── prompts/prompts.ts # Chapter 06 — Prompts: reusable, user-selected instructions
│ └── client/client.ts # Chapter 04/07 — Host/Client: discovery → invocation
| Chapter | Where it lives |
|---|---|
| 01 — The integration problem | Why this server knows nothing about its host: server/server.ts |
| 02 — What MCP is | config/config.ts (identity), server/server.ts (capabilities only — no LLM) |
| 03 — MCP vs tool calling | Step 2 of client/client.ts: selection is a model decision, invocation is MCP |
| 04 — Host, Client, Server | index.ts (transport), client/client.ts (host owns the 1:1 client) |
| 05 — The Tools Primitive | tools/tools.ts — description-as-prompt, inputSchema, outputSchema, annotations, isError |
| 06 — Resources and Prompts | resources/resources.ts (resources/list, resources/read), prompts/prompts.ts (prompts/list, prompts/get) |
| 07 — The tool lifecycle | client/client.ts — the numbered steps; taskService.ts is the deterministic middle |
| 08 — Security and production | isError messages written as recovery instructions; resources are read-only |
1 · The tool contract and the implementation are separate files (src/tools/tools.ts vs src/services/taskService.ts)
The model sees name, description and inputSchema — never taskService. That is
Chapter 05's whole point: "a tool is a contract written for a reader who cannot read your
code." Keeping them in different files makes the boundary impossible to blur by accident.
2 · The failure path returns a result, not a protocol error (complete_task)
{"isError": true, "content": [...]} means the call ran and the operation failed, so the
text goes back into the model's context — which makes it an instruction channel. The message
therefore names the fix (Call list_tasks to read the exact titles, then retry.) instead of
just reporting Error.
3 · The URI is the resource's identity (company://policies/return-policy)
https:// would be a lie here: the client cannot fetch it — only this server can. A custom
scheme keeps the address honest and stops a host from trying to dereference something it
should not.
# start it, then paste newline-delimited JSON-RPC on stdin
printf '%s\n%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"1.0.0"}}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | npm run server --silentThe initialize reply declares tools, resources and prompts capabilities, and
tools/list returns the full definitions — schemas and annotations included. To browse it
with a UI instead:
npx @modelcontextprotocol/inspector npx tsx src/index.ts
# one method, no UI — and note the era flag: the Inspector defaults to the
# *legacy* (2025-era `initialize`) handshake, so `--protocol-era modern` is what
# exercises a 2026-07-28 client and `server/discover`.
npx @modelcontextprotocol/inspector --cli npx tsx src/index.ts --protocol-era modern --method tools/listRunning both is the habit worth forming while the ecosystem migrates: legacy proves you still serve the
clients that exist, modern proves you serve the ones arriving.
src/index.ts is the only transport-aware file in the project. STDIO is the local binding; the
remote one is Streamable HTTP, and createServer() does not change at all:
npm install @modelcontextprotocol/express @modelcontextprotocol/node express// Chapter 04 — the same server behind a different door.
import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler } from "@modelcontextprotocol/server";
import { createServer } from "./server/server.js";
const handler = createMcpHandler(createServer);
const app = createMcpExpressApp({ host: "127.0.0.1" });
const nodeHandler = toNodeHandler(handler);
app.all("/mcp", (request, response) => {
void nodeHandler(request, response, request.body);
});
app.listen(8081, "127.0.0.1");That is Chapter 01 in practice: the host depends on the protocol, the server on its backend, and nothing depends on a vendor — so the binding is a detail, not a rewrite.