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.
# create the project
npm init -y
npm install @modelcontextprotocol/sdk zod better-sqlite3
# run your server
node server.jsThis 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
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.
// 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) }] };
}
);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.
In `registerTool`, what job does the `inputSchema` (written with zod) do?
#Step 3 — Register a resource
// 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
// 4. Connect over stdio and start listening for the agent.
const transport = new StdioServerTransport();
await server.connect(transport);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.
{
"mcpServers": {
"sales-connector": {
"command": "node",
"args": [
"/absolute/path/to/server.js"
]
}
}
}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.
Put the four steps of building the Node.js MCP server in the order they appear in server.ts.
Register a resource: server.registerResource("schema", "schema://shop", ...)Create the server: new McpServer({ name: "sales-connector", version: "1.0.0" })Register a tool: server.registerTool("query_sales", { inputSchema, ... }, handler)Connect over stdio: await server.connect(new StdioServerTransport())
In the query_sales handler, the agent sends arguments { metric: "revenue", groupBy: "customer", limit: 3 }. Which SQL does the server build and run?
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);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);
A teammate rewrites the query_sales handler so the agent can run any query it wants. What's wrong with this design?
// 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) }] };
}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:
# Write your solution here