Your task is to build a server that exposes add-task, a tool that adds an item to a
shared task list, then call it successfully. The code below shows the shape of each piece;
you fill in the specifics. There is a solution to check yourself against once you have tried it.
Set up the project
mkdir mcp-demo && cd mcp-demo
npm init -y
npm install @modelcontextprotocol/server zod tsx
mkdir src The shape of a server
Every server in this course has the same skeleton. The interesting part is what you register inside the factory; everything around it is the same each time:
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
function createServer(): McpServer {
const server = new McpServer({ name: "server-name", version: "1.0.0" });
// Anything this connection owns goes here, alongside the registrations.
// server.registerTool(...)
return server;
}
serveStdio(createServer);
console.error("server-name running on stdio"); new McpServer({ name, version }) creates the server and
gives it an identity. Clients report that name and version back to the user, so it shows up in VS
Code's MCP panel and in Inspector's connection info.
createServer is a factory , a
function that builds a server rather
than a server sitting at module scope. Both serving entries call it to get a fresh instance:
serveStdio once per connection, and createMcpHandler once per HTTP
request when you move to HTTP in lesson 9. Everything connection-scoped goes inside it, both the
registrations and any state they close over, so nothing leaks between callers.
serveStdio(createServer) owns the transport. It reads JSON-RPC
messages from stdin, calls your factory to build the instance that serves the connection, and
writes replies to stdout. You never touch the streams yourself.
The banner goes to stderr. console.error is what makes it safe:
stdout carries the protocol, as lesson 2 covered.
The three arguments to registerTool
server.registerTool(
"tool-name", // 1. name
{ // 2. config
title: "Display Name",
description: "What the tool does, written for the model that has to choose it",
inputSchema: z.object({
argumentName: z.string().describe("What this argument is for")
})
},
async ({ argumentName }) => { // 3. handler
// Do the work, then return the result.
return { content: [{ type: "text", text: "result" }] };
}
); The name is the identifier a client uses to call the tool. Lowercase with hyphens is the convention.
The config describes the tool. title is an optional display name.
description is what the model reads when deciding whether to invoke this tool, since
tools are model-controlled, so write it for the model rather than for a human reading your source.
inputSchema takes a single z.object({ ... }) declaring the
arguments, and .describe() on a field is documentation the model reads too. Lesson 7
goes deeper on both.
The handler is your code. It receives the validated arguments and returns a
content array of typed blocks, always an array even for a single block.
✏️ Exercise: build add-task
Write the real thing. Create src/stdio-server.ts from the skeleton above and register
one tool in it:
- A
tasksarray declared insidecreateServer, so it belongs to that connection. - A tool named
add-task, titledAdd Task, described so a model can tell what it is for. - One required argument,
title, a string, with a.describe()explaining it. - A handler that pushes the title onto
tasksand returns the textAdded:followed by that title.
Name the server demo-server. The rest of the course builds on this exact file, adding a
resource that reads the list back and a prompt that uses it.
Verify it
Run it first. You should see the banner and then nothing:
npx tsx src/stdio-server.ts demo MCP server running on stdio Then call the tool. MCP Inspector's CLI mode is the fastest way to do that without wiring up an editor, and its output is your pass or fail:
npx @modelcontextprotocol/inspector --cli npx tsx src/stdio-server.ts \
--method tools/call --tool-name add-task --tool-arg title="Buy milk" {
"content": [
{
"type": "text",
"text": "Added: Buy milk"
}
]
}
Get that back and the tool is registered, its schema accepted your argument, and your handler
returned a well-formed result. An error naming add-task means the name does not match;
an empty content array means the result shape is wrong, which lesson 8 covers in
depth.
Solution
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
function createServer(): McpServer {
const server = new McpServer({ name: "demo-server", version: "1.0.0" });
const tasks: string[] = [];
server.registerTool(
"add-task",
{
title: "Add Task",
description: "Add a task to the shared task list",
inputSchema: z.object({
title: z.string().describe("A short description of the task")
})
},
async ({ title }) => {
tasks.push(title);
return { content: [{ type: "text", text: `Added: ${title}` }] };
}
);
return server;
}
serveStdio(createServer);
console.error("demo MCP server running on stdio"); Call it from your assistant
The CLI call is exact arguments in, exact response out; it's for you, not for whoever actually
uses this server. You registered demo-server with your assistant back in
lesson 2, before src/stdio-server.ts existed, so it couldn't connect. There's a
server for it to launch now.
If you're using GitHub Copilot, run MCP: List Servers from the
Command Palette and start demo-server.
If you're using Claude Code, start a new session in this project, or run
/mcp to connect.
Once it's running, ask it to add a task in your own words, without naming the tool, for example
"add a task to buy milk." Watch it invoke add-task on its own rather than you calling
it directly, this is the difference between the fast CLI loop and what an actual user does with
your server.
Check your understanding
Why a factory instead of one server?
`serveStdio` and `createMcpHandler` both take a function that builds a server, rather than a server instance you created once. What does that buy you?