Build a ServerIntermediate8 min07 / 10

Build a Node.js Server

Build a real MCP connector in Node.js with the official SDK — create the server, register a tool and a resource, and connect over stdio — while the agent never touches your database directly.

You've traced how a question flows through the MCP loop. Now let's build the server that sits on the far end of that loop. We'll use the official @modelcontextprotocol/sdk for Node.js to write a real connector — one that exposes a query_sales tool (an action the agent invokes) and a schema resource (read-only context the agent can pull in) to any MCP-aware agent (like Claude) over stdio.

The whole file is about 70 lines and breaks into four clean steps: create the server, register a tool, register a resource, and connect. First, spin up a project and pull in three packages: the MCP SDK itself, zod for describing tool inputs, and better-sqlite3 for the demo database.

terminal
# create the project
npm init -y
npm install @modelcontextprotocol/sdk zod better-sqlite3

# run your server
node server.js
Note

This code is read-only here

An MCP server needs stdio, the real SDK, and a host to launch it — so we won't "run" it in this lesson. Instead, read the code to understand each piece, then wire the finished file into a host (Claude Desktop, an IDE, or your own agent) to see it live, exactly as the closing config shows.

#Step 1 — Create the server

server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import Database from "better-sqlite3";

const db = new Database("shop.db", { readonly: true });

// 1. Create the server — this is your MCP "connector".
const server = new McpServer({
  name: "sales-connector",
  version: "1.0.0",
});

#Step 2 — Register a tool

Now the interesting part. registerTool exposes a capability the agent can invoke. Notice the split: *the agent decides when to call it; your code decides what it does.* The inputSchema is written with zod — each field becomes part of the JSON schema the agent reads to learn how to call the tool. The handler receives those validated arguments, builds the SQL, runs it, and returns the rows as text content.

server.ts
// 2. Expose a TOOL. The agent decides *when* to call it;
//    your code decides *what* it does. The agent never sees the DB directly.
server.registerTool(
  "query_sales",
  {
    title: "Query sales",
    description: "Aggregate revenue by customer or by month.",
    inputSchema: {
      metric: z.enum(["revenue", "recency"]),
      groupBy: z.enum(["customer", "month"]).optional(),
      limit: z.number().int().max(50).default(5),
    },
  },
  async ({ groupBy, limit }) => {
    const sql =
      groupBy === "month"
        ? `SELECT strftime('%Y-%m', created_at) AS month,
             SUM(amount) AS revenue
           FROM orders GROUP BY month ORDER BY month LIMIT ?`
        : `SELECT c.name AS customer, SUM(o.amount) AS total
           FROM orders o JOIN customers c ON c.id = o.customer_id
           GROUP BY c.name ORDER BY total DESC LIMIT ?`;

    const rows = db.prepare(sql).all(limit);
    return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
  }
);
Think of it like

The agent never sees the database

The agent can only send a tools/call for query_sales with structured arguments — it never gets a database handle, never writes SQL, never learns your connection string. Your server is the safety boundary. It decides which tools exist, validates every argument against the zod schema, and owns the one and only line that touches the DB (db.prepare(sql).all(limit)). Even if the model asked for something reckless, it can only reach the tools you chose to expose.

Quick check

In `registerTool`, what job does the `inputSchema` (written with zod) do?

#Step 3 — Register a resource

server.ts
// 3. Expose a RESOURCE — read-only context the agent can pull in.
server.registerResource(
  "schema",
  "schema://shop",
  { title: "Database schema", mimeType: "text/plain" },
  async (uri) => ({
    contents: [{ uri: uri.href, text: "orders(id, customer_id, amount, created_at)" }],
  })
);

#Step 4 — Connect over stdio

server.ts
// 4. Connect over stdio and start listening for the agent.
const transport = new StdioServerTransport();
await server.connect(transport);
Tip

Wiring it into a host

Once the file exists, a host launches it and the connection is live. In claude_desktop_config.json you add an entry under mcpServers pointing at the file (see below). The host runs node server.js, speaks JSON-RPC over stdio, and your query_sales tool shows up for the agent.

claude_desktop_config.json
{
  "mcpServers": {
    "sales-connector": {
      "command": "node",
      "args": [
        "/absolute/path/to/server.js"
      ]
    }
  }
}
Quick check

Why does the server stay silent until you call `server.connect(transport)`?

Key takeaways

  • A Node.js MCP server is built with the official @modelcontextprotocol/sdk in four steps: create the McpServer, register tools, register resources, and connect over a transport.
  • registerTool splits responsibility cleanly — the agent decides *when* to call a tool; your handler decides *what* it does. The zod inputSchema both advertises and validates the arguments.
  • The agent never sees the database: it only sends tools/call with structured arguments, and the server owns the one line that touches the DB — making the server the safety boundary.
  • A resource is read-only context addressed by a URI (schema://shop) that the agent can pull in, distinct from a tool it invokes.
  • The server is silent until server.connect(new StdioServerTransport()) attaches a transport; a host then launches it and the tools appear to the agent.
Practice challenges
Test yourself · earn XP
0/4
Reorder the lines#1

Put the four steps of building the Node.js MCP server in the order they appear in server.ts.

1
Register a resource: server.registerResource("schema", "schema://shop", ...)
2
Create the server: new McpServer({ name: "sales-connector", version: "1.0.0" })
3
Register a tool: server.registerTool("query_sales", { inputSchema, ... }, handler)
4
Connect over stdio: await server.connect(new StdioServerTransport())
Predict the output#2

In the query_sales handler, the agent sends arguments { metric: "revenue", groupBy: "customer", limit: 3 }. Which SQL does the server build and run?

predict-output
const sql =
  groupBy === "month"
    ? `SELECT strftime('%Y-%m', created_at) AS month,
         SUM(amount) AS revenue
       FROM orders GROUP BY month ORDER BY month LIMIT ?`
    : `SELECT c.name AS customer, SUM(o.amount) AS total
       FROM orders o JOIN customers c ON c.id = o.customer_id
       GROUP BY c.name ORDER BY total DESC LIMIT ?`;
const rows = db.prepare(sql).all(limit);
Fill in the blank#3

Complete the two calls that finish the server: attach a stdio transport and start listening.

// 4. Connect over stdio and start listening for the agent.
const transport = new ();
await server.(transport);
Fix the bug#4

A teammate rewrites the query_sales handler so the agent can run any query it wants. What's wrong with this design?

fix-bug
// query_sales handler (WRONG for MCP)
inputSchema: { rawSql: z.string() },
...
async ({ rawSql }) => {
  const rows = db.prepare(rawSql).all();
  return { content: [{ type: "text", text: JSON.stringify(rows) }] };
}
Your turn
Practice exercise

Extend the connector with a second tool called inventory_lookup that lists products whose stock is below a given threshold. Describe (1) the registerTool call — its name, a one-line description, and a zod inputSchema with a single below field (an integer with a sensible default), and (2) the SQL the handler would run against a products(id, name, stock, reorder_level) table. In one sentence, explain why adding this tool does not give the agent any new access to the database beyond what you allow.

Try it yourself — a starting point to build on:

starter.py
# Write your solution here