# DD-22 — Lab Specification: Build a Production MCP Server in TypeScript

**Duration**: 60-75 minutes · **Language**: TypeScript (Node 20+) · **External dependencies**: `@modelcontextprotocol/sdk`, `tsx` (for running TS directly)

This lab builds a real MCP server. Not a simulation. The server speaks the MCP protocol over stdio, implements two tools with JSON Schema validation, handles errors in three classes, and shuts down gracefully on SIGTERM. You will test it with a client harness that exercises the full lifecycle.

---

## Phase 0 — Setup (5 min)

1. Ensure Node 20+ is installed: `node --version`
2. Create a working directory:
```bash
mkdir mcp-ticket-server && cd mcp-ticket-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D tsx typescript @types/node
```
3. Create `server.ts`. This is your server. Create `client.ts`. This is your test harness.

**Checkpoint**: `npx tsx --version` prints a version. Dependencies installed.

---

## Phase 1 — Implement the Server (25 min)

Write `server.ts` implementing a ticket-system MCP server with:

### 1a. Two tools with JSON Schema inputs

- `search_tickets` — input: `{ query: string (1-200 chars), status?: "open"|"closed"|"all" (default "open"), limit?: integer (1-50, default 25) }`. Returns up to N fake ticket objects `{ id, title, status, created_at }`.
- `get_ticket` — input: `{ id: integer (>=1) }`. Returns a single fake ticket `{ id, title, status, body, created_at }` or `{ found: false }`.

Both schemas MUST have `additionalProperties: false`.

Use Zod for runtime validation (the SDK supports Zod schemas directly via `zodToJsonSchema` conversion, or validate manually before dispatch).

### 1b. A fake data store

In-memory array of ~10 fake tickets. Generate them in a function. Do not call a real API.

### 1c. The three-class error model

- Unknown tool name → `{ isError: true, content: [{ type: "text", text: "Unknown tool: \"<name>\"" }] }`
- Validation failure → `{ isError: true, content: [{ type: "text", text: "Invalid arguments: <detail>" }] }`
- Execution failure (dispatch throws) → `{ isError: true, content: [{ type: "text", text: "Tool execution failed: <message>" }] }`

### 1d. Graceful shutdown

Handle `SIGTERM` and `SIGINT`. On signal: set a `shuttingDown` flag, log `[ticket-mcp] received SIGTERM, shutting down` to **stderr**, and exit 0.

### 1e. Logging discipline

All logs go to `process.stderr.write(...)`. Never `console.log` — that writes to stdout and corrupts the protocol stream.

**Reference**: The teaching document (01-teaching-document.md, section DD-22.4) contains a full reference implementation. Use it as your guide, but write your own version — do not copy-paste without understanding each section.

**Checkpoint**: `npx tsx server.ts` starts without error and logs `[ticket-mcp] server started, 2 tools registered` to stderr. (It will block waiting for stdio input — that's correct. Ctrl+C to stop for now.)

---

## Phase 2 — Implement the Test Client (15 min)

Write `client.ts` that:

1. Spawns the server as a child process: `spawn("npx", ["tsx", "server.ts"], { stdio: ["pipe", "pipe", "inherit"] })`
2. Implements a minimal JSON-RPC 2.0 message writer (write JSON + newline to `child.stdin`) and reader (parse line-delimited JSON from `child.stdout`).
3. Exercises the full lifecycle:

### 3a. Initialize handshake
```typescript
// Send initialize
send({ jsonrpc: "2.0", id: 1, method: "initialize",
  params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "test-client", version: "1.0" } } });
const initResp = await recv(1);
console.log("Server capabilities:", JSON.stringify(initResp.result.capabilities));

// Send initialized notification
send({ jsonrpc: "2.0", method: "notifications/initialized" });
```

### 3b. List tools
```typescript
send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
const listResp = await recv(2);
console.log("Tools:", listResp.result.tools.map(t => t.name));
```

### 3c. Call a valid tool
```typescript
send({ jsonrpc: "2.0", id: 3, method: "tools/call",
  params: { name: "search_tickets", arguments: { query: "login", status: "open", limit: 5 } } });
const callResp = await recv(3);
console.log("Result:", callResp.result.content[0].text);
```

### 3d. Exercise each error class
- **Class 1 (unknown tool)**: call `name: "delete_ticket"` → expect `isError: true`, text contains "Unknown tool"
- **Class 2 (validation failure)**: call `search_tickets` with `{ query: "x", limit: 999 }` → expect `isError: true`, text contains "Invalid arguments"
- **Class 3 (execution failure)**: call `get_ticket` with `{ id: -1 }` after modifying your validator to allow it through but having dispatch throw → expect `isError: true`, text contains "Tool execution failed"

### 3e. Test graceful shutdown
```typescript
child.kill("SIGTERM");
// Verify the server logs the shutdown message to stderr and exits with code 0
const exitCode = await new Promise(r => child.on("exit", r));
console.log("Server exited with code:", exitCode);  // should be 0
```

**Checkpoint**: `npx tsx client.ts` runs all five sub-tests and prints results. All three error classes produce the expected `isError` payload. The server exits 0 on SIGTERM.

---

## Phase 3 — The stdout Corruption Demo (10 min)

This phase demonstrates the most common MCP bug.

1. Temporarily add `console.log("handling request")` at the top of your `tools/call` handler in `server.ts`.
2. Run the client again. Observe what happens: the client receives the log string as if it were a JSON-RPC message, fails to parse it (or parses it as invalid JSON), and either errors or hangs.
3. Remove the `console.log`. Change it to `process.stderr.write("handling request\n")`. Run the client again — it works, and you can see the log on stderr without breaking the protocol.

**Deliverable**: Write a one-paragraph explanation (in a comment in your client.ts or a separate notes file) of why stdout corruption breaks the protocol and why stderr is safe.

---

## Phase 4 — Capability Negotiation Experiment (10 min)

Modify your client's initialize message to advertise a capability the server doesn't expect — for example, add `roots: { listChanged: true }` to your client capabilities.

1. Observe: the server's initialize response still shows only its own capabilities. The server did not "accept" roots — it just noted the client offers them.
2. Now modify the server to advertise a capability your client didn't offer — for example, add `logging: {}` to the server capabilities.
3. Have your client attempt to use the logging capability (send a method the server's logging enables). What happens?

**Deliverable**: A note documenting that capabilities are the intersection of both sides. A capability offered by only one side cannot be used in the session.

---

## Phase 5 — Stretch Goals

If you finish early, implement one or more:

1. **Add a resource.** Expose `ticket://<id>` as a readable resource. Implement `resources/list` and `resources/read`. Have your client read a ticket by URI instead of calling the tool. Observe the trust-boundary difference: the client (not the model) initiates the read.

2. **Add a prompt.** Expose a prompt called `summarize_ticket` that takes an `{ id }` parameter and returns a rendered prompt string: "Summarize the following support ticket in two sentences: [ticket content]". Implement `prompts/list` and `prompts/get`.

3. **Add subscription debouncing.** Expose a resource `tickets://recent` that "changes" every 100ms (use a setInterval to mutate the underlying data). Implement `resources/subscribe` and `resources/unsubscribe`. Then implement debouncing: coalesce notifications to at most one per second. Verify that a 1-second-subscribed client receives one update per second, not ten.

4. **Add a third tool with a side effect.** Implement `close_ticket` that marks a ticket closed. Add an idempotency key parameter: if the same key is sent twice, the second call is a no-op. Verify by sending the same call twice.

5. **Switch to HTTP+SSE transport.** The SDK supports a StreamableHTTP transport. Refactor your server to expose itself over HTTP instead of stdio. Run two clients simultaneously against one server. Observe that the protocol is identical; only the framing changed.

---

## Deliverables Checklist

- [ ] `server.ts` — MCP server with two tools, JSON Schema validation, three-class error model, graceful shutdown, stderr-only logging
- [ ] `client.ts` — test harness exercising: initialize handshake, tools/list, valid tools/call, all three error classes, SIGTERM shutdown
- [ ] stdout corruption demo completed and explained
- [ ] capability negotiation experiment documented
- [ ] (stretch) at least one of: resource, prompt, subscription debouncing, idempotency key, HTTP+SSE transport

---

## Solution Key

The teaching document (01-teaching-document.md, section DD-22.4) contains a near-complete reference implementation of `server.ts`. The key implementation details to verify in your solution:

1. **`additionalProperties: false`** appears in every tool's input schema.
2. **Validation happens before dispatch** — a validation failure never reaches the business logic.
3. **The three error classes return distinguishable text payloads** — the model (or test client) can tell which class it got.
4. **All logging uses `process.stderr.write`** — never `console.log`.
5. **SIGTERM handler exits 0** after logging the shutdown.
6. **The client sends `initialize` → waits for response → sends `notifications/initialized`** before any other request.

---

## What This Lab Teaches

This lab is the difference between understanding MCP as a concept and having built one. After completing it, you will have:

- Written a real JSON-RPC 2.0 server that speaks the MCP protocol.
- Implemented the three-primitive model (at least tools; resources and prompts as stretch).
- Internalized why stdout corruption is fatal and stderr is safe.
- Exercised capability negotiation and observed the intersection rule.
- Built the three-class error model and seen why distinguishing failure classes matters for model recovery.
- Implemented graceful shutdown — the discipline every production server needs.

The server you build here is the foundation for Course 4 Module E07 (MCP-at-scale), where you will deploy a fleet of these servers behind a registry, add a policy layer, and instrument an audit log.
