Someone adds a server to .cursor/mcp.json and the Claude Code users never see it. Someone
else copies .vscode/mcp.json over .mcp.json to fix that, and Claude Code loads zero
servers without a word of complaint. Four hand-edited files describe the same three servers in
four different ways, and only the last one edited is current.
What you get
You will end up with one source file, a script that renders .mcp.json, .cursor/mcp.json,
.vscode/mcp.json, and cline_mcp_settings.json from it, and a CI check that fails when any
of the four drifts. This is for you if one repository is opened by more than one editor.
Short answer
Keep one source file that lists every server and every secret, and render the four editor
files from it with a script that CI runs as a drift check. Each editor wants a different
shape: Claude Code, Cursor, and Cline read mcpServers, VS Code reads servers with typed
inputs, and each refers to a secret in its own syntax. A file copied from one editor to
another loads silently with zero servers or the wrong transport.
You will need
Node 22 or later, and a repository that at least two editors open. Verified 2026-09-25 against Node 22.22.2. The four file formats come from each editor’s own documentation: Claude Code, Cursor, VS Code, and Cline. No editor was driven by a script here: the loader below reads each file the way its documentation says the editor does.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A generator script from one source of truth | Several editors on one repository, secrets that must stay out of git, and a CI job to run it in | A script to own, and a renderer to revisit whenever an editor changes its format, which VS Code and Cline have both done | One editor, or a team that will not run a script before committing |
| A shared gateway endpoint | Many servers on many machines, and credentials and tool access to manage in one place | A service to run and keep up, an extra hop on every tool call, and the per-editor controls such as VS Code inputs and Cline auto-approval flattened behind one URL | Two servers and one laptop |
| Per-editor config files | One or two servers, one editor, and a file edited twice a year | Four files that part company the first time someone adds a server to one of them, and a copy between editors that loads nothing | More than one editor opens the repository |
The generator keeps each editor’s own features, VS Code’s password inputs and Cline’s auto-approval list among them, and in exchange it is code that has to follow four formats. A gateway such as the Docker MCP Gateway or MetaMCP is one URL in every editor and one place credentials live. Every tool call pays for the hop, and the gateway is one more service someone runs. Per-editor files cost nothing until the second editor arrives.
Start from one source file
The source is not any editor’s format. It names each server, its transport, and the placeholders for secrets, and it lists the secrets so a renderer can refuse a placeholder nobody declared.
{
"servers": {
"acme-docs": {
"transport": "http",
"url": "https://docs.example.com/mcp",
"headers": { "Authorization": "Bearer ${DOCS_TOKEN}" }
},
"meters": {
"transport": "stdio",
"command": "node",
"args": ["tools/meter-mcp.mjs", "--read-only"],
"env": { "METERS_API_KEY": "${METERS_API_KEY}" }
}
},
"secrets": {
"DOCS_TOKEN": "Token for the Acme documentation server",
"METERS_API_KEY": "API key for the meters service, read-only account"
}
}
${DOCS_TOKEN} is a placeholder, never a value. The source is committed, so a token in it is
a token in git history. Each renderer turns the placeholder into whatever its editor uses to
read a secret at run time.
Render each editor’s file in its own shape
The four files look interchangeable and are not. Claude Code reads mcpServers, wants
"type": "http" on a remote entry, and expands ${VAR} in command, args, env, url,
and headers; its documentation says an entry with a url and no type is skipped and
reported.1 Cursor reads mcpServers, takes a bare url for a remote server, and
interpolates ${env:NAME}. VS Code reads a top-level servers object, a type on each
entry, and an inputs array that turns a secret into a prompt the editor shows once and
stores.2 Cline reads mcpServers, needs "type": "streamableHttp" because an entry with
no type is read as the legacy SSE transport, carries disabled and autoApprove, and
documents no variable expansion at all.3
Each renderer is a few lines. VS Code’s is the longest, because the secrets become a second top-level section:
function renderVsCode(src) {
const inputs = Object.entries(src.secrets ?? {}).map(([name, description]) => ({ type: 'promptString', id: inputId(name), description, password: true }))
const servers = {}
for (const [name, s] of Object.entries(src.servers)) {
servers[name] = substitute(s.transport === 'stdio' ? { type: 'stdio', ...stdioFields(s) } : { type: 'http', ...httpFields(s) }, (v) => `\${input:${inputId(v)}}`)
}
return { inputs, servers }
}
What it writes:
{
"inputs": [
{
"type": "promptString",
"id": "docs-token",
"description": "Token for the Acme documentation server",
"password": true
},
{
"type": "promptString",
"id": "meters-api-key",
"description": "API key for the meters service, read-only account",
"password": true
}
],
"servers": {
"acme-docs": {
"type": "http",
"url": "https://docs.example.com/mcp",
"headers": {
"Authorization": "Bearer ${input:docs-token}"
}
},
"meters": {
"type": "stdio",
"command": "node",
"args": [
"tools/meter-mcp.mjs",
"--read-only"
],
"env": {
"METERS_API_KEY": "${input:meters-api-key}"
}
}
}
}
The demo renders all four into a scratch directory and then reads each one back as its editor would:
node demo.mjs
rendered 4 files from mcp-servers.json, and what each editor loads
Claude Code .mcp.json 2 servers: acme-docs http, meters stdio
Cursor .cursor/mcp.json 2 servers: acme-docs remote, meters stdio
VS Code .vscode/mcp.json 2 servers: acme-docs http, meters stdio
Cline cline_mcp_settings.json 2 servers: acme-docs streamable http (fill in by hand: DOCS_TOKEN), meters stdio (fill in by hand: METERS_API_KEY)
The Cline line is the one cost the generator cannot remove. With no documented expansion syntax, its file has to carry either the placeholder or the value. The loader reports the placeholder, and someone types the value into the editor’s own settings once.
Copy a file between editors and watch what loads
The same demo then copies each rendered file into another editor’s place, which is what a person does when a colleague’s editor “just needs the same servers”:
node demo.mjs
the same files copied between editors
.vscode/mcp.json as Claude Code .mcp.json 0 servers
.cursor/mcp.json as Claude Code .mcp.json 1 servers: meters stdio; acme-docs: has a url but no type; skipped
.cursor/mcp.json as Cline cline_mcp_settings.json 2 servers: acme-docs sse (legacy), meters stdio
.mcp.json as VS Code .vscode/mcp.json 0 servers
Each line is a different failure. The VS Code file under Claude Code’s name has no
mcpServers key, so it is a valid JSON file describing no servers, and no error is the
correct behavior. The Cursor file under Claude Code’s name loses its remote server, because
url without type is the documented configuration error. The Cursor file under Cline’s
name keeps both servers and connects the remote one over the deprecated transport, which a
Streamable HTTP server answers with a 405. The Claude Code file under VS Code’s name is the
first line in reverse.
Run the drift check in CI
The committed files are outputs, and outputs drift the first time someone edits one by
hand. --check renders the source and compares it with what is on disk, byte for byte:
node generate.mjs --out generated --check
4 files match the source
Run that on every pull request, and run node generate.mjs to regenerate after a change to
the source. The last part of the demo edits one rendered file by hand and runs the check
again:
node demo.mjs
the drift check, before and after a hand edit
all four files match the source
.cursor/mcp.json: differs from what the source renders
The check names the file and nothing else, which is enough: the fix is always to edit the source and regenerate, never to edit the file.
Check it worked
Ten tests hold each renderer to its editor’s documented format and each loader to the
documented behavior. The literal to look for is acme-docs streamable http on the Cline line, which
means the remote entry carries the type the legacy default would otherwise swallow.
node --test generate.test.mjs
1..10
# tests 10
# suites 0
# pass 10
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 175.040672
One test asserts that no rendered file contains a secret value and that each carries its own
reference syntax, ${DOCS_TOKEN}, ${env:DOCS_TOKEN}, and ${input:docs-token}. Another
copies the VS Code file to Claude Code’s name and asserts zero servers and zero problems,
because a check that only fails on errors would never see that case. A third runs --check
from a directory with a space in its path and expects exit status 1. A script that compared
its own path with a URL there would skip its main block and exit 0, having checked nothing.
When it goes wrong
Claude Code lists the server with a warning about a missing variable. The ${VAR} in the
rendered file names an environment variable that is not set on that machine. The
documentation
says the config still loads with the text unexpanded, so set the variable on that machine.
The source cannot carry Claude Code’s ${VAR:-default} instead: Cursor and VS Code have no
form for it, so the generator refuses it rather than write it into their files as text.
Claude Code shows the server as pending approval. Project-scoped servers in .mcp.json need
a one-time approval in an interactive session, and claude mcp reset-project-choices clears
earlier answers.
VS Code prompts for the token on every start. The input’s id in inputs does not match
the ${input:id} in the server entry, so the stored value is never found. The loader reports
this as an undefined input; the generator derives both from the same secret name so they
cannot differ.
Cline connects and every tool call fails. The remote entry has no type, so Cline used the
legacy SSE transport against a Streamable HTTP server. Regenerate; the renderer always
writes streamableHttp.
The drift check fails on a machine where nothing changed. Line endings or a formatter rewrote the file. The check compares bytes, so commit the generator’s output untouched and exclude the four files from any formatter that runs on JSON.
When not to do this
Do not render a file for an editor nobody on the team uses. Each target is a format to
track, and Cline’s has already moved once, from a bare url to a typed one. Delete a target
the day its last user leaves.
Do not put a token in the source file to make the Cline file complete. The source is committed, and a value there is in every clone. Leave the placeholder, and let the loader’s “fill in by hand” line be the reminder it is.
Do not run a gateway to solve a four-file problem. A gateway earns its hop when the servers outnumber the machines and credentials need one owner, not when two developers want the same three servers. Start with the generator, and add the gateway when the operating cost of the servers themselves is the problem.
Do not hand-edit a rendered file, even to try something. The check will fail, and the teammate who sees it fail will regenerate and lose your edit. Change the source, regenerate, and commit both together.
Related how-tos
Last verified
Verified 2026-09-25 against Node 22.22.2. Every output block is what the command preceding it printed. No editor was run. The four loaders follow the documented behavior of each editor, and the copies in the output are what those loaders make of a file written for another editor.
Footnotes
-
The Claude Code documentation names the exact message for a
urlwithout atype. It adds that thetypefield takesstreamable-httpas an alias forhttp, so that configurations copied from server documentation work without modification. The protocol calls the transport Streamable HTTP, the editor calls ithttp, and the alias exists because a copied block travels further than either name. ↩︎ Back to text -
VS Code’s configuration reference describes two formats and reads both: its own,
.vscode/mcp.jsonwith aserversobject, and what it calls the portable format,.mcp.jsonwithmcpServers. The editor with the odd shape is the one editor here that also reads the common one. That leaves a workspace with two possible files and one word, portable, to say which of them the other editors will understand. ↩︎ Back to text -
Cline’s MCP page says that omitting
type“defaults to the legacyssetransport for backward compatibility”, and in the same sentence tells you to setstreamableHttpexplicitly. The MCP specification’s deprecated features registry lists that transport for removal. A default is a decision made for everyone who did not make one, and here it points at the transport the protocol is retiring. ↩︎ Back to text