The Model Context Protocol (MCP) is a standard way to expose tools to an LLM: declare a tool once on a small server, and every MCP-aware client — ours or anyone's — can discover and call it without bespoke glue. That portability is the whole point. In theLAB's ADI fleet we run a handful of Node MCP servers, one per domain, and the chat backend wires the model to all of them through a single tool-call loop.
This walks through how those servers are built: the transport, the tool declaration, a minimal working server.js, a real production schema, and how we run and route to them. It picks up where Tool Calling and Structured Outputs left off — that article is the model side of the loop; this is the server side.
01One server per domain
Each ADI MCP server owns exactly one slice of the world and binds to localhost over Streamable HTTP:
mariadb· :3333 — SQL-backed tools (shopping_list, web_search)memory· :3334 — persistent knowledge-graph memorytodo· :3336 — the unified to-do listnotes· :3337 — Docmost-style notesmedia· :3338 — the local media libraryfiles· :9122 — sandboxed file read/write
Keeping them orthogonal means a change to notes can't break todos, and a small local model never has to disambiguate two tools that almost do the same thing.
02Declare a tool
A tool is a name, a tight description, and an inputSchema. The description and the schema are the entire interface the model sees — so enums and required fields do real work, steering a small local model toward valid calls instead of hopeful guesses.
{
name: "add_todo",
description: "Create a to-do item. Use parent_id to make a sub-task.",
inputSchema: {
type: "object",
required: ["title"], # only the title is mandatory
properties: {
title: { type: "string", description: "Short task title" },
description: { type: "string", description: "Optional detail" },
parent_id: { type: "integer", description: "Parent task for a sub-task" },
category_id: { type: "integer" },
priority: { type: "string", enum: ["low","medium","high"] },
status: { type: "string", enum: ["open","done"] },
due_date: { type: "string", description: "YYYY-MM-DD" },
due_time: { type: "string", description: "HH:MM" },
repeat_type: { type: "string", enum: ["none","daily","weekly","monthly"] }
}
}
}
priority: "urgent" if you let it. An enum closes that door, and a short required list keeps the model from stalling on optional fields. The matching update_todo tool requires only id and changes nothing it isn't given — partial updates without clobbering.
03A minimal server.js
The official SDK gives you the Server class and two transports: StreamableHTTPServerTransport for HTTP and StdioServerTransport for stdio. We use HTTP behind express so the backend can reach every server over the tailnet. Here is the whole skeleton — one tool, one handler, one port.
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StreamableHTTPServerTransport }
from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
const server = new Server(
{ name: "todo", version: "1.0.0" },
{ capabilities: { tools: {} } },
);
# advertise the tool list when the client calls tools/list
server.setRequestHandler("tools/list", async () => ({
tools: [{
name: "get_todos",
description: "List to-dos with optional filters.",
inputSchema: {
type: "object",
properties: {
view: { type: "string",
enum: ["all","today","upcoming","overdue","open","done"] },
limit: { type: "integer" }
}
}
}]
}));
# run the tool and return concise structured text
server.setRequestHandler("tools/call", async (req) => {
const { name, arguments: args } = req.params;
if (name !== "get_todos") throw new Error(`unknown tool: ${name}`);
const rows = await queryTodos(args); # validate args, hit the DB
return { content: [{ type: "text", text: formatTodos(rows) }] };
});
const app = express();
const transport = new StreamableHTTPServerTransport({});
await server.connect(transport);
app.use("/mcp", (req, res) => transport.handleRequest(req, res));
app.listen(3336, "127.0.0.1"); # localhost only — never 0.0.0.0
The DB-backed servers add mysql2/promise for a pooled connection; everything else stays this small. A handler always returns { content: [{ type: "text", text }] } — keep that text concise and structured, because it goes straight back into the model's context.
04Run it as a service
Each server is a systemd unit pointing at its checkout, with credentials and the bind host/port pulled from an EnvironmentFile. Nothing secret lives in the repo.
# /etc/systemd/system/mcp-todo.service
[Service]
EnvironmentFile=/opt/mcp-servers/mcp-todo/.env # DB creds, HOST, PORT
ExecStart=/usr/bin/node /opt/mcp-servers/mcp-todo/server.js --http
Restart=on-failure
127.0.0.1 or a tailnet address and let the firewall do the rest. An MCP file-write tool reachable from the open internet is a remote shell with extra steps.
05Wire it into the chat backend
The backend holds a static list of servers as { name, baseUrl } pairs. On each request it calls tools/list on every server, flattens the results into the tool array it advertises to the model, and when the model emits tool_calls it routes each one to the server that owns that tool name — the same loop described in the tool-calling article. Adding a new capability to ADI is then just: write a server, register one line, restart the backend.
Design tips
- Keep tools few and orthogonal — overlap is where small models pick wrong.
- Validate every argument in the handler; the schema is a hint, not a guarantee.
- Return concise, structured text, not raw rows or stack traces.
- One server per domain, one domain per server. Resist the megaserver.