<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Your First MCP Server

Your First MCP Server

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 tasks array declared inside createServer, so it belongs to that connection.
  • A tool named add-task, titled Add 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 tasks and returns the text Added: 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

Question 1 of 2

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?