- What is an MCP server? It is a program that exposes tools, resources, and prompts to AI applications over the Model Context Protocol, which uses JSON-RPC 2.0 messages.
- The current protocol revision is 2026-07-28, as of November 8, 2026. It removed the
initializehandshake and protocol sessions, so the code here uses the v2 TypeScript SDK packages. - This guide builds a notes server with a search tool, a resource, and a write tool that waits for a user's approval through the host.
- Every code block ran on November 8, 2026 with Node.js 26.12.1,
@modelcontextprotocol/server2.4.0,@modelcontextprotocol/client2.4.0,zod4.6.7,tsx4.23.15, and MCP Inspector 2.14.0. claude mcp addand its connection check ran against a throwaway config on Claude Code 2.1.318. A live Claude Code session with a model did not run.
An MCP server gives AI applications a standard way to call your code and read your data. If you searched for what is an MCP server, the short answer is a program that speaks the Model Context Protocol so that any compatible host can use it. This guide answers that question, builds one server from read-only to a confirmed write, and finishes with an Inspector workflow for CI and a security checklist taken from the spec.
#What is an MCP server
An MCP server is a program that exposes tools, resources, and prompts to AI applications over the Model Context Protocol. It runs on your machine or a remote host and answers JSON-RPC 2.0 requests. The model sits behind the host, and the server is one of the things that host can reach.
Anthropic introduced MCP in November 2024 as an open standard for connecting AI applications to data and tools. To answer what is an MCP server precisely, the architecture overview names three participants. A host is the AI application, a client is the connector the host creates for one server, and a server is the program that provides context.
A server can offer three primitives. The server overview defines who controls each one, and that column decides how a host treats them.
| Primitive | Controlled by | What it is | In this guide |
|---|---|---|---|
| Tool | The model | A function exposed to the model, with typed arguments | search_notes, append_note |
| Resource | The application | Data the client reads and attaches as context | notes://{name} |
| Prompt | The user | A template a person picks, often as a slash command | Not used |
The model side of this picture is the agent loop, which turns a tool call from the model into a request to your server. Tool definitions and results also take up room in the context window, which is why context engineering applies to server design.
#How an MCP server differs from an API
An MCP server is a layer for AI hosts, and it usually calls an API you already run. A REST API serves developers who read documentation and write client code. An MCP server describes each operation in a schema that a host fetches at runtime, then returns results as content for the model.
| Question | REST API | MCP server |
|---|---|---|
| Who consumes it | Code a developer wrote | A host application and its model |
| How features are found | Docs or an OpenAPI file, at build time | tools/list, at runtime |
| How a caller reads a failure | HTTP status and body | isError: true with text for the model |
| Who approves a side effect | Your application | The host, with a person who can deny the call |
Skip the server if you have one application, one model, and one integration. A plain function tool in your own code is smaller and easier to debug. Build an MCP server when two or more hosts need the same capability, or when people outside your team will connect their own agents.
#What changed in revision 2026-07-28
The current MCP revision is 2026-07-28, and it made the protocol stateless. Each request now carries its protocol version and client capabilities in _meta, the initialize handshake is gone, and every server must answer server/discover. A server that follows the older revisions needs code changes.
The changelog lists the details. These changes affect how you write a server:
- Protocol sessions and
Mcp-Session-Idare gone, so state that spans calls travels in explicit handles passed as tool arguments. - Servers no longer send requests to clients. A handler that needs input returns an
input_requiredresult, and the client retries the call with the answer. - HTTP+SSE, Roots, Sampling, and Logging are marked deprecated, which means new servers should avoid them.
The TypeScript SDK split to match. The older @modelcontextprotocol/sdk package, at 1.32.2 on November 8, 2026, tops out at revision 2025-11-25 in its own constants.
The v2 packages implement 2026-07-28. The SDK README says v1 gets fixes for at least six months after the July 27, 2026 release of v2. The first version of this guide used v1, so its imports changed in this update.
Older clients keep working. serveStdio serves both protocol eras by default, and the handler below returned the same results to clients on 2025-11-25 and 2026-07-28.
#How to build an MCP server in TypeScript
To build an MCP server, install the SDK's server package, register each tool with a zod schema, and hand a factory function to a transport. The steps below produce a stdio server that searches a folder of Markdown notes. The SDK needs Node.js 20 or later, and this run used 26.12.1.
Create the project:
mkdir notes-mcp && cd notes-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server @modelcontextprotocol/client zod
npm install -D tsx
mkdir notes
printf '# KV cache\n\nThe KV cache stores attention keys and values.\n' > notes/kv-cache.mdSave the server as server.ts:
import { constants } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { McpServer, ResourceNotFoundError, ResourceTemplate } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
const NOTES_DIR = path.resolve(process.env.NOTES_DIR ?? "./notes");
const READ_FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW;
// One folder, Markdown files only, no subpaths.
function notePath(name: string): string {
const file = path.resolve(NOTES_DIR, name);
if (path.dirname(file) !== NOTES_DIR || path.extname(file) !== ".md") {
throw new Error(`Invalid note name "${name}"`);
}
return file;
}
function createServer(): McpServer {
const server = new McpServer({ name: "notes", version: "1.0.0" });
server.registerTool(
"search_notes",
{
title: "Search notes",
description: "Find Markdown notes that contain a phrase. Returns matching file names, one per line.",
inputSchema: z.object({ query: z.string().min(2).describe("Phrase to find, case-insensitive") }),
annotations: { readOnlyHint: true, openWorldHint: false },
},
async ({ query }) => {
const entries = await readdir(NOTES_DIR, { withFileTypes: true });
const files = entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name).sort();
const hits: string[] = [];
for (const file of files) {
const text = await readFile(notePath(file), { encoding: "utf8", flag: READ_FLAGS });
if (text.toLowerCase().includes(query.toLowerCase())) hits.push(file);
}
return { content: [{ type: "text", text: hits.length ? hits.join("\n") : "No matches." }] };
},
);
server.registerResource(
"note",
new ResourceTemplate("notes://{name}", { list: undefined }),
{ title: "Note", description: "One note as Markdown", mimeType: "text/markdown" },
async (uri, { name }) => {
try {
const text = await readFile(notePath(String(name)), { encoding: "utf8", flag: READ_FLAGS });
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text }] };
} catch {
throw new ResourceNotFoundError(uri.href);
}
},
);
return server;
}
void serveStdio(createServer);
console.error("notes MCP server running on stdio");From the one zod schema, the SDK derives the JSON Schema that hosts see, validates arguments before your handler runs, and types the handler. The describe() text is the only documentation the model gets for that argument, so write it for a reader who has nothing else.
notePath confines every read to one folder, and O_NOFOLLOW makes the open fail on a symbolic link. The catch block turns any read failure into a standard not-found error, so absolute paths never reach the model.
#Test it with a client script
Test the server with the SDK's client before you involve a host, because a script can assert exact results and needs no API key. The client below connects over stdio, negotiates the newest protocol era, and calls the tool with good and bad arguments. Save it as client.ts:
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
const client = new Client({ name: "smoke-test", version: "1.0.0" }, { versionNegotiation: { mode: "auto" } });
await client.connect(new StdioClientTransport({ command: "npx", args: ["tsx", "server.ts"] }));
console.log("era:", client.getProtocolEra());
const { tools } = await client.listTools();
console.log("tools:", tools.map((t) => t.name));
const call = async (name: string, args: Record<string, unknown>) => {
const { content, isError } = await client.callTool({ name, arguments: args });
console.log(name, JSON.stringify(args), "=>", JSON.stringify({ content, isError }));
};
await call("search_notes", { query: "kv" });
await call("search_notes", { query: "k" });
const note = await client.readResource({ uri: "notes://kv-cache.md" });
console.log("resource:", JSON.stringify(note.contents[0].text));
await client.readResource({ uri: "notes://..%2F..%2Fetc%2Fpasswd" }).catch((e) => console.log("rejected:", e.code, e.message));
await client.close();Run it with npx tsx client.ts. The server's startup line on stderr is omitted here:
era: modern
tools: [ 'search_notes' ]
search_notes {"query":"kv"} => {"content":[{"type":"text","text":"kv-cache.md"}]}
search_notes {"query":"k"} => {"content":[{"type":"text","text":"Input validation error: Invalid arguments for tool search_notes: query: Too small: expected string to have >=2 characters"}],"isError":true}
resource: "# KV cache\n\nThe KV cache stores attention keys and values.\n"
rejected: -32602 Resource not found: notes://..%2F..%2Fetc%2FpasswdThe second call breaks the min(2) rule, and the SDK answers with a tool result marked isError: true. The tools page of the spec classes input validation failures as tool execution errors, which clients should pass to the model so the next call can correct the arguments. An unknown tool name is a protocol error instead, and it came back as a rejected call with code -32602.
MCP over stdio is newline-delimited JSON, so you can talk to the server with no SDK at all:
echo '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' | npx tsx server.ts 2>/dev/null{"result":{"supportedVersions":["2026-07-28"],"capabilities":{"tools":{"listChanged":true},"resources":{"listChanged":true}},"resultType":"complete","ttlMs":0,"cacheScope":"private","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"notes","version":"1.0.0"}}},"jsonrpc":"2.0","id":1}#Add a write tool with confirmation
A write tool should ask a person through the host before it changes anything. In revision 2026-07-28, the server returns an input_required result that names the question, the host collects the answer, and the client retries the same call with the answer attached. The file stays untouched until that retry arrives.
A confirm argument on the tool would not do this job. Model output supplies the arguments, so the flag would be model output approving itself. Elicitation routes the question to the user through the client, outside the model's output.
Replace the two matching imports at the top of server.ts with these:
import { appendFile, readdir, readFile } from "node:fs/promises";
import {
acceptedContent,
inputRequired,
inputResponse,
McpServer,
ResourceNotFoundError,
ResourceTemplate,
type CallToolResult,
type InputRequiredResult,
} from "@modelcontextprotocol/server";Add this block above createServer, then add registerAppendNote(server); just before return server;:
const APPEND_FLAGS = constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW;
const noteName = z.string().regex(/^[\w-]+\.md$/).describe("Note file name, such as kv-cache.md");
const approval = z.object({ confirm: z.boolean().meta({ title: "Write this text to the note" }) });
// At most 10 writes per minute for this process.
const recentWrites: number[] = [];
function underWriteLimit(now = Date.now()): boolean {
while (recentWrites.length && recentWrites[0] < now - 60_000) recentWrites.shift();
if (recentWrites.length >= 10) return false;
recentWrites.push(now);
return true;
}
function registerAppendNote(server: McpServer) {
server.registerTool(
"append_note",
{
title: "Append to a note",
description: "Append text to one note after the user approves the exact change. Creates the note if it is missing.",
inputSchema: z.object({ name: noteName, text: z.string().min(1).max(2000) }),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
async ({ name, text }, ctx): Promise<CallToolResult | InputRequiredResult> => {
const answer = inputResponse(ctx.mcpReq.inputResponses, "confirm");
if (answer.kind === "elicit" && answer.action !== "accept") {
return { content: [{ type: "text", text: `Nothing written: the user chose ${answer.action}.` }] };
}
const approved = acceptedContent(ctx.mcpReq.inputResponses, "confirm", approval);
if (approved === undefined) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({ message: `Append to ${name}?\n\n${text}`, requestedSchema: approval }),
},
});
}
if (!approved.confirm) {
return { content: [{ type: "text", text: "Nothing written: the box was left unchecked." }] };
}
if (!underWriteLimit()) {
return { content: [{ type: "text", text: "Write limit reached. Try again in a minute." }], isError: true };
}
try {
await appendFile(notePath(name), `\n${text}\n`, { flag: APPEND_FLAGS });
} catch (err) {
console.error(err);
return { content: [{ type: "text", text: `Could not write ${name}.` }], isError: true };
}
return { content: [{ type: "text", text: `Appended ${text.length} characters to ${name}.` }] };
},
);
}The handler runs once per round. On the first entry there is no answer, so it returns the question.
On the retry, acceptedContent validates the answer against the same schema, and only then does the handler write. The SDK's input-required guide calls these responses untrusted for that reason.
Test both answers with a script that stands in for the host's dialog. Save it as write-test.ts:
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
const reply = process.argv[2]; // "accept" or "decline"
const client = new Client(
{ name: "write-test", version: "1.0.0" },
{ versionNegotiation: { mode: "auto" }, capabilities: { elicitation: { form: {} } } },
);
// Stands in for the host's approval dialog.
client.setRequestHandler("elicitation/create", async (request) => {
console.log("dialog:", JSON.stringify(request.params.message));
return reply === "accept" ? { action: "accept", content: { confirm: true } } : { action: "decline" };
});
await client.connect(new StdioClientTransport({ command: "npx", args: ["tsx", "server.ts"] }));
const { content, isError } = await client.callTool({
name: "append_note",
arguments: { name: "kv-cache.md", text: "Reviewed on November 8." },
});
console.log(JSON.stringify({ content, isError }));
await client.close();Output of npx tsx write-test.ts accept, then npx tsx write-test.ts decline, with the server's stderr line omitted:
dialog: "Append to kv-cache.md?\n\nReviewed on November 8."
{"content":[{"type":"text","text":"Appended 23 characters to kv-cache.md."}]}
dialog: "Append to kv-cache.md?\n\nReviewed on November 8."
{"content":[{"type":"text","text":"Nothing written: the user chose decline."}]}The other paths behaved like this in the same run:
| Case | Result |
|---|---|
| Box left unchecked | Nothing written: the box was left unchecked. |
| Client without elicitation, 2026-07-28 | Rejected call, code -32021, nothing written |
| Client without elicitation, 2025-11-25 | isError: true, nothing written |
name set to ../secrets.md | Schema rejects it before the handler runs |
| Note is a symbolic link | Could not write link.md. |
| Over 10 write attempts in a minute | Write limit reached. Try again in a minute. |
This check keeps the approval out of model output, and it cannot prove a person answered. A raw tools/call that arrived with inputResponses already attached wrote the file on the first try, because the server keeps no state between rounds.
Claude Code also lets users auto-answer these requests with an Elicitation hook. Keep your real limits in the server: the folder boundary, the schema, and the write cap.
#Debug with MCP Inspector
MCP Inspector is the project's reference tool for testing MCP servers without a model or an API key. It connects to your server, lets you run each tool by hand, and shows the JSON-RPC traffic. It has a browser UI, a terminal UI, and a CLI.
Version 2.14.0 requires Node.js 22.23.4, 24.22.1, or 26.12.1 and later on those lines.
Start the browser UI from the project folder:
npx @modelcontextprotocol/inspector npx tsx server.tsThe launch banner prints a local URL with a per-launch API token. Leave that token on, since DANGEROUSLY_OMIT_AUTH disables it on a tool that can start processes.
In the page, flip the toggle on the server card until it reads Connected. The card also shows the negotiated revision, which was MCP 2025-11-25 by default in 2.14.0.
Open the Tools tab, pick append_note, fill in the form, and press Execute Tool. An Elicitation Request dialog then offers Cancel, Decline, and Submit, and the side panel lists tools/call and elicitation/create with their timings.
The CLI suits CI because a failed tool sets the exit code. To test the 2026-07-28 path, add --protocol-era modern:
npx @modelcontextprotocol/inspector --cli npx tsx server.ts --method tools/call --tool-name search_notes --tool-arg query=k
echo "exit: $?"{
"content": [
{
"type": "text",
"text": "Input validation error: Invalid arguments for tool search_notes: query: Too small: expected string to have >=2 characters"
}
],
"isError": true
}
exit: 5Exit code 5 means the tool returned isError: true or was not found, and the CLI also writes a one-line error envelope to stderr, omitted above. Pin an exact Inspector version in CI so a new release cannot change a build.
If a server misbehaves, check stdout first. The stdio transport forbids anything on stdout except valid MCP messages.
In this test, a plain console.log line was skipped by the 2.4.0 client and claude mcp list still reported Connected. A JSON-shaped log line reached the client's onerror as a schema failure. Other clients can be stricter, so send logs to stderr with console.error.
#Connect it to Claude Code
Register the server with claude mcp add and use absolute paths, because the host starts the process itself. The Claude Code MCP docs moved to code.claude.com, and the old docs.anthropic.com address now redirects there. Everything after -- is the launch command, and --env sets variables for the server process.
claude mcp add --env NOTES_DIR="$PWD/notes" --transport stdio notes -- "$PWD/node_modules/.bin/tsx" "$PWD/server.ts"
claude mcp listOutput, with the temporary project path shortened:
Added stdio MCP server notes with command: /path/to/notes-mcp/node_modules/.bin/tsx /path/to/notes-mcp/server.ts to local config
Checking MCP server health…
notes: /path/to/notes-mcp/node_modules/.bin/tsx /path/to/notes-mcp/server.ts - ✔ ConnectedThis ran on Claude Code 2.1.318 with temporary HOME and CLAUDE_CONFIG_DIR folders, so no real configuration changed. The health check started the server and connected. The /mcp panel, the approval dialog in the terminal, and a model calling these tools were not run, because no API key was available.
The default scope is local, stored in ~/.claude.json for that one project. Use --scope project to share a server through .mcp.json, or --scope user for every project. Claude Code warns when tool output exceeds 10,000 tokens and limits it to 25,000 by default, as of November 8, 2026.
Other hosts take the same command, arguments, and environment in their own settings. The MCP introduction names several, and the platforms directory lists more coding agents.
#MCP server security: what the spec requires
MCP gives a model a path to run code, so the spec puts consent, input checks, and access limits on implementers. Its tools page says servers must validate inputs, apply access controls, rate limit calls, and sanitize outputs. Hosts should show inputs and ask a person to confirm sensitive operations.
| Spec requirement for servers | In the notes server | Still your job |
|---|---|---|
| Validate all tool inputs | zod schemas and notePath | Rules a schema cannot express |
| Implement access controls | One folder, no symbolic links | The process keeps your user's permissions |
| Rate limit tool invocations | 10 writes a minute | Limit reads that cost money |
| Sanitize tool outputs | File names only, no absolute paths in errors | Note text can still carry instructions |
Treat everything a tool returns as untrusted data. Anthropic's tool-use docs warn that tool results can carry indirect prompt injection, and a note that says "ignore your instructions" reaches the model as text. The spec also says clients must treat tool annotations as untrusted unless they come from a trusted server, so readOnlyHint is a label for the host and enforces nothing.
#Local servers
A local server runs with the permissions of whoever starts it. The security best practices describe startup commands that exfiltrate SSH keys, and they require one-click installers to show the full command for approval. Read the command before you add any server, and prefer stdio over a local HTTP port so other processes cannot reach it.
#Remote servers
Authorization is optional in the protocol, but a remote server holding user data needs it. The authorization page builds on OAuth 2.1 for HTTP transports, and it tells stdio servers to read credentials from the environment instead. Check these before you expose a server:
- Publish OAuth 2.0 Protected Resource Metadata (RFC 9728), and answer unauthenticated requests with
401and aWWW-Authenticateheader. - Validate that each access token was issued for your server. Clients must send the RFC 8707
resourceparameter to make that possible. - Never forward a client's token to another API. The spec forbids this token passthrough.
- Prefer Client ID Metadata Documents for client registration, since Dynamic Client Registration is deprecated in this revision.
- Validate the
Originheader and return403if it is invalid, as the Streamable HTTP page requires, and bind local servers to 127.0.0.1. - Never treat a state handle as proof of identity, and require per-client consent if your server proxies a third-party API.
To serve the same tools over HTTP, pass the factory to createMcpHandler. This guide did not run an HTTP deployment or an OAuth flow, so treat the list above as the spec's requirements and test yours against them.
#Ship your first MCP server
Start with one read-only tool over data you already own, and add writes only after a person has used it for a week. Once you can explain what is an MCP server doing in your stack, you can decide what else belongs in it. A narrow first server is easier to secure and cheaper in context.
Use this order for the first release:
- Write one read-only tool with a schema and a description aimed at the model.
- Add an Inspector CLI call with a pinned version to CI, and fail the build on a nonzero exit code.
- Register the server in one host and log the arguments in each tool call.
- Add one write tool with elicitation, a rate limit, and a hard boundary on what it can touch.
Before you connect a server to an agent that runs unattended, read how an AI agent loop fails and set its limits first. Then measure the result with LLM evaluation, because tool descriptions change model behavior as much as prompts do. The MCP topic hub and the agents hub collect related guides, and /news tracks protocol and SDK releases.
#Questions
What is an MCP server in simple terms?
An MCP server is a small program that makes your tools and data available to AI applications through one standard protocol. A coding agent or chat app connects to it, asks what it offers, and calls those functions when needed. You write the server once, and any compatible host can use it.
What is the difference between an API and an MCP server?
An API is written for developers who read its documentation and write client code. An MCP server is written for AI hosts, which fetch its tool list and JSON schemas at runtime. Most MCP servers call an existing API internally, so the two work together and the server adds a discoverable layer.
Should my MCP server use stdio or HTTP?
Use stdio when the host launches the server on the same machine, such as a tool for local files or a command-line program. Use Streamable HTTP when the server runs remotely or serves many users. A remote server needs authentication, Origin header validation, and token checks that a local stdio server does not.
What is the difference between an MCP tool and a resource?
A tool is a function exposed to the model with typed arguments, such as searching notes or creating a ticket. A resource is data addressed by a URI that the client application reads and attaches as context. The MCP specification calls tools model-controlled and resources application-controlled, so the host treats them differently.
Do MCP servers need authentication?
Authorization is optional in the MCP specification, and a local stdio server should read credentials from its environment. A remote HTTP server that handles user data should implement the OAuth-based flow, publish Protected Resource Metadata, validate that each token was issued for that server, and never pass a client's token to another API.
Are MCP servers safe to install?
A local MCP server runs with the permissions of the user who starts it, so treat it like any program you install. The specification warns that a startup command can run arbitrary code and that tool descriptions are untrusted unless the server is trusted. Read the exact command before you approve it.
What is MCP Inspector used for?
MCP Inspector is the project's reference tool for testing MCP servers without a model or an API key. It connects to a server and lets you list tools, run them, read resources, and watch the JSON-RPC messages. It has a browser UI, a terminal UI, and a CLI with exit codes that suit CI jobs.