The Free Encyclopedia

Building MCP Tools for a Local LLM

Revision as of Jun 25, 2026 23:03 by migration.

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 memory
  • todo · :3336 — the unified to-do list
  • notes · :3337 — Docmost-style notes
  • media · :3338 — the local media library
  • files · :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"] }
    }
  }
}
Why enums and required earn their keep A 4B model will happily invent 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
Bind to 127.0.0.1, always These servers have no auth of their own — they trust the network boundary. Bind to 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.