Building Your Own MCP Server
This is a member-only chapter. Log in with your Signal Over Noise membership email to continue.
Log in to readModule 4: Building Your Own MCP Server
You don’t have to use servers other people built. If you have an internal system, a personal data source, or an API that no existing server covers, you can write the server yourself. The tooling has improved enough that a basic server takes an afternoon.
I’ve shipped several: mcp-fantastical connects Claude to Fantastical for calendar access, mcp-arr connects Claude to Radarr and Sonarr for media management (the one that got traction without me expecting it), and mcp-kit provides reusable utilities. I’ll draw from those as examples.
What You’re Building
An MCP server is a small programme that:
- Connects to a data source or service
- Exposes a set of tools with defined inputs and outputs
- Executes those tools when Claude calls them
- Returns structured results
The programme runs as a separate process. Claude Code spawns it, talks to it over stdio, and tears it down when the session ends. Your server doesn’t need to manage its own lifecycle — Claude Code handles that.
Setup
Create a new directory and initialise it:
mkdir my-mcp-server
cd my-mcp-server
npm init -y
Install the SDK:
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/node tsx
Create a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true
},
"include": ["src/**/*"]
}
Add to package.json:
{
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
}
}
The Minimal Server
Create src/index.ts:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const server = new Server(
{ name: "my-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "hello",
description: "Returns a greeting",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "The name to greet",
},
},
required: ["name"],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "hello") {
const name = request.params.arguments?.name as string;
return {
content: [{ type: "text", text: `Hello, ${name}!` }],
};
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);
That’s a complete MCP server. It exposes one tool. Claude can call it. Build it, configure it in settings.json, and it works.
Tool Design
The hello example is trivial. Real tools need more thought.
Name clearly. Tool names are how Claude decides what to use. get_calendar_events is better than fetch or calendar. Be specific enough that Claude can distinguish between your tools without guessing.
Write useful descriptions. Claude reads these descriptions when deciding whether to call a tool. “Returns a greeting” is fine for a demo. For real tools, write what the tool does, when to use it, and what it returns. The description is your tool’s interface contract with Claude.
Keep input schemas tight. Define exactly what each parameter is and whether it’s required. Don’t make everything optional when some parameters are genuinely required. Don’t mark things as required when Claude should be able to call the tool without them.
Return structured text. The content array can contain text, images, or other types. For most purposes, return clear text that Claude can reason about. If you’re returning data, format it in a way that’s easy to parse — JSON, markdown tables, or plain prose depending on what Claude will do with it.
Connecting to a Real Service
Here’s the pattern from mcp-fantastical, simplified. The server calls a macOS shortcut that Fantastical exposes, parses the result, and returns formatted calendar events:
import { execSync } from "child_process";
function getEvents(startDate: string, endDate: string): string {
try {
const result = execSync(
`shortcuts run "Get Calendar Events" --input-path /dev/null`,
{ encoding: "utf8" }
);
return parseEvents(result);
} catch (error) {
throw new Error(`Failed to fetch events: ${error}`);
}
}
The key point: the server is a thin wrapper. It calls the actual service, handles errors, formats results, and returns them. The MCP layer is just the interface between that logic and Claude.
Error Handling
Don’t let errors crash your server silently. Claude will see a generic failure and have no idea why. Be explicit:
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const toolName = request.params.name;
if (toolName === "get_events") {
const { start_date, end_date } = request.params.arguments as {
start_date: string;
end_date: string;
};
if (!start_date || !end_date) {
return {
content: [{ type: "text", text: "Error: start_date and end_date are required" }],
isError: true,
};
}
try {
const events = await fetchEvents(start_date, end_date);
return { content: [{ type: "text", text: events }] };
} catch (error) {
return {
content: [{ type: "text", text: `Failed to fetch events: ${error}` }],
isError: true,
};
}
}
throw new Error(`Unknown tool: ${toolName}`);
});
The isError: true flag tells Claude the call failed so it can respond appropriately — retry with different parameters, tell the user something went wrong, or try an alternative approach.
Configuring Your Server
Once built, add it to settings.json. For a TypeScript server you can run with tsx during development:
"my-server": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/src/index.ts"]
}
For production (compiled), use node:
"my-server": {
"command": "node",
"args": ["/absolute/path/to/dist/index.js"]
}
For servers that need credentials:
"my-server": {
"command": "node",
"args": ["/absolute/path/to/dist/index.js"],
"env": {
"API_KEY": "your-key-here",
"API_URL": "https://api.example.com"
}
}
When to Publish
You don’t have to publish a server to use it. Personal servers running on your own machine are a perfectly valid use case — mcp-kit started that way. If you build something others would find useful, the bar for publishing is low: a clear README, working installation instructions, and a willingness to field the occasional issue.
The MCP ecosystem benefits from more servers. If yours solves a real problem, publish it.
Module 5 covers stack design — once you have several servers, how you make them work together without making Claude’s job harder.
Check Your Understanding
Answer all questions correctly to complete this module.
1. What is an MCP server's role in relation to its data source?
2. What does isError: true in a tool response tell Claude?
3. What is the recommended approach for tool descriptions?
Pass the quiz above to unlock
Save failed. Please try again.