Skip to main content

MCP Servers

A single canonical registry defines every MCP server once. At chezmoi apply time, generators render per-tool configs for Cursor, Claude Code, Gemini, Pi, OMP, Codex, OpenCode, and GitHub Copilot CLI, avoiding eight hand-maintained copies of the same server list.

Use this page when adding, removing, or debugging an MCP server, or when tracing how a server reaches a given assistant.

Mental model

PieceRole
home/.chezmoidata/mcp_servers.yamlSource of truth for server declarations
scripts/mcp_registry.pyNormalizes registry entries and resolves $(command) strings through a login shell
scripts/generate_mcp_configs.pyEmits tool-specific { "mcpServers": { ... } } documents
Tool injectorsPreserve live runtime-owned config while replacing only the MCP section
,mcp-token bridgeInject a fresh bearer per request when a tool cannot perform the hosted OAuth flow itself

The registry mechanics are generic. The currently declared server set is work-profile-only and Elastic-domain-specific.

Registry: mcp_servers.yaml

Each entry is one of two shapes:

ShapeRequired fieldsOptional fields
Command servername, work_only, command, args (list)exclude_tools (a list of tool names) to omit the server for specific tools that cannot express per-tool membership via oauth_by_tool
HTTP servername, work_only, type: http, urloauth_by_tool for per-tool OAuth client metadata, since tools expect different OAuth field shapes

work_only: true servers are emitted only when the isWork chezmoi variable is set. The personal profile currently emits no declared MCP servers.

The work set includes scsi-main, scsi-local, and slack:

ServerCurrent behavior
scsi-mainHosted Semantic Code Search server using Elastic SSO/OAuth. Claude, Gemini, and Pi keep tool-specific OAuth metadata. Cursor, OMP, Copilot, and Codex use ,mcp-token --bridge at runtime; Cursor keeps OAuth client metadata for a dedicated mint workspace.
scsi-localLocal SCSI stdio backend emitted to every work-profile harness, including OMP, Copilot, and Codex.
slackSlack MCP server with per-tool OAuth metadata. Cursor, OMP, Copilot, and Codex use the shared token bridge at runtime.

OMP, Copilot, Codex, and Cursor get scsi-local as a stdio server and scsi-main plus slack as local ,mcp-token --bridge stdio servers that forward to the hosted endpoints with per-request bearer injection. OpenCode gets scsi-local only: its injector intentionally emits command servers and skips every HTTP entry.

Hosted OAuth exceptions

Copilot cannot run the hosted servers' OAuth flows itself. It hardcodes its OAuth redirect to http://127.0.0.1:{port}/, which is not registered for the SCSI Okta app or the public Slack client.

Slack's MCP authorization server offers no dynamic client registration and requires a client secret at the token endpoint: grant_types = [authorization_code, refresh_token], token_endpoint_auth_methods = [client_secret_post].

Both scsi-main and slack therefore give their copilot block a tokenBridge value naming the ,mcp-token token source. Copilot rides the rotating token cursor-cli already minted rather than running OAuth itself, and the bridge re-reads that cache per request, so a session never depends on a token captured at launch.

Codex supports streamable HTTP MCP natively, but its OAuth callback settings are global: mcp_oauth_callback_port / mcp_oauth_callback_url. The hosted SCSI and Slack apps need different approved callback registrations, and its bearer_token_env_var support reads the env var once at launch, dying with that token.

Both scsi-main and slack therefore give their codex block the same tokenBridge value, so Codex spawns the identical per-request stdio bridge.

Cursor can run the hosted OAuth flows, but its native HTTP MCP client has been observed mid-session as enabled with 0 tools after token/session failure. The Cursor tokenBridge entries therefore match Copilot/Codex for runtime transport. OAuth minting stays on a dedicated mint workspace at ~/.cache/mcp-token/oauth-mint/.cursor/mcp.json (OAuth HTTP shapes only): ,mcp-token silent rotate and browser login always use that cwd so project OAuth config wins over the user-level bridge (cursor-agent loads project then user MCP config; project wins).

Using it

Add or change a server

  1. Edit home/.chezmoidata/mcp_servers.yaml and set work_only appropriately.

  2. Preview the rendered per-tool configs:

    chezmoi diff
  3. Regenerate them:

    chezmoi apply

Verification:

chezmoi apply
python3 -m json.tool < ~/.cursor/mcp.json
python3 -c "import json; print(list(json.load(open('$HOME/.claude.json')).get('mcpServers', {})))"
codex mcp list # bridge servers appear as local command servers
copilot mcp list # lists the loaded Copilot servers and their transport types

Refresh a hosted token by hand

To refresh manually, run:

,mcp-token <server> --login

Add --quiet when you do not want cursor-agent auth output in the terminal.

You normally do not run this by hand. The ,mcp-token --bridge stdio servers rotate behind the seam whenever a request finds the current token short, missing, or rejected.

