← Back to Blog

Build an MCP Server in 15 Minutes with the TypeScript SDK

Your AI agent can chat, but it cannot touch your systems. It cannot read that internal runbook, write to your notes, or query your database. You paste context in one hand, it forgets it in the next turn, and you are back to copy-pasting data across a chat window.

Model Context Protocol (MCP) is the open standard that fixes this. You write a small server that exposes tools, resources, or prompts, and any MCP host, Claude Code, VS Code, Cursor, or your own application, connects to it and lets the model call those tools directly. One server, many hosts. No per-app integration code.

This guide builds a working server from scratch, a shared team-notes store, and connects it to a real host. You need Node.js 20 or later and nothing else.

Prerequisites

  • Node.js 20 or later (check with node --version)
  • An MCP host to test against: Claude Code, VS Code, or the MCP Inspector

Step 1: Set up the project

The TypeScript SDK v2 ships ES modules only, so type=module matters. tsx runs TypeScript directly, so there is no build step.

mkdir team-notes && cd team-notes
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod tsx
mkdir src

zod is the one schema library you need. From a single Zod object the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types.

Step 2: Write the server

Create src/index.ts. Two tools: read-note and write-note, backed by a local JSON file.

import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';

const NOTES_FILE = 'notes.json';

function createServer(): McpServer {
  const server = new McpServer({ name: 'team-notes', version: '1.0.0' });

  server.registerTool(
    'read-note',
    {
      description: 'Read a note by key from the shared notes file',
      inputSchema: z.object({
        key: z.string().describe('The note key to read, e.g. deploy-runbook'),
      }),
    },
    async ({ key }) => {
      if (!existsSync(NOTES_FILE)) {
        return { content: [{ type: 'text', text: 'No notes file yet.' }] };
      }
      const notes = JSON.parse(readFileSync(NOTES_FILE, 'utf8'));
      const value = notes[key];
      return {
        content: [
          { type: 'text', text: value ? String(value) : 'Note not found.' },
        ],
      };
    }
  );

  server.registerTool(
    'write-note',
    {
      description: 'Write a note under a key to the shared notes file',
      inputSchema: z.object({
        key: z.string().describe('The note key, e.g. deploy-runbook'),
        value: z.string().describe('The note text to store'),
      }),
    },
    async ({ key, value }) => {
      const notes = existsSync(NOTES_FILE)
        ? JSON.parse(readFileSync(NOTES_FILE, 'utf8'))
        : {};
      notes[key] = value;
      writeFileSync(NOTES_FILE, JSON.stringify(notes, null, 2));
      return { content: [{ type: 'text', text: 'Saved.' }] };
    }
  );

  return server;
}

void serveStdio(createServer);
console.error('team-notes MCP server running on stdio');

registerTool takes a name, a config, and an async handler. The handler returns a list of typed content blocks. Return isError: true when a call fails so the model sees the failure and can react to it.

Step 3: Run it

npx tsx src/index.ts

The banner lands on stderr and nothing else happens. An stdio server waits on stdin for a client to start the conversation. Stop it with Ctrl+C.

Step 4: Test it without a host

The MCP Inspector is a local web app that launches any stdio server and lets you call its tools directly.

npx @modelcontextprotocol/inspector npx tsx src/index.ts

In the browser tab it opens, click Connect, open the Tools tab, and call write-note then read-note. This is the fastest way to confirm your server works before wiring up a full host.

Step 5: Connect it to a host

Every host registers the same server with a launch command. Replace the script path with your absolute path.

Claude Code

The CLI has a first-class command, no config file editing needed:

claude mcp add team-notes -- npx tsx /home/you/team-notes/src/index.ts
claude mcp list

Restart Claude Code for the tools to load, then ask it to "write a note on how I deploy" and watch it call the tool.

Claude Desktop

Config lives in ~/.config/claude/claude_desktop_config.json on Linux (macOS uses ~/Library/Application Support/Claude/claude_desktop_config.json). Add the server under the mcpServers key, then fully quit and reopen the app.

{
  "mcpServers": {
    "team-notes": {
      "command": "npx",
      "args": ["tsx", "/home/you/team-notes/src/index.ts"]
    }
  }
}

env is the place for API keys. Use it instead of baking secrets into args.

The one mistake that breaks every stdio server

stdout is the protocol channel. Any console.log or stray print that lands on stdout corrupts the JSON-RPC stream, and the host sees a server that works once then dies, or never sees tools at all. Log with console.error, which goes to stderr. This is the single most common failure when people wire up a custom MCP server.

Two more get you most of the way:

  • Use absolute paths for both the script and the module resolution, and install dependencies inside the project folder. GUI clients do not share your shell PATH or working directory.
  • After editing a config file, restart the client completely. Closing the window often does not reload MCP servers.

If a host does not see your tools, test the server alone with the Inspector first. If the Inspector sees them, the host config is the problem, not your server.

MCP vs. plain function calling

MCP wins when tools are reused across hosts or agents: a filesystem server, a database connector, a GitHub tool. You write it once and any MCP-compatible app picks it up. Plain tool-use (function calling) is fine when the tool only serves one application and you do not need shared schemas or a discovery mechanism. And if you just need a two-line HTTP call, skip the protocol and call the API directly.

For local, single-machine tooling, stdio transport is the default: the host launches your server as a subprocess and owns its lifetime. When many clients must share one endpoint over the network, serve the same createServer factory over HTTP instead.

What's next

  • Add a third tool that touches your real database or internal API, and give it a proper inputSchema.
  • Run the server over HTTP so multiple clients share it.
  • Look at the official reference servers for filesystem, memory, and PostgreSQL to see broader patterns.

References

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation