An MCP server is an adapter between an AI application and a capability you control. The protocol makes tools discoverable; it does not make an unsafe capability safe. A production-quality server still needs narrow operations, schema validation, authorization, idempotency, timeouts, and useful errors.
This guide builds the smallest real server first: one read-like, side-effect-free tool over stdio. The example is compiled and exercised by a client in the companion repository. From there, we will draw the boundary between local stdio and remote Streamable HTTP deployments.
Understand the server primitives
An MCP server can expose three different primitives:
| Primitive | Controlled by | Use it for |
|---|---|---|
| Tool | Model | An operation the model may propose, such as searching tickets |
| Resource | Application | Read-only context identified by a URI |
| Prompt | User | A reusable prompt template or workflow entry point |
That distinction matters. “Read the current runbook” often fits a resource better than a tool. “Delete the deployment” is a tool, but it should also require authorization and likely human approval. The MCP server specification defines the protocol behavior; your application remains responsible for policy.
Build the working stdio server
The complete example lives in examples/mcp-stdio. Its dependencies are deliberately exact so the tutorial stays reproducible:
{
"dependencies": {
"@modelcontextprotocol/sdk": "1.29.0",
"zod": "4.4.3"
},
"devDependencies": {
"@types/node": "24.13.3",
"typescript": "6.0.3"
}
}
The server registers one tool with input and output schemas:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "bounded-math",
version: "1.0.0",
});
server.registerTool(
"add",
{
title: "Add two finite numbers",
description: "Returns the sum of a and b. This tool has no side effects.",
inputSchema: {
a: z.number().finite(),
b: z.number().finite(),
},
outputSchema: {
result: z.number().finite(),
},
},
async ({ a, b }) => {
const result = a + b;
if (!Number.isFinite(result)) {
return {
content: [{
type: "text",
text: "The sum is outside JavaScript's finite-number range.",
}],
isError: true,
};
}
return {
content: [{ type: "text", text: String(result) }],
structuredContent: { result },
};
},
);
const transport = new StdioServerTransport();
await server.connect(transport);
Run the end-to-end check from the project root:
cd examples/mcp-stdio
bun install
bun run check
The expected terminal line is:
MCP handshake, discovery, and tool call passed.
The test client starts the server as a child process, completes the MCP handshake, verifies discovery returns only add, calls it, asserts the structured result is 42, and confirms an overflowing sum returns a tool error. This catches incompatible imports, protocol startup failures, result-shape drift, and an important numeric edge case.
Why return both content and structuredContent
content gives clients a broadly compatible representation. structuredContent gives a program an object it can validate and use without parsing prose. When a tool declares outputSchema, keep the returned object consistent with it. The SDK can validate the contract, but your automated test should also assert the fields consumers depend on.
Keep stdout clean
With stdio, standard input and standard output carry protocol messages. Debug output on stdout can corrupt the stream. Send diagnostics to standard error, or use a logger configured for a separate sink.
Design tool contracts around outcomes
A weak tool is a thin wrapper around a powerful API:
request(method, url, body)
That shape invites server-side request forgery, privilege escalation, and accidental writes. Prefer outcome-oriented tools:
get_invoice(invoice_id)
create_refund_preview(invoice_id, reason)
submit_approved_refund(preview_id, approval_id)
Each operation can have its own authorization rule, rate limit, audit event, and idempotency behavior. The model sees less irrelevant flexibility, and the server has a concrete policy boundary.
For every tool, write down:
- whether it reads or writes;
- which principal and tenant it acts for;
- the maximum scope of one call;
- whether retrying is safe;
- which failures are retryable; and
- whether execution requires a separate approval.
Tool descriptions are model-visible input, not a security control. The OWASP MCP Security Cheat Sheet recommends per-tool least privilege, schema integrity, isolation, human approval for sensitive actions, and validation of tool results.
Return errors the client can act on
Separate three failure classes:
- Invalid request: the arguments fail the declared schema. The SDK should reject it before business logic.
- Expected tool failure: the request is valid, but the operation cannot complete—for example, an invoice does not exist. Return a tool result with
isError: trueand a safe explanation. - Server failure: the database connection or an invariant failed. Log a correlation ID and return a non-sensitive message.
Do not leak stack traces, SQL, credentials, or upstream response bodies to the model. Preserve the detailed cause in server telemetry where access is controlled.
For writes, accept or derive an idempotency key and persist it with the result. A timeout does not prove an operation failed; the client may have lost the response after the side effect completed.
Choose the transport from the trust boundary
The official TypeScript server guide recommends stdio for local, process-spawned integrations and Streamable HTTP for remote servers. The older HTTP+SSE transport exists for backward compatibility and is deprecated for new deployments.
Use stdio when
- the host launches a local server executable;
- credentials can come from the host process environment;
- one host owns the server lifecycle; and
- the tool should not be reachable on the network.
Stdio reduces network exposure, but it does not sandbox the process. A local MCP server inherits whatever filesystem, network, and credential access the launcher grants it.
Use Streamable HTTP when
- several clients need a managed service;
- authentication and authorization happen at a service boundary;
- you need centralized rate limits and audit logs; or
- operations may use resumable or asynchronous protocol features.
For a remote deployment, add controls outside the MCP handler:
- TLS at the edge;
- authentication that establishes the user or workload identity;
- authorization inside every tool using that identity;
- origin validation where browser clients are possible;
- body, connection, and execution limits;
- per-principal rate and cost budgets;
- session storage appropriate to your scaling model; and
- graceful shutdown that stops accepting new work before terminating sessions.
The MCP transport specification also warns local HTTP servers to validate the Origin header and bind to loopback rather than all interfaces. The SDK’s Express helper includes DNS-rebinding protection for localhost deployments; do not replace it casually with an unguarded listener.
Production release checklist
Before exposing a real capability, verify:
- tool names, descriptions, and schemas are reviewed like public API surface;
- inputs have length, count, format, and enum bounds—not only types;
- URLs are allowlisted and resolved addresses cannot reach private infrastructure;
- writes use scoped credentials and idempotency keys;
- destructive or externally visible actions have an approval boundary;
- errors are useful without exposing sensitive internals;
- logs include principal, tenant, tool, duration, outcome, and correlation ID;
- tool arguments and results are redacted before telemetry export;
- dependency versions and tool definitions are pinned and monitored for changes; and
- a real client test exercises handshake, discovery, success, invalid input, expected failure, and cancellation.
Once the server contract is stable, build the other half deliberately: connect a typed MCP client, inspect capabilities, and handle tool results.
Sources and freshness notes
This tutorial was checked against the MCP specification dated 2025-11-25, the official TypeScript SDK documentation, and the official SDK repository on July 18, 2026. Recheck transport and authorization guidance before adopting a newer specification or SDK major version.