Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 59 additions & 14 deletions internal/agents/claudecode/adapter.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package claudecode

import (
"encoding/json"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -111,11 +112,28 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result,
}

// 1. Project .mcp.json — create if absent, skip otherwise.
mcpAction, err := agents.WriteIfNotExists(w, filepath.Join(env.Root, ".mcp.json"), ProjectMCPJSON, opts)
if err != nil {
return res, fmt.Errorf(".mcp.json: %w", err)
//
// If gortex is already registered at user scope (~/.claude.json), a
// project .mcp.json adds a second registration under the same name.
// Claude Code keys OAuth tokens per endpoint and flags this as a
// "conflicting scopes" diagnostic. The user-scope entry already
// serves this repo machine-wide, so skip the project file unless
// --force. (A pre-existing .mcp.json is left in place — we never
// delete it — but we warn so the user can resolve the duplication.)
mcpPath := filepath.Join(env.Root, ".mcp.json")
if !opts.Force && env.Home != "" && userScopeGortexRegistered(env.Home) && !pathExists(mcpPath) {
logWarn(w, "gortex is already registered at user scope (%s); skipping project .mcp.json to avoid a Claude Code \"conflicting scopes\" warning. Re-run with --force to write it anyway (e.g. for teammates without a global install).", userClaudeJSONPath(env.Home))
res.Files = append(res.Files, agents.FileAction{Path: mcpPath, Action: agents.ActionSkip, Reason: "gortex already registered at user scope"})
} else {
if !opts.Force && env.Home != "" && userScopeGortexRegistered(env.Home) && pathExists(mcpPath) {
logWarn(w, "gortex is registered at both user scope (%s) and project scope (%s); Claude Code may warn about conflicting scopes — keep one with `claude mcp remove gortex -s user` or `-s project`.", userClaudeJSONPath(env.Home), mcpPath)
}
mcpAction, err := agents.WriteIfNotExists(w, mcpPath, ProjectMCPJSON, opts)
if err != nil {
return res, fmt.Errorf(".mcp.json: %w", err)
}
res.Files = append(res.Files, mcpAction)
}
res.Files = append(res.Files, mcpAction)

// 2. MCP permissions in .claude/settings.json — merge, not create.
permAction, err := installPermissions(w, filepath.Join(env.Root, ".claude", "settings.json"), opts)
Expand Down Expand Up @@ -602,23 +620,26 @@ func installGlobalSubAgents(w io.Writer, home string, opts agents.ApplyOpts) ([]
// existing file is malformed JSON, it's backed up before we
// overwrite.
func upsertGlobalMCPConfig(w io.Writer, path string, opts agents.ApplyOpts) (agents.FileAction, error) {
exe, err := os.Executable()
if err != nil {
// Fall back to bare "gortex" on PATH. Reasonable for
// homebrew / go install deployments.
exe = "gortex"
}
// Prefer the bare "gortex" command when it resolves on PATH to the
// binary we're running, so this user-scope entry matches the
// portable project .mcp.json template byte-for-byte. Claude Code
// keys OAuth tokens per endpoint, so a user-scope stanza that
// disagrees with a project-scope one trips its "conflicting scopes"
// diagnostic. Falls back to the absolute path only when gortex is
// not on PATH (e.g. a Windows install whose dir isn't on PATH).
entry := map[string]any{
"command": exe,
"command": agents.ResolveGortexCommand(),
"args": []string{"mcp"},
"env": map[string]any{},
}

// Try a direct merge first. MergeJSON handles malformed JSON
// with a timestamped backup already.
// Try a direct merge first. MergeJSON handles malformed JSON with a
// timestamped backup already. UpsertMCPServerWithMigration rewrites
// a stale Gortex-authored stanza (including the older absolute-path
// form) in place without clobbering a user's hand-rolled wrapper.
action, err := agents.MergeJSON(w, path, func(root map[string]any, existed bool) (bool, error) {
_ = existed
return agents.UpsertMCPServer(root, "gortex", entry, agents.ApplyOpts{Force: opts.Force}), nil
return agents.UpsertMCPServerWithMigration(root, "gortex", entry, agents.ApplyOpts{Force: opts.Force}), nil
}, opts)
if err != nil {
return agents.FileAction{}, err
Expand All @@ -637,6 +658,30 @@ func upsertGlobalMCPConfig(w io.Writer, path string, opts agents.ApplyOpts) (age
return action, nil
}

// userScopeGortexRegistered reports whether ~/.claude.json already
// registers a "gortex" MCP server at user scope. A project .mcp.json
// written on top of that produces a second registration under the same
// name, which Claude Code flags as a "conflicting scopes" warning
// because it stores OAuth tokens per endpoint. A missing or malformed
// file is treated as "not registered" — we only suppress the project
// write on positive evidence of a user-scope entry.
func userScopeGortexRegistered(home string) bool {
data, err := os.ReadFile(userClaudeJSONPath(home))
if err != nil {
return false
}
var root map[string]any
if err := json.Unmarshal(data, &root); err != nil {
return false
}
servers, ok := root["mcpServers"].(map[string]any)
if !ok {
return false
}
_, ok = servers["gortex"]
return ok
}

// Paths — user-level files.

func userClaudeJSONPath(home string) string {
Expand Down
62 changes: 62 additions & 0 deletions internal/agents/claudecode/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,3 +440,65 @@ func TestClaudeCodeDryRunWritesNothing(t *testing.T) {
}
}
}

// TestProjectModeSkipsMCPWhenUserScopeRegistered is the regression
// guard for issue #201: a project `gortex init` must NOT create a
// project .mcp.json when gortex is already registered at user scope —
// that double-registration is what trips Claude Code's "conflicting
// scopes" diagnostic. --force overrides the skip (so a maintainer can
// still commit a .mcp.json for teammates without a global install).
func TestProjectModeSkipsMCPWhenUserScopeRegistered(t *testing.T) {
SetConfigDirOverride("")

env, buf := agentstest.NewEnv(t)
a := New()

// Seed a user-scope gortex registration in ~/.claude.json.
claudeJSON := userClaudeJSONPath(env.Home)
if err := os.MkdirAll(filepath.Dir(claudeJSON), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
seed := `{"mcpServers":{"gortex":{"command":"gortex","args":["mcp"],"env":{}}}}`
if err := os.WriteFile(claudeJSON, []byte(seed), 0o644); err != nil {
t.Fatalf("seed user config: %v", err)
}

mcpPath := filepath.Join(env.Root, ".mcp.json")

// Without --force: the project .mcp.json must be skipped and the
// user warned.
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
if _, err := os.Stat(mcpPath); err == nil {
t.Errorf("project .mcp.json was written despite a user-scope gortex registration")
}
if !strings.Contains(buf.String(), "already registered at user scope") {
t.Errorf("expected a conflicting-scopes warning, got: %q", buf.String())
}

// With --force: the project .mcp.json is written anyway.
if _, err := a.Apply(env, agents.ApplyOpts{Force: true}); err != nil {
t.Fatalf("apply --force: %v", err)
}
if _, err := os.Stat(mcpPath); err != nil {
t.Errorf("--force should still write project .mcp.json: %v", err)
}
}

// TestProjectModeWritesMCPWhenNoUserScope confirms the common path is
// unchanged: with no user-scope gortex registration, the project
// .mcp.json is created as before.
func TestProjectModeWritesMCPWhenNoUserScope(t *testing.T) {
SetConfigDirOverride("")

env, _ := agentstest.NewEnv(t)
a := New()

if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
if _, err := os.Stat(filepath.Join(env.Root, ".mcp.json")); err != nil {
t.Errorf("project .mcp.json should be written when no user-scope entry exists: %v", err)
}
}
107 changes: 102 additions & 5 deletions internal/agents/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ package agents

import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
)

// UpsertMCPServer merges a gortex-flavored MCP server stanza into a
Expand Down Expand Up @@ -79,17 +83,18 @@ func UpsertMCPServerWithMigration(root map[string]any, serverName string, entry
}

// IsGortexAuthoredMCPEntry returns true for MCP server stanzas that
// look like Gortex wrote them — `command == "gortex"` and the args
// list starts with the `mcp` subcommand. Used by global-mode
// installers to migrate their own legacy stanzas without clobbering
// user-customized servers.
// look like Gortex wrote them — a command naming the gortex binary
// (bare "gortex" or an absolute path whose basename is gortex) and an
// args list starting with the `mcp` subcommand. Used by global-mode
// installers to migrate their own legacy stanzas — including the older
// absolute-path form — without clobbering user-customized servers.
func IsGortexAuthoredMCPEntry(entry any) bool {
m, ok := entry.(map[string]any)
if !ok {
return false
}
cmd, _ := m["command"].(string)
if cmd != "gortex" {
if !commandIsGortex(cmd) {
return false
}
args, ok := m["args"].([]any)
Expand All @@ -100,6 +105,98 @@ func IsGortexAuthoredMCPEntry(entry any) bool {
return first == "mcp"
}

// commandIsGortex reports whether an MCP stanza's command string names
// the gortex binary — either the bare "gortex"/"gortex.exe" name or an
// absolute path whose basename is gortex (the legacy os.Executable()
// form a pre-fix `gortex install` baked into ~/.claude.json). Lets the
// global installer recognize and migrate its own older stanza in place
// instead of leaving a stale absolute path that disagrees with the
// bare-`gortex` project .mcp.json template.
func commandIsGortex(cmd string) bool {
return gortexCommandBase(cmd) == "gortex"
}

// gortexCommandBase extracts the binary base name from a command
// string, tolerating both / and \ separators so a path written on one
// OS is still recognized when parsed on another (matters for
// cross-platform tests; in production the path is always native). The
// trailing extension (.exe on Windows) is stripped.
func gortexCommandBase(cmd string) string {
if cmd == "" {
return ""
}
base := cmd
if i := strings.LastIndexAny(base, `/\`); i >= 0 {
base = base[i+1:]
}
return strings.TrimSuffix(base, filepath.Ext(base))
}

// ResolveGortexCommand returns the command string an installer should
// bake into a gortex MCP server stanza. It prefers the bare "gortex"
// name — portable across machines and byte-identical to the project
// .mcp.json template — but only when "gortex" on PATH resolves to the
// same binary that is currently running. Matching the project template
// matters for Claude Code specifically: it stores OAuth tokens per
// endpoint (command + args), so a user-scope entry that disagrees with
// a project-scope entry trips its "conflicting scopes" diagnostic.
//
// When the running binary is not reachable on PATH (e.g. a Windows
// install whose program directory is not on PATH) it falls back to the
// absolute os.Executable() path so the entry still launches. Under
// `go run` (a transient temp build) or any other ambiguity it falls
// back to the bare name rather than bake a path that won't exist later.
func ResolveGortexCommand() string {
exe, exeErr := os.Executable()
lp, lpErr := exec.LookPath("gortex")
return resolveGortexCommandFrom(exe, exeErr, lp, lpErr, sameFile)
}

// resolveGortexCommandFrom is the pure decision core of
// ResolveGortexCommand, split out so the PATH/executable inputs can be
// injected in tests.
func resolveGortexCommandFrom(exe string, exeErr error, lookPath string, lookErr error, same func(a, b string) bool) string {
exeUsable := exeErr == nil && exe != "" && gortexCommandBase(exe) == "gortex" && !isUnderTempDir(exe)
if lookErr == nil && lookPath != "" {
// On PATH: collapse to the bare name when it points at the
// binary we are running (or we cannot trust os.Executable),
// so the entry matches the portable project template.
if !exeUsable || same(lookPath, exe) {
return "gortex"
}
}
if exeUsable {
// Not on PATH, but we know exactly where we live: pin the
// absolute path so the entry launches.
return exe
}
return "gortex"
}

// isUnderTempDir reports whether p lives under the OS temp directory —
// the tell-tale of a `go run` / `go test` transient build that must not
// be baked into a long-lived config.
func isUnderTempDir(p string) bool {
return strings.HasPrefix(p, filepath.Clean(os.TempDir())+string(os.PathSeparator))
}

// sameFile reports whether two paths reference the same on-disk binary,
// resolving symlinks and falling back to os.SameFile. A pure string
// match short-circuits the stat calls.
func sameFile(a, b string) bool {
if a == b {
return true
}
if ra, err := filepath.EvalSymlinks(a); err == nil {
if rb, err := filepath.EvalSymlinks(b); err == nil && ra == rb {
return true
}
}
fa, e1 := os.Stat(a)
fb, e2 := os.Stat(b)
return e1 == nil && e2 == nil && os.SameFile(fa, fb)
}

// MCPEntriesEqual compares two MCP stanzas by their JSON-marshaled
// form. Round-tripping is the simplest way to handle the []string vs
// []any drift between freshly-built entries and entries decoded from
Expand Down
Loading
Loading