- Book: AI That Acts
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You run claude mcp add, the CLI prints an Added ... line, and you move on. That line only means the config was written. It says nothing about whether your server started, according to the Claude Code MCP docs. Then you ask Claude whether a flag is on in production, and it may grep your repo instead of calling the tool you just wrote.
There is a second surprise in the same docs. Claude Code doesn't put your tool definitions in context when a session starts. Tool search is on by default, so only tool names and server instructions load up front, and Claude goes looking for the rest when it thinks it needs them. If your names and instructions don't say what the tool is for, Claude has nothing to search for.
Skip the "should you use MCP" debate. You'll build two typed tools over a local SQLite file of feature flags, register them with Claude Code, and then look at the three mistakes that make a tool invisible or make it misfire.
Setup: one package, no build step
The official TypeScript SDK moved to v2 in July 2026. v2 is the stable line, released on 2026-07-27 against the 2026-07-28 MCP spec, and v1.x keeps getting bug fixes and security updates for at least six months after that. The practical difference for you: v2 splits server and client into separate packages. Many tutorials online still import from the v1 package, @modelcontextprotocol/sdk. This one uses @modelcontextprotocol/server, version 2.3.1 at the time of writing, which needs Node 20 or later and depends on Zod ^4.2.0.
You also don't need tsx or a compile step. Node runs .ts files directly through type stripping, on by default since v22.18.0 and v23.6.0 and marked stable in v24.12.0 and v25.2.0. Node's built-in node:sqlite module has been unflagged since v22.13.0, so the database needs no native build either. Put together, everything below needs Node 22.18 or later.
mkdir flags-mcp && cd flags-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod
mkdir src
The type=module line matters because the SDK ships ES modules only. Now seed a small database in src/seed.ts:
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync('flags.db');
db.exec(`
CREATE TABLE IF NOT EXISTS flags (
key TEXT NOT NULL,
env TEXT NOT NULL CHECK (env IN ('dev', 'staging', 'prod')),
enabled INTEGER NOT NULL DEFAULT 0,
owner TEXT NOT NULL,
PRIMARY KEY (key, env)
);
INSERT OR IGNORE INTO flags VALUES
('checkout.new_tax_engine', 'prod', 0, 'payments'),
('checkout.new_tax_engine', 'staging', 1, 'payments'),
('search.semantic_rerank', 'prod', 1, 'search');
`);
db.close();
Run it once with node src/seed.ts. One note on naming: Node v26.11.0 renamed the class to Database and kept DatabaseSync as a documentation-only deprecated alias, so DatabaseSync works on every version from 22.13 up. If you would rather not depend on a module the Node docs still label a release candidate, better-sqlite3 uses the same db.prepare(sql) prepared-statement style.
The server: two tools, 69 lines
Here is src/server.ts in full. One read tool, one write tool, both with Zod input schemas.
import { DatabaseSync } from 'node:sqlite';
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
const dbPath = process.env.FLAGS_DB;
if (!dbPath) throw new Error('FLAGS_DB must be an absolute path');
const db = new DatabaseSync(dbPath);
const Env = z.enum(['dev', 'staging', 'prod'])
.describe('Deployment environment. Production is "prod".');
type Text = { type: 'text'; text: string };
const text = (t: string): Text => ({ type: 'text', text: t });
function createServer(): McpServer {
const info = { name: 'flags', version: '1.0.0' };
const server = new McpServer(info, {
instructions: 'Feature flags per environment. Use these tools '
+ 'to answer "is X on in prod?" or to flip a flag.',
});
server.registerTool('list_flags', {
title: "'List feature flags',"
description: 'List feature flags in one environment with '
+ 'their on/off state and owning team. Optional key filter.',
inputSchema: z.object({
env: Env,
key_contains: z.string().max(64).optional()
.describe('Case-insensitive fragment of the flag key'),
}),
annotations: { readOnlyHint: true },
}, async ({ env, key_contains }) => {
const rows = db.prepare(
`SELECT key, enabled, owner FROM flags
WHERE env = ? AND key LIKE ? ORDER BY key LIMIT 50`,
).all(env, `%${key_contains ?? ''}%`);
return { content: [text(JSON.stringify(rows))] };
});
server.registerTool('set_flag', {
title: "'Turn a feature flag on or off',"
description: 'Turn one existing feature flag on or off in one '
+ 'environment. Never creates flags. Returns an error if the '
+ 'key does not exist in that environment.',
inputSchema: z.object({
flag_key: z.string().regex(/^[a-z0-9_.-]{1,64}$/)
.describe('Exact flag key, e.g. "checkout.new_tax_engine"'),
env: Env,
enabled: z.boolean(),
}),
}, async ({ flag_key, env, enabled }) => {
const res = db.prepare(
'UPDATE flags SET enabled = ? WHERE key = ? AND env = ?',
).run(enabled ? 1 : 0, flag_key, env);
if (res.changes === 0) {
const msg = `No flag "${flag_key}" in ${env}. `
+ 'Call list_flags to see valid keys.';
return { isError: true, content: [text(msg)] };
}
console.error(`[flags] ${flag_key}@${env} -> ${enabled}`);
const state = enabled ? 'on' : 'off';
return { content: [text(`${flag_key} is ${state} in ${env}`)] };
});
return server;
}
void serveStdio(createServer);
serveStdio takes a factory, not a server instance. It serves MCP over stdio from that factory and decides which protocol era each connection speaks, so you don't write version-negotiation code yourself.
The instructions string is a real ServerOptions field. With tool search on, it is one of the few things Claude reads before it decides to look for your tools.
The SDK turns each Zod schema into the JSON Schema the model sees and validates arguments before your handler runs. Bad arguments come back as an ordinary result with isError: true, and the handler never executes. The set_flag handler uses the same isError shape for "no such flag", with a hint telling the model what to call next.
readOnlyHint is advisory: annotations never change how the SDK runs a tool, and hosts use them to decide what to show the person approving the call. The code leaves set_flag without annotations on purpose, since flipping a production flag is exactly the call a human should see.

