How to Build an MCP Server for Your Own Tool

A working MCP server in TypeScript in about fifty lines: three tools, tested in the MCP Inspector, connected to Claude Code. Then the part that decides whether assistants use it well: how to design the tools.

Updated 8 min read

To build an MCP server for your own tool, install the official TypeScript SDK package @modelcontextprotocol/server with Zod, create an McpServer, register each tool with registerTool (a name, a description, an input schema and a handler that calls your product), and serve it over stdio. Test it in the MCP Inspector, add it to Claude Code with one command, and only then decide whether it needs to run remotely over HTTP. The code is the easy half. The half that decides whether assistants use your server well is the design of the tools: what each one does, how it is described, and what it says when it refuses. If you are still deciding whether you need a server at all, read MCP vs REST API first.

Before you start: what the server wraps

An MCP server is a thin layer. It should call your product’s existing API or service layer, not reach into the database around it, so that an assistant is allowed exactly what a person using the same account is allowed. Write down the five or so jobs people would actually ask an assistant to do in your tool. Those become your tools; the endpoints behind them are an implementation detail.

1. Set up the project

The SDK’s version 2 split into separate packages, and servers use @modelcontextprotocol/server. It is ES modules only, so the project needs "type": "module". The setup below follows the SDK’s own first-server tutorial (opens in a new tab), with tsx to run TypeScript without a build step.

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

2. Write the server

This server manages a small task list with three tools. The Map stands in for your product; in a real server, each handler would call your API. Save it as src/index.ts.

src/index.ts
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';

// Stands in for your product's real API or database.
const tasks = new Map<string, { title: string; done: boolean }>();
let next = 1;

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

  server.registerTool(
    'tasks_list',
    {
      description: 'List tasks with their ids. Pass open: true for unfinished ones only.',
      inputSchema: z.object({ open: z.boolean().optional() }),
    },
    async ({ open }) => {
      const rows = [...tasks].filter(([, t]) => !open || !t.done);
      const text = rows.length
        ? rows.map(([id, t]) => id + (t.done ? ' [done] ' : ' [open] ') + t.title).join('\n')
        : 'No tasks.';
      return { content: [{ type: 'text', text }] };
    },
  );

  server.registerTool(
    'tasks_add',
    {
      description: 'Add a task. Returns its id, e.g. T-3.',
      inputSchema: z.object({
        title: z.string().min(1).describe('What needs doing, in a few words'),
      }),
    },
    async ({ title }) => {
      const id = 'T-' + next++;
      tasks.set(id, { title, done: false });
      return { content: [{ type: 'text', text: 'Added ' + id + ': ' + title }] };
    },
  );

  server.registerTool(
    'tasks_complete',
    {
      description: 'Mark a task done by its id, e.g. T-3.',
      inputSchema: z.object({ id: z.string() }),
    },
    async ({ id }) => {
      const task = tasks.get(id);
      if (!task) {
        return {
          isError: true,
          content: [{ type: 'text', text: 'No task ' + id + '. Call tasks_list to see the ids that exist.' }],
        };
      }
      task.done = true;
      return { content: [{ type: 'text', text: id + ' marked done.' }] };
    },
  );

  return server;
}

void serveStdio(createServer);
console.error('tasks MCP server running on stdio');
  • The Zod schema is the single source of truth: the SDK turns it into the JSON Schema the model sees, and validates arguments before your handler runs.
  • .describe() on a field becomes that field’s description in the schema. Use it wherever a name alone could be misread.
  • A failure the assistant can fix is returned as a result with isError: true and a sentence saying what to do next, not thrown.
  • Over stdio, standard output is the protocol channel. Log with console.error; a stray console.log corrupts the stream.

3. Test it in the MCP Inspector

The MCP Inspector (opens in a new tab) is the reference tool for testing servers. It runs through npx (it needs Node 22.19 or newer) and starts your server for you. The web client opens in a browser, where the Tools tab lists your tools and lets you call each one with arguments you type.

Terminal
# Browser UI
npx @modelcontextprotocol/inspector npx tsx src/index.ts

# Command line: list the tools, then call one
npx @modelcontextprotocol/inspector --cli npx tsx src/index.ts --method tools/list
npx @modelcontextprotocol/inspector --cli npx tsx src/index.ts \
  --method tools/call --tool-name tasks_complete --tool-arg id=T-9

The last call should come back with isError: true and the sentence about tasks_list, and the CLI exits with a non-zero status when a tool reports an error, which makes it easy to put a few of these calls in CI. Test the refusals as carefully as the successes: they are what an assistant reads when something goes wrong.

4. Connect it to a client

In Claude Code, add it as a stdio server. Everything after -- is the command Claude Code runs to start it, as the Claude Code MCP documentation (opens in a new tab) describes. Use an absolute path so it starts from any folder.

Terminal
claude mcp add --transport stdio tasks -- npx tsx /absolute/path/to/tasks-mcp/src/index.ts

