Plugin MCP servers: ${user_config.*} is not substituted inside the oauth block, and the unsubstituted clientId gets cached in the keychain
Environment
- Claude Code 2.1.231 (also observed installing the plugin built with CLI 2.1.246)
- macOS (darwin 25.6), plugin loaded both via
--plugin-dirand via a git marketplace install
Summary
A plugin bundling a remote HTTP MCP server can make its endpoint per-installation configurable with userConfig + ${user_config.KEY} — that works, including default values. But the same placeholder inside the server's oauth block is not substituted: the OAuth authorize request is sent with the literal placeholder as client_id.
A follow-on issue makes recovery confusing: the unsubstituted clientId is persisted in the keychain (Claude Code-credentials, under mcpOAuth/<serverName>|<hash>), and later fixes to the plugin's .mcp.json are ignored — auth keeps using the cached client config until the keychain entry is removed by hand.
Repro
plugin.json:
{
"name": "example",
"userConfig": {
"api_url": { "type": "string", "title": "Endpoint", "description": "MCP endpoint", "default": "https://api.example.com/mcp" },
"oauth_client_id": { "type": "string", "title": "OAuth client id", "description": "client id", "default": "example-client" }
}
}
.mcp.json:
{
"mcpServers": {
"example": {
"type": "http",
"url": "${user_config.api_url}",
"oauth": { "clientId": "${user_config.oauth_client_id}" }
}
}
}
- Load the plugin, run
/mcpand authenticate the server. - Observe the browser authorize URL:
https://auth.example.com/oauth/authorize?response_type=code&client_id=%24%7Buser_config.oauth_client_id%7D&...
url resolved to the default correctly (claude mcp list shows the substituted endpoint); oauth.clientId did not.
- Fix
.mcp.jsonto a literal"clientId": "example-client", restart, authenticate again: the authorize URL still carries the literal placeholder — it comes from the cachedmcpOAuthkeychain entry, not the config.
Expected
${user_config.KEY}substitution to apply to theoauthobject like other non-shell JSON fields (url,headers,env) — or the restriction to be documented (the docs currently don't addressoauth).- The cached per-server OAuth client config to be invalidated when the server's configured
oauthblock changes, so a config fix takes effect without manual keychain surgery.
Use case
An official plugin for a product that customers also self-host: each installation needs its own MCP endpoint and OAuth client id. The endpoint half works beautifully with userConfig defaults; the client id half silently sends the placeholder.
🤖 Filed with Claude Code