How to Build Your Own MCP Server
March 15, 2026 · 4 min read
TL;DR
- An MCP server is a process that exposes tools, resources, and prompts over a standard transport (stdio or HTTP)
- The TypeScript SDK handles protocol framing — you define tools as typed functions and the SDK does the rest
- Test locally with Claude Desktop before deploying; the everything-server is a useful reference implementation
- Keep tools focused and idempotent where possible; avoid side effects that are hard to undo
Building your own MCP server lets you give AI assistants access to any internal system, API, or data source you control. If your company has an internal tool, a proprietary database, or a custom workflow that no existing server covers, a custom server is the right answer.
This guide walks through building a working MCP server from scratch using the official TypeScript SDK. By the end you will have a server that exposes a real tool, connects to Claude Desktop, and is ready to extend.
What an MCP Server Actually Is
Before writing code, it helps to be precise about what an MCP server is at the protocol level.
An MCP server is a process that communicates over a defined transport (stdio or HTTP/SSE) using JSON-RPC 2.0 messages. It implements a handful of methods:
initialize/initialized— handshake with the clienttools/list— return available toolstools/call— execute a tool and return a resultresources/list/resources/read— optional: expose file-like dataprompts/list/prompts/get— optional: expose prompt templates
The TypeScript and Python SDKs handle all of this framing. You define what your tools do; the SDK handles the protocol.
Prerequisites
- Node.js 18 or later
- npm or pnpm
- Claude Desktop (for local testing)
- Basic TypeScript knowledge
Project Setup
Create a new directory and initialize it:
mkdir my-mcp-server
cd my-mcp-server
npm init -yInstall the MCP SDK and TypeScript:
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/node tsxCreate a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}Add a build script to package.json:
{
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
}
}Writing Your First Tool
Create src/index.ts. We will build a server that exposes a get_weather tool — a simple example that hits a public API and returns structured data.
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-mcp-server",
version: "0.1.0",
},
{
capabilities: {
tools: {},
},
}
);Declaring Tools
Register a handler for tools/list to tell clients what your server can do:
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "get_weather",
description:
"Get the current weather for a city. Returns temperature in Celsius and a short condition description.",
inputSchema: {
type: "object",
properties: {
city: {
type: "string",
description: "The city name, e.g. 'London' or 'New York'",
},
},
required: ["city"],
},
},
],
};
});The inputSchema is a JSON Schema object. Claude uses it to understand what arguments to pass. Be specific in your descriptions — vague schemas lead to incorrect invocations.
Implementing Tools
Register a handler for tools/call:
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "get_weather") {
const { city } = request.params.arguments as { city: string };
// Replace with a real weather API in production
const response = await fetch(
`https://wttr.in/${encodeURIComponent(city)}?format=j1`
);
if (!response.ok) {
return {
content: [
{
type: "text",
text: `Failed to fetch weather for ${city}: HTTP ${response.status}`,
},
],
isError: true,
};
}
const data = (await response.json()) as {
current_condition: Array<{
temp_C: string;
weatherDesc: Array<{ value: string }>;
}>;
};
const condition = data.current_condition[0];
return {
content: [
{
type: "text",
text: `Weather in ${city}: ${condition.temp_C}°C, ${condition.weatherDesc[0].value}`,
},
],
};
}
return {
content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
isError: true,
};
});Starting the Transport
Add the transport setup at the bottom of the file:
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP server running on stdio");
}
main().catch((err) => {
console.error("Server error:", err);
process.exit(1);
});Note that output goes to stderr for logs — stdout is reserved for the MCP protocol messages.
Adding Resources
Resources expose data the AI can read, similar to files. They are optional but useful for structured reference data.
import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
// Add "resources: {}" to capabilities in the Server constructor
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: "weather://config",
name: "Weather Server Configuration",
mimeType: "application/json",
},
],
};
});
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
if (request.params.uri === "weather://config") {
return {
contents: [
{
uri: "weather://config",
mimeType: "application/json",
text: JSON.stringify({ version: "0.1.0", defaultUnit: "celsius" }),
},
],
};
}
throw new Error(`Resource not found: ${request.params.uri}`);
});Testing Locally
Build the server
npm run buildConfigure Claude Desktop
Open your Claude Desktop configuration file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Add your server:
{
"mcpServers": {
"my-mcp-server": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/dist/index.js"]
}
}
}Restart Claude Desktop. Open a new conversation and type something like "What's the weather in Tokyo?" — Claude should invoke your get_weather tool automatically.
Checking Logs
If the server does not appear, check Claude Desktop's MCP logs:
- macOS:
~/Library/Logs/Claude/mcp-server-my-mcp-server.log
Most startup errors are caused by incorrect paths, missing node in PATH, or TypeScript compilation errors.
Using tsx for Development
During development, skip the build step and run TypeScript directly:
npx tsx src/index.tsUpdate your Claude Desktop config to use tsx:
{
"mcpServers": {
"my-mcp-server": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/my-mcp-server/src/index.ts"]
}
}
}Handling Environment Variables
Credentials should never be hardcoded. Pass them as environment variables in the config:
{
"mcpServers": {
"my-mcp-server": {
"command": "node",
"args": ["/absolute/path/to/dist/index.js"],
"env": {
"WEATHER_API_KEY": "your-key-here"
}
}
}
}Access them in your server:
const apiKey = process.env.WEATHER_API_KEY;
if (!apiKey) {
throw new Error("WEATHER_API_KEY environment variable is required");
}Common Mistakes
Returning errors incorrectly. Return { content: [...], isError: true } from a tool call rather than throwing an exception. Thrown exceptions crash the server process; isError: true signals a recoverable tool failure.
Writing to stdout. Any console.log() in your server will corrupt the MCP protocol stream. Always use console.error() for logging.
Imprecise input schemas. If your schema says a field is optional but your implementation requires it, Claude will sometimes omit it and your tool will fail. Be accurate.
Large tool responses. Each tool response goes through the AI's context window. Keep responses focused — return the information the AI needs, not entire database dumps.
Reference Implementation
The everything-server from the official MCP repository implements every protocol feature and is the best reference when you are unsure how something should work. Review its source when adding resources, prompts, or sampling support.
Next Steps
Once your server works locally, you can:
- Publish it to npm so others can install it with
npx - Deploy it as an HTTP server using
StreamableHTTPServerTransportfor remote access - Add prompt templates to guide how Claude uses your tools
- Register it on the MCP server directory for broader discovery
A working MCP server is typically 100-200 lines of TypeScript. The protocol is simple by design — focus on making your tools reliable and your schemas precise, and the rest takes care of itself.