Model Context Protocol (MCP): Build and Use MCP Servers in TypeScript
Understand MCP hosts, clients, servers, and transports, build a TypeScript MCP server with tools, resources, and prompts, and use it from Claude Code and your agents.
On this page· The architecture
Why this matters
Every AI application needs tools, and without a standard, every integration is custom: your GitHub tool works in your app but not in your editor, and your teammate rebuilds it for theirs. The Model Context Protocol (MCP) is an open standard for connecting AI applications to tools and data. Build an MCP server once, and it works in Claude Code, Claude Desktop, IDEs, and your own agents. For full-stack engineers, MCP servers are also a practical product surface: a way to make your service usable by AI agents.
The architecture#
Hover or tap a component to see what it does.
Show text descriptionHide text description
MCP standardizes the connection between AI applications and the systems they use, so one server works with every compatible host.
- Host app: The application the user works in. It owns the model connection and decides which servers to connect and which tool calls to allow.
- MCP client 1: Keeps a 1:1 session with a server: negotiates capabilities, lists tools, and forwards calls.
- MCP client 2: A second session, fully isolated from the first.
- Local server: Runs on the user's machine as a subprocess. Exposes tools such as search_docs and resources such as files.
- Remote server: Runs as a web service with OAuth. Exposes tools such as create_ticket to many users.
- Files & index: The local data the server wraps.
- SaaS API: The real system behind the remote server.
Four roles:
- Host: the application the user works in, such as Claude Code or your own agent app. It owns the model connection and decides what is allowed.
- Client: a connection inside the host, one per server.
- Server: a program that exposes capabilities in front of a real system: files, a database, a SaaS API.
- Transport: how client and server talk. stdio for local servers that the host starts as a subprocess; Streamable HTTP for remote servers shared by many users.
Messages are JSON-RPC 2.0, and the SDKs hide the details.
What a server can expose#
| Primitive | Controlled by | Use for | Example |
|---|---|---|---|
| Tools | The model | Actions and queries the model decides to run | search_notes, create_ticket |
| Resources | The application | Data the host can attach as context | notes://42, a file, a schema |
| Prompts | The user | Reusable prompt templates the user picks, such as a slash command | /summarize-notes |
Tools are by far the most used. They map directly onto the tool use you already know: a name, a description, and an input schema.
A session, message by message#
Step 1 of 9
initialize (protocol version, capabilities)
The client announces which protocol version and features it supports.
Show text descriptionHide text description
The messages are JSON-RPC 2.0. The SDKs handle them for you, but knowing the sequence makes debugging easy. Participants, left to right: Model, Host + client, MCP server.
- Host + client → MCP server: initialize (protocol version, capabilities) · The client announces which protocol version and features it supports.
- MCP server → Host + client: result: server capabilities · The server answers with its name, version, and capabilities: tools, resources, prompts.
- Host + client → MCP server: notifications/initialized (asynchronous)
- Host + client → MCP server: tools/list · The client fetches tool names, descriptions, and JSON schemas.
- MCP server → Host + client: tools: search_docs, get_page
- Host + client → Model: request with tool definitions · The host converts MCP tools into tool definitions in its model request.
- Model → Host + client: tool_use search_docs · The model decides to call a tool. The host may ask the user for approval here.
- Host + client → MCP server: tools/call search_docs {query}
- MCP server → Host + client: result: content blocks · The result is returned to the model as a tool_result, and the conversation continues.
Build an MCP server#
This server exposes a small notes store. It uses the official TypeScript SDK and Zod schemas:
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const notes = new Map<string, { title: string; body: string; tags: string[] }>([
["1", { title: "Deploy checklist", body: "Run migrations, then deploy the web app, then the workers.", tags: ["ops"] }],
["2", { title: "Pricing decision", body: "Starter stays at $29 until Q1; review churn monthly.", tags: ["pricing"] }],
]);
const server = new McpServer({ name: "notes", version: "1.0.0" });
server.registerTool(
"search_notes",
{
title: "Search notes",
description: "Search notes by keyword in titles and bodies. Returns note IDs and titles; read a note with get_note.",
inputSchema: { query: z.string().min(2).describe("Keyword, such as 'deploy'") },
outputSchema: { results: z.array(z.object({ id: z.string(), title: z.string() })) },
annotations: { readOnlyHint: true },
},
async ({ query }) => {
const q = query.toLowerCase();
const results = [...notes]
.filter(([, n]) => `${n.title} ${n.body}`.toLowerCase().includes(q))
.map(([id, n]) => ({ id, title: n.title }));
return {
content: [{ type: "text", text: JSON.stringify(results) }],
structuredContent: { results },
};
},
);
server.registerTool(
"get_note",
{
title: "Get note",
description: "Read the full text of one note by ID.",
inputSchema: { id: z.string() },
annotations: { readOnlyHint: true },
},
async ({ id }) => {
const note = notes.get(id);
if (!note) return { content: [{ type: "text", text: `No note with id ${id}` }], isError: true };
return { content: [{ type: "text", text: `# ${note.title}\n\n${note.body}` }] };
},
);
server.registerTool(
"add_note",
{
title: "Add note",
description: "Create a new note. Use only when the user asks to save something.",
inputSchema: { title: z.string().min(1).max(120), body: z.string().max(10_000), tags: z.array(z.string()).default([]) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
},
async ({ title, body, tags }) => {
const id = String(notes.size + 1);
notes.set(id, { title, body, tags });
return { content: [{ type: "text", text: `Created note ${id}` }] };
},
);
// A resource template: hosts can attach notes://<id> as context.
server.registerResource(
"note",
new ResourceTemplate("notes://{id}", { list: undefined }),
{ title: "Note", description: "A single note as Markdown", mimeType: "text/markdown" },
async (uri, { id }) => {
const note = notes.get(String(id));
if (!note) throw new Error(`No note with id ${String(id)}`);
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: `# ${note.title}\n\n${note.body}` }] };
},
);
// A prompt template the user can pick, for example as a slash command.
server.registerPrompt(
"summarize_notes",
{
title: "Summarize notes about a topic",
description: "Find and summarize all notes about a topic",
argsSchema: { topic: z.string() },
},
({ topic }) => ({
messages: [{ role: "user", content: { type: "text", text: `Search my notes about "${topic}", read the relevant ones, and summarize them in five bullet points.` } }],
}),
);
// stdout carries the protocol: log to stderr only.
await server.connect(new StdioServerTransport());
console.error("notes MCP server running on stdio");Details that matter:
- Descriptions are prompts. The model reads them to decide when to call a tool. Say when to use it, not just what it does.
isError: truereports a failure to the model as a result, likeis_errorin plain tool use.- Annotations such as
readOnlyHintanddestructiveHintlet hosts decide which calls need approval. They are hints from the server, not guarantees, so hosts should only trust them from servers they trust. - Never write to stdout in a stdio server except through the transport; a stray
console.logcorrupts the protocol stream.
Use it from Claude Code#
Register the server with Claude Code's CLI, and its tools appear in your coding sessions:
claude mcp add notes -- node /absolute/path/to/notes-server.ts
claude mcp listTo share the server with your team, check a project-scoped configuration into the repository as .mcp.json:
{
"mcpServers": {
"notes": { "command": "node", "args": ["./mcp/notes-server.ts"] }
}
}During development, the MCP Inspector (npx @modelcontextprotocol/inspector node notes-server.ts) opens a web UI where you can list and call tools by hand, which is much faster than debugging through a model.
Use it from your own agent#
Your app can be an MCP host too. The MCP SDK's client starts the server and talks to it; the Anthropic SDK's mcpTools helper turns the server's tools into tools for the tool runner. A small adapter bridges a small difference between the two SDKs' types:
import Anthropic from "@anthropic-ai/sdk";
import { mcpTools, type MCPClientLike } from "@anthropic-ai/sdk/helpers/beta/mcp";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
const mcp = new Client({ name: "my-agent", version: "1.0.0" });
await mcp.connect(new StdioClientTransport({ command: "node", args: ["notes-server.ts"] }));
// The MCP SDK's callTool type also allows a legacy { toolResult } shape from very old servers.
// Narrow it to the current shape the Anthropic helper expects.
const isCurrent = (r: unknown): r is CallToolResult => Array.isArray((r as { content?: unknown } | null)?.content);
const caller: MCPClientLike = {
async callTool(params) {
const result = await mcp.callTool(params);
if (!isCurrent(result)) throw new Error("Unsupported legacy tool result");
return result;
},
};
try {
const { tools } = await mcp.listTools();
console.log("tools:", tools.map((t) => t.name).join(", "));
// Expose only read-only tools to this agent: least privilege.
const readOnly = tools.filter((t) => t.annotations?.readOnlyHint === true);
const anthropic = new Anthropic();
const final = await anthropic.beta.messages.toolRunner({
model: "claude-opus-5-5",
max_tokens: 4096,
max_iterations: 6,
tools: mcpTools(readOnly, caller),
messages: [{ role: "user", content: "What is our deploy order? Check my notes." }],
});
console.log(final.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join(""));
} finally {
await mcp.close(); // also stops the server subprocess
}Local or remote?#
| stdio (local) | Streamable HTTP (remote) | |
|---|---|---|
| Runs | On the user's machine, started by the host | As a web service you deploy |
| Users | One | Many |
| Auth | Inherits the user's local permissions | OAuth 2.1 per user |
| Good for | Files, local dev tools, personal data | SaaS integrations, company-wide tools |
Start with stdio while you design the tools; the tool code stays the same when you later serve it over HTTP. The custom MCP server project builds a remote server with authentication.
Security: an MCP server is code with your permissions#
- Only install servers you trust. A local server runs as you, with access to your files and credentials.
- Tool descriptions and results are untrusted input to the model. A malicious server, or a document a tool returns, can contain instructions aimed at the model (prompt injection). Hosts should require approval for consequential actions regardless of what the text says.
- Least privilege. Expose only the tools an agent needs, scope tokens narrowly, and prefer read-only tools.
- Validate inputs on the server. Zod schemas do it for you; also check authorization per call in remote servers.
- Treat destructive tools specially. Mark them with annotations, and confirm with the user in the host.
Common mistakes#
console.login a stdio server. Useconsole.errorfor logs.- One giant "do anything" tool. Small, well-described tools are easier for models to use correctly and easier to permit.
- Returning huge payloads. Tool results go into the model's context. Paginate and summarize.
- Exposing every tool to every agent. Filter the list per use case.
- Trusting annotations from unknown servers. They are hints, not guarantees.
Try it
Extend and inspect the server
- Add a
list_tagstool that returns every tag with its note count, markedreadOnlyHint: true. - Open the server in the MCP Inspector and call each tool by hand, including
get_notewith an ID that does not exist. - Register it with Claude Code and ask "Which of my notes are about pricing?". Check which tools it called.
Check your understanding
Key takeaways
- MCP standardizes how AI applications connect to tools and data: hosts run clients, clients connect to servers over stdio or Streamable HTTP.
- Servers expose tools (model-controlled), resources (application-controlled), and prompts (user-controlled).
- The TypeScript SDK's
registerToolwith Zod schemas makes servers short; the Anthropic SDK'smcpToolsplugs them into your agent. - Treat servers as code running with your permissions: install trusted ones, expose the least privilege, and confirm destructive actions.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- Tool Use (Function Calling) with Claude in TypeScriptLet the model call your functions: define tools, run the tool-use loop by hand, handle errors and parallel calls, then use the SDK tool runner with Zod-typed tools.
- Project: Build a Remote MCP Server for Your Product with Next.jsExpose your product to AI agents: a remote MCP server in a Next.js route with Streamable HTTP, hashed API tokens, scopes, per-user data access, and tests.
- Security for LLM Apps: Prompt Injection, Data Leaks, and Agent RisksThreat-model an AI feature, defend against direct and indirect prompt injection, block exfiltration and SSRF, scope tools, and protect secrets and personal data.