This post is about MCP, the Model Context Protocol.
In the tool-calling post we
gave an agent a get_weather tool. It worked. But that tool only exists
inside that one script. Claude Desktop can’t see it. Cursor can’t see it.
Your next project can’t see it either.
MCP fixes that. You put the tool in a small server. Any app that speaks MCP can use it.
WITHOUT MCP WITH MCP
every app rewrites every tool write the tool once
Claude ──► weather tool Claude ─────┐
Cursor ──► weather tool (again) Cursor ─────┼──► MCP server ──► weather
agent ──► weather tool (again) agent ─────┘ (one copy)
I’ll go through ten small lessons. We build a tiny server, test it with
no AI at all, plug it into Claude Desktop, then into a LangGraph agent,
first with createAgent and then as a graph you build yourself. At the
end we expose the pgvector notes from the RAG posts as an MCP tool.
This uses the MCP TypeScript SDK v2 (@modelcontextprotocol/server 2.x),
@langchain/mcp-adapters 2.x and LangChain 1.x, all current in October
2026. If another tutorial shows @modelcontextprotocol/sdk or
MultiServerMCPClient, that’s the older API.
The words you’ll see
- Server — your program. It exposes tools.
- Client — the connector inside an app that talks to your server.
- Host — the app itself: Claude Desktop, Cursor, or your agent script.
- Tool — a function the model can call. Same idea as before.
- Transport — how client and server talk.
stdiolocally, HTTP remotely. - Inspector — a browser UI for poking at a server by hand.
MCPAdapter— the LangChain piece that turns MCP tools into agent tools.ToolNode— the LangGraph node that runs tools, MCP ones included.
Setup
mkdir mcp-demo && cd mcp-demo
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod
npm install langchain @langchain/langgraph @langchain/mcp-adapters @langchain/openrouter
You need Node.js 20.6 or newer and an OpenRouter API key from openrouter.ai. Lessons 3 to 5 need no key at all.
One thing is different from the other posts. An MCP server is its own program, so this post has a few small files instead of one. Each one is short.
Now the lessons.
Lesson 1: the problem MCP solves
Look at the picture at the top again. That’s the whole problem.
- Every AI app wants tools.
- Without a shared protocol, each app needs its own copy of each tool.
- With MCP, a tool lives in one server, and every app connects to it.
That’s all MCP is: a standard way for an app to ask a server “what tools do you have?” and “run this one.” The server answers in a fixed format. The app doesn’t care what language the server is written in.
Lesson 2: the three roles
Three words come up constantly. Here’s who’s who.
HOST CLIENT SERVER
the app you use the connector your program
(Claude Desktop, ──► inside the host ──► (weather-server.mjs)
Cursor, your agent) speaks MCP exposes tools
- The host is the thing with the model in it.
- The client is a small piece inside the host that speaks MCP.
- The server is yours. It holds the tools.
You write the server. The host and client already exist. In lesson 6, your
agent script is the host and MCPAdapter is the client.
Lesson 3: your first MCP server
One file. One tool. Save it as weather-server.mjs.
// weather-server.mjs
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import * as z from "zod";
const server = new McpServer({ name: "weather", version: "1.0.0" });
server.registerTool(
"get_weather",
{
description: "Get the current weather for a city",
inputSchema: z.object({
city: z.string().describe("The city to look up, e.g. 'Paris'"),
}),
},
async ({ city }) => ({
content: [{ type: "text", text: `It's 22°C and sunny in ${city}.` }],
}),
);
// stdio: the client talks to us over stdin and stdout.
await server.connect(new StdioServerTransport());
console.error("weather server ready");
An MCP tool has the same four parts as a LangChain tool. Put them side by side:
LangChain tool MCP tool
tool(fn, { server.registerTool(
name: "get_weather", "get_weather",
description: "...", { description: "...",
schema: z.object({...}), inputSchema: z.object({...}) },
}) async (args) => ({ content: [...] }))
Name, description, schema, function. The only difference is the return
value. A LangChain tool returns a string. An MCP tool returns
{ content: [{ type: "text", text }] }.
One rule that bites everyone the first time: do not console.log in a
stdio server. The protocol runs over stdout. A stray log line breaks it.
Use console.error, which goes to stderr and is safe.
Lesson 4: test it with no AI at all
Before we add a model, let’s see the server work on its own. MCP ships an Inspector, a browser UI that connects to any server.
npx @modelcontextprotocol/inspector node weather-server.mjs
A page opens. Click Tools, then List Tools. You’ll see
get_weather. Fill in a city and run it.
Inspector (browser UI)
│ stdin / stdout
▼
weather-server.mjs
│
▼
get_weather("Paris") → "It's 22°C and sunny in Paris."
No model was involved. The Inspector is just a client. This is the fastest way to find out whether a bug is in your server or in your agent.
Lesson 5: plug it into Claude Desktop
Same file, no changes. Tell Claude Desktop where it is.
On macOS the config lives at
~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"weather": {
"command": "node",
"args": ["/absolute/path/to/weather-server.mjs"]
}
}
}
Restart Claude Desktop. Ask “what’s the weather in Paris?” and it calls your tool.
Claude Desktop (host)
│ starts: node weather-server.mjs
│ stdin / stdout
▼
weather-server.mjs ──► get_weather
Cursor and VS Code use the same command and args shape in their own
config files. One server file, three apps, zero changes. That’s the point
of the protocol.
Lesson 6: use it from a LangGraph agent
Now the headline. Your own agent as the host, with MCPAdapter as the
client.
- The adapter starts your server as a child process.
listTools()asks it for its tools and turns them into LangChain tools.- You hand those to
createAgent, exactly like in the tool-calling post.
ask-weather.mjs (host)
│
▼
createAgent ──► MCPAdapter (client)
│ starts: node weather-server.mjs
│ stdin / stdout
▼
weather-server.mjs ──► get_weather
// ask-weather.mjs
import { createAgent } from "langchain";
import { MCPAdapter } from "@langchain/mcp-adapters";
import { ChatOpenRouter } from "@langchain/openrouter";
// Paste your key here. Fine for a script on your own machine.
process.env.OPENROUTER_API_KEY = "sk-or-v1-paste-your-key-here";
const MODEL = new ChatOpenRouter({
model: "openai/gpt-4o-mini",
temperature: 0,
});
// The adapter runs your server as a child process and connects to it.
const adapter = new MCPAdapter({
servers: {
weather: { command: "node", args: ["./weather-server.mjs"] },
},
});
try {
const tools = await adapter.listTools();
console.log("tools from MCP:", tools.map((tool) => tool.name));
// [ "weather_get_weather" ]
const agent = createAgent({ model: MODEL, tools });
const result = await agent.invoke({
messages: [{ role: "user", content: "What's the weather in Paris?" }],
});
console.log(result.messages.at(-1).content);
} finally {
await adapter.close(); // stops the child process
}
Two small things to notice:
- The tool arrives as
weather_get_weather. The adapter prefixes the server name, so tools from different servers can’t collide. - Keep the adapter open while the agent runs, and close it in
finally. Closing it stops the server process.
From here the agent doesn’t know or care that the tool came over MCP. It runs the same ask → run → feed back loop as any other tool.
One thing to be clear about: createAgent builds a LangGraph graph for
you. You just can’t see it. In the next lesson we build that graph by
hand, so you can see exactly where the MCP tools plug in.
So far one server file has been used by three different hosts:
weather-server.mjs ◄── Inspector
◄── Claude Desktop
◄── your LangGraph agent
one file. zero changes.
Lesson 7: the same agent as a LangGraph graph
createAgent is handy, but it hides where the MCP tools go. Let’s build
the same agent as a LangGraph graph, so you can see it.
If graphs are new to you, the LangGraph post explains state, nodes and edges. Here’s the shape we need. It’s the agent loop from the tool-calling post, with MCP tools inside it:
START
│
▼
agent ◄───────────────┐ the model, which knows the MCP tools
│ │
asked for a tool? │
│ │ │
yes no │
│ │ │
▼ ▼ │
tools END │ ToolNode runs the MCP tool
│ │
└──── back to agent ──┘
The tools from listTools() are ordinary LangChain tools. So they go in
the same two places as always:
bindTools(tools)— so the model knows they exist.new ToolNode(tools)— so the graph can run them.
// ask-weather-graph.mjs
import { StateGraph, START, MessagesAnnotation } from "@langchain/langgraph";
import { ToolNode, toolsCondition } from "@langchain/langgraph/prebuilt";
import { MCPAdapter } from "@langchain/mcp-adapters";
import { ChatOpenRouter } from "@langchain/openrouter";
process.env.OPENROUTER_API_KEY = "sk-or-v1-paste-your-key-here";
const MODEL = new ChatOpenRouter({
model: "openai/gpt-4o-mini",
temperature: 0,
});
const adapter = new MCPAdapter({
servers: {
weather: { command: "node", args: ["./weather-server.mjs"] },
},
});
try {
const tools = await adapter.listTools(); // the MCP tools, as LangChain tools
const modelWithTools = MODEL.bindTools(tools);
const graph = new StateGraph(MessagesAnnotation)
.addNode("agent", async (state) => {
const reply = await modelWithTools.invoke(state.messages);
return { messages: [reply] };
})
.addNode("tools", new ToolNode(tools)) // runs weather_get_weather over MCP
.addEdge(START, "agent")
.addConditionalEdges("agent", toolsCondition) // "tools" or END
.addEdge("tools", "agent") // loop back
.compile();
const result = await graph.invoke({
messages: [{ role: "user", content: "What's the weather in Paris?" }],
});
console.log(result.messages.at(-1).content);
} finally {
await adapter.close();
}
Same answer as lesson 6. Now you can see the whole path. The model asks
for weather_get_weather. toolsCondition routes to tools. ToolNode
sends the call over MCP to your server. The result loops back to the
model, which writes the answer.
This is exactly what createAgent builds for you. Use createAgent day
to day. Build the graph yourself when you want to add your own nodes
around the tools, say a step that logs or checks every call.
Lesson 8: beyond tools, resources
Tools are things the model can do. MCP has two more kinds of thing a server can offer.
tools → things the model can DO get_weather, search_notes
resources → things the model can READ a file, a config, an index
prompts → reusable templates less common; skip for now
A resource has a URI and no arguments. It’s for data you want the host to be able to read, like a list of files.
server.registerResource(
"notes-index",
"notes://index",
{
title: "Notes index",
description: "The list of note files",
mimeType: "text/plain",
},
async (uri) => ({
contents: [{ uri: uri.href, text: "bike.md\nflat.md" }],
}),
);
Tools cover most real needs. This lesson is here so you know resources exist, not because you’ll need one on day one.
Lesson 9: go remote over HTTP
stdio means the host starts your server on the same machine. That’s
great locally. It doesn’t work for a server running somewhere else.
For that, MCP uses HTTP. Same server code, different transport.
stdio: agent ──starts──► node weather-server.mjs (same machine)
HTTP: agent ──fetch────► http://host:3000/mcp (anywhere)
Install the Node HTTP transport:
npm install @modelcontextprotocol/node
// weather-http-server.mjs
import { createServer } from "node:http";
import {
localhostHostValidation,
localhostOriginValidation,
NodeStreamableHTTPServerTransport,
} from "@modelcontextprotocol/node";
import { McpServer } from "@modelcontextprotocol/server";
import * as z from "zod";
function buildServer() {
const server = new McpServer({ name: "weather", version: "1.0.0" });
server.registerTool(
"get_weather",
{
description: "Get the current weather for a city",
inputSchema: z.object({ city: z.string() }),
},
async ({ city }) => ({
content: [{ type: "text", text: `It's 22°C and sunny in ${city}.` }],
}),
);
return server;
}
// Only accept requests from this machine. A sane default for a demo.
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();
createServer(async (request, response) => {
if (!validateHost(request, response) || !validateOrigin(request, response)) {
return;
}
const server = buildServer(); // stateless: a fresh server per request
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await server.connect(transport);
await transport.handleRequest(request, response);
}).listen(3000, "127.0.0.1");
console.log("MCP server on http://127.0.0.1:3000/mcp");
Run it in one terminal. In the agent, the only change is how you point at the server:
const adapter = new MCPAdapter({
servers: {
weather: { url: "http://127.0.0.1:3000/mcp" },
},
});
Everything else in the agent file stays the same. And yes, this server
can console.log, because stdout isn’t the protocol channel any more.
Lesson 10: your notes as an MCP tool
Now put it to work. In the RAG post we stored two notes in Postgres with pgvector and searched them by meaning. Let’s expose that search as an MCP tool, so any agent can ask questions about your notes.
You need Postgres from that post running, with the notes indexed. If you
haven’t, follow its setup and run node rag.ts index once.
"When should I change the chain?"
│
▼
agent node ──► ToolNode ──► MCP ──► notes-server: search_notes
│
├─ embed the question (OpenRouter)
├─ pgvector: the 3 closest chunks
└─ return them as text
│
▼
agent node reads the chunks ──► "Replace it once the wear gauge passes 0.75"
npm install pg openai
// notes-server.mjs
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { Client } from "pg";
import OpenAI from "openai";
import * as z from "zod";
// Paste your key here. Fine for a script on your own machine.
const API_KEY = "sk-or-v1-paste-your-key-here";
const openrouter = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: API_KEY,
});
const database = new Client({
connectionString: "postgres://postgres:postgres@localhost:5432/postgres",
});
await database.connect();
async function searchNotes(question) {
const response = await openrouter.embeddings.create({
model: "openai/text-embedding-3-small",
input: [question],
});
const questionEmbedding = response.data[0].embedding;
const result = await database.query(
`SELECT source, content, embedding <=> $1::vector AS distance
FROM chunks
ORDER BY embedding <=> $1::vector
LIMIT 3`,
[JSON.stringify(questionEmbedding)],
);
return result.rows;
}
const server = new McpServer({ name: "notes", version: "1.0.0" });
server.registerTool(
"search_notes",
{
description:
"Search my personal notes (bike, flat) and return the most relevant passages",
inputSchema: z.object({
question: z.string().describe("What you want to find in the notes"),
}),
},
async ({ question }) => {
const chunks = await searchNotes(question);
const text = chunks
.map((chunk) => `[${chunk.source}]\n${chunk.content}`)
.join("\n\n");
return { content: [{ type: "text", text: text || "No matching notes." }] };
},
);
await server.connect(new StdioServerTransport());
console.error("notes server ready");
The search code is lifted straight from the RAG post. Embed the question,
compare it to every chunk with <=>, keep the closest three. The only new
part is wrapping it in registerTool.
Now an agent that uses it. It’s the graph from lesson 7, holding the notes tool instead of the weather one:
// ask-notes.mjs
import { StateGraph, START, MessagesAnnotation } from "@langchain/langgraph";
import { ToolNode, toolsCondition } from "@langchain/langgraph/prebuilt";
import { MCPAdapter } from "@langchain/mcp-adapters";
import { ChatOpenRouter } from "@langchain/openrouter";
process.env.OPENROUTER_API_KEY = "sk-or-v1-paste-your-key-here";
const MODEL = new ChatOpenRouter({
model: "openai/gpt-4o-mini",
temperature: 0,
});
const adapter = new MCPAdapter({
servers: {
notes: { command: "node", args: ["./notes-server.mjs"] },
},
});
try {
const tools = await adapter.listTools(); // [ "notes_search_notes" ]
const modelWithTools = MODEL.bindTools(tools);
const graph = new StateGraph(MessagesAnnotation)
.addNode("agent", async (state) => {
const reply = await modelWithTools.invoke(state.messages);
return { messages: [reply] };
})
.addNode("tools", new ToolNode(tools)) // runs notes_search_notes over MCP
.addEdge(START, "agent")
.addConditionalEdges("agent", toolsCondition)
.addEdge("tools", "agent")
.compile();
const systemMessage = {
role: "system",
content:
"Answer using the search_notes tool. If the notes don't contain " +
"the answer, say you don't know.",
};
const questions = [
"When should I change the bike chain?",
"Which bin goes out on Friday?",
];
for (const question of questions) {
const result = await graph.invoke({
messages: [systemMessage, { role: "user", content: question }],
});
console.log(`\nQ: ${question}`);
console.log(`A: ${result.messages.at(-1).content}`);
}
} finally {
await adapter.close();
}
Running it
node ask-notes.mjs
Q: When should I change the bike chain?
A: Replace the chain once the wear gauge drops past 0.75, which has
been about every 3,000 km. Doing it early saves the cassette.
Q: Which bin goes out on Friday?
A: General waste goes out every Friday. The bin store code is 4417.
Add notes-server.mjs to your Claude Desktop config the same way as in
lesson 5, and Claude can search your notes too. Nothing in the server
changes.
About trusting tools
Two honest notes before you ship one of these.
- The model picks the arguments. Treat them like form input from a
stranger. The zod schema checks the shape, but you still write the
parameterised SQL (we did, with
$1) and you never pass an argument straight to a shell command. - Keep the HTTP server local until you add auth. The
localhostHostValidationandlocalhostOriginValidationguards in lesson 9 reject requests from other machines. Take them off only once you’ve put real authentication in front.
About that hardcoded key
A key pasted into a script on your own machine is fine. A key in a script that ends up in a git repo is not. Bots scan public GitHub for keys.
Before you commit, drop the lines that set the key, and run like this:
node --env-file=.env ask-notes.mjs
Put OPENROUTER_API_KEY=sk-or-v1-... in a .env file, and add .env to
.gitignore. For the server, read it with process.env.OPENROUTER_API_KEY
instead of the constant.
Where to go from here
- Add more tools to the notes server:
add_note,list_sources. The agent picks them up automatically. - Put the HTTP server behind real auth and run it somewhere, so your team’s agents can share it.
- Connect two servers at once.
MCPAdaptertakes many entries underservers, and the name prefix keeps their tools apart. - Add your own node to the graph from lesson 7. A step before
ToolNodethat logs every tool call is a good first one. - Guard a risky tool. The same human-in-the-loop middleware works on MCP tools, because by the time the agent sees them they’re ordinary tools.
- Look at A2A, the sister protocol for agents talking to other agents. MCP is for tools. A2A is for peers.
That’s the whole idea. Write the tool once, in a small server. Then any host, Claude, Cursor or your own agent, can pick it up and use it.