An MCP server is a long-lived process that speaks JSON-RPC 2.0 over stdio (or HTTP), advertises a set of tools, resources, and prompts in response to an initialize handshake, and answers tools/call requests with structured results. That is the whole protocol. The hard part is not the protocol; the hard part is making a server that survives a real workload — input validation, timeouts, secret handling, deploy story, and a public name that other agents can pin against.
The reason the search results for this query are dominated by beginner tutorials is that “build an MCP server” reads like a how-to, and the SERP rewards how-tos. The reality is closer to a small backend project than a library demo. The server has to handle malformed input, be safe to share with another developer, and not collapse when the model calls it with a 4 KB argument the docs did not mention.
This post is the version I would want to read before starting. A working server, deployed, with auth, errors that mean something, and a path to share it.
Table of contents
- What the protocol actually requires
- The TypeScript or Python question
- A working TypeScript server, start to finish
- The four things that decide if the server survives contact with agents
- The deploy story: stdio, HTTP, and the gap between them
- Publishing the server so other people can use it
- A short checklist before you ship the first version
- FAQ
What the protocol actually requires
The Model Context Protocol is a JSON-RPC 2.0 conversation. The client (the agent) opens a transport, sends initialize with its protocol version and capabilities, the server replies with its own capabilities, then the client sends tools/list to discover what the server can do, and tools/call to invoke a specific tool with arguments.
The four things the server has to do well:
- Reply to
initializepromptly with a stableserverInfo.nameandserverInfo.version. The agent caches the version. Bump it when the schema changes; never change tool signatures silently. - Reply to
tools/listwith a complete and honest schema. Each tool needs a name, a description the agent can read, and aninputSchemain JSON Schema. Vague descriptions produce confused calls; wrong schemas produce crashes. - Handle
tools/calldefensively. Validate the input. Catch every error. Return a structured error with a useful message. The agent will retry on ambiguous failures, which means a bad error message becomes a tight loop of confused calls. - Stay alive. The transport is long-lived. A server that crashes on a missing argument takes the agent’s tool list with it until the transport is re-established.
That is the entire contract. Everything else is tooling, transport, and opinion.
The TypeScript or Python question
Either is fine. The official SDKs are mature in both languages, and the protocol is small enough that the SDK choice does not lock you in.
TypeScript is the right pick if the team is already shipping JavaScript and the server is going to be deployed on a Node-friendly runtime. The zod-based schema definition is the best part of the experience — a single source of truth that validates input, types the call site, and feeds the JSON Schema the agent consumes. The trade-off is the Node ecosystem’s tendency to ship 800 MB of node_modules for a 200-line server. Pin a runtime, pin a lockfile, ship a Docker image.
Python is the right pick if the server is calling Python-native tools — data tools, ML pipelines, anything in the scientific stack. The pydantic v2 models give you the same single-source-of-truth pattern as zod, and FastAPI-style Pydantic models are familiar to anyone who has built a web service in the last five years. The trade-off is the deploy story: pick a base image that does not ship 1.2 GB of CUDA drivers you do not need.
For a team that has not built one before, start with TypeScript. The feedback loop is faster, the JSON Schema tooling is the best in class, and the deploy story is the same shape on both sides once you stop arguing about the language.
A working TypeScript server, start to finish
The smallest useful server exposes a single tool, validates its input, and returns structured output. Here is one that adds two numbers, with the validation, the error handling, and the deploy story in place.
Project layout:
mcp-add/
├── package.json
├── tsconfig.json
├── Dockerfile
├── src/
│ ├── index.ts # entry point, stdio transport
│ ├── tools/
│ │ └── add.ts # the actual tool
│ └── schemas.ts # zod schemas, the source of truth
package.json (the lockfile is the source of truth; commit it):
{
"name": "@yourorg/mcp-add",
"version": "0.1.0",
"type": "module",
"bin": { "mcp-add": "./dist/index.js" },
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.23.0"
}
}
src/schemas.ts — the schemas feed both the JSON Schema the agent sees and the runtime validation:
import { z } from "zod";
export const AddInput = z.object({
a: z.number().describe("The first addend"),
b: z.number().describe("The second addend"),
});
export type AddInput = z.infer<typeof AddInput>;
src/tools/add.ts — the tool itself:
import { AddInput } from "../schemas.js";
export const addTool = {
name: "add",
description: "Add two numbers and return the sum as a string. Use when the user asks for arithmetic on two numeric values.",
inputSchema: {
type: "object",
properties: {
a: { type: "number", description: "The first addend" },
b: { type: "number", description: "The second addend" },
},
required: ["a", "b"],
},
async run(args: unknown) {
const parsed = AddInput.parse(args);
return {
content: [
{ type: "text", text: String(parsed.a + parsed.b) },
],
};
},
};
src/index.ts — the transport, with error handling that does not crash the server:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { addTool } from "./tools/add.js";
const server = new Server(
{ name: "mcp-add", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler("tools/list", async () => ({
tools: [
{
name: addTool.name,
description: addTool.description,
inputSchema: addTool.inputSchema,
},
],
}));
server.setRequestHandler("tools/call", async (request) => {
if (request.params.name !== addTool.name) {
throw new Error(`Unknown tool: ${request.params.name}`);
}
return addTool.run(request.params.arguments);
});
const transport = new StdioServerTransport();
await server.connect(transport);
The interesting lines are the ones that look boring. Server declares the version. setRequestHandler covers both halves of the protocol. The transport is a one-liner because stdio is the default. The error path on an unknown tool name returns a structured Error, which the SDK turns into a JSON-RPC error response, which the agent reads and acts on instead of looping.
The four things that decide if the server survives contact with agents
Once the hello-world is working, the next question is whether the server holds up. The four decisions that matter:
1. What goes in the tool description. The agent decides when to call a tool based on the description. A vague description gets the tool called for the wrong reasons. A two-sentence description with a use case is enough; a three-paragraph essay gets summarized anyway. The description is a contract, not a manual.
2. How errors are returned. A thrown exception with a stack trace is debuggable for the developer and useless for the agent. A structured error with a human-readable message and a stable code is what the agent needs to decide whether to retry, ask the user, or give up. The MCP SDK supports both shapes; the choice is yours.
3. Where secrets live. The server process has to be able to reach the secrets it needs. The wrong shape is secrets in the config file (committed by accident, leaked in logs, impossible to rotate). The right shape is secrets in the platform’s secret store, injected as env vars at process start. The same rule applies to MCP servers as to any other backend.
4. What the timeout is. An MCP call has a budget. The agent will eventually time out and move on, but a slow server poisons the conversation. Set explicit timeouts on every I/O call, fail fast on the slow path, and surface the timeout as a structured error so the agent can decide whether the call is worth retrying.
The deploy story: stdio, HTTP, and the gap between them
stdio is the default and the right answer for local use. The agent spawns the server as a child process, talks to it over stdin/stdout, and tears it down when the editor closes. The trade-off: the server has to be on the same machine as the agent, which means every user has to install it.
HTTP is the answer for shared servers. The server runs on a host the agent can reach, the agent opens an HTTP transport, and the server serves many agents. The trade-off is the operational one — the server is now a real backend, with uptime, auth, and a URL.
The gap between them is auth. A stdio server is implicitly trusted (the user ran it). An HTTP server has to prove the agent is allowed to call it. The protocol supports bearer tokens and OAuth; the implementation is on you. The interesting design call is whether to make the HTTP server publicly addressable (useful for hosted MCP) or to keep it on a private network (the right answer for internal tools). For a team that wants the latter, the platform question is the same as for any internal API: a private network with managed services that the agent runtime can reach, and secrets injected from a store the team controls.
The fastest deploy path for an HTTP MCP server today is a Docker image on a managed platform that handles TLS, scaling, and the secret store. The Dockerfile for the TypeScript server above is about ten lines. The image is small. The deploy is one push. The rest of the work is the URL, the auth, and the health check.
Publishing the server so other people can use it
A server that only you can run is a private tool. A server other developers can install is a public tool, and the publishing story is what makes the difference.
For an npm package, the path is npx your-package, which is what the config snippet at the top of this post used. Pin the version, ship a bin entry, document the JSON Schema the agent will see, and write the description for the agent — not for the developer. A README that explains how to install the package is for the developer; the description inside the tool is for the model.
For Python, the equivalent is uvx your-package or pipx run your-package. The Python MCP SDK has the same shape as the TypeScript one; the only difference is the packaging.
For a hosted server, the publish story is a hosted URL, a public or auth-gated endpoint, and a name. The agent config is just a url instead of a command. The deployment pain is the same as for any backend, with one extra step: documenting the JSON Schema the server expects, so the agent can discover what it can call.
A short checklist before you ship the first version
- The server has a stable
nameandversionit returns ininitialize. - Every tool has a description an agent can act on, not a paragraph a developer has to read.
- The input schema is a real JSON Schema, not a free-form
any. - Errors are structured, with a human message and a stable code.
- Every I/O call has a timeout, and the timeout is short enough to keep the conversation responsive.
- Secrets are loaded from env vars, never from the config file.
- The server has a health check the platform can call.
- The lockfile is committed, the runtime is pinned, and the deploy is reproducible.
If you can tick all eight, the server is shippable. Everything beyond that is iteration. The interesting work is the part where the description is good enough that the agent calls the tool correctly on the first try, the part where the errors are useful enough to debug from the agent’s transcript, and the part where the deploy is boring enough that nobody has to think about it.
How this fits the rest of the stack
An MCP server that gets used by other agents is a real piece of infrastructure, and the cost of running it should be modelled the same way as any other service — runtime, memory, bandwidth, storage, and database if it has one. The RunxBuild hosting calculator is the quick way to model that — pick the runtime size, the memory tier, the storage, and the expected request volume, and the calculator shows what the MCP server costs to run at the team’s actual usage rather than what the free tier hides.
Useful related references:
FAQ
What language should I build an MCP server in?
Either TypeScript or Python. The official SDKs are mature in both. Pick the one your team is already shipping in; the protocol is small enough that the SDK choice does not matter.
How is an MCP server different from a normal API?
A normal API answers HTTP requests. An MCP server answers JSON-RPC requests, advertises a schema, and lives next to an agent. The protocol is small; the operational story is what changes.
Do I need a database to build an MCP server?
No. Many useful servers are stateless — they wrap an external API, do a calculation, or read a file. Add a database when the server needs to remember something between calls.
How do I authenticate an MCP server?
For stdio, no auth is needed — the user ran the process. For HTTP, the protocol supports bearer tokens and OAuth. The choice depends on whether the server is public, internal, or per-user.
Can an MCP server call another MCP server?
Yes. A server can act as a client to another server. The interesting question is whether the composition is worth the complexity — for most cases, putting the logic in one server is simpler.
How do I deploy an MCP server?
Stdio: publish an npm or Python package. HTTP: deploy a Docker image on a managed platform that handles TLS, scaling, and the secret store. The deploy story is the same as for any long-lived backend.