Can You Build a Working MCP Server in 15 Minutes? (MCP Part 1)

MCP in 5 minutes, then build and test your first MCP server in TypeScript: two tools, a resource, a scripted client and MCP Inspector, with real output.

TheTechLane MCP Series Part 01 cover on a deep forest green terminal background with a copper plug and socket artwork, a large TL logo tile and the headline Can You Build a Working MCP Server in 15 Minutes?

Every AI app wants to call your code.

Your editor wants to read your files. Your chatbot wants to query your database. Your agent wants to hit your internal API.

Without a standard, you'd write a custom integration for every pair.

The Model Context Protocol (MCP) fixes that. You write one server, and any MCP-compatible app can use it.

In the next 15 minutes, you'll build one.

This is Part 1 of TheTechLane's MCP from Zero to Production series.

One practical step per part, from your first local server to a secure, observable, deployed MCP service.

In this part, you'll learn:

  • What MCP is, in five minutes, without the jargon

  • How a host, a client and a server talk to each other

  • How to build an MCP server in TypeScript with two tools and a resource

  • How to test it with a tiny scripted client and with MCP Inspector

  • The stdout mistake that breaks most first servers

Let's start with the big picture.


Prerequisites

You need three things:

  • Node.js 20 or newer (this guide was tested on Node.js 24)

  • A terminal and a code editor

  • Basic TypeScript or JavaScript: functions, async/await, objects

You don't need an API key. You don't need Claude Desktop or Cursor yet.

We'll connect real AI clients in Part 4.


What Is MCP? The 5-Minute Version

MCP is an open protocol that lets AI applications discover and call external tools and data through one standard JSON-RPC interface.

The official MCP architecture overview describes three participants:

  • Host: the AI app the user sees, like Claude Desktop, Cursor or your own agent

  • Client: a connector inside the host that keeps one connection to one server

  • Server: your program, which exposes capabilities

Here's the flow in one picture:

Host (AI app)
    ↓ creates
MCP Client  ──JSON-RPC──▶  MCP Server (your code)
                               ↓
                     tools · resources · prompts

A server can offer three main kinds of capabilities:

  • Tools: functions the model can call, like convert_units

  • Resources: read-only data the app can load as context, like a file or a doc

  • Prompts: reusable prompt templates the user can pick

Messages travel over a transport.

The MCP transports specification defines two standard ones: stdio, where the client launches your server as a child process, and Streamable HTTP, for remote servers.

Today you'll use stdio. It's the simplest way to start.

Want the full conceptual tour first? Read Model Context Protocol (MCP) Explained: The Complete Developer Guide.


Step 1: Create the Project

Make a folder and initialise it:

mkdir tl-mcp-starter && cd tl-mcp-starter
npm init -y

Install the official MCP TypeScript SDK server and client packages, plus Zod for input schemas:

npm install @modelcontextprotocol/server @modelcontextprotocol/client zod

Install TypeScript and Node.js types as dev dependencies:

npm install -D typescript @types/node

This tutorial uses @modelcontextprotocol/server 2.4.0, @modelcontextprotocol/client 2.4.0, zod 4.6.7 and TypeScript 7.0.2, the versions npm installed on 9 November 2026.

That's SDK v2, the current stable line.

You'll still see the older single @modelcontextprotocol/sdk package in many tutorials. It's now the v1 maintenance line, and v2 splits it into separate server and client packages. The v2 migration guide covers the differences.


Step 2: Configure TypeScript and ES Modules

The v2 SDK ships ES modules only, so mark your package as a module.

Replace the generated package.json with this:

{
  "name": "tl-mcp-starter",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node build/server.js",
    "test:client": "node build/client.js"
  },
  "dependencies": {
    "@modelcontextprotocol/client": "^2.4.0",
    "@modelcontextprotocol/server": "^2.4.0",
    "zod": "^4.6.7"
  },
  "devDependencies": {
    "@types/node": "^26.6.6",
    "typescript": "^7.0.2"
  }
}

The "type": "module" line tells Node.js to treat .js files as ECMAScript modules.

Now create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}

Keep the "types": ["node"] line.

The server package README notes that TypeScript 6 and later no longer auto-include @types/* packages. Without that line, our first build failed with Cannot find name 'process'.


Step 3: Write Your First MCP Server

Create src/server.ts.

Start with the imports:

import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";

Then open a factory function that builds the server:

function createServer(): McpServer {
  const server = new McpServer({
    name: "tl-starter-server",
    version: "1.0.0",
  });

Everything in the next three snippets goes inside this function. Why a factory? Because serveStdio calls it to build the instance that serves each connection.

Add Tool 1: A Word Counter

Tools are registered with registerTool. You give it a name, a config with a description and an input schema, and a handler:

  server.registerTool(
    "word_count",
    {
      title: "Word Counter",
      description: "Count words and characters in a piece of text and estimate reading time",
      inputSchema: z.object({
        text: z.string().min(1).describe("The text to analyse"),
      }),
    },
    async ({ text }) => {
      const words = text.trim().split(/\s+/).length;
      const chars = text.length;
      const minutes = Math.max(1, Math.round(words / 200));
      return {
        content: [
          {
            type: "text",
            text: `${words} words, ${chars} characters, about ${minutes} min read`,
          },
        ],
      };
    }
  );

In v2, inputSchema is a full z.object(...), the only schema you write.

From it, the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and types the handler's arguments.

If you're coming from v1, registerTool replaces the old server.tool(...) helper.

Add Tool 2: A Unit Converter

This one shows how to report a tool error properly:

  const factors: Record<string, number> = {
    "km-mi": 0.621371,
    "mi-km": 1.609344,
    "kg-lb": 2.204623,
    "lb-kg": 0.453592,
  };

  server.registerTool(
    "convert_units",
    {
      title: "Unit Converter",
      description: "Convert a value between km and mi, or kg and lb, and C and F",
      inputSchema: z.object({
        value: z.number().describe("The number to convert"),
        from: z.enum(["km", "mi", "kg", "lb", "C", "F"]),
        to: z.enum(["km", "mi", "kg", "lb", "C", "F"]),
      }),
    },
    async ({ value, from, to }) => {
      let result: number | undefined;
      if (from === "C" && to === "F") result = (value * 9) / 5 + 32;
      else if (from === "F" && to === "C") result = ((value - 32) * 5) / 9;
      else if (factors[`${from}-${to}`]) result = value * factors[`${from}-${to}`];

      if (result === undefined) {
        return {
          content: [{ type: "text", text: `Can't convert ${from} to ${to}` }],
          isError: true,
        };
      }
      return {
        content: [
          { type: "text", text: `${value} ${from} = ${result.toFixed(2)} ${to}` },
        ],
      };
    }
  );

The isError: true flag matters.

The MCP tools specification separates protocol errors from tool execution errors. A bad unit pair is a tool error, so you return it in the result. The model can read it and try again.

Add a Resource

Resources are read-only context. Register one with a URI:

  server.registerResource(
    "units-guide",
    "docs://units-guide",
    {
      title: "Supported Units",
      description: "Which unit pairs convert_units supports",
      mimeType: "text/plain",
    },
    async (uri) => ({
      contents: [
        {
          uri: uri.href,
          text: "Supported pairs: km<->mi, kg<->lb, C<->F",
        },
      ],
    })
  );

The app decides when to load a resource. The MCP resources specification calls them application-driven, while tools are model-controlled.

Close the Factory and Serve It

Return the server, close the function, and hand the factory to serveStdio:

  return server;
}

// serveStdio reads requests on stdin and writes responses to stdout
serveStdio(createServer);
// Log to stderr: stdout is reserved for JSON-RPC messages
console.error("tl-starter-server running on stdio");

In v2, serveStdio replaces the old new StdioServerTransport() plus server.connect() wiring. The SDK stdio guide covers clean shutdown too.

That console.error isn't a typo.

On stdio, stdout carries the protocol. The transports spec says the server MUST NOT write anything to stdout that isn't a valid MCP message.

Logs belong on stderr, which clients may capture or show.

Build it:

npx tsc

No output means it compiled.


How a Client Talks to Your Server

Before you test, it helps to see the conversation.

UML sequence diagram of one MCP session over stdio: the host starts a session, the MCP client spawns node build/server, sends initialize with protocol version 2025-11-25, receives tools and resources capabilities, sends notifications/initialized, calls tools/list, then tools/call convert_units for 10 km to mi and gets 6 point 21 mi, reads the docs://units-guide resource and passes results back to the model

How an MCP client talks to your server: initialize, list, call, read

Every session follows the MCP lifecycle:

  1. The client sends initialize with its protocol version and capabilities

  2. The server replies with its version, capabilities and info

  3. The client sends notifications/initialized

  4. Now the client can call tools/list, tools/call, resources/list and resources/read

You can watch this happen by piping raw JSON-RPC into the server:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"raw","version":"0.0.1"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"convert_units","arguments":{"value":100,"from":"C","to":"F"}}}' \
  | node build/server.js 2>/dev/null

Here's the real output:

{"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true},"resources":{"listChanged":true}},"serverInfo":{"name":"tl-starter-server","version":"1.0.0"}},"jsonrpc":"2.0","id":1}
{"result":{"content":[{"type":"text","text":"100 C = 212.00 F"}]},"jsonrpc":"2.0","id":2}

The server answered with protocol version 2025-11-25.

That's the classic initialize handshake, which the SDK calls the legacy era. The newer 2026-07-28 revision replaces initialize with a server/discover advertisement, and SDK v2 serves both from the same code.

It also advertised tools and resources capabilities automatically, because you registered both.

That's the whole protocol. Everything else is convenience.


Step 4: Test It With a Scripted Client

Raw JSON is fine once. For repeatable tests, use the SDK's own Client.

Create src/client.ts:

import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";

const transport = new StdioClientTransport({
  command: "node",
  args: ["build/server.js"],
});

const client = new Client({ name: "tl-test-client", version: "1.0.0" });

await client.connect(transport);
console.log("Connected to:", client.getServerVersion()?.name);

const { tools } = await client.listTools();
console.log("Tools:", tools.map((t) => t.name).join(", "));

const wc = await client.callTool({
  name: "word_count",
  arguments: { text: "MCP lets AI apps talk to your tools in one standard way" },
});
console.log("word_count ->", JSON.stringify(wc.content));

const conv = await client.callTool({
  name: "convert_units",
  arguments: { value: 10, from: "km", to: "mi" },
});
console.log("convert_units ->", JSON.stringify(conv.content));

const bad = await client.callTool({
  name: "convert_units",
  arguments: { value: 10, from: "km", to: "kg" },
});
console.log("bad pair isError:", bad.isError);

const { resources } = await client.listResources();
console.log("Resources:", resources.map((r) => r.uri).join(", "));

const doc = await client.readResource({ uri: "docs://units-guide" });
const first = doc.contents[0];
console.log("units-guide ->", "text" in first ? first.text : "(binary)");

await client.close();

StdioClientTransport launches your server as a child process. client.connect() runs the whole initialize handshake for you.

Build and run:

npx tsc && node build/client.js

Real output from our run:

tl-starter-server running on stdio
Connected to: tl-starter-server
Tools: word_count, convert_units
word_count -> [{"type":"text","text":"12 words, 55 characters, about 1 min read"}]
convert_units -> [{"type":"text","text":"10 km = 6.21 mi"}]
bad pair isError: true
Resources: docs://units-guide
units-guide -> Supported pairs: km<->mi, kg<->lb, C<->F

The first line is your server's stderr log, passed through to the terminal.

Both tools work, the bad pair comes back as a tool error, and the resource loads.

Now try an argument the schema forbids, like from: "m". We ran it, and the SDK rejected it before the handler ran:

{"content":[{"type":"text","text":"Input validation error: Invalid arguments for tool convert_units: from: Invalid option: expected one of \"km\"|\"mi\"|\"kg\"|\"lb\"|\"C\"|\"F\""}],"isError":true}

Curious about the newer protocol era? Pass { versionNegotiation: { mode: "auto" } } as the second Client argument. In our run, client.getProtocolEra() returned modern with version 2026-07-28, while the default client stayed on legacy.

The SDK protocol versions guide explains both eras.

You just built and tested a real MCP server.


Step 5: Explore It in MCP Inspector

Scripts are great for CI. For poking around, use the official MCP Inspector.

Run it against your built server:

npx @modelcontextprotocol/inspector node build/server.js

It opens a local web UI in your browser.

From there you can:

  • Click Connect and watch the initialize handshake

  • Open the Tools tab, pick convert_units, fill the form and run it

  • Open the Resources tab and read docs://units-guide

  • Use the History pane to inspect each JSON-RPC request and response

The Inspector reads your Zod-generated schema, so every input field appears automatically.


Debugging Your First Server

Most first-server bugs fall into a few buckets.

Flowchart of the build, test and debug loop for a first MCP server: edit src/server, compile with npx tsc, run the scripted client, then fix stray stdout logs, missing type module or wrong handler results and rebuild, until all calls pass and you explore the server in MCP Inspector, with a symptom to fix panel

The first-server loop: build, test, fix, then explore in Inspector

1. Logging to stdout

A console.log in the server puts non-JSON text on the protocol channel.

We added console.log("hi") and sent an initialize request. Here's stdout, trimmed:

hi
{"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true},"re

The channel now opens with a line no JSON-RPC parser accepts. Some clients skip it, others drop the connection. Use console.error for logs.

2. Forgetting to rebuild

node build/server.js runs compiled JavaScript. Edit src/server.ts, then run npx tsc again.

3. Missing "type": "module"

Without it, Node.js treats your build output as CommonJS and the import statements fail.

4. Throwing for bad input

If a tool can fail for normal reasons, return isError: true with a helpful message instead of crashing.

5. Vague descriptions

The model picks tools by reading their descriptions. "Convert a value between km and mi" beats "converter".


Quick Recap

  • MCP connects AI hosts to your code through clients and servers speaking JSON-RPC

  • A server exposes tools (model-controlled), resources (app-controlled) and prompts

  • SDK v2 uses @modelcontextprotocol/server and @modelcontextprotocol/client, with registerTool, registerResource and serveStdio

  • Zod shapes become JSON Schemas and validate inputs for you

  • stdio servers must log to stderr, never stdout

  • Test with the SDK Client for repeatable checks and MCP Inspector for exploring


Exercises

Try these before Part 2:

1. Add a Third Tool

Add slugify, which turns "Hello MCP World" into hello-mcp-world. Call it from client.ts.

2. Add a Resource Template

Look up ResourceTemplate in the SDK resources guide and expose units://{pair} that describes one unit pair.

3. Break It on Purpose

Add console.log("hi") after serveStdio(createServer), rebuild, and pipe a raw initialize request into the server. Spot the bad line, then fix it.

4. Assert, Don't Print

Turn client.ts into a test that exits with code 1 if any output doesn't match what you expect.


Continue Learning

You're currently at Part 1 of the MCP from Zero to Production series.

Previous

Model Context Protocol (MCP) Explained: The Complete Developer Guide, the background explainer for this series.

Next

Part 2: MCP Tools, Resources and Prompts in Depth (coming next)

Related reading:


Get New Guides in Your Inbox

Want Part 2 the moment it's published?

Subscribe to the free TheTechLane newsletter for new MCP parts, tutorials and developer guides.


Sources


Have a Question?

If your server won't connect, leave a comment below with your error message and the command you ran.

Happy Coding! 🚀


Follow TheTechLane

Keep learning AI, development, and modern developer technologies with TheTechLane.

Learn • Build • Share

Follow @thetechlanehq for more developer guides, tutorials, and practical technology content.