Plugin MCP servers: ${user_config.*} is not substituted inside the oauth block, and the unsubstituted clientId gets cached in the keychain

Status Open
Reported on v2.1.231
Maintainer reply None cached
Activity 0 comments · opened Aug 27, 2026

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-dir and 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}" }
    }
  }
}
  1. Load the plugin, run /mcp and authenticate the server.
  2. 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.

  1. Fix .mcp.json to a literal "clientId": "example-client", restart, authenticate again: the authorize URL still carries the literal placeholder — it comes from the cached mcpOAuth keychain entry, not the config.

Expected

  • ${user_config.KEY} substitution to apply to the oauth object like other non-shell JSON fields (url, headers, env) — or the restriction to be documented (the docs currently don't address oauth).
  • The cached per-server OAuth client config to be invalidated when the server's configured oauth block 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

View original on GitHub ↗