Skip to content
AI Full-Stack Wiki

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.

Difficulty: Intermediate19 min readUpdated
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#

Model Context Protocol architectureA host application such as Claude Code or your own app runs one MCP client per server. A local server is started as a subprocess and speaks over stdio; a remote server is reached over Streamable HTTP. Each server exposes tools, resources, and prompts in front of a real system.stdioHOST PROCESSHost appClaude Code, your appMCP client 1one per serverMCP client 2one per serverLocal serverdocs searchRemote serverticketing APIFiles & indexSaaS API

Hover or tap a component to see what it does.

MCP architecture: a host runs one client per server; local servers run as subprocesses over stdio, remote servers over Streamable HTTP, and each server exposes tools, resources, and prompts for a real system.
Show 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#

PrimitiveControlled byUse forExample
ToolsThe modelActions and queries the model decides to runsearch_notes, create_ticket
ResourcesThe applicationData the host can attach as contextnotes://42, a file, a schema
PromptsThe userReusable 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#

MCP session lifecycleThe client sends initialize with its capabilities, the server answers with its own, the client confirms with initialized, lists tools, passes them to the model, and forwards the model's tool call to the server as tools/call.initializediscoveruseModelHost +clientMCP serverinitialize(protocolversion,capabilities)result: servercapabilitiesnotifications/initializedtools/listtools:search_docs,get_pagerequest withtooldefinitionstool_usesearch_docstools/callsearch_docs{query}result: contentblocks

Step 1 of 9

initialize (protocol version, capabilities)

The client announces which protocol version and features it supports.

An MCP session: the client and server negotiate capabilities, the client lists tools and passes them to the model, and a tool call from the model is forwarded to the server as tools/call.
Show 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.

  1. Host + client → MCP server: initialize (protocol version, capabilities) · The client announces which protocol version and features it supports.
  2. MCP server → Host + client: result: server capabilities · The server answers with its name, version, and capabilities: tools, resources, prompts.
  3. Host + client → MCP server: notifications/initialized (asynchronous)
  4. Host + client → MCP server: tools/list · The client fetches tool names, descriptions, and JSON schemas.
  5. MCP server → Host + client: tools: search_docs, get_page
  6. Host + client → Model: request with tool definitions · The host converts MCP tools into tool definitions in its model request.
  7. Model → Host + client: tool_use search_docs · The model decides to call a tool. The host may ask the user for approval here.
  8. Host + client → MCP server: tools/call search_docs {query}
  9. 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:

notes-server.ts
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: true reports a failure to the model as a result, like is_error in plain tool use.
  • Annotations such as readOnlyHint and destructiveHint let 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.log corrupts the protocol stream.

Use it from Claude Code#

Register the server with Claude Code's CLI, and its tools appear in your coding sessions:

terminal
claude mcp add notes -- node /absolute/path/to/notes-server.ts
claude mcp list

To share the server with your team, check a project-scoped configuration into the repository as .mcp.json:

.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:

agent-with-mcp.ts
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)
RunsOn the user's machine, started by the hostAs a web service you deploy
UsersOneMany
AuthInherits the user's local permissionsOAuth 2.1 per user
Good forFiles, local dev tools, personal dataSaaS 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.log in a stdio server. Use console.error for 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

  1. Add a list_tags tool that returns every tag with its note count, marked readOnlyHint: true.
  2. Open the server in the MCP Inspector and call each tool by hand, including get_note with an ID that does not exist.
  3. Register it with Claude Code and ask "Which of my notes are about pricing?". Check which tools it called.

Check your understanding

Who decides when an MCP tool is called?
Why must a stdio MCP server avoid console.log?
Which transport fits a company-wide ticketing integration used by many people?

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 registerTool with Zod schemas makes servers short; the Anthropic SDK's mcpTools plugs 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.