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.
| Check | How it showed up in practice | Catchable before launch? |
|---|---|---|
| Valid JSON | Trailing comma, missing closing brace | Yes |
command exists on PATH | Swapped version managers and lost uvx | Yes |
args is an array | Wrote a bare string when passing a single argument | Yes |
env references resolve | Referenced an environment variable the shell never set | Yes |
url has a scheme | Wrote localhost:8931/mcp with no protocol | Yes |
| The server actually responds | Process started, handshake never came back | No |
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 launchableNotice 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 6It 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 launchableThe 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.
- As an npm script:
"mcp:check": "node scripts/mcp-preflight.mjs .antigravity/settings.json" - As a shell function, so it is muscle memory right after editing the config
- 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.