DEV Community

Cover image for Writing a cross-client config installer for MCP servers in TypeScript
pengspirit
pengspirit

Posted on

Writing a cross-client config installer for MCP servers in TypeScript

Anthropic's Model Context Protocol shipped without two things developers immediately wanted: a registry, and a tool to wire a server into a client without hand-editing JSON. This post is about the second one — specifically the mcpr install command we shipped in @incultnitollc/mcpr@0.2.0, what it actually does, and the bugs we hit building it.

If you've never integrated an MCP server, the current flow is:

  1. Find the server on GitHub.
  2. Read the README for its launch command.
  3. Copy a JSON fragment.
  4. Paste it into the right config file for your client.
  5. Restart the client.
  6. Repeat per server. Per client. Per arg change.

Step 4 alone is a maze. Claude Desktop reads from ~/Library/Application Support/Claude/claude_desktop_config.json on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windows. Cline (the VS Code extension) reads from ~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json. Cursor, Continue, VS Code MCP, and Zed each have their own. The schemas overlap but aren't identical.

The goal of mcpr install is to collapse step 4 into two commands — search the registry for the server, then install the slug it hands back:

npx -y @incultnitollc/mcpr search filesystem
npx -y @incultnitollc/mcpr install npm-agent-infra-mcp-server-filesystem --client claude-desktop
Enter fullscreen mode Exit fullscreen mode

search prints the slug; you paste that slug into install. The slug isn't a free-form string you guess — install without one errors out, because the slug is a key the registry resolves, not an argument you invent. (More on why that matters in the next section.)

That second command does five things worth talking about: npm resolution from the registry, a cross-OS path matrix, a JSON deep-merge that doesn't clobber sibling servers, atomic writes with backups, and file-mode preservation (which we got wrong the first time).

npm-resolve from the registry

The slug isn't a free-form string. It's a key in the MCP Registry's Supabase, where each server row carries an npm_package field. The install path looks the slug up, derives the launch command, and writes it into the client config.

This gives us a useful sandbox boundary: only servers that resolve to an npm package are installable through mcpr install. There's no --from-url escape hatch in v0.2.0. If the registry doesn't have it, the CLI refuses, and you fall back to editing JSON by hand. That's deliberate — it keeps the threat model tight while the registry is small, and it forces server authors to publish to npm (which they should anyway, for npx -y reachability).

The derived launch entry looks like this for a registry server with slug everything:

{
  "command": "npx",
  "args": ["-y", "@modelcontextprotocol/server-everything"]
}
Enter fullscreen mode Exit fullscreen mode

That object is what gets merged into the client config under mcpServers.everything.

The cross-OS, cross-client path matrix

Every supported client has a different config location, and most of them differ per OS. We keep this in a single resolver module so adding a new client is one entry rather than a scavenger hunt across the codebase.

The shape is roughly:

type ClientId = "claude-desktop" | "cline";

const CONFIG_PATHS: Record<ClientId, Partial<Record<NodeJS.Platform, string>>> = {
  "claude-desktop": {
    darwin: "~/Library/Application Support/Claude/claude_desktop_config.json",
    win32:  "%APPDATA%/Claude/claude_desktop_config.json",
    linux:  "~/.config/Claude/claude_desktop_config.json",
  },
  cline: {
    darwin: "~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
    // ...
  },
};
Enter fullscreen mode Exit fullscreen mode

os.platform() picks the row. ~ and %APPDATA% are expanded with the usual os.homedir() / process.env.APPDATA lookups. If a (client, platform) pair isn't supported, the CLI exits non-zero with the unsupported pair named — not a generic "couldn't find config." Naming the missing combination is the difference between a bug report we can act on and one we can't.

v0.2.0 ships Claude Desktop and Cline. v1.2 will add candidates from Cursor, Continue, VS Code MCP, and Zed. The matrix is the entire reason that addition is small.

JSON deep-merge, not shallow overwrite

This is the part developers asked for most loudly. A naive installer would read the config, set config.mcpServers = { [slug]: entry }, and write it back. That destroys every other server the user had configured. It also stomps top-level keys like theme, autoUpdate, or whatever the client happens to track alongside MCP servers.

The actual flow:

  1. Read existing JSON. If the file doesn't exist, start from {}.
  2. Parse with a tolerant parser (trailing commas in user-edited configs are real).
  3. Deep-merge: preserve all sibling keys, preserve all sibling servers, set mcpServers[slug] to the new entry.
  4. Serialize with stable key ordering and a final newline.

