Type to search the docs and updates.

Browse documentation

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 stdio server runs a command on the machine; http, sse, and ws servers 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 mcpServers limits 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.

FieldRequiredDefaultDescription
nameyesnoneServer identifier and key in the generated config.
descriptionnoemptyFree-form documentation. Dropped where the target's MCP schema has no such key (Junie, Warp).
typenostdiostdio, http, sse, or ws. Remote transports write an explicit type; stdio stays implicit. A ws entry emits no server on Augment, Factory, and Qoder.
commandstdio onlynoneExecutable to launch.
argsnoemptyArgument list for the command. An element can hold a reference.
envnoemptyEnvironment variables for the server. Each value is a reference or a plain setting marked !literal.
urlhttp/sse/ws onlynoneEndpoint URL. It can hold a reference.
headersnoemptyHTTP headers for http/sse. Each value is a reference or a plain setting marked !literal.
cwdnoemptyWorking directory for a stdio server, where supported.
timeoutnoemptyUnits vary by target: milliseconds on most.
oauthnoemptyOAuth settings. The shape is target-specific; see the target page.
disablednofalseSee disabled support by target.
rootsnoemptyList 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:

  1. Now, LINT035 is a warning, and sync writes the value as before. lint --strict fails on it.
  2. A later release makes it an error, and lint and sync fail 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.

Targetenvheaders
Claude Code, Crush, OpenHands, Gemini${NAME}, ${NAME:-default}${NAME}, ${NAME:-default}
Factory, Kiro${NAME}${NAME}
AmpLeft out${NAME}
Cursor, Windsurf${env:NAME}${env:NAME}
OpenCode{env:NAME}{env:NAME}
CodexKEY: ${KEY} adds KEY to env_vars. Any other reference is left outAuthorization: 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, ZedLeft outLeft 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.

Targeturlargs
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, ZedServer left outServer 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 env value 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 a Bearer prefix 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, _3 if that still clashes. Equal values share one name. Codex forwards a variable only under its key's own name. A renamed env value such as API_KEY: ${GH_API_KEY} is left out of .codex/config.toml with 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}/db or Bearer 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 url or argument is exactly one URL, only its credentials become references: a user: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 as api_key, access_token, or clientSecret (not token_type, key_id, or sortKey). A parameter named exactly auth, sig, signature, code, session, jwt, or bearer counts too, but country_code does not. A ; separates parameters like &, and #access_token=... counts like ?access_token=.... Each reads <SERVER>_<NAME>, so postgresql://admin:pw@db/app in server pg becomes postgresql://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 a sh -c command or --url=....
  • In args, the value of a long flag with a credential name becomes a reference named after the flag, as --api-key value or --api-key=value. In server gh, --api-key X becomes --api-key ${GH_API_KEY}. A flag such as --token-file, --key-id, --sort-key, or --no-token is not a credential. These values stay as written: a $NAME, a file path (starting with /, ~, ./, ../, or a drive such as C:\), 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 as streamable-http. A value in its own argument must not start with -. After a flag whose name ends in key, apikey, authentication, or authorization, it also needs at least 8 characters, so --require-api-key run stays as written.
  • An argument that is a whole token by its prefix becomes ${<SERVER>_TOKEN}, then _2, _3 for more tokens. The prefixes are ghp_, gho_, ghu_, ghs_, ghr_, github_pat_, glpat-, xoxb-, xoxp-, xoxa-, xapp-, sk_live_, sk_test_, rk_live_, sk-proj-, and sk-ant- with at least 8 more characters, npm_ with at least 30, AIza with 35, sk- with at least 20 more letters or digits, a digit among them, and no further dash, and AKIA with 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 Bearer with 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: value or a JSON pair "name": "value" with a credential key and a literal value, as in a headers value X-Api-Key: ... or {"apiKey": "..."};
    • a --header or -H argument such as Authorization: ..., Cookie: ..., or X-Api-Key: ... with a literal value;
    • a user:password@host without a scheme, as in root:pw@db:3306;
    • a NAME=value or --name value with a credential name inside a longer value, as in sh -c "API_TOKEN=... exec srv" or sh -c "npx srv --token ...";
    • an all-digit password before #, or before / with no host, as in redis://:1234#5@host.
  • A value with %26, %3D, or %3F is read again with those decoded once. A value without a credential, nested URLs included, imports unchanged.
  • Import never rewrites command, cwd, Continue requestOptions keys other than headers, or an x-<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 secret or -psecret, since -p may 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, or authorization;
    • a Bearer token shorter than 20 characters with only letters;
    • a token in a URL path, such as a Slack webhook https://hooks.slack.com/services/..., also under import --global;
    • a URL username that is a token without one of the prefixes above;
    • a token format not listed above.
  • A query or fragment parameter named exactly auth or code always 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.

TargetExtra fields
Codexenv_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
Crushenabled_tools, disabled_tools, sessionless
Geminitrust, includeTools, excludeTools
Qodertrust, includeTools, excludeTools, alwaysAllow
KiroautoApprove, disabledTools, oauthScopes
FactorydisabledTools, connectTimeout
Claude CodealwaysLoad, bareElicitationCapability, headersHelper
CursorenvFile, auth
Copilot / VS CodeenvFile, dev, sandboxEnabled (VS Code file only), tools (Copilot CLI files only)
ContinueconnectionTimeout, requestOptions
OpenHandsauth: 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.

TargetBehavior
Antigravity, Crush, Factory, Kiro, Qoder, WindsurfNative disabled
CodexMapped to enabled = false
Kilo Code, OpenCode, ZedMapped to "enabled": false
CopilotA 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 CodeMapped to project disabledMcpjsonServers in .claude/settings.json for servers emitted to .mcp.json. Import restores it
Cursor, Augment, Junie, Trae, WarpStripped with a note. Disable the server in the tool itself