Generation pipeline

The common pipeline has two stages:

StageScriptPurpose
Normalize registryscripts/mcp_registry.pyResolve $(command) strings through a login shell
Generate per-tool shapescripts/generate_mcp_configs.pyEmit a tool-specific { "mcpServers": { ... } } document

Per-tool transforms handle schema differences, such as Cursor's auth.CLIENT_ID vs the standard oauth shape.

Pi gets one extra block:

{ "settings": { "autoAuth": true } }

That lets pi-mcp-adapter run OAuth + reconnect automatically on the first tool call to a needs-auth server. It still opens the browser in an interactive session; it is not headless auth. Other tools have their own config schemas and do not get this block.

Tools whose config is not plain JSON get dedicated injectors with explicit ownership rules:

InjectorOwnership rule
scripts/inject_mcp_into_codex_toml.pyReplaces a # __MCP_SERVERS__ marker in the authoritative profile base, then reattaches only valid runtime MCP approvals, hook trust hashes, project trust levels, and unsigned-32-bit TUI model-availability counters. Other live tables are discarded.
scripts/inject_mcp_into_opencode_jsonc.pyReplaces a "mcp": "__MCP_SERVERS__" placeholder with local command servers; HTTP entries are intentionally skipped.
scripts/merge_claude_mcp.pySurgically updates only the mcpServers key in ~/.claude.json, which Claude Code also writes runtime state into.

Per-tool targets

ToolTarget file (mcpServers)Rendered by hook
Cursor~/.cursor/mcp.jsonrun_onchange_after_07-generate-mcp-configs.sh.tmpl
Claude Code~/.claude.json (mcpServers key)run_onchange_after_07-generate-mcp-configs.sh.tmpl
Pi~/.pi/agent/mcp.jsonrun_onchange_after_07-generate-mcp-configs.sh.tmpl
OMP~/.omp/agent/mcp.jsonrun_onchange_after_07-generate-mcp-configs.sh.tmpl
Gemini~/.gemini/settings.jsonrun_onchange_after_07-merge-gemini-settings.sh.tmpl
OpenCode~/.config/opencode/opencode.jsoncrun_onchange_after_07-merge-opencode-config.sh.tmpl
Codex~/.codex/config.tomlrun_onchange_after_07-merge-codex-config.sh.tmpl
Copilot~/.copilot/mcp-config.jsonrun_onchange_after_07-merge-copilot-config.sh.tmpl

Copilot transform and launch wiring

Copilot transform behavior:

Server classEmitted shape
stdiotype: "local" with tools: ["*"]
OAuth HTTPtype: "http" with oauthClientId, auth.redirectPort, and oauthScopes
Token-bridge HTTPtype: "local" running ,mcp-token <source> --bridge --url <url> when the block carries tokenBridge

Copilot's wired servers:

  • slack and scsi-main are emitted as token-bridge stdio servers.
  • Each copilot block sets tokenBridge: "<source>"; the generator emits the bridge command with the server's registry URL, so no Authorization value or placeholder is ever baked into the config.
  • scsi-local is emitted as a stdio server (type: "local"). It has no OAuth because it runs locally with pass Elasticsearch credentials, so no token is needed.

The built-in github-mcp-server is provided by Copilot and is not emitted.

Per-request token bridge

mcp-token --bridge --url <endpoint> speaks stdio MCP to the agent and forwards each message as an HTTP POST with a freshly selected bearer. OMP, Copilot, and Codex spawn it like any local MCP server, so their sessions no longer depend on any single token's lifetime.

Per request, the bridge reads the freshest still-valid token from cursor-cli's per-project OAuth caches at ~/.cursor/projects/*/mcp-auth.json. Cursor runs the authorization_code flow with its own approved clients (Slack workspace app / SCSI Elastic Okta) and refreshes the rotating token in place.

Bridge eventBehavior
Token missing or below BLOCKING_ROTATE_TTL_SECONDSRotate synchronously through cursor's refresh grant; if the refresh chain fails, one lock owner opens browser OAuth while concurrent requests wait and reuse the result.
401 / 403 mid-sessionRe-acquire (rotating), retry once, and only when the retry would use a different token.
404 after a session was establishedRe-play the cached initialize handshake (new Mcp-Session-Id, response suppressed) and retry, so server-side session expiry never kills the agent session.
SSE responseEvery data: event streams through in order (progress notifications before the response).
stdin EOFBest-effort DELETE of the server session, then exit.

SCSI tokens are JWTs, so ,mcp-token uses their exp. Direct --login still guarantees runway.

Rotation rules

--login rotates silently below MIN_TTL_SECONDS; --login --no-proactive-rotation (used by ,cursor) keeps that proactive rotation off the critical path while the final BLOCKING_ROTATE_TTL_SECONDS window, expired tokens, and revoked tokens still rotate synchronously.

The Cursor mode checks the current working workspace's own mcp-auth.json before the session starts. The wrapper includes authenticated HTTP servers carrying either cursor-cli's oauth block, Cursor IDE's auth.CLIENT_ID shape, or a ,mcp-token --bridge command entry (tokenBridge runtime). It resolves Cursor's project directory from matching .workspace-trusted metadata, with Cursor's deterministic path slug as the fallback. A workspace with a missing access token or a JWT at/under EXPIRY_SKEW_SECONDS is first seeded by copying the newest verified cached chain into its cache — token chains are not workspace-bound, so cursor accepts the copy; opaque candidates must pass the liveness probe and JWTs are exp-checked before seeding, and only that server's entry is written. Only when no verifiable chain exists does cursor-agent mcp login <server> run (in the mint workspace when present); an existing refresh chain remains runtime-owned. This prevents a valid token in another project cache from masking an unauthenticated current workspace without adding a live MCP handshake to every launch, and without a browser login for every fresh worktree.

Why cursor-agent cannot do this itself: hourly access-token expiry is already handled silently by cursor's own refresh grant, but the refresh grant needs an existing refresh_token as input, and cursor's per-project caches are its trust boundary — it never reads another project's chain. A brand-new worktree therefore has nothing to refresh, and cursor's only built-in recovery is the browser authorization_code flow. Seeding is the one row in the matrix where the wrapper and a bare cursor-agent launch genuinely differ; a browser login is only ever required again when the refresh chain itself dies (revocation, admin policy, long idle), not on any expiry schedule. Launching cursor-agent directly stays fine when a mid-session popup is acceptable — the wrapper's value is eliminating the guaranteed one-popup-per-server cost of each fresh worktree.

Silent rotation relies on cursor running the provider's refresh_token grant whenever a stored access token stops working. Before rotation or browser login, ,mcp-token runs Cursor's idempotent mcp enable <server> in the selected OAuth workspace because Cursor gates both auth paths on its local approved list. When the mint workspace exists, ,mcp-token seeds/rotates that project's cache and runs cursor-agent mcp list-tools <server> there so list-tools hits OAuth HTTP config rather than the user-level bridge. Otherwise it invalidates the access token in the newest project cache that holds a refresh_token and whose .workspace-trusted records an existing workspace directory, runs the same bounded list-tools there, and cursor writes the freshly minted chain back in place with no browser and without revoking the in-flight token running sessions already hold.

Concurrent rotations serialize through ~/.cache/mcp-token/rotation.lock and recheck whether rotation remains due before touching the shared cache. Bridge recovery keeps the same lock through browser fallback, so multiple Copilot/Codex sessions cannot open duplicate login tabs. Cursor's login output stays off the bridge's JSON-RPC stdout; the bridge announces the interactive recovery on stderr and resumes the blocked MCP request after authorization succeeds.

Opaque token liveness

Opaque tokens such as Slack expose no expiry. The local refresh ledger under ~/.cache/mcp-token/ can pin a token the provider has since revoked, so ledger state and cache mtime alone do not prove an opaque token is live.

--login validates the ledger-selected opaque token with a minimal MCP initialize probe against the server's URL from the mint-workspace OAuth config when present, otherwise from ~/.cursor/mcp.json (including a bridge entry's --url argument):

Probe resultBehavior
2xxKeep the token.
401 / 403Treat it as revoked, try a synchronous silent rotation first, then probe other cached opaque tokens newest-cache first and adopt a live one without a browser.
No live candidateRun the cursor browser flow.
Network errors, timeouts, 5xx, or missing URLLeave liveness unknown and preserve the existing ledger token rather than forcing a browser login.

Plain reads stay local and never probe.

Copilot config safety

The rendered ~/.copilot/mcp-config.json carries no secrets — bridge entries name only the command, token source, and URL — and rendering happens entirely at chezmoi apply time. ,copilot remains the stable launcher all ,copilot-* provider wrappers route through; it passes arguments through except for bare --resume, where it selects from ~/.copilot/session-store.db and invokes --session-id=<id> to avoid Copilot 1.0.73's temporary-session MCP startup race.

The config is still written 0600 and ~/.copilot/ is forced to 0700.

Codex bridge wiring

Codex's wired servers:

  • slack and scsi-main are emitted by scripts/inject_mcp_into_codex_toml.py as ,mcp-token --bridge command servers. No bearer value or env-var contract is written to ~/.codex/config.toml.
  • ,codex performs no token work at launch; the bridge owns auth per request. The wrapper only injects local llama.cpp model catalog metadata when a local model is selected.
  • scsi-local is emitted as a normal stdio server in ~/.codex/config.toml.

LetsFG is intentionally not exposed through the shared MCP registry because its tools are irrelevant to most sessions. Agents load its skill on demand instead. See Tool configs for details.