Before:

{
  "theme": "dark",
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

After mcpr install everything --client claude-desktop:

{
  "theme": "dark",
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"]
    },
    "everything": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-everything"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

theme survives. filesystem survives. everything is added.

Refuse to clobber, unless told otherwise

If mcpServers.<slug> already exists, the default behavior is to refuse and exit non-zero. The user sees the existing entry, the proposed entry, and a one-line hint about --force.

Two flags govern the override:

  • --force overwrites the existing entry (still preserves siblings).
  • --dry-run writes nothing and prints the planned post-merge JSON to stdout.

--dry-run is the flag we use most in tests. It also turns out to be the flag users reach for first when they want to inspect what mcpr would do without trusting it yet, which is the correct instinct.

Atomic write with timestamped backup

Config files holding API keys deserve more care than fs.writeFileSync. If the process dies mid-write, you do not want a half-written JSON file to be the only copy.

The write path:

  1. Read the original. If it exists, copy it to <config>.bak.<unix-timestamp>.
  2. Write the new content to <config>.tmp.
  3. fsync the temp file.
  4. rename the temp file over the original. rename within the same filesystem is atomic on POSIX.

The .bak.<timestamp> suffix matters. If a user runs mcpr install ten times, they get ten distinct backups, not a .bak that quietly overwrites the only good copy from yesterday.

The file-mode bug (and the fix)

This is the part worth reading. We shipped a first cut of the install command, self-reviewed it, and caught a real security regression before any user ran it.

The bug: the write step used fs.writeFileSync(tmpPath, json) with no mode argument. Node's default mode for new files is 0o644 — owner read/write, everyone else read.

Claude Desktop's config can contain env entries with API keys. Many users (correctly) set their config to 0o600 — owner read/write only, no group, no world. The install step, by writing a fresh file with the default mode, was silently widening 0o600 to 0o644. On a shared machine, every other user could now read your OpenAI key.

The fix is small:

// Before:
fs.writeFileSync(tmpPath, json);
fs.renameSync(tmpPath, configPath);

// After:
const originalMode = fs.existsSync(configPath)
  ? fs.statSync(configPath).mode & 0o777
  : 0o600; // safe default for new configs

fs.writeFileSync(tmpPath, json, { mode: originalMode });
fs.renameSync(tmpPath, configPath);
Enter fullscreen mode Exit fullscreen mode

Two notes on this:

  • For a fresh config (file didn't exist), we default to 0o600, not 0o644. The default should fail closed.
  • & 0o777 strips the file-type bits from stat.mode. Forgetting that mask gives you a number that looks right in octal but has the wrong high bits.

Shipped in commit 3af5724. The lesson is older than the bug: file-mode preservation belongs in the same mental category as preserving sibling JSON keys. Both are about respecting state the user already set.

Tests

The CLI ships with 29 vitest unit tests covering the resolver, the merge, the refuse-vs-force matrix, dry-run output, mode preservation, and backup naming. The merge and mode tests use fixtures: real claude_desktop_config.json shapes with sibling servers, real 0o600 permissions, real Windows path strings.

We also ran a live end-to-end smoke against Claude Desktop on macOS: install into an empty config (noop diff against expected), install when the slug already exists (refused), install with --force (overwrote, sibling survived). All green. The unit tests caught the logic bugs; the live test caught one path-expansion bug that only showed up against the real config directory.

What's next, and a specific ask

v0.2.0 supports Claude Desktop and Cline. v1.2 will add one or two of: Cursor, Continue, VS Code MCP, Zed. If you have a strong opinion about which one should ship first — based on what you actually use day-to-day, not what's trending — open an issue on the repo with your client and your mcpServers schema if it differs from Claude Desktop's.

The companion pieces are also open source: mcp-probe validates server behavior, and mcp-vouch scores servers against the OWASP MCP Top 10 and emits an A–F trust grade. The registry web UI surfaces those scores on each listing.

Repo: https://github.com/Incultnitollc/mcp-registry (MIT)
npm: https://www.npmjs.com/package/@incultnitollc/mcpr
Web: https://mcp-registry-dh5.pages.dev

The two things I'd most like external eyes on: (1) the client path matrix — if your client config lives somewhere I haven't mapped, send the exact path and platform, and (2) the merge semantics for env entries. Today they get merged as-is; there's a reasonable argument for refusing to merge env blocks at all and forcing the user to confirm, because env values are often secrets and silent overwrite is the wrong default. PRs welcome on both.

Top comments (0)