stdio gives the server one channel to the host. Anything that is not JSON-RPC on stdout jams it.
Plug it into Claude Code
Test it alone first. The MCP Inspector launches a stdio server and lets you call its tools by hand:
npx @modelcontextprotocol/inspector \
-e FLAGS_DB="$PWD/flags.db" node src/server.ts
The -e flag is how the Inspector passes an environment variable to a stdio server. Then register it. Everything after -- is the command Claude Code runs, passed through untouched. If the server name comes right after --env, the CLI reads the name as another KEY=value pair and rejects it, so put --transport stdio in between:
claude mcp add --env FLAGS_DB="$PWD/flags.db" --transport stdio \
flags -- node "$PWD/src/server.ts"
$PWD expands at registration time, so the stored command and database path are absolute. That is why the server refuses to start without FLAGS_DB: a relative flags.db would resolve against whatever directory Claude Code launched the process from, and node:sqlite would quietly create a new, empty file there with no flags table in it. The default scope is local, available only to you in the current project. Use --scope project to write it to .mcp.json for your team, or --scope user for every project.
Now check that it connected, not just that it was saved. claude mcp list prints ✔ Connected or ✘ Failed to connect per server, claude mcp get flags adds an Issue: line on failure, and /mcp inside a session shows the same status.
Start claude in the project and ask something like "Which checkout flags are off in prod?". Claude should call mcp__flags__list_flags with env: "prod" and key_contains: "checkout". Tool names in Claude Code follow the mcp__<server>__<tool> pattern, and the permission rule syntax uses the same names. To skip the prompt for the read tool only, add this to .claude/settings.json:
{
"permissions": {
"allow": ["mcp__flags__list_flags"]
}
}
set_flag keeps asking. Leave it that way.
Three mistakes that make a tool invisible or wrong
1. Names and descriptions that say nothing
Rename the tools to query and update, set both descriptions to "Runs a flag operation", drop the instructions, and the server still connects fine. Discovery breaks. With only names and server instructions loaded at session start, Claude has to guess that a tool called query answers questions about production flags, and it may go to grep instead.
Anthropic's engineering post on writing tools for agents puts the bar plainly: describe the tool the way you would describe it to a new hire, and name parameters unambiguously, user_id rather than user. The schema above says flag_key, not key, for that reason. A field's .describe() text is the only documentation the model gets for that argument. Claude Code also truncates each tool description and each server's instructions at 2,048 characters by default, so put the deciding detail first.

With tool search, the name, the description and the server instructions are how Claude finds your tool.
2. One console.log on stdout
The stdio transport spec is strict: the server must not write anything to stdout that is not a valid MCP message, and it may write UTF-8 to stderr for logging. On stdio, stdout is the wire, and the host parses every line as JSON-RPC.
One console.log added while chasing a bug is enough to break it. Anything other than JSON-RPC on stdout makes the host drop the connection, so the handler above logs with console.error. If a dependency might print, redirect it at the top of the file:
console.log = console.error;
That catches console.log calls. It does not catch code that writes to process.stdout directly, so keep chatty libraries out of the server process.
3. Loose input schemas
The tempting version is one line:
inputSchema: z.object({ env: z.string(), key: z.string() }),
It type-checks, and it fails quietly. The model asks for env: "production", the WHERE env = ? clause matches nothing, the tool returns [], and Claude can report, with full confidence, that there are no flags. No exception is thrown and no log line is written. The empty array is the only symptom, and it looks like a correct answer.
The z.enum version moves that failure to the boundary. "production" fails validation before the handler runs, the model gets an isError result naming the problem, and it can retry with "prod". The regex on flag_key does the same for typos and stray whitespace. Tight schemas help Claude Code too. It excludes any tool whose schema fails two API checks: top-level property names of 1 to 64 ASCII letters, digits, _, . or -, and a valid JSON Schema 2020-12 document. It also flattens a root-level anyOf/oneOf/allOf into one object and moves the branch rules into the description. Keep z.object at the root and short snake_case property names, and you stay clear of both.
If this was useful
A typed tool with a sharp description and a strict schema is most of what makes an agent call the right thing with the right arguments. AI That Acts is the book in the series about exactly that: tool calling, function schemas and your first working agent in TypeScript, built up from the same pieces this server uses.
The whole AI in TypeScript series, from your first LLM call to agents in production, is at xgabriel.com/ai-in-typescript.

Top comments (1)
the bit about Claude Code only loading tool names + descriptions for discovery is the part that bit me. I had two servers both exposing a "search" tool and the vague description never got picked. did you try making the descriptions more specific (what it searches, what it returns) and see if selection got cleaner? also
claude mcp addsucceeding only means the config landed. I still do a quick list-tools smoke after restart.