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.
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.
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: trueand a sentence saying what to do next, not thrown. - Over stdio, standard output is the protocol channel. Log with
console.error; a strayconsole.logcorrupts 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.
# 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.
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 usesfenbs_list_items,fenbs_create_item,fenbs_commentand so on. - One tool per job, not per endpoint.
fenbs_create_itemchecks for a similar open or recently finished task before it files anything, and answerscreated: falsewith the likely matches instead of making a duplicate. - Say when to call it, not just what it does.
fenbs_whoamiis 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.
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.