Run /mcp inside Claude Code to see it connected, then ask for something in plain words: “add a task to write the release notes, then list what is open”. Other clients take the same command and arguments in their own configuration; the pages for Cursor, Copilot in VS Code and Codex CLI show where each keeps it.

5. Design the tools: the part that matters

An assistant chooses between your tools from their names, descriptions and schemas alone, every session. Anthropic’s engineering guide to writing tools for agents (opens in a new tab) is the best long read on this; these are the rules that matter most, with examples from the fenbs server, which has thirty tools.

  • Prefix every name with your product. Clients connect to many servers at once, and two of them will both have a search. fenbs uses fenbs_list_items, fenbs_create_item, fenbs_comment and so on.
  • One tool per job, not per endpoint. fenbs_create_item checks for a similar open or recently finished task before it files anything, and answers created: false with the likely matches instead of making a duplicate.
  • Say when to call it, not just what it does. fenbs_whoami is described as “Call this first — it tells you what you are allowed to do before you try.”
  • Constrain inputs. Use an enum for fixed values (fenbs lanes are backlog, next, doing, done), mark what is required, and give ranges: priority is an integer from 1 to 10, with the description saying 1 is most urgent.
  • Make refusals explain themselves. On fenbs, a refusal names the permission that was missing and the role the caller holds, so the assistant can tell its person why, instead of retrying.
  • Return what the model needs, not everything you have. Filters and small pages beat a dump of every record, because everything you return is context the model has to read.

One protocol change affects design directly. The 2026-07-28 revision removed protocol-level sessions, so a server cannot rely on remembering anything between calls on a connection, as the 2026-07-28 changelog (opens in a new tab) sets out. If a job spans several calls, return an explicit id from the first and take it as an argument on the rest, the way tasks_add returns T-3 for tasks_complete to use.

6. Going remote over HTTP

A stdio server runs on each user’s machine. To offer your server to everyone, serve it over Streamable HTTP instead. The SDK’s @modelcontextprotocol/node package adapts it to Node’s HTTP server; its HTTP serving guide (opens in a new tab) shows this shape. The factory runs once per request, so register tools inside it, never on a shared instance.

src/http.ts (npm install @modelcontextprotocol/node)
import { createServer } from 'node:http';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { toNodeHandler, localhostHostValidation, localhostOriginValidation } from '@modelcontextprotocol/node';

const handler = createMcpHandler(() => {
  const server = new McpServer({ name: 'tasks', version: '1.0.0' });
  // register the same tools here
  return server;
});

const nodeHandler = toNodeHandler(handler);
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();

createServer((req, res) => {
  if (!validateHost(req, res) || !validateOrigin(req, res)) return;
  void nodeHandler(req, res);
}).listen(3000, '127.0.0.1');

The host and origin checks protect a local server from DNS rebinding. Point the Inspector at http://127.0.0.1:3000/mcp with --transport http to test it. Before this server leaves your machine it needs sign-in: how to add authentication to an MCP server covers the OAuth pieces, and MCP security best practices covers the rest.

How the fenbs server is put together

The fenbs server at https://fenbs.ai/api/mcp is one example of these choices in production. It speaks JSON-RPC over HTTP POST and calls the same service layer as the web interface, so a person and an assistant are refused for the same reasons in the same words. A permission refusal comes back as a tool result, not a protocol error, because the assistant has to read it and explain it. A caller with no credential gets a 401 that tells the client how to sign the person in. And a client that cannot open a browser sends a token issued by hand under Settings, with a name, scopes and an optional expiry, as an Authorization: Bearer header instead. A client that speaks only stdio runs the fenbs-mcp bridge (npx -y fenbs-mcp), which relays stdin and stdout to the same endpoint with that token in the FENBS_TOKEN environment variable.

Related

What happens between your server and the model: MCP vs function calling. Instructions that sit alongside a server: MCP vs Claude Skills. What people look for when they choose one: what to look for in an MCP server for project management. The fenbs server itself: MCP docs.

Questions people ask.

Which package do I install to build an MCP server in TypeScript?

For version 2 of the official SDK, @modelcontextprotocol/server, plus zod for schemas. Version 1 used the single @modelcontextprotocol/sdk package; it is still maintained on its own branch, but new servers should start on version 2.

Should I start with stdio or HTTP?

Start with stdio. It is the quickest to test and needs no sign-in, because only the client that launched it can talk to it. Move to Streamable HTTP when other people need to use the server without installing it, and add authentication at the same time.

How many tools should an MCP server have?

As few as cover the jobs people actually ask for. Every tool definition is text the model reads, and similar tools make it pick the wrong one. Merge endpoints into jobs and split a tool only when its description stops fitting in a few sentences.

How do I test an MCP server without an AI client?

Use the MCP Inspector. Its web client lets you list and call tools by hand, and its command-line mode can list tools and call them from a script or a CI job.

Start with one thing.

There is nothing to set up first. Write one line and you’ve started.