- Published on
Building Your Own MCP Server
- Authors

- Name
- Kevin Morales
The Model Context Protocol gives language models structured access to external systems through a server you control. Instead of shoving context into a system prompt and hoping the model figures out what to do with it, MCP lets you define explicit tools, resources, and prompts that the model can call on demand. Building a server is straightforward. Building one that works reliably with a real model in a real workflow takes more thought than the docs suggest.
What You're Actually Building
An MCP server is a process that speaks the MCP protocol over either stdio or HTTP with Server-Sent Events. The client, which is typically an agent framework inside your IDE, connects to the server and discovers what it exposes. The server declares three types of things:
- Tools - functions the model can call. The model decides when and whether to call them based on the conversation.
- Resources - data the model can read. Static files, database records, API responses, anything you want the model to have access to without it needing to ask.
- Prompts - reusable prompt templates the client can surface to users.
Most servers only need tools. Resources are useful when you have content the model should be able to browse rather than fetch on demand. Prompts are rarely the right choice unless you're building a product where users are explicitly selecting workflows.
Project Setup
The TypeScript SDK is the most complete and best documented. Start there unless you have a specific reason to use Python.
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
The minimal server structure that covers most use cases:
src/
index.ts entry point, transport setup
tools/
index.ts tool registry
*.ts individual tool implementations
resources/
index.ts resource registry
types.ts shared types
Keeping tools in separate files prevents the main server file from becoming unreadable once you have more than a handful of tools.
Tools
This is where most of the work lives. A tool has a name, a description, an input schema, and a handler function.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
const server = new McpServer({
name: 'my-server',
version: '1.0.0',
})
server.tool(
'get_project_status',
"Returns the current build status, open PR count, and last deploy time for a project. Use this when the user asks about a project's health or deployment state.",
{
project_id: z.string().describe('The project identifier, e.g. "api-service" or "web-app"'),
include_prs: z.boolean().optional().default(true).describe('Whether to include open PR count'),
},
async ({ project_id, include_prs }) => {
const status = await fetchProjectStatus(project_id, include_prs)
return {
content: [{ type: 'text', text: JSON.stringify(status, null, 2) }],
}
}
)
Writing Tool Descriptions That Work
The description is the only signal the model has for deciding whether to use a tool. Bad descriptions produce two failure modes: the model calls the tool when it shouldn't, or it doesn't call it when it should.
A good description answers three questions:
- What does this tool return or do?
- Under what conditions should the model use it?
- What are the constraints?
Compare these two descriptions for the same tool:
Bad:
'Gets user data'
Good:
'Fetches a user record by ID including name, email, role, and account status. Use this when you need specific user details and have a user ID. For searching users by name or email, use search_users instead.'
The second description tells the model what data it gets, when to use it, and critically, when to use a different tool instead. That last part matters. Without it, the model will call the most generic-sounding tool and then figure out it was wrong.
Input Schema Design
Zod schemas become JSON Schema for the model. Every field should have a .describe() call. The model uses field descriptions to understand what values are valid.
{
date_range: z.object({
start: z.string().describe('ISO 8601 date string, e.g. "2024-01-15"'),
end: z.string().describe('ISO 8601 date string, e.g. "2024-01-31"'),
}).describe('Inclusive date range for the query'),
status: z.enum(['active', 'inactive', 'pending']).describe('Filter by account status'),
limit: z.number().int().min(1).max(100).default(25).describe('Number of results to return'),
}
Use enums over free-form strings wherever the valid values are bounded. The model will hallucinate plausible-sounding values for string fields and pass them as-is.
Error Handling
Throwing an error inside a tool handler propagates as a protocol-level error. The model sees this as the tool failing, not as information about what went wrong. For expected failure cases, return an error result instead:
;async ({ user_id }) => {
const user = await db.users.findById(user_id)
if (!user) {
return {
content: [{ type: 'text', text: `No user found with ID: ${user_id}` }],
isError: true,
}
}
return {
content: [{ type: 'text', text: JSON.stringify(user) }],
}
}
With isError: true, the model knows the call technically succeeded but produced an error state. It can reason about this and either try a different approach or report back to the user. A thrown exception gives it much less to work with.
Throw for genuine unexpected failures. Return error results for anything that's a predictable outcome, such as not found, invalid input that passed schema validation, or permission denied.
Returning Structured Data
The content array can hold multiple items. When returning data the model needs to reason about, plain JSON with a consistent structure works better than prose:
return {
content: [
{
type: 'text',
text: JSON.stringify({
found: true,
user: { id, name, email, role },
metadata: { query_time_ms: elapsed },
}),
},
],
}
Avoid returning large blobs of unstructured text. The model has to parse it, and it will make mistakes on inconsistent formats.
Resources
Resources are identified by URI. A resource can be static or dynamic via a URI template.
// Static resource
server.resource(
'config://app/settings',
'Current application configuration including feature flags and environment settings',
async () => ({
contents: [
{
uri: 'config://app/settings',
mimeType: 'application/json',
text: JSON.stringify(await loadConfig()),
},
],
})
)
// Dynamic resource with URI template
server.resource(
'db://users/{id}',
new ResourceTemplate('db://users/{id}', { list: undefined }),
'User record from the database',
async (uri, { id }) => ({
contents: [
{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(await db.users.findById(id)),
},
],
})
)
The practical question is when to use a resource versus a tool that fetches data. Resources work well for content the model should be able to browse proactively, like documentation, configuration, or a list of available entities. Tools work better when fetching the data requires parameters that come from the conversation or when you need to do something beyond just reading.
If your "resource" needs more than one or two URI parameters to be useful, it's probably a tool.
Prompts
Prompts are templates with arguments. The client surfaces them to users as selectable workflows.
server.prompt(
'review_pull_request',
'Review a pull request for correctness, style, and potential issues',
{
pr_url: z.string().describe('GitHub pull request URL'),
focus: z.enum(['correctness', 'style', 'performance', 'security']).optional(),
},
({ pr_url, focus }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Review the pull request at ${pr_url}${
focus ? ` with a focus on ${focus}` : ''
}. Check for bugs, unclear logic, missing error handling, and any obvious issues.`,
},
},
],
})
)
Prompts are most useful in IDE integrations where users are explicitly selecting a slash command. In an agent context where the model is driving the conversation, prompts add less value because the model constructs its own instructions anyway.
Transport
stdio
For local tooling and VS Code integration, stdio is the right choice. The client spawns the server process and communicates over stdin/stdout.
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
const transport = new StdioServerTransport()
await server.connect(transport)
VS Code reads MCP server configuration from .vscode/mcp.json in your workspace:
{
"servers": {
"my-server": {
"type": "stdio",
"command": "node",
"args": ["${workspaceFolder}/dist/index.js"],
"env": {
"DATABASE_URL": "postgres://localhost/mydb"
}
}
}
}
Once the file exists, VS Code detects it automatically and prompts you to start the server. You can also define servers in user settings under mcp.servers if you want them available across all workspaces rather than scoped to one project.
One catch with stdio: anything you write to stdout that isn't a valid MCP message breaks the protocol. That means console.log in your tool handlers goes to stdout and corrupts the connection. Use console.error for debug output, since it goes to stderr.
// Breaks the stdio connection
console.log('fetching user', id)
// Safe
console.error('fetching user', id)
HTTP with SSE
For servers that need to be accessible over a network, SSE transport is the answer. This is the right choice when the server is shared across multiple clients or when you're deploying it as a service.
import express from 'express'
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'
const app = express()
const transports: Record<string, SSEServerTransport> = {}
app.get('/sse', async (req, res) => {
const transport = new SSEServerTransport('/messages', res)
transports[transport.sessionId] = transport
await server.connect(transport)
})
app.post('/messages', async (req, res) => {
const { sessionId } = req.query
await transports[sessionId as string]?.handlePostMessage(req, res)
})
app.listen(3000)
Debugging
The MCP Inspector is a browser-based tool that connects directly to your server and lets you call tools, read resources, and inspect what the server exposes. It's faster than testing through VS Code every time since it removes the editor's own layer of reasoning.
npx @modelcontextprotocol/inspector node dist/index.js
It opens a UI at localhost:5173 where you can see every tool, resource, and prompt your server declares, call them with custom inputs, and inspect the raw response. Most issues surface here before you wire the server into VS Code.
For stdio servers, the inspector wraps your process. For SSE servers, point it at your server's URL.
Common Pitfalls
Vague tool names cause ambiguous selection. get_data, fetch_info, query all suffer from the same problem: the model has to guess what they do from the description alone. get_project_build_status, fetch_user_by_email, query_inventory_by_sku are unambiguous.
Too many tools with overlapping descriptions. If you have fifteen tools that all could plausibly apply to a user's request, the model picks one roughly at random. Group related operations, consolidate where it makes sense, and be explicit in each description about the cases it doesn't cover.
Returning errors as text without isError: true. The model reads the text, sees something that looks like an error message, and doesn't know whether to retry or report it. Set isError: true on failure cases so the model handles them correctly.
Not handling auth in the server. If your tools talk to external APIs, keep the credentials in the server process, not in the tool inputs. The model shouldn't be passing API keys around. Use environment variables loaded at server startup and reference them in your handlers.
Synchronous blocking operations in async handlers. File system and network calls should be properly awaited. A blocking call in a tool handler stalls the entire server since MCP servers are typically single-process.
Putting It Together
The server entry point wires transport and tools together:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { registerTools } from './tools/index.js'
import { registerResources } from './resources/index.js'
const server = new McpServer({
name: 'my-server',
version: '1.0.0',
})
registerTools(server)
registerResources(server)
const transport = new StdioServerTransport()
await server.connect(transport)
// tools/index.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { registerProjectTools } from './project.js'
import { registerUserTools } from './user.js'
export function registerTools(server: McpServer) {
registerProjectTools(server)
registerUserTools(server)
}
Each tool file gets its own registration function and is responsible for its own imports and handler logic. At scale this matters: a server with forty tools across six domains needs a structure that makes individual tools easy to find and modify without touching anything else.
The protocol itself is not the hard part. The hard part is writing tool descriptions that produce reliable model behavior, and that only improves through testing with actual conversations. Use the Inspector for iteration speed, then test through VS Code once the behavior looks right.