Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

320 lines
13 KiB
Go

package agents
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
)
// UpsertMCPServer merges a gortex-flavored MCP server stanza into a
// map that follows the standard {"mcpServers": {<name>: {...}}}
// shape used by Claude Code, Cursor, VS Code, Continue.dev, Cline,
// and Kiro. Returns true when the map was modified (false when a
// gortex stanza was already present and opts.Force is off).
//
// serverName is the key under mcpServers (canonically "gortex").
// entry is the stanza value — adapters produce their own variant
// when the target client uses a different shape (e.g. Cline's
// alwaysAllow list, Kiro's autoApprove list).
func UpsertMCPServer(root map[string]any, serverName string, entry map[string]any, opts ApplyOpts) (changed bool) {
servers, ok := root["mcpServers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if _, exists := servers[serverName]; exists && !opts.Force {
return false
}
servers[serverName] = entry
root["mcpServers"] = servers
return true
}
// RemoveMCPServer deletes serverName from the {"mcpServers": {...}}
// map, pruning the parent key when removal leaves it empty. Returns
// true when the map was modified. Counterpart to UpsertMCPServer for
// uninstall paths — all other servers (and the user's other config)
// are left untouched.
func RemoveMCPServer(root map[string]any, serverName string) (changed bool) {
servers, ok := root["mcpServers"].(map[string]any)
if !ok {
return false
}
if _, exists := servers[serverName]; !exists {
return false
}
delete(servers, serverName)
if len(servers) == 0 {
delete(root, "mcpServers")
} else {
root["mcpServers"] = servers
}
return true
}
// UpsertMCPServerWithMigration is like UpsertMCPServer but also
// rewrites entries that look Gortex-authored (any `gortex mcp ...`
// stanza) even without opts.Force. This lets `gortex install` swap
// the legacy `mcp --index . --watch` shape — which fails Cursor's
// global-cwd handshake — for the daemon-proxy shape on next install.
//
// If the existing entry is already byte-identical to `entry`, returns
// false so the caller reports "already-configured" instead of a noisy
// rewrite. User-customized entries (anything that doesn't look like
// Gortex authored it) are left untouched unless opts.Force is set.
func UpsertMCPServerWithMigration(root map[string]any, serverName string, entry map[string]any, opts ApplyOpts) (changed bool) {
servers, ok := root["mcpServers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if existing, exists := servers[serverName]; exists {
if MCPEntriesEqual(existing, entry) {
return false
}
if !opts.Force && !IsGortexAuthoredMCPEntry(existing) {
return false
}
}
servers[serverName] = entry
root["mcpServers"] = servers
return true
}
// IsGortexAuthoredMCPEntry returns true for MCP server stanzas that
// 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 !commandIsGortex(cmd) {
return false
}
args, ok := m["args"].([]any)
if !ok || len(args) == 0 {
return false
}
first, _ := args[0].(string)
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"
}
// ResolveGortexHookBinary returns the gortex binary reference a Claude
// Code hook command should embed. It shares ResolveGortexCommand's
// same-file decision core so the hook process and the MCP server/daemon
// never resolve to different binaries — a side-by-side install (CI
// sandbox, a downloaded release run before linking, a dev build) would
// otherwise bake the running binary into the MCP stanza but a stray
// PATH gortex into the hook, and the hook could then consult a
// different daemon than the one serving the session's graph tools.
//
// It differs from ResolveGortexCommand only in output preference: a
// hook fires in a shell whose PATH may not match install time, so it
// pins an absolute path whenever the running binary is usable rather
// than the bare name. The returned value is a bare binary reference
// with no subcommand — callers append " hook" (and any --mode suffix).
func ResolveGortexHookBinary() string {
exe, exeErr := os.Executable()
lp, lpErr := exec.LookPath("gortex")
return resolveGortexHookBinaryFrom(exe, exeErr, lp, lpErr, sameFile)
}
// resolveGortexHookBinaryFrom is the pure decision core of
// ResolveGortexHookBinary, split out so the PATH/executable inputs can
// be injected in tests. It resolves to the same on-disk binary as
// resolveGortexCommandFrom in every case where the running binary is
// usable (both pin the running exe, or the bare name that PATH resolves
// back to it); it only prefers an absolute path over the bare name.
//
// The ephemeral guard is deliberately on the running binary alone
// (exeUsable's !isUnderTempDir(exe)): a `go run` transient build must
// never be baked into a long-lived hook, so we fall back to the PATH
// gortex — the same file the MCP stanza collapses to — instead. A
// PATH lookup that itself points into a temp dir is the user's own
// PATH and is left to resolve, matching resolveGortexCommandFrom.
func resolveGortexHookBinaryFrom(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 exeUsable {
// The running binary is a stable, non-ephemeral gortex: pin it.
// This is the same file the MCP stanza launches — whether that
// stanza used the bare name (PATH gortex IS this binary) or the
// absolute exe (off-PATH, or a different gortex on PATH).
return exe
}
if lookErr == nil && lookPath != "" {
// Running binary isn't a bakeable path (go run temp build,
// unstattable, or not named gortex). Fall back to PATH's gortex
// — the file the MCP stanza collapses to — as an absolute path.
return lookPath
}
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
// an existing mcp.json on disk. Exported so the doctor can compare an
// on-disk stanza against DefaultGortexMCPEntry() to flag a stale entry.
func MCPEntriesEqual(a, b any) bool {
aj, err := json.Marshal(a)
if err != nil {
return false
}
bj, err := json.Marshal(b)
if err != nil {
return false
}
var av, bv any
if err := json.Unmarshal(aj, &av); err != nil {
return false
}
if err := json.Unmarshal(bj, &bv); err != nil {
return false
}
return reflect.DeepEqual(av, bv)
}
// DefaultGortexMCPEntry returns the shared {command, args, env}
// stanza most clients accept for project-local MCP configs (where
// the editor launches the process with cwd set to the project root).
// Adapters that want extra keys wrap this and add them (e.g. Cline's
// alwaysAllow, Kiro's autoApprove).
//
// The command intentionally points at the bare "gortex" binary on
// PATH rather than os.Executable() — users who installed via
// Homebrew or `go install` get a stable path, and installers that
// run `go build -o /tmp/...` don't bake the transient path into
// long-lived configs.
// gortexMCPArgs is the one canonical args list every adapter emits.
// Both project and global configs use it: the daemon resolves the active
// workspace per session from the request handshake, so no cwd-relative
// flag (--index/--watch) and no proxy flag (--proxy) is needed. `gortex
// mcp` proxies to (and auto-starts) the daemon and falls back to an
// embedded server on its own.
func gortexMCPArgs() []string { return []string{"mcp"} }
func DefaultGortexMCPEntry() map[string]any {
return map[string]any{
"command": "gortex",
"args": gortexMCPArgs(),
"env": map[string]string{"GORTEX_INDEX_WORKERS": "8"},
}
}
// GlobalGortexMCPEntry returns the daemon-proxy MCP entry suitable
// for user-level (global) configs, where the editor may launch the
// MCP process with cwd set to the user's home directory rather than
// any open project — Cursor's global-mcp behaviour reported in
// gortexhq/gortex#19.
//
// The proxy shape carries no cwd-relative state: the daemon resolves
// the active workspace per session from the request handshake, so
// the global config never trips the strict entry-point check on the
// home directory. If no daemon is running, `gortex mcp --proxy` exits
// with a clear "no daemon" error rather than silently falling back
// to a broken indexer.
// GlobalGortexMCPEntry is now identical to DefaultGortexMCPEntry — the
// canonical `["mcp"]` shape carries no cwd-relative or proxy flag. Kept
// as a named function so existing call sites compile.
func GlobalGortexMCPEntry() map[string]any {
return DefaultGortexMCPEntry()
}