ANTIGRAVITY LABJP
Articles/Integrations
Integrations/2026-08-20Beginner

Find the one broken MCP entry before Antigravity starts, with 40 lines of Node

A single typo in your MCP settings can take every server down with it. Here is a small preflight script that catches the mistakes statically, the real output from a deliberately broken config, and the duplicate-key trap that JSON hides from you.

MCP23Antigravity CLI28configuration5troubleshooting110Node.js4

One morning I opened Antigravity and the tool list was empty. Every MCP server that had worked the day before was gone.

The cause was the last entry I had added before going to bed: I wrote args as a string instead of an array. That one typo took the unrelated servers down with it. It cost me about twenty minutes of reading logs to find the line.

CLI 1.1.14 (August 18) improved this. An invalid MCP server entry is now logged and skipped, and the remaining servers still load. That is a welcome fix. But environments that have not picked up the update yet still behave the old way, and if the settings file is not valid JSON at all, every version fails the same way. If the parser cannot read the file, it has no way to decide which single entry to skip.

As an indie developer moving between several projects, I edit MCP configs often, which means I break them often. This particular failure is one I have hit more than once.

Reading the config before launch turns out to be faster than reading logs after it. Forty lines of Node were enough.

Before writing any of it, it is worth knowing where the file you are checking actually lives. MCP servers can be declared per project and globally, and the two are merged. A server that "disappeared" is sometimes a server whose global declaration was shadowed by a project-level one you edited months ago. The script below takes a path as an argument for exactly this reason: run it against each file separately, and you find out which one you have been editing.

What you can learn without starting anything

When I listed the MCP configuration mistakes I had actually made, almost all of them were catchable by a static check.

CheckHow it showed up in practiceCatchable before launch?
Valid JSONTrailing comma, missing closing braceYes
command exists on PATHSwapped version managers and lost uvxYes
args is an arrayWrote a bare string when passing a single argumentYes
env references resolveReferenced an environment variable the shell never setYes
url has a schemeWrote localhost:8931/mcp with no protocolYes
The server actually respondsProcess started, handshake never came backNo

One row deserves a caveat. Checking that command resolves on PATH tells you the binary is reachable from the shell running the script, which is not always the same environment the editor launches servers in. It is still worth checking, because a missing binary is always a failure — but a passing check is weaker evidence than the others.

Only the last row genuinely requires launching. Everything above it can be rejected earlier. The point of the check is not to guarantee that your servers work — it is to make sure that the only failures left are the ones worth reading logs for.

The preflight script

It needs nothing but Node.js, and takes one argument: the path to your settings file.

#!/usr/bin/env node
import { readFileSync, accessSync, constants } from "node:fs";
import { delimiter, join } from "node:path";
 
const file = process.argv[2];
if (!file) { console.error("usage: node mcp-preflight.mjs <settings.json>"); process.exit(2); }
 
let raw, cfg;
try { raw = readFileSync(file, "utf8"); }
catch (e) { console.error(`FATAL cannot read file: ${e.message}`); process.exit(2); }
try { cfg = JSON.parse(raw); }
catch (e) {
  console.error(`FATAL not valid JSON: ${e.message}`);
  const m = /position (\d+)/.exec(e.message);
  if (m) console.error(`      -> look around line ${raw.slice(0, Number(m[1])).split("\n").length}`);
  process.exit(2);
}
 
const servers = cfg.mcpServers ?? {};
const names = Object.keys(servers);
if (names.length === 0) { console.error("FATAL mcpServers is empty"); process.exit(2); }
 
function which(cmd) {
  if (cmd.includes("/")) { try { accessSync(cmd, constants.X_OK); return cmd; } catch { return null; } }
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
    try { accessSync(join(dir, cmd), constants.X_OK); return join(dir, cmd); } catch {}
  }
  return null;
}
 
