Stop 4 of 10 · Weekly
A read-only MCP server
Four tools over the studio inventory, a database handle opened read-only, a row cap, and a test client you drive by hand before Claude ever sees it.
The question a spreadsheet cannot answer
Dany wants to ask, in a sentence: which active clients are on the old platform, out of retainer within sixty days, and failed a check this week. The database can answer it. Getting the answer into Claude by hand means pasting a table, which means pasting more than the question needed.
An MCP server puts the database behind four named tools. Claude calls the one it needs and gets back the rows it asked for.
Four tools, and the one that does not exist
| Tool | Returns |
|---|---|
list_clients |
Active clients with status, start date and site count |
get_site |
One site by url or id, with platform, repository and client |
retainer_terms |
Term, monthly hours, renewal date and notice period for a client |
recent_checks |
The last nightly results for one site, newest first, capped |
There is no update_site, no add_client, no run_query. A general query tool is a write tool that has not been asked to write yet.
The server
The package name, the import paths and the registration signature all live on the MCP documentation at modelcontextprotocol.io. Read that page against the version you install; this is the shape as the build-a-server guide has it.
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { DatabaseSync } from "node:sqlite";
import { z } from "zod";
const db = new DatabaseSync(process.env.STUDIO_DB, { readOnly: true });
const server = new McpServer({ name: "studio-inventory", version: "1.0.0" });
server.registerTool(
"recent_checks",
{
description: "Returns the last nightly check results for one site, newest first, up to a limit of 50.",
inputSchema: z.object({
site_id: z.number().int().describe("Site id from get_site"),
limit: z.number().int().min(1).max(50).default(10)
})
},
async ({ site_id, limit }) => {
const rows = db.prepare(
"select ran_at, status_code, ms, note from checks where site_id = ? order by ran_at desc limit ?"
).all(site_id, limit);
return { content: [{ type: "text", text: JSON.stringify(rows) }] };
}
);
await server.connect(new StdioServerTransport());
Three things are load-bearing. The handle is read-only, so a write cannot happen even by accident. The query is parameterised, so a site id is a value and never SQL. The limit is capped in the schema, so the largest possible answer is fifty rows rather than the table.
I have an MCP server over my studio inventory database with four read-only tools: list_clients, get_site, retainer_terms and recent_checks. Before I connect it to Claude, I want to drive it by hand. Tell me how to run the MCP Inspector against a stdio server started with node, and give me a list of twelve calls to make: four that should work, four with input that is empty, negative or missing, and four with input designed to get more rows than the cap allows or to reach a table the tools do not name. For each call, tell me what a correctly built server returns, so I can compare rather than guess.
Write a node --test file for my MCP server module. It should assert that exactly four tools are registered and name them, that a database handle opened with the read-only option throws when an insert is attempted, that recent_checks with a limit of 2000 is rejected by the schema rather than clamped silently, and that a site_id that does not exist returns an empty list rather than an error. Build the fixture database from the migration files rather than committing a database file.