MCP servers
mcps/ connects agents to outside tools and data through the Model Context Protocol: a browser, a database, GitHub, an issue tracker, internal APIs. Each tool keeps servers in its own file and shape, such as .mcp.json, .cursor/mcp.json, or [mcp_servers.*] in .codex/config.toml. One YAML file per server feeds all of them.
- Declared once. Add or change a server in one file; every tool picks it up on the next sync.
- Local or remote. A
stdioserver runs a command on the machine;http,sse, andwsservers connect to a URL. - Tool options kept. Timeouts, tool filters, OAuth, and approval settings that only some tools read are passed on where they apply.
- Narrowed per agent. An agent's
mcpServerslimits which servers one subagent may reach.
Write one
agnostic-ai new mcp filesystem creates mcps/filesystem.yaml. It is pure YAML with no markdown body, one file per server.
name: filesystem
description: Local filesystem access for the model.
type: stdio
command: npx
args:
- -y
- "@modelcontextprotocol/server-filesystem"
env:
ROOT: !literal /tmp
A remote server for two tools, defined but switched off:
name: docs-search
description: Search the internal docs.
type: http
url: https://mcp.example.com/mcp
disabled: true
targets: [claude, codex]
Real servers to copy are on the MCP recipes page.
Fields
name is the server identifier, not the filename. It may contain package-style slashes, such as npm:@modelcontextprotocol/server-sequential.thinking. Such names are percent-encoded in YAML filenames and kept as-is in every generated config. Other spec kinds need one safe path segment, because their names become output paths.
A server needs command (stdio) or url (remote). agnostic-ai lint reports a missing one as LINT008. validate and sync do not, so some targets get a server that cannot start. See lint.
| Field | Required | Default | Description |
|---|---|---|---|
name | yes | none | Server identifier and key in the generated config. |
description | no | empty | Free-form documentation. Dropped where the target's MCP schema has no such key (Junie, Warp). |
type | no | stdio | stdio, http, sse, or ws. Remote transports write an explicit type; stdio stays implicit. A ws entry emits no server on Augment, Factory, and Qoder. |
command | stdio only | none | Executable to launch. |
args | no | empty | Argument list for the command. An element can hold a reference. |
env | no | empty | Environment variables for the server. Each value is a reference or a plain setting marked !literal. |
url | http/sse/ws only | none | Endpoint URL. It can hold a reference. |
headers | no | empty | HTTP headers for http/sse. Each value is a reference or a plain setting marked !literal. |
cwd | no | empty | Working directory for a stdio server, where supported. |
timeout | no | empty | Units vary by target: milliseconds on most. |
oauth | no | empty | OAuth settings. The shape is target-specific; see the target page. |
disabled | no | false | See disabled support by target. |
roots | no | empty | List of {uri, name} objects, for targets that support MCP roots. |
Secrets and plain settings
A value in env or headers is a reference by default: ${NAME}, ${NAME:-default}, or $${NAME} text. A header may put one word before it, as in Bearer ${API_KEY}. Mark a plain setting that is not a secret with !literal:
env:
GITHUB_TOKEN: ${GITHUB_TOKEN}
NODE_ENV: !literal production
!literal is YAML only. Sync writes NODE_ENV: production to every tool, the same file a plain production gave. An editor using the YAML language server reports an unknown tag until you add !literal scalar to its yaml.customTags setting.
agnostic-ai lint warns on any other value (LINT035). The finding names the server, the field, and the key, never the value. sync prints how many there are. A value with text around a reference, such as postgres://u:pw@${HOST}/db, counts, since that text may be the secret. Empty values, numbers, booleans, x-<target> blocks, url, and args are not checked.
The rule comes in two phases:
- Now, LINT035 is a warning, and sync writes the value as before.
lint --strictfails on it. - A later release makes it an error, and
lintandsyncfail on it. The CHANGELOG announces the switch one release ahead.
agnostic-ai migrate --only secrets rewrites existing specs. A value import reads as a credential becomes a reference, such as GITHUB_TOKEN: ${GITHUB_TOKEN}, named the way import names it. The command lists each variable to set, never the value. Sync then writes the reference instead of the value. Every other value gets !literal, so sync writes the same files. The migration leaves two cases to you: a key with a credential name whose value has no credential shape, such as API_KEY: sk-live-abc, and a credential around a reference.
Environment references
Write a token or key as ${NAME} in an env or headers value. The spec then names the variable and never holds the secret. The reference can be the whole value or part of it:
env:
GITHUB_TOKEN: ${GITHUB_TOKEN}
headers:
Authorization: Bearer ${API_KEY}
${NAME:-default} falls back to default when NAME is unset. Only Claude Code, Crush, OpenHands, and Gemini document that form. Any other ${...}, such as ${env:NAME} or ${input:id}, is not a reference in a spec.
Sync writes each tool's own form. Where a tool cannot read a reference in that field, sync leaves the key out. It prints a note naming the server, the key, and the variable, and never writes the reference as plain text. This applies to a whole target with no form, Amp env, a ${NAME:-default} outside the four tools above, and any other ${...}. A literal value is written as it is.
| Target | env | headers |
|---|---|---|
| Claude Code, Crush, OpenHands, Gemini | ${NAME}, ${NAME:-default} | ${NAME}, ${NAME:-default} |
| Factory, Kiro | ${NAME} | ${NAME} |
| Amp | Left out | ${NAME} |
| Cursor, Windsurf | ${env:NAME} | ${env:NAME} |
| OpenCode | {env:NAME} | {env:NAME} |
| Codex | KEY: ${KEY} adds KEY to env_vars. Any other reference is left out | Authorization: Bearer ${NAME} sets bearer_token_env_var. Another header of exactly ${NAME} joins env_http_headers. Any other reference is left out |
| Continue | ${{ secrets.NAME }} on stdio servers | ${{ secrets.NAME }} under requestOptions.headers |
| Antigravity, Augment, Copilot, Junie, Kilo Code, Qoder, Trae, Warp, Zed | Left out | Left out |
Kiro expands only the variables approved under Mcp Approved Env Vars in its settings. Factory fails the connection when a referenced variable is unset, and Claude Code passes the unexpanded ${NAME} text to the server.
agnostic-ai doctor lists each referenced variable that is unset in your shell, in top-level fields and in the x-<target> block of each selected target. It checks disabled servers too, since several tools start them anyway, and it can list a variable that an override replaces. A tool started from a desktop launcher may see a different environment, and Continue also reads .env files, so treat the list as a hint.
Continue's IDE extensions read secrets from project .env, .continue/.env, or ~/.continue/.env files. Its CLI also reads process environment variables. See Continue's secret resolution. Keep .env files out of Git.
References in url and args
A ${NAME} can also sit in url or in an args element, such as a host that differs per machine or a token a server takes on its command line:
url: https://${API_HOST}/mcp
args: [--token, "${GH_TOKEN}"]
Sync writes each tool's own form. A tool that reads no reference in that field gets no server at all, because dropping one argument would change the command. The note names the server, the field, and the variable. Sync checks only the field the tool writes for the server's transport, after any x-<target> override that tool applies. A ${NAME:-default} follows the same rule outside Claude Code, Crush, OpenHands, and Gemini.
A tool's own form at the top level, such as ${env:NAME}, is copied as text to every tool that does not read that form. agnostic-ai lint reports it as LINT028. Write ${NAME}, or put the native text under x-<target>:.
${workspaceFolder}, ${workspaceFolderBasename}, ${userHome}, and ${pathSeparator} are tool variables, not environment references, and stay as written. So does any other ${...}, such as ${input:id}, and every literal URL or argument.
| Target | url | args |
|---|---|---|
| Claude Code, Crush, OpenHands, Gemini | ${NAME}, ${NAME:-default} | ${NAME}, ${NAME:-default} |
| Amp | ${NAME} | Server left out |
| Cursor, Windsurf | ${env:NAME} | ${env:NAME} |
| OpenCode | {env:NAME} | {env:NAME} |
| Continue | ${{ secrets.NAME }} | ${{ secrets.NAME }} |
| Codex, Factory, Kiro, Antigravity, Augment, Copilot, Junie, Kilo Code, Qoder, Trae, Warp, Zed | Server left out | Server left out |
Import reads each tool's form in url and args back as ${NAME}, including a whole-argument $NAME on Gemini and Crush. A tool reference to a variable named like one of the four tool variables, such as Cursor's ${env:workspaceFolder}, is kept as written, so it never becomes the tool variable. Import keeps a literal URL or argument as written, except for the credentials in it.
Literal ${NAME} text
Some servers expand ${NAME} themselves, such as mcp-remote in a header argument. Write $${NAME} to pass the text through unchanged. It works in url, args, env, and headers:
command: npx
args: ["-y", "mcp-remote", "https://mcp.example.com", "--header", "x-chroma-token: $${X_CHROMA_TOKEN}"]
Sync writes ${X_CHROMA_TOKEN} to every built-in tool: no tool form, no left-out server, no note. An external adapter receives the spec as written, $${NAME} included. Quote it inside a [...] list, as you would ${NAME}. Only $${ is special. Any other $$ is written as it is.
A tool that expands ${NAME} in that field fills in the value from its own environment before the server sees it. These are Claude Code, Crush, OpenHands, and Gemini in url and args, Amp in url, and the tools with ${NAME} in the env and headers table. None of them documents an escape for a literal ${, so sync still writes the server. The tool reads its own environment, not the server's env block. The server gets the same value unless its env sets that variable to something else. A value expanded in args shows in the process list, as it does for a ${NAME} reference. When the variable is unset and has no default, Claude Code keeps the ${NAME} text and warns in claude mcp list. In a remote server's url and headers, some credential variables read as empty instead (Claude Code MCP docs).
When sync leaves out a reference a tool cannot read, the note names the $${NAME} spelling to use instead.
What import writes
import reads each tool's own form back as ${NAME}, including a whole-value $NAME on Gemini and Crush and %NAME% on Gemini. A ${NAME} in a field the tool never expands is text, so the spec gets $${NAME} and the tool's file keeps ${NAME}, as in Warp, Zed, and Codex args. A $${NAME} in a field whose form is ${NAME}, such as Gemini args, imports as $$${NAME}, so sync writes the same text back. Gemini reads it as $ plus the value. A ${NAME} in a Codex env table is text too, so it no longer imports as a variable Codex forwards; env_vars still does. Import also replaces every literal env and headers value with a reference, because it cannot tell a token from a plain setting, and a spec is meant to be committed. A plain setting such as NODE_ENV: production becomes NODE_ENV: ${NODE_ENV}, a variable you must now set. Factory fails the connection without it. Put a plain setting back by hand when it is not a secret.
- An
envvalue reads the variable its key names. - A header reads
<SERVER>_<HEADER>in upper case, with any character other than a letter or digit as_, and keeps aBearerprefix outside the reference. - When two different values would share a name, or the import already references that name, each one reads
<SERVER>_<KEY>instead, then_2,_3if that still clashes. Equal values share one name. Codex forwards a variable only under its key's own name. A renamedenvvalue such asAPI_KEY: ${GH_API_KEY}is left out of.codex/config.tomlwith a note. Rename the variable to the key when Codex needs that server. - A
${NAME:-default}loses its default, since a default is a value too. - A value with text around its references, such as
postgres://u:pw@${HOST}/dborBearer sk-1 ${EXTRA}, is replaced whole, since that text may be the secret. So is a value with any other${...}, such as a VS Code${input:id}prompt, which sync could not write. The output names the prompt. - A Crush
$(command)value becomes a reference, and the output names the command it ran. - When a
urlor argument is exactly one URL, only its credentials become references: auser:password@password, a username that is a token by the prefixes below (https://ghp_...@github.com), and the value of a query or fragment parameter with a credential name, such asapi_key,access_token, orclientSecret(nottoken_type,key_id, orsortKey). A parameter named exactlyauth,sig,signature,code,session,jwt, orbearercounts too, butcountry_codedoes not. A;separates parameters like&, and#access_token=...counts like?access_token=.... Each reads<SERVER>_<NAME>, sopostgresql://admin:pw@db/appin serverpgbecomespostgresql://admin:${PG_PASSWORD}@db/app. The rest of the URL stays as written. This includes a URL whose base is a reference, such as${API_BASE}/mcp?token=.... A credential that is already a reference loses its default. So does a reference whose default is a URL with a credential, such as${MCP_URL:-https://x.example/mcp?token=...}. The output names the field and variable, never the URL. - One URL means no whitespace, quotes, or
$${, starting with a scheme or a${NAME}. Import never rewrites any other value, such as ash -ccommand or--url=.... - In
args, the value of a long flag with a credential name becomes a reference named after the flag, as--api-key valueor--api-key=value. In servergh,--api-key Xbecomes--api-key ${GH_API_KEY}. A flag such as--token-file,--key-id,--sort-key, or--no-tokenis not a credential. These values stay as written: a$NAME, a file path (starting with/,~,./,../, or a drive such asC:\), a file name ending in.json,.txt,.pem,.key,.env,.yaml,.yml,.toml,.crt,.p12, or.db, a value starting with@, a number,true,false,yes,no,on,off, and lower-case words joined by dashes such asstreamable-http. A value in its own argument must not start with-. After a flag whose name ends inkey,apikey,authentication, orauthorization, it also needs at least 8 characters, so--require-api-key runstays as written. - An argument that is a whole token by its prefix becomes
${<SERVER>_TOKEN}, then_2,_3for more tokens. The prefixes areghp_,gho_,ghu_,ghs_,ghr_,github_pat_,glpat-,xoxb-,xoxp-,xoxa-,xapp-,sk_live_,sk_test_,rk_live_,sk-proj-, andsk-ant-with at least 8 more characters,npm_with at least 30,AIzawith 35,sk-with at least 20 more letters or digits, a digit among them, and no further dash, andAKIAwith 16 upper-case letters or digits. A JWT (eyJ...with three dot-separated parts) counts too. - Import leaves the server out and names the server and field when a value it does not rewrite holds a literal credential, or a value still shows one after the rewrite. A credential here is any of these:
- a URL password or credential parameter (a quoted value such as
token='...'counts, a quoted'${NAME}'does not); - a token by its prefix inside a longer value;
- a
Bearerwith a literal token that has a digit, one of-._~+/=, or at least 20 characters, as in-H 'Authorization: Bearer ...'or a JSON headers value; - a line
Name: valueor a JSON pair"name": "value"with a credential key and a literal value, as in a headers valueX-Api-Key: ...or{"apiKey": "..."}; - a
--headeror-Hargument such asAuthorization: ...,Cookie: ..., orX-Api-Key: ...with a literal value; - a
user:password@hostwithout a scheme, as inroot:pw@db:3306; - a
NAME=valueor--name valuewith a credential name inside a longer value, as insh -c "API_TOKEN=... exec srv"orsh -c "npx srv --token ..."; - an all-digit password before
#, or before/with no host, as inredis://:1234#5@host.
- a URL password or credential parameter (a quoted value such as
- A value with
%26,%3D, or%3Fis read again with those decoded once. A value without a credential, nested URLs included, imports unchanged. - Import never rewrites
command,cwd, ContinuerequestOptionskeys other thanheaders, or anx-<target>block. A credential in any of them leaves the server out, and so does a key there with a credential name and a literal value. - Import does not find these credentials, so check such servers by hand:
- a short flag such as
-p secretor-psecret, since-pmay be a port or profile, or a single-dash flag such as-token secret; - a credential value that starts with
-in its own argument after a flag; - a value shorter than 8 characters in its own argument after a flag whose name ends in
key,apikey,authentication, orauthorization; - a
Bearertoken shorter than 20 characters with only letters; - a token in a URL path, such as a Slack webhook
https://hooks.slack.com/services/..., also underimport --global; - a URL username that is a token without one of the prefixes above;
- a token format not listed above.
- a short flag such as
- A query or fragment parameter named exactly
authorcodealways becomes a reference, even when it holds no secret.
Import prints each replacement and the variable to set. import --global cannot change a server, because the user files it adopts must render back unchanged. It leaves out each server with a literal credential and names the server and field, never the value. That is any credential above, and an env or header value that has a credential name or holds a credential by its shape. A plain setting such as NODE_ENV: production, a path, a number, and a value that is only a reference after one word, such as Bearer ${TOKEN}, still import as written, a plain string marked !literal. A value under a credential name without a credential shape stays unmarked, so lint asks about it. Add a left-out server by hand under local/mcps, or write it with a ${NAME} reference.
Credential names
A name holds a credential when its last word is token, secret, password, passwd, pwd, pass, accesstoken, authtoken, credential, credentials, cookie, bearer, apikey, authentication, or authorization. Words split at punctuation and at camelCase, so clientSecret and API_TOKEN count and token_type does not. A one-word name that ends in password, passwd, secret, or token counts too, as PGPASSWORD and MYTOKEN do.
A last word key counts unless the word before it is sort, cache, public, partition, primary, foreign, idempotency, routing, object, row, hash, map, lookup, group, dedup, shard, index, unique, composite, natural, surrogate, ssh, gpg, pgp, s3, or id. So api_key, OPENAI_KEY, and --openai-key count, and sort_key, cache_key, --public-key, and --ssh-key do not.
An env or header key, a key in an x-<target> block or requestOptions, and a name in a Name: value line or JSON pair also count when the last word is cred or auth, as MY_CRED and X_AUTH do. A flag such as --auth oauth does not.
Target-only fields
These fields apply only to the listed targets. Other targets ignore them.
| Target | Extra fields |
|---|---|
| Codex | env_vars, env_http_headers, http_headers_helper, auth, required, startup_timeout_sec, startup_timeout_ms, tool_timeout_sec, default_tools_approval_mode, scopes, oauth_resource, experimental_environment, enabled_tools, disabled_tools |
| Crush | enabled_tools, disabled_tools, sessionless |
| Gemini | trust, includeTools, excludeTools |
| Qoder | trust, includeTools, excludeTools, alwaysAllow |
| Kiro | autoApprove, disabledTools, oauthScopes |
| Factory | disabledTools, connectTimeout |
| Claude Code | alwaysLoad, bareElicitationCapability, headersHelper |
| Cursor | envFile, auth |
| Copilot / VS Code | envFile, dev, sandboxEnabled (VS Code file only), tools (Copilot CLI files only) |
| Continue | connectionTimeout, requestOptions |
| OpenHands | auth: oauth, or a truthy oauth, which sets auth: "oauth" on a remote server in ~/.openhands/mcp.json |
On Amp, set x-amp.includeTools. Use x-factory, x-kilo, or x-continue to override the matching top-level options for that target.
Claude Code's alwaysLoad and bareElicitationCapability preserve explicit true and false through import and sync. Omit a flag to keep Claude Code's default. Set them at the top level or under x-claude, where they override the matching top-level value. Other targets omit both flags. See Claude Code MCP behavior for tool loading and connection compatibility.
x-claude:
alwaysLoad: false
bareElicitationCapability: true
disabled support by target
Only the targets listed were checked.
| Target | Behavior |
|---|---|
| Antigravity, Crush, Factory, Kiro, Qoder, Windsurf | Native disabled |
| Codex | Mapped to enabled = false |
| Kilo Code, OpenCode, Zed | Mapped to "enabled": false |
| Copilot | A disabledMcpServers entry in .github/copilot/settings.json for Copilot CLI. Stripped from both MCP files with a note; disable the server in VS Code for that half |
| Claude Code | Mapped to project disabledMcpjsonServers in .claude/settings.json for servers emitted to .mcp.json. Import restores it |
| Cursor, Augment, Junie, Trae, Warp | Stripped with a note. Disable the server in the tool itself |