// JSON silently overwrites duplicate keys, so count declarations in the raw text
const declared = [...raw.matchAll(/^[\t ]*"([A-Za-z0-9_.-]+)"[\t ]*:[\t ]*\{/gm)].map((m) => m[1]);
const dupes = [...new Set(declared.filter((n) => names.includes(n) && declared.filter((x) => x === n).length > 1))];
for (const d of dupes) console.log(`WARN ${d} is declared more than once (only the last one is read)`);
 
let bad = 0;
for (const name of names) {
  const s = servers[name] ?? {};
  const errs = [];
  if (!/^[A-Za-z0-9_-]+$/.test(name)) errs.push("server name contains unsupported characters");
  if (s.url) {
    if (!/^https?:\/\//.test(s.url)) errs.push(`url does not start with http(s): ${s.url}`);
  } else if (typeof s.command !== "string" || s.command.length === 0) {
    errs.push("command is missing (required when there is no url)");
  } else if (!which(s.command)) {
    errs.push(`command not found on PATH: ${s.command}`);
  }
  if (s.args !== undefined && !Array.isArray(s.args)) errs.push("args is not an array");
  for (const [k, v] of Object.entries(s.env ?? {})) {
    if (typeof v !== "string") { errs.push(`env.${k} is not a string`); continue; }
    const ref = /^\$\{?([A-Z0-9_]+)\}?$/.exec(v);
    if (ref && !process.env[ref[1]]) errs.push(`env.${k} references ${ref[1]}, which is unset`);
    if (v === "") errs.push(`env.${k} is an empty string`);
  }
  if (errs.length) { bad++; console.log(`NG  ${name}`); for (const e of errs) console.log(`      - ${e}`); }
  else console.log(`OK  ${name}`);
}
console.log(`---\n${names.length - bad} of ${names.length} entries look launchable`);
process.exit(bad ? 1 : 0);

A few notes on why it is written this way.

The script walks PATH itself instead of shelling out to which, because minimal CI images do not always ship it. The implementation is incomplete on Windows, though: there is no extension resolution, so npx.cmd will not be found. Add a branch that iterates PATHEXT if you run there.

Deriving the line number from the parse error is a fallback for runtimes that only report a character offset. On Node.js 22 the message already carried (line 6 column 5), so on a recent runtime those three lines are redundant.

Exit codes are split deliberately: 2 when the file cannot be parsed, 1 when individual entries are malformed, 0 when everything passes. That makes it easy to branch on from a shell or a CI step.

Running it against a broken config

I wrote a config with five servers and planted a common mistake in three of them. This is the real output.

OK  filesystem
OK  sqlite
NG  internal-api
      - url does not start with http(s): localhost:8931/mcp
NG  notes
      - args is not an array
      - env.NOTES_TOKEN references NOTES_TOKEN, which is unset
NG  search
      - env.SEARCH_ENDPOINT is an empty string
---
2 of 5 entries look launchable

Notice that notes reports two problems at once. The loop finishes every check for an entry instead of bailing on the first error, and that is deliberate: fixing one issue, relaunching, and failing on the next one in the same entry is exactly the round trip this script is meant to remove.

Adding a single trailing comma to the file produced this instead:

FATAL not valid JSON: Expected double-quoted property name in JSON at position 156 (line 6 column 5)
      -> look around line 6

It stops before any per-entry checks. Guessing which server is at fault in a file the parser cannot read would only produce a confident wrong answer.

When command cannot be found, the cause is usually the environment rather than the config, and Fixing spawn npx ENOENT When an Antigravity MCP Server Won't Start goes into that specifically — mostly the case where PATH differs when the app is launched from the GUI.

JSON quietly overwrites duplicate names

This was the part that surprised me while writing the script.

I passed a config that declared the same server name twice, and got:

WARN notes is declared more than once (only the last one is read)
NG  notes
      - command not found on PATH: bunx
---
0 of 1 entries look launchable

The file lists two servers, and the summary says one. Per the JSON spec, a repeated key wins last, and the earlier object disappears without a warning. It is not an error.

What makes this nasty is the shape of the symptom. You do not see "server failed to start" — you see "that one server is somehow running with an old configuration", and nothing appears in the logs. If you grow your config by copying an existing block, the moment you forget to rename it you have hit this.

That is why the duplicate check reads the raw text rather than the parsed object. It is a naive regular expression that some formatting styles will slip past, but it reliably catches the copy-paste case, which is the one that actually happens.

Putting it somewhere you will keep using it

A check you have to remember to run is a check you will stop running. I keep it in three places.

  1. As an npm script: "mcp:check": "node scripts/mcp-preflight.mjs .antigravity/settings.json"
  2. As a shell function, so it is muscle memory right after editing the config
  3. As the first step of CI, if the config lives in a shared repository

The third one matters most on a team. "It works on my machine because I have uvx installed" is a much calmer conversation to have on a pull request than in someone else's morning. Because the exit codes are separated, the CI step stays a one-liner.

If you would rather not add a file to the repository, the same logic collapses into a shell one-liner for the JSON check alone: node -e "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" settings.json. That catches the trailing-comma class of failure, which in my own history accounts for more lost mornings than every other cause combined. The longer script exists because the remaining causes are the ones that are hard to see by eye.

Once the preflight passes, the failures that remain are the interesting ones: the configuration is fine and the server still will not answer. From there the question shifts from syntax to design — specifically, which tools you hand the agent at all. I wrote up how I narrow that surface in Scope the MCP Tools You Hand an Agent: A Least-Privilege Allowlist Design, which is one of the premium articles readers support this site with.

Run your current settings file through the script once. If everything comes back OK, then the next time a launch fails you will know the logs are worth 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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Integrations2026-06-01
Fixing spawn npx ENOENT When an Antigravity MCP Server Won't Start
Your MCP server config JSON looks correct, but Antigravity logs spawn npx ENOENT and the server stays gray. This is almost always a PATH inheritance problem, not a broken server. Here is how to diagnose and fix it.
Integrations2026-08-04
Startup Dropped to 3ms, and the First Turn Saw Zero Tools
Non-blocking MCP loading cut startup from 2,907ms to 3ms. The wait did not disappear — it moved into the first turn. Here is where it went, measured, plus a readiness gate that waits only for what a turn actually needs.
Integrations2026-07-19
When an Unresponsive MCP Server Freezes Your Agent: Separate Timeouts for Connect, List, and Call
Antigravity CLI 1.1.3 closed the case where an unresponsive MCP server stalls an agent forever, by adding timeouts to connect, list-tools, and call-tool. This walks through why the three boundaries fail differently, and builds a defensive wrapper with a circuit breaker and failure-only notifications, backed by working code and a week of overnight runs.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →