Skip to content

Commit fb5548e

Browse files
authored
Name stdio MCP OAuth clients and isolate auth caches (#268)
## Summary - Set the OAuth client name for each stdio MCP target, including Goose's printed YAML. - Give each target a separate mcp-remote auth cache and callback port so clients can authorize independently and do not share a client identity. Preserve existing explicit callback ports. - Preserve existing environment settings, mcp-remote options, and other OAuth metadata fields. Reject external metadata files with a clear error instead of changing them. - Include Goose's required enabled field in the printed config. ## Validation - Extended install tests for client names, cache isolation, distinct callback ports, preserved explicit ports, existing metadata and environment settings, malformed settings, duplicate OAuth metadata, and repeated installs. - Built the CLI and parsed the printed Goose config as YAML, including its enabled flag, string callback-port argument, metadata, and cache path. - Verified all five generated stdio configs can hold independent callback listeners simultaneously using mcp-remote 0.14.3. Verified earlier configs without a port upgrade correctly. - Live desktop OAuth flows were not exercised. ## Rollout Existing stdio installs will prompt for authorization again when the new per-client cache is used. A custom nonempty MCP_REMOTE_CONFIG_DIR remains in place and may require manual cache cleanup. Existing explicit callback ports remain in place; custom ports must be distinct across clients. Existing shared cache files are left untouched because other MCP clients may use them. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes how stdio MCP OAuth is configured and where tokens are stored, which can force re-authorization and affect users with custom ports or metadata; install remains conservative on invalid or external metadata. > > **Overview** > Stdio MCP installs now generate **per-target** `mcp-remote` invocations with a dedicated OAuth **client name**, **callback port** (46093–46097), and **`MCP_REMOTE_CONFIG_DIR`** under `~/.mcp-auth/kernel-<target>` so multiple editors can authorize independently without sharing identity or cache. > > Install merge logic was reworked: default args come from `stdioArgs(spec)` instead of per-target `stdioArgs` slices; existing `args` keep custom flags/versions while upgrading URL, inserting a port when missing, and merging `--static-oauth-client-metadata` (other JSON fields preserved; external `@` metadata files fail with a clear error). Non-empty custom cache paths are left alone; empty cache values are replaced. > > **Goose** printed YAML now includes `enabled: true`, the same stdio args/env as JSON targets, via a new `gooseConfig` helper; `gopkg.in/yaml.v3` is a direct dependency for parsing that output in tests. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9039534. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Sayan- <1415138+Sayan-@users.noreply.github.com>
1 parent f22b917 commit fb5548e

5 files changed

Lines changed: 384 additions & 60 deletions

File tree

‎cmd/mcp/config.go‎

Lines changed: 127 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"path/filepath"
99
"runtime"
10+
"slices"
1011
"sort"
1112
"strconv"
1213
"strings"
@@ -29,17 +30,18 @@ type configField struct {
2930
}
3031

3132
type targetSpec struct {
32-
target Target
33-
description string
34-
path func(string) string
35-
section string
36-
transport transport
37-
fields []configField
38-
stdioArgs []string
39-
remove []string
40-
legacyName string
41-
legacyKey string
42-
printOnly bool
33+
target Target
34+
description string
35+
path func(string) string
36+
section string
37+
transport transport
38+
fields []configField
39+
clientName string
40+
callbackPort int
41+
remove []string
42+
legacyName string
43+
legacyKey string
44+
printOnly bool
4345
}
4446

4547
func homePath(parts ...string) func(string) string {
@@ -80,18 +82,17 @@ func appDataPath(home string) string {
8082
var targetSpecs = []targetSpec{
8183
{target: TargetCursor, description: "Cursor editor", path: homePath(".cursor", "mcp.json"), section: "mcpServers", transport: http,
8284
fields: []configField{{name: "url", value: KernelMCPURL}}, remove: []string{"type"}},
83-
{target: TargetClaude, description: "Claude Desktop app", path: claudePath, section: "mcpServers", transport: stdio},
85+
{target: TargetClaude, description: "Claude Desktop app", path: claudePath, section: "mcpServers", transport: stdio, clientName: "Claude Desktop", callbackPort: 46093},
8486
{target: TargetClaudeCode, description: "Claude Code CLI", path: homePath(".claude.json"), section: "mcpServers", transport: http,
8587
fields: []configField{{name: "type", value: "http"}, {name: "url", value: KernelMCPURL}}},
86-
{target: TargetAntigravity, description: "Google Antigravity", path: homePath(".gemini", "config", "mcp_config.json"), section: "mcpServers", transport: stdio,
87-
stdioArgs: []string{"-y", "mcp-remote", KernelMCPURL, "--static-oauth-client-metadata", "{\"client_name\":\"Antigravity\"}"}},
88-
{target: TargetWindsurf, description: "Windsurf editor", path: homePath(".codeium", "windsurf", "mcp_config.json"), section: "mcpServers", transport: stdio},
88+
{target: TargetAntigravity, description: "Google Antigravity", path: homePath(".gemini", "config", "mcp_config.json"), section: "mcpServers", transport: stdio, clientName: "Antigravity", callbackPort: 46094},
89+
{target: TargetWindsurf, description: "Windsurf editor", path: homePath(".codeium", "windsurf", "mcp_config.json"), section: "mcpServers", transport: stdio, clientName: "Windsurf", callbackPort: 46095},
8990
{target: TargetVSCode, description: "Visual Studio Code", path: vsCodePath, section: "servers", transport: http,
9091
legacyName: "settings.json", legacyKey: "mcp.servers",
9192
fields: []configField{{name: "url", value: KernelMCPURL}, {name: "type", value: "http"}}},
92-
{target: TargetGoose, description: "Goose AI", path: homePath(".config", "goose", "config.yaml"), transport: stdio, printOnly: true},
93+
{target: TargetGoose, description: "Goose AI", path: homePath(".config", "goose", "config.yaml"), transport: stdio, clientName: "Goose", callbackPort: 46096, printOnly: true},
9394
// Current Zed settings omit source; its settings migrator removes that old field.
94-
{target: TargetZed, description: "Zed editor", path: homePath(".config", "zed", "settings.json"), section: "context_servers", transport: stdio,
95+
{target: TargetZed, description: "Zed editor", path: homePath(".config", "zed", "settings.json"), section: "context_servers", transport: stdio, clientName: "Zed", callbackPort: 46097,
9596
remove: []string{"source"}},
9697
{target: TargetFx, description: "fx coding agent", path: homePath(".fx", "mcp.json"), section: "mcp", transport: http,
9798
fields: []configField{{name: "type", value: "http"}, {name: "url", value: KernelMCPURL}, {name: "oauth", value: map[string]any{}, ifMissing: true, skipWhen: "bearer_token_env"}}},
@@ -126,6 +127,14 @@ func getConfigPath(target Target) (string, error) {
126127
return spec.path(home), nil
127128
}
128129

130+
func clientCacheDir(target Target) (string, error) {
131+
home, err := os.UserHomeDir()
132+
if err != nil {
133+
return "", fmt.Errorf("failed to get home directory: %w", err)
134+
}
135+
return filepath.Join(home, ".mcp-auth", "kernel-"+string(target)), nil
136+
}
137+
129138
func GetConfigPath(target Target) (string, error) {
130139
return getConfigPath(target)
131140
}
@@ -140,7 +149,7 @@ func Install(target Target) error {
140149
return err
141150
}
142151
if spec.printOnly {
143-
return installForGoose(path)
152+
return installForGoose(path, spec)
144153
}
145154
return installConfig(path, spec)
146155
}
@@ -319,11 +328,7 @@ func mergeConfig(data []byte, spec targetSpec, legacy map[string]json.RawMessage
319328
}
320329
fields := spec.fields
321330
if spec.transport == stdio {
322-
args := spec.stdioArgs
323-
if args == nil {
324-
args = []string{"-y", "mcp-remote", KernelMCPURL}
325-
}
326-
args, err = mergeStdioArgs(kernel, args)
331+
args, err := mergeStdioArgs(kernel, spec)
327332
if err != nil {
328333
return nil, err
329334
}
@@ -367,10 +372,57 @@ func mergeConfig(data []byte, spec targetSpec, legacy map[string]json.RawMessage
367372
return nil, err
368373
}
369374
}
375+
if spec.transport == stdio {
376+
if err := addClientCacheDir(&root, kernelPath, spec.target); err != nil {
377+
return nil, err
378+
}
379+
}
370380
return root.Pack(), nil
371381
}
372382

373-
func mergeStdioArgs(kernel *hujson.Object, defaults []string) ([]string, error) {
383+
func addClientCacheDir(root *hujson.Value, kernelPath string, target Target) error {
384+
kernel, err := objectAt(root, kernelPath)
385+
if err != nil {
386+
return err
387+
}
388+
if _, exists := member(kernel, "env"); !exists {
389+
if err := patch(root, "add", kernelPath+"/env", map[string]any{}); err != nil {
390+
return err
391+
}
392+
}
393+
env, err := objectAt(root, kernelPath+"/env")
394+
if err != nil {
395+
return err
396+
}
397+
current, exists := member(env, "MCP_REMOTE_CONFIG_DIR")
398+
if exists {
399+
normalized := current.Clone()
400+
normalized.Standardize()
401+
var path string
402+
if err := json.Unmarshal(normalized.Pack(), &path); err != nil {
403+
return fmt.Errorf("invalid MCP_REMOTE_CONFIG_DIR: %w", err)
404+
}
405+
if path != "" {
406+
return nil
407+
}
408+
}
409+
cacheDir, err := clientCacheDir(target)
410+
if err != nil {
411+
return err
412+
}
413+
return patch(root, "add", kernelPath+"/env/MCP_REMOTE_CONFIG_DIR", cacheDir)
414+
}
415+
416+
func stdioArgs(spec targetSpec) []string {
417+
return []string{"-y", "mcp-remote", KernelMCPURL, strconv.Itoa(spec.callbackPort), "--static-oauth-client-metadata", clientMetadata(spec.clientName)}
418+
}
419+
420+
func clientMetadata(clientName string) string {
421+
return fmt.Sprintf(`{"client_name":%q}`, clientName)
422+
}
423+
424+
func mergeStdioArgs(kernel *hujson.Object, spec targetSpec) ([]string, error) {
425+
defaults := stdioArgs(spec)
374426
value, exists := member(kernel, "args")
375427
if !exists {
376428
return defaults, nil
@@ -385,22 +437,62 @@ func mergeStdioArgs(kernel *hujson.Object, defaults []string) ([]string, error)
385437
return defaults, nil
386438
}
387439
args[2] = KernelMCPURL
388-
if len(defaults) > 3 {
389-
flag := defaults[3]
390-
for i := 3; i < len(args); i++ {
391-
if args[i] == flag {
392-
if i+1 >= len(args) {
393-
return nil, fmt.Errorf("kernel args missing value for %s", flag)
394-
}
395-
args[i+1] = defaults[4]
396-
return args, nil
397-
}
440+
if len(args) == 3 {
441+
args = append(args, defaults[3])
442+
} else if _, err := strconv.Atoi(args[3]); err != nil {
443+
args = slices.Insert(args, 3, defaults[3])
444+
}
445+
flagIndex := -1
446+
for i := 4; i < len(args); i++ {
447+
if args[i] != defaults[4] {
448+
continue
449+
}
450+
if flagIndex != -1 || i+1 >= len(args) {
451+
return nil, fmt.Errorf("invalid kernel args for %s", defaults[4])
398452
}
399-
args = append(args, defaults[3:]...)
453+
flagIndex = i
454+
}
455+
if flagIndex == -1 {
456+
return append(args, defaults[4:]...), nil
457+
}
458+
metadata, err := mergeClientMetadata(args[flagIndex+1], spec.clientName)
459+
if err != nil {
460+
return nil, err
400461
}
462+
args[flagIndex+1] = metadata
401463
return args, nil
402464
}
403465

466+
func mergeClientMetadata(raw, clientName string) (string, error) {
467+
if strings.HasPrefix(raw, "@") {
468+
return "", fmt.Errorf("kernel OAuth metadata is in a separate file; set client_name to %q there", clientName)
469+
}
470+
if !json.Valid([]byte(raw)) {
471+
return "", fmt.Errorf("invalid kernel OAuth metadata: expected JSON object")
472+
}
473+
metadata, err := hujson.Parse([]byte(raw))
474+
if err != nil {
475+
return "", fmt.Errorf("invalid kernel OAuth metadata: %w", err)
476+
}
477+
if err := validateConfigKeys(&metadata, ""); err != nil {
478+
return "", err
479+
}
480+
obj, err := objectAt(&metadata, "")
481+
if err != nil {
482+
return "", err
483+
}
484+
if current, exists := member(obj, "client_name"); exists {
485+
var name string
486+
if err := json.Unmarshal(current.Pack(), &name); err == nil && name == clientName {
487+
return raw, nil
488+
}
489+
}
490+
if err := patch(&metadata, "add", "/client_name", clientName); err != nil {
491+
return "", err
492+
}
493+
return string(metadata.Pack()), nil
494+
}
495+
404496
func pointerName(name string) string {
405497
return strings.ReplaceAll(strings.ReplaceAll(name, "~", "~0"), "/", "~1")
406498
}

0 commit comments

Comments
 (0)