Build an MCP server in Node.js and use it from a LangGraph agent

16 min read
On this page

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. stdio locally, 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 localhostHostValidation and localhostOriginValidation guards 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. MCPAdapter takes many entries under servers, and the name prefix keeps their tools apart.
  • Add your own node to the graph from lesson 7. A step before ToolNode that 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.


Share: