July 31, 2026 · 11 min read · @alexcloudstar
How to build an MCP server: a practical walkthrough
The Model Context Protocol (MCP) is the emerging standard for giving coding agents like Cursor, Claude Code, and Codex access to tools and data outside their own context window. If you've used an MCP server to let an agent query a database, search docs, or hit a third-party API, the natural next question is: how hard is it to build one?
Not very, structurally. The MCP TypeScript SDK handles the protocol plumbing (transport, message framing, capability negotiation) for you. The actual work is designing the tools themselves: what they do, what they refuse to do, and how they fail safely when something goes wrong.
This walkthrough builds one from scratch, using a real, published server as the working example: makers-page-mcp, which lets an agent draft, get human approval for, and publish posts to X. It's a good teaching example precisely because it isn't a toy: it has real state, a real external API with real costs, and real failure modes an agent can trigger.
What an MCP server actually is
Stripped down, an MCP server is a process that:
- Advertises a set of tools (and optionally resources and prompts) with typed input schemas.
- Listens for tool calls from a client (your coding agent) over a transport, most commonly
stdio. - Executes the requested tool and returns a structured result.
That's the entire contract. There's no required database, no required web framework, no required deployment target. The simplest possible MCP server is a single Node process that registers one tool and talks over stdin/stdout.
Set up the project
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
@modelcontextprotocol/sdk is the official TypeScript SDK; it gives you McpServer and the transport classes. zod isn't required by the protocol, but it's the SDK's preferred way to describe tool input schemas, and you get runtime validation for free.
Build the entry point
Every tool call comes in over a transport. stdio is the right default: it's what every major agent (Cursor, Claude Desktop, Claude Code, Codex, Copilot) speaks natively, and it needs zero network configuration, since the client spawns your server as a child process and talks to it over its stdin/stdout.
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
const main = async () => {
const server = new McpServer({ name: "my-mcp-server", version: "0.1.0" })
// registerXTools(server) calls go here
const transport = new StdioServerTransport()
await server.connect(transport)
}
main().catch((error) => {
console.error("my-mcp-server failed to start:", error)
process.exit(1)
})
In makers-page-mcp, the real entry point looks almost identical, just wiring in multiple tool groups instead of one:
const server = new McpServer({ name: "makers-page-mcp", version })
registerDraftTools(server, config)
registerApprovalTools(server, config)
registerPublishTools(server, config)
registerAccountTools(server, config)
const transport = new StdioServerTransport()
await server.connect(transport)
Keeping each tool group in its own file (tools/draft.ts, tools/approve.ts, tools/publish.ts, tools/account.ts) rather than one giant file pays off fast: tools tend to accumulate edge cases, and each group usually needs its own small set of dependencies (a store, an API client, config).
One easy mistake early on: never write logs to stdout. stdio transport means stdout is the protocol channel, and a stray console.log will corrupt every message after it. Use console.error for anything diagnostic; stderr is safe and most clients surface it for debugging.
Register your first tool
A tool is a name, a description, an input schema, and a handler. The description matters more than it looks like it should: it's the only thing the agent has to decide when and how to call your tool, so write it like documentation for a very literal reader, not a one-line comment.
const textResult = (text: string): CallToolResult => ({
content: [{ type: "text", text }],
})
const errorResult = (text: string): CallToolResult => ({
content: [{ type: "text", text }],
isError: true,
})
server.registerTool(
"get_x_account",
{
title: "Get connected X account",
description:
"Check whether this MCP server is connected to an X account and, if so, return the account's username and id.",
inputSchema: {},
},
async () => {
const connected = await xClient.isConnected()
if (!connected) {
return errorResult(
"Not connected to X. Run the auth command described in the README to authorize this server with your X account.",
)
}
try {
const me = await xClient.getMe()
return textResult(`Connected to X as @${me.username} (${me.name}).`)
} catch (error) {
if (error instanceof NotAuthenticatedError) return errorResult(error.message)
if (error instanceof XApiError) return errorResult(`X API error (${error.status}): ${error.message}`)
throw error
}
},
)
A few things worth copying directly:
inputSchemauses bare Zod schemas per field, not a wrappedz.object. The SDK builds the object schema for you; this keeps individual field descriptions (via.meta({ description: ... })) attached to the field the agent actually needs to fill in.get_x_accountabove takes no input, so it's just{}.- Every result is
{ content: [...] }, and errors are the same shape withisError: trueset, not a thrown exception surfaced as a protocol-level failure. This lets the agent read a clear, human-readable error message and decide what to do next, rather than getting an opaque transport error. - A small
textResult/errorResulthelper pays for itself immediately once you have more than two or three tools, since every handler needs both. - Catch specific error classes, not
Errorin general.NotAuthenticatedErrorandXApiErrorget their own error messages here because the fix is different for each (run the auth flow vs. an X-side problem); anything else is a genuine bug and gets rethrown instead of silently swallowed as a generic tool error.
Design the tools around a real workflow, not a CRUD API
It's tempting to expose your data model 1:1 as tools (create_x, get_x, update_x, delete_x). Resist that. Design tools around the actual task an agent is doing, and let the state machine underneath enforce the rules a raw CRUD API would leave to the caller.
makers-page-mcp exposes exactly the tools the "draft a post, get approval, publish it" workflow needs, no more:
| Tool | What it does |
|---|---|
create_draft | Save a new draft post. |
list_drafts | List drafts, optionally filtered by status. |
get_draft | Fetch a single draft by id. |
update_draft | Edit a draft's text; resets an approved/rejected draft back to draft. |
approve_draft | Mark a draft approved. Required before publishing. |
reject_draft | Mark a draft rejected, or manually reconcile a stuck draft. |
publish_draft | Publish an approved draft. Returns the live URL. |
get_x_account | Check connection status. |
The state machine behind those tools (draft → approved → publishing → published, with rejected as an escape hatch) lives in one place, a DraftStore, so every tool goes through the same transition rules instead of re-implementing them:
async approve(id: string): Promise<Draft> {
const draft = await this.get(id)
if (draft.status !== "draft") {
throw new InvalidDraftTransitionError(id, draft.status, "approved")
}
const updated: Draft = { ...draft, status: "approved", updatedAt: new Date().toISOString() }
await this.write(updated)
return updated
}
This is the actual point of a well-designed MCP server: it isn't a thin wrapper that lets an agent do anything the underlying API allows, it's a workflow with guardrails the agent literally cannot bypass, because the tool won't let it.
Treat "the agent will call this wrong or twice" as a certainty
Agents retry. Agents get interrupted mid-task. Agents sometimes call the same tool twice in quick succession because a previous response was ambiguous. If a tool has a real-world side effect, especially a paid or irreversible one, you have to design for that instead of hoping it doesn't happen.
publish_draft is the clearest example. Posting to X costs real money per call, so a duplicate publish isn't a cosmetic bug, it's a bill. Two things make it safe:
1. A per-resource lock, so two overlapping calls for the same draft can't both pass the status check before either one writes:
const draftLocks = createKeyedLock()
export const withDraftLock = <T>(id: string, fn: () => Promise<T>): Promise<T> =>
draftLocks.withLock(id, fn)
2. A three-way outcome on the actual network call, because "the request failed" isn't one outcome, it's at least three:
const statusBeforePublish = draft.status
await store.beginPublishing(id)
let tweet
try {
tweet = await xClient.createTweet(draft.text)
} catch (error) {
// NotAuthenticatedError means we never had a token to send the
// request with, and XApiError means X sent back a definitive HTTP
// response (even an error one), in both cases we know for certain
// nothing was posted, so it's safe to revert and allow a retry.
if (error instanceof NotAuthenticatedError) {
await store.revertPublishing(id, statusBeforePublish)
return errorResult(error.message)
}
if (error instanceof XApiError) {
await store.revertPublishing(id, statusBeforePublish)
return errorResult(`X API error (${error.status}): ${error.message}`)
}
if (error instanceof NetworkError) {
// A failure inside the actual fetch (timeout, DNS, connection
// reset, ...) is genuinely ambiguous: X may have received and
// processed the request before the connection dropped. Reverting
// here would let an agent retry and risk a real, paid duplicate
// post. Leave the draft in "publishing" and require manual
// reconciliation (reject_draft or update_draft) after the user
// has checked their X account.
return errorResult(
`Publishing draft "${id}" failed with a network error, so it may or may not have posted: ${error.message}\n\n` +
'Check your X account before doing anything else. The draft has been left with status "publishing" ' +
"and will NOT be retried automatically.",
)
}
// Anything else is an unexpected local failure that happened before
// any request reached X, so it's not ambiguous: safe to revert.
await store.revertPublishing(id, statusBeforePublish)
const message = error instanceof Error ? error.message : String(error)
return errorResult(`Publishing draft "${id}" failed unexpectedly before reaching X: ${message}`)
}
That middle case, a network error with an unknown outcome, is the one most tool implementations get wrong, because the instinct is to always revert and allow a retry. For anything with real-world side effects, "unknown" has to mean "stop and ask a human," not "assume it's safe to try again."
The state persisted for each draft (beginPublishing before the call, markPublished only after a confirmed success) also survives a process crash mid-publish. If the server dies between the tweet going out and the local record updating, the draft is stuck in publishing, not silently reset to approved, so a restarted agent can't accidentally retry a post that already went live.
Put a real approval gate in front of anything irreversible
If a tool can do something a user would want veto power over, before it happens, make the approval step a separate tool call the agent cannot skip, not a confirmation prompt baked into a single mega-tool.
publish_draft refuses to run unless the draft's status is already approved (configurable, but on by default):
if (draft.status !== "approved" && config.requireApproval) {
return errorResult(
`Draft "${id}" has status "${draft.status}". Approve it with approve_draft before publishing.`,
)
}
This works because it's enforced by the tool itself, not by prompting the agent to "ask the user first." An agent can be told to ask first and forget, or a user can approve a workflow that quietly skips the ask. A hard status check that a separate approve_draft tool call must satisfy first has no such gap.
Validate everything at the edge
Your tool's input schema (Zod, in the SDK's case) catches shape errors: wrong type, missing field. It won't catch domain-specific validity, like "this text is too long for the platform's post limit" or "this id isn't even a valid identifier." Validate those explicitly, before anything touches storage or a paid API:
export type ValidationResult = { ok: true } | { ok: false; error: string }
export const validateXPostText = (text: string, maxLength: number): ValidationResult => {
const trimmed = text.trim()
if (trimmed.length === 0) {
return { ok: false, error: "Post text cannot be empty." }
}
const length = weightedLength(trimmed)
if (length > maxLength) {
return {
ok: false,
error:
`Post text is approximately ${length} characters (X's weighted count, treating each URL as 23), ` +
`which exceeds the ${maxLength} character limit.`,
}
}
return { ok: true }
}
weightedLength here isn't text.length: it counts by Unicode code point (so emoji count once, not twice, the way UTF-16 length would count them) and normalizes any URL down to X's fixed t.co weight, since X shortens every link to the same length regardless of how long the real URL is. A validator like this only has value if it actually models the platform's real limit, not just a naive character count.
The same discipline applies to anything used to construct a file path. makers-page-mcp stores each draft as {id}.json on disk, so id is checked against a strict UUID regex before it's ever joined into a path, closing off path traversal via a crafted id like ../../../.ssh/id_rsa:
const DRAFT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
This is easy to skip because "the agent wouldn't send that" feels true most of the time. Treat every tool input the way you'd treat untrusted input from the open internet, because functionally, that's what it is: text an LLM generated, which could itself have been shaped by a prompt injection somewhere upstream.
Handling auth without a browser-based OAuth callback server running forever
If your server talks to an API that needs OAuth, you generally don't want the MCP server itself to be a long-running web server just to catch a redirect. makers-page-mcp splits this into a separate one-time CLI step (bun run auth / npx makers-page-mcp-auth) that spins up a short-lived local HTTP listener only for the duration of the authorization flow, then writes tokens to disk and exits:
const credentials = await runAuthorizationCodeFlow(config, (authorizeUrl) => {
console.error("Open this URL in your browser to authorize makers-page-mcp:\n")
console.error(authorizeUrl)
console.error("\nWaiting for authorization...")
})
await store.write(credentials)
console.error(`\nAuthorized. Credentials saved to ${config.credentialsPath}`)
The MCP server itself just reads the stored credentials (and refreshes them as needed) on every tool call. This keeps the actual MCP process simple and stateless with respect to auth, and means the interactive browser step never has to happen inside an agent's tool call, which would be a strange and fragile place for a human-in-the-loop OAuth flow to live anyway.
Ship it so any client can use it
Because stdio is a universal transport, the only client-specific part of distribution is the config block each tool expects. A typical entry looks the same everywhere, just under a different file:
{
"mcpServers": {
"my-mcp-server": {
"command": "npx",
"args": ["-y", "my-mcp-server"]
}
}
}
Cursor reads this from ~/.cursor/mcp.json, Claude Desktop from its own claude_desktop_config.json, Claude Code from .mcp.json, and so on. If you publish to npm with a bin entry, npx -y your-package is enough for a user to run it with zero install step, which is worth optimizing for over "clone and build from source" as the primary path.
{
"bin": {
"my-mcp-server": "dist/index.js"
}
}
A minimal checklist before you call it done
- Tools map to a workflow, not a database table. If a tool name is a verb on a noun that mirrors your schema 1:1, ask whether it should instead enforce a transition (
approve_draft, notupdate_draft_status). - Every side-effecting tool has an explicit failure taxonomy. "It worked," "it definitely didn't work," and "unknown" are three different code paths, not one
catchblock. - Irreversible or costly actions require a separate, unskippable approval tool call, not a prompt instruction to "ask first."
- All input is validated at the tool boundary, including anything used to build a file path or query.
- Nothing writes to stdout except protocol messages. Diagnostics go to stderr.
- Config comes from environment variables with sane, documented defaults, so a client config block is copy-pasteable without a wall of setup.
None of this is exotic engineering. It's the same discipline you'd apply to any API that a semi-autonomous caller can invoke without a human reading every request first, which, once an agent is in the loop, is exactly what an MCP tool is.
Frequently asked questions
Do I need a database for an MCP server?
No. Plenty of useful MCP servers, including the one used as an example here, just read and write JSON files on disk. Reach for a real database only when you have concurrent access patterns a simple per-key lock and atomic file write can't handle.
Should my MCP server run over HTTP instead of stdio?
Use stdio unless you specifically need a server reachable over a network from a client that can't spawn a local process, such as a hosted/remote MCP deployment. stdio is simpler, has no auth surface of its own, and is what every major coding agent supports first.
How do I test an MCP server without a full agent in the loop?
Write unit tests against your tool handlers directly, most of the logic (validation, state transitions, error taxonomy) doesn't depend on the MCP SDK at all, and structure code so a handler's real dependencies (a store, an API client) can be swapped for lightweight fakes in tests. Save manual testing in an actual agent for the integration behavior: does the tool description lead the agent to call it correctly, and does the returned text read sensibly in context.
What's the biggest mistake first-time MCP server authors make?
Treating every tool call as trusted, single-shot, and idempotent by default. Agents retry, get interrupted, and occasionally act on stale or ambiguous state. Any tool with a real side effect needs to assume it will eventually be called twice, out of order, or after a crash, and stay correct anyway.
The takeaway
Building an MCP server is mostly a protocol formality: register tools, return structured content, run over stdio. The actual engineering work, the part that determines whether an agent using your server is trustworthy or dangerous, is the same work good API design has always required: clear state machines, honest failure modes, and hard guardrails in front of anything expensive or irreversible. Get that part right, and the protocol layer genuinely is the easy part.
If you're building a developer tool and want early feedback plus a free, indexed profile, submit it to makers.page. Real usage, real feedback, and a dofollow link if it is a fit.
Related reading: