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
320 lines
13 KiB
Go
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()
|
|
}
|