Antigravity × MCP in Practice — Spotting Dead Servers and Measuring What Tool Definitions Actually Cost
The MCP servers listed in your Antigravity settings.json may no longer exist. Here is a runnable audit script that checks npm for deprecation, measured token costs for tool definitions, and a rewrite on SDK 2.x — all reproducible on your own machine.
Half the servers in my config were no longer being published
It started with a line that stopped working.
I reopened an Antigravity settings.json I had not touched in a while and restarted the servers listed under mcpServers. Several failed to come up, silently. Before chasing logs, the right question was simpler: is this package still being distributed at all?
The answer from the npm registry was worse than I expected. Of the servers in that once-common configuration, six were marked deprecated and two did not exist. A config file gives you no warning for either — the server just fails to start.
The MCP specification is stable. Its reference implementations are not. So instead of ranking servers, this piece starts somewhere more useful: how to verify mechanically that your config is still alive, and what each server actually costs you once it is connected. Every step here is reproducible, and I would rather you re-measure these numbers on your own machine than take mine.
Auditing settings.json mechanically
The first change is to stop evaluating config files from memory. This script pulls the npm package name out of each mcpServers entry and checks the registry's dist-tags, time, and deprecated fields.
#!/usr/bin/env node// mcp-audit.mjs — checks whether the npm packages listed in a// settings.json mcpServers block are still alive.import { readFileSync } from "node:fs";const configPath = process.argv[2];if (!configPath) { console.error("usage: node mcp-audit.mjs <path-to-settings.json>"); process.exit(2);}const cfg = JSON.parse(readFileSync(configPath, "utf8"));const servers = cfg.mcpServers ?? {};// Pull the first argument that looks like an npm package name (skip -y, --db-path, etc.)function pickPackage(entry) { const args = entry.args ?? []; if (!/^(npx|pnpx|bunx)$/.test(entry.command ?? "")) return null; return args.find((a) => !a.startsWith("-") && /^(@[\w.-]+\/)?[\w.-]+$/.test(a)) ?? null;}const STALE_DAYS = 365;let worst = 0;for (const [name, entry] of Object.entries(servers)) { const pkg = pickPackage(entry); if (!pkg) { console.log(`- ${name}: not an npm-distributed server, skipping`); continue; } const res = await fetch(`https://registry.npmjs.org/${pkg.replace("/", "%2f")}`); if (res.status === 404) { console.log(`❌ ${name}: ${pkg} does not exist on npm`); worst = Math.max(worst, 2); continue; } if (!res.ok) { console.log(`? ${name}: ${pkg} lookup failed (HTTP ${res.status})`); continue; } const doc = await res.json(); const latest = doc["dist-tags"]?.latest; const published = doc.time?.[latest]; const deprecated = doc.versions?.[latest]?.deprecated; const ageDays = Math.floor((Date.now() - Date.parse(published)) / 86400000); if (deprecated) { console.log(`❌ ${name}: ${pkg}@${latest} is deprecated — ${String(deprecated).slice(0, 60)}`); worst = Math.max(worst, 2); } else if (ageDays > STALE_DAYS) { console.log(`⚠️ ${name}: ${pkg}@${latest} last published ${ageDays} days ago (${published.slice(0, 10)})`); worst = Math.max(worst, 1); } else { console.log(`✅ ${name}: ${pkg}@${latest} (published ${published.slice(0, 10)}, ${ageDays} days ago)`); }}process.exit(worst);
No dependencies — Node 22's built-in fetch is enough. Feeding it a configuration of the kind that circulated widely produces this:
❌ github: @modelcontextprotocol/server-github@2025.4.8 is deprecated — Package no longer supported.
❌ postgres: @modelcontextprotocol/server-postgres@0.6.2 is deprecated — Package no longer supported.
❌ sqlite: @modelcontextprotocol/server-sqlite does not exist on npm
❌ notion: @notionhq/client-mcp does not exist on npm
✅ filesystem: @modelcontextprotocol/server-filesystem@2026.7.10 (published 2026-07-10, 42 days ago)
✅ playwright: @playwright/mcp@0.0.79 (published 2026-08-06, 15 days ago)
- internal: not an npm-distributed server, skipping
exit=2
The 0/1/2 exit codes exist so this can live in CI. I run it monthly now. Config files start rotting the moment you write them, and the one thing worth automating is noticing that they have.
STALE_DAYS is set to 365 because MCP reference implementations ship on roughly annual cycles. If your config mixes in internal servers that release quarterly, tighten that threshold to match reality.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A runnable audit script that flags deprecated or missing npm packages in your settings.json, with exit codes you can wire into CI
✦Measured token cost of tool definitions across four official reference servers: 37 tools consuming 9,426 tokens before the conversation starts
✦An SDK 2.x rewrite plus verified results for what a model actually reads when your handler throws
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Which official reference servers are still maintained
Running the same check across the entire @modelcontextprotocol scope gives the following. These are registry values as of 2026-08-21.
Package
Latest
Last published
Status
server-filesystem
2026.7.10
2026-07-10
Maintained
server-memory
2026.7.4
2026-07-04
Maintained
server-sequential-thinking
2026.7.4
2026-07-04
Maintained
server-everything
2026.8.18
2026-08-18
Maintained
server-pdf
1.7.5
2026-07-23
Maintained
server-github
2025.4.8
2025-04-08
Deprecated
server-gitlab
2025.4.25
2025-04-25
Deprecated
server-slack
2025.4.25
2025-04-25
Deprecated
server-puppeteer
2025.5.12
2025-05-12
Deprecated
server-brave-search
0.6.2
2024-12-04
Deprecated
server-sqlite
—
—
404
There is one conclusion to draw. "It's official, so it's safe" has stopped working as a selection criterion. Reference implementations remain as demonstrations of the specification, while integrations meant for production have moved to the vendors themselves — Notion at @notionhq/notion-mcp-server (2.5.1, 2026-07-25), browser automation at @playwright/mcp (0.0.79, 2026-08-06).
For GitHub, GitLab, Slack, and PostgreSQL I will state only the verifiable fact: npm distribution has stopped. Check the vendor's own documentation for where those integrations live now. If I guessed here, this article would rot the same way next year — which is exactly why the verdict belongs in a script rather than in prose.
Measuring what tool definitions cost you
The criterion that actually matters is neither star count nor release cadence. It is how much context disappears the moment you connect a server. An MCP client fetches tools/list on connect and hands the model every tool name, description, and input schema. Those seats are taken before you type anything.
So I measured. Connecting to four official reference servers, serializing the tools/list response, and tokenizing with cl100k_base:
Server
Tools
JSON bytes
Tokens
Per tool
server-filesystem
14
13,663
3,330
237
server-memory
9
11,503
2,945
327
server-sequential-thinking
1
4,707
1,091
1,091
server-everything
13
8,124
2,060
158
All four
37
—
9,426
255
Four servers, 9,426 tokens. All of it loaded before a single instruction.
The sequential-thinking row is the one that changed how I think about this. A single tool costing 1,091 tokens, because its description runs 2,781 characters. Meanwhile everything fits 13 tools into 2,060 tokens — 158 each. Tool count is not a proxy for cost. Description and schema verbosity is the cost.
You can reproduce the measurement with this. Because it observes from the client side, the number reflects what actually reaches the model rather than what the docs describe.
// probe.mjs — measure the token footprint of a server's tools/listimport { Client } from "@modelcontextprotocol/client";import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";import { writeFileSync } from "node:fs";const targets = [ ["filesystem", ["node", "node_modules/@modelcontextprotocol/server-filesystem/dist/index.js", "/tmp/sandbox"]], ["memory", ["node", "node_modules/@modelcontextprotocol/server-memory/dist/index.js"]],];const out = {};for (const [name, argv] of targets) { const transport = new StdioClientTransport({ command: argv[0], args: argv.slice(1) }); const client = new Client({ name: "probe", version: "1.0.0" }, { capabilities: {} }); await client.connect(transport); const { tools } = await client.listTools(); const json = JSON.stringify(tools); out[name] = tools; console.log( `${name.padEnd(22)} tools=${String(tools.length).padStart(3)} ` + `bytes=${String(Buffer.byteLength(json, "utf8")).padStart(6)}` ); await client.close();}// Leave tokenization to a separate process (tiktoken or similar)writeFileSync("tools.json", JSON.stringify(out, null, 1));
Push the resulting tools.json through any tokenizer and you get the same columns. I used cl100k_base, but encodings differ by model, so the ratios between servers are more actionable than the absolute values.
Where I landed in practice: three servers connected at all times, no more. Anything beyond that goes into a per-project config and gets enabled only when needed. Dropping the habit of connecting things "just in case" changed the quality of responses in a way I did not expect — my read is that a smaller menu makes the choice easier.
npx, or calling the local dist directly
One more place where config style shows up in measurements: whether command says npx or node.
Three runs each against the same server-filesystem, timing connect through tools/list completion, with npx's cache already warm:
Launch method
Median
Three runs
node node_modules/.../dist/index.js
246ms
237 / 246 / 248ms
npx -y @modelcontextprotocol/server-filesystem
615ms
598 / 615 / 632ms
About 2.5×, or 369ms in absolute terms. For one server that is noise. But it applies per server — five connected servers means roughly 1.8 seconds spent on package resolution at every startup, and that assumes a warm cache. A cold one adds the download on top.
npx -y has a second property worth naming. Unless you pin the version, resolution can change between launches. That is usually the entry point for "it worked yesterday and doesn't this morning." The closer you get to production, the more it pays to install servers as explicit dependencies and invoke them with node.
Three primitives, and the one you will actually use
MCP defines Tools, Resources, and Prompts. The spec treats them as peers; building with them does not.
Tools — functions the model calls. You can build almost anything with these alone
Resources — data the model reads, referenced by URI and loaded into context without a tool round-trip
Prompts — reusable templates that depend on client-side UI support, so they are fine to defer in your own servers
Start with Tools only. Resources start earning their keep when the same data gets referenced repeatedly — loading it once beats calling a tool each time.
Rewriting a custom server on SDK 2.x
The SDK has moved too. @modelcontextprotocol/sdk sits at 1.30.0 (2026-07-27), and on the same date @modelcontextprotocol/server, /client, /node, and /core shipped as 2.0.0, splitting the package apart. If you are only writing a server, @modelcontextprotocol/server is all you need.
The older pattern — instantiating Server, registering ListToolsRequestSchema and CallToolRequestSchema handlers, then dispatching on tool name with a switch — is now McpServer and registerTool. Hand-written JSON Schema is gone as well. What follows is code I ran and verified.
// inventory-server.mjsimport { McpServer } from "@modelcontextprotocol/server";import { serveStdio } from "@modelcontextprotocol/server/stdio";import { z } from "zod";const DB = { "SKU-1001": { tokyo: 12, osaka: 0 }, "SKU-1002": { tokyo: 0, osaka: 5 } };serveStdio(() => { const server = new McpServer({ name: "inventory", version: "1.0.0" }); server.registerTool("get_inventory", { title: "Inventory lookup", description: "Returns stock levels per warehouse for a product ID. Omit warehouseId for the total across all warehouses.", inputSchema: z.object({ productId: z.string().regex(/^SKU-\d{4}$/), warehouseId: z.enum(["tokyo", "osaka"]).optional(), }), outputSchema: z.object({ productId: z.string(), total: z.number(), byWarehouse: z.record(z.string(), z.number()), }), annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, }, async ({ productId, warehouseId }) => { const row = DB[productId]; if (!row) { // Give the model something it can act on return { isError: true, content: [{ type: "text", text: `Product ID ${productId} not found. Valid examples: ${Object.keys(DB).join(", ")}`, }], }; } const byWarehouse = warehouseId ? { [warehouseId]: row[warehouseId] ?? 0 } : row; const output = { productId, total: Object.values(byWarehouse).reduce((a, b) => a + b, 0), byWarehouse, }; return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: output }; }); return server;});
Three things changed relative to the older style.
inputSchema now takes a zod schema directly, and the SDK generates the JSON Schema. That removes the failure mode where a hand-written schema and the handler's type annotations drift apart because you updated only one.
outputSchema paired with structuredContent puts types on the return value too. When tools returned text alone, there was always room for the model to mis-parse the JSON.
The readOnlyHint and idempotentHint annotations feed the client's decision about whether to show a confirmation dialog. Skip them on a read-only tool and every harmless lookup gets an extra prompt. Just as importantly: do not put them on anything that writes.
What the model reads when your handler throws
Error handling was the part I could not settle by reading. Does a throw surface as a protocol error that fails the call, or as a tool-level error? Here is what I observed.
Case
What the client receives
Success
content plus structuredContent, no exception
Structured isError return
isError: true plus "Valid examples: SKU-1001, SKU-1002"
Schema-violating argument (productId: "nope")
isError: true plus Input validation error with the violated pattern included
throw new Error("boom") inside the handler
isError: true plus boom, nothing more
Three findings that matter in practice.
First, throw does not propagate — the SDK catches it and converts it to isError: true. The call returns successfully. So the difference between throw and an explicit isError is not whether things break. It is purely the quality of the string the model gets to read. With throw new Error("boom"), that string is four characters, and there is no path to recovery in it.
Second, schema violations are rejected before the handler runs.productId: "nope" is stopped by the regex, and the error message includes the pattern it failed. The model can read that and correct itself. Declaring a narrow schema beats accepting z.string() and validating inside the handler — you write less validation code and get a more recoverable failure.
Third, error messages should be written for the model, not for a human reader. Do not stop at "product not found"; include examples of valid values. The text you log and the text you return are serving different audiences. Since realizing that, the only question I ask when writing an isError message is whether a model reading it could choose its next move.
Where permissions and secrets belong
Least privilege is correct as a principle, but the decision that actually protects you is choosing not to grant something.
A GitHub-style token rarely needs more than repo; admin:org is unnecessary and delete_repo has no justification. Slack-style integrations cover most use cases with chat:write and channels:read. The hesitation usually comes from anticipating a future need. Adding the scope when that need arrives has a strictly lower expected cost.
Keep secrets out of the config file and pass them through the environment.
# .env.antigravity (add it to .gitignore)GITHUB_TOKEN=YOUR_GITHUB_TOKENSLACK_BOT_TOKEN=YOUR_SLACK_BOT_TOKENNOTION_API_KEY=YOUR_NOTION_KEY
Before adding any third-party server, inspect it with @modelcontextprotocol/inspector (2.3.0, 2026-08-19). Launch it via npx @modelcontextprotocol/inspector node dist/index.js, run the tools by hand, and watch for unexpected network calls or excessive scope requests. Looking at a server in isolation is safer than wiring it into your config and calling it from Antigravity first.
A note from an indie developer
When I started with MCP, I connected everything. Decide later whether you need it; more options can't hurt — that was the reasoning.
The 9,426-token figure above is what changed my mind. That much context is spoken for before the conversation begins, and most of it describes tools with nothing to do with the day's task. What I had filed under "harmless if unused" was very clearly taking up room.
Working solo, this kind of invisible cost accumulates without anyone flagging it. On a team someone might notice; alone, it resolves into a vague sense that things feel less sharp lately. So my policy now is to build the measurement before I need it. Neither probe.mjs nor mcp-audit.mjs is clever code. But unclever code running on a schedule has helped more than clever code I meant to write.
Checking the throw behavior gave me the same feeling. I had read the documentation and believed I understood it; throwing an actual error said otherwise. Without knowing it converts to isError, I would have kept writing throw new Error() and never noticed the model was receiving four characters.
Verifying something by hand takes about as long as reading one article about it. I still drift toward the reading, and that is something I am working on.
If you want to act on one thing
Three steps are enough.
Run mcp-audit.mjs against your current settings.json. It takes about a minute. If it returns exit=2, that config has been carrying dead servers
Feed the survivors to probe.mjs and total the tokens for everything you keep connected. Deciding what to drop is much easier with the number in front of you than by feel
If you maintain your own server, move it to registerTool and replace bare throw calls with structured isError returns. Half a day covers it
Measuring before deciding turns out to be the thing that stops the ecosystem's churn from setting your agenda. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.