The MCP Practitioner Course · Lesson 2 of 7

Building Your First MCP Server

> ~3 hours · stdio server with Tools, Resources, and Prompts. Connected to Claude Desktop. The five things that go wrong on your first run.

By the end of this module you'll have a working MCP server in 45 lines of TypeScript, connected to Claude Desktop, with one tool, one resource, and one prompt. You'll have invoked your code from a real conversation. Most of the work is in the prose — the tool descriptions — not the code.

---

The 45-line server

Make a directory and initialize TypeScript:

```bash mkdir mcp-weather cd mcp-weather npm init -y npm install @modelcontextprotocol/sdk zod npm install -D typescript @types/node tsx npx tsc --init ```

Set `"type": "module"` in `package.json`. Set `"module": "esnext"`, `"target": "es2022"`, `"moduleResolution": "bundler"` in `tsconfig.json`.

Create `server.ts`:

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

const server = new McpServer({ name: "weather", version: "0.1.0" })

server.tool( "get_weather", "Returns the current weather for a city. Use when the user asks about weather.", { city: z.string().describe("City name, e.g. 'Scranton, PA'") }, async ({ city }) => ({ content: [{ type: "text", text: `The weather in ${city} is 64°F and sunny.` }], }), )

const transport = new StdioServerTransport() await server.connect(transport) console.error("[weather] server running on stdio") ```

That's the whole server. The `McpServer` class is the SDK's high-level abstraction. `server.tool` registers one tool with name, description, input schema (Zod), and an async handler. `StdioServerTransport` wires it to stdin/stdout.

Critical: `console.error` goes to stderr. Never log to stdout in a stdio server — the host parses stdout as JSON-RPC frames.

---

Wire it into Claude Desktop

Open Claude Desktop's config file:

  • macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
  • Windows: `%APPDATA%\Claude\claude_desktop_config.json`

Add:

```json { "mcpServers": { "weather": { "command": "npx", "args": ["tsx", "/absolute/path/to/mcp-weather/server.ts"] } } } ```

Restart Claude Desktop fully. Open a new conversation and type "what's the weather in Scranton?" Claude calls your tool. Magic moment.

---

The five things that will go wrong on your first run
  • Stdout pollution. Any `console.log` (in your code or a dependency) breaks the JSON-RPC parser. Use `console.error` for everything.
  • Path is not absolute. Claude Desktop launches from its own working directory. Use absolute paths in the config.
  • Module resolution. ESM vs CJS bites everyone once. The `tsx` runner in the config above handles both. Don't try to use `node` directly without compiling first.
  • Forgot to restart. Claude Desktop reads the config at startup. After every config change, fully quit and reopen.
  • Tool description is too sparse. "Weather tool" tells the model nothing. Write what it does, when to use it, and what its outputs look like.

---

Tool descriptions are prompts (the most important skill in MCP)

The biggest mistake new MCP authors make is treating tool descriptions like API documentation written for a developer. They're not. They're prompts written for a language model that has to decide, mid-conversation, whether this tool is the right one to call.

A bad description: *"Sends an email."*

The model reading that has to guess: when should I call it? What about email drafts? What if the user said "draft an email" — is that a different intent?

A good description: *"Sends a transactional email immediately to a single recipient. Use this only when the user has explicitly asked to send (not draft, not preview). For drafts, use the `create_email_draft` tool instead. The recipient must be a verified contact in our address book; unverified addresses will return an error and require user confirmation."*

That description tells the model when to call, when not to call, what the related tool is, and what the failure mode looks like. The prose IS the engineering work.

Use Zod's `.describe()` on every parameter. The description appears in the schema the model reads. Use the strictest types you can — `z.string().email()` is dramatically better than `z.string()`.

---

When to use a Resource instead of a Tool

The distinction is more useful than it looks.

Use a resource when:

  • The thing is identifiable by URI (a file path, a record ID)
  • It's read-only (no parameters, no side effects)
  • The user might want to *browse* a list and attach one to the chat manually
  • The content is the value — the model is going to consult it

Use a tool when:

  • The operation has parameters that change behavior
  • It might write to a system, send a notification, or affect the world
  • The model needs to invoke it autonomously based on conversational context

When in doubt, ship as a tool. Tools are more flexible.

---

Prompts — the primitive everybody forgets

Prompts are server-defined templates that surface in the host UI as user-invokable commands. In Claude Desktop they appear as slash commands.

The use case: a repeatable task that benefits from a careful, well-tuned prompt and would be tedious to retype every time. Build one prompt; every user gets the same quality output. For a productized MCP service, prompts are often where the IP lives — the tools do the data plumbing, the prompts encode the methodology.

Examples that work well:

  • `/weekly-status` — generates a status report from the user's last 7 days of tickets, commits, and calendar
  • `/audit-table` with argument `table_name` — runs a database table audit using a long expert-level diagnostic prompt
  • `/qbr-deck` with argument `customer_id` — drafts a quarterly business review deck from CRM data

---

Lab — Add a resource and a prompt to the weather server
  • Add a resource `weather://cities` that returns the list of cities your tool supports. Use `server.resource()`.
  • Add a prompt `weather-briefing` taking a `cities` array argument. The server constructs a prompt template; the model fills in the briefing.
  • Reload Claude Desktop. The resource should appear in the attachment menu; the prompt should appear in the slash-command list.
  • Inspect the new `resources/list` and `prompts/list` traffic in the Inspector to confirm clean schemas.

---

Test harness (the discipline that pays off forever)

Two-window development loop:

  • Window 1: editor + `tsx --watch server.ts`. Edit, save, reload.
  • Window 2: the MCP Inspector pointed at your server, kept open across reloads.

Every change in Window 1, immediately verify in Window 2. Don't add Claude Desktop to the loop until you're shipping.

Refactor your tool handlers into directly-importable functions and unit-test them with Vitest:

```typescript import { describe, it, expect } from "vitest" import { handleGetWeather } from "./server.js"

describe("get_weather", () => { it("returns weather for a city", async () => { const r = await handleGetWeather({ city: "Scranton" }) expect(r.content[0].text).toContain("Scranton") }) }) ```

For end-to-end tests, the SDK ships an `InMemoryTransport` that wires a client and server in the same process. Fast, no subprocess, catches real protocol bugs.

---

An anti-pattern that will save you a week

Don't build "god tools" that take a giant `action` string and a freeform `params` object. The model will misuse them constantly. Instead, build many small, narrowly-scoped tools with strict schemas.

`create_invoice`, `void_invoice`, `email_invoice_to_customer` — three tools, three clear jobs. Not one `invoice_action` tool with twelve modes.

---

Pre-work for Module 3
  • Sign up for free tiers on Cloudflare Workers, AWS, and Vercel (you don't need to use all three; we'll deploy to Cloudflare in the lab)
  • Install Wrangler: `npm install -g wrangler`
  • Optionally sign up for Auth0 or Clerk (free dev tier) — we'll need an OAuth provider in Module 4

Module 3 takes the local stdio server you just built and deploys it as a remote, network-reachable service that can be shared across hosts and users.

Previous: Foundations and Protocol Mechanics · Next: Transports, Hosting, and Remote MCP