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
428 lines
15 KiB
Go
428 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/zzet/gortex/internal/daemon"
|
|
gortexmcp "github.com/zzet/gortex/internal/mcp"
|
|
)
|
|
|
|
// coldStartTools is the static core catalogue the proxy answers a cold-start
|
|
// tools/list with — the hot tools every client needs first — before the
|
|
// daemon's full live list arrives. Deliberately small; the client refreshes on
|
|
// the daemon's tools/list_changed notification once the connection is live.
|
|
var coldStartTools = []string{
|
|
"smart_context", "search_symbols", "find_usages", "get_callers",
|
|
"get_symbol_source", "get_file_summary", "read_file", "get_repo_outline",
|
|
}
|
|
|
|
// answerColdStart returns a locally-synthesized JSON-RPC response for a frame
|
|
// that can be answered without the daemon — initialize and tools/list — so an
|
|
// MCP client completes its handshake immediately while the daemon connects in
|
|
// the background. ok is false for any other frame (notably tools/call), which
|
|
// must reach the daemon (or the embedded fallback). The active tool-surface
|
|
// preset is applied to the cold-start list so a restricted session never sees a
|
|
// tool it isn't allowed to call.
|
|
func answerColdStart(frame []byte, surface *gortexmcp.ToolSurface) (reply []byte, ok bool) {
|
|
var peek struct {
|
|
Method string `json:"method"`
|
|
ID json.RawMessage `json:"id"`
|
|
}
|
|
if json.Unmarshal(frame, &peek) != nil {
|
|
return nil, false
|
|
}
|
|
switch peek.Method {
|
|
case "initialize":
|
|
return staticInitializeResult(peek.ID), true
|
|
case "tools/list":
|
|
return staticToolsListResult(peek.ID, surface), true
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
// staticInitializeResult builds the cold-start initialize response: enough for
|
|
// the client to proceed, with instructions that the full catalogue is arriving.
|
|
func staticInitializeResult(id json.RawMessage) []byte {
|
|
return jsonRPCResult(id, map[string]any{
|
|
"protocolVersion": "2025-06-18",
|
|
"serverInfo": map[string]any{"name": "gortex", "version": gortexmcp.Version},
|
|
"capabilities": map[string]any{"tools": map[string]any{"listChanged": true}},
|
|
"instructions": "Gortex is connecting to its daemon — the full tool catalogue arrives momentarily.",
|
|
})
|
|
}
|
|
|
|
// staticToolsListResult answers tools/list with the cold-start core set, minus
|
|
// anything an active surface preset disallows.
|
|
func staticToolsListResult(id json.RawMessage, surface *gortexmcp.ToolSurface) []byte {
|
|
tools := make([]map[string]any, 0, len(coldStartTools))
|
|
for _, n := range coldStartTools {
|
|
if surface != nil && surface.Active() && !surface.Allows(n) {
|
|
continue
|
|
}
|
|
tools = append(tools, map[string]any{"name": n})
|
|
}
|
|
return jsonRPCResult(id, map[string]any{"tools": tools})
|
|
}
|
|
|
|
// jsonRPCResult marshals a JSON-RPC 2.0 success response echoing the request id.
|
|
func jsonRPCResult(id json.RawMessage, result any) []byte {
|
|
body, _ := json.Marshal(map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"result": result,
|
|
})
|
|
return body
|
|
}
|
|
|
|
// runProxy relays MCP JSON-RPC traffic between stdio (the MCP client) and
|
|
// the daemon's Unix socket. Exactly what `gortex mcp` does when it
|
|
// detects a running daemon and isn't forced to embedded mode.
|
|
//
|
|
// Returns (true, nil) when the proxy ran and finished cleanly. Returns
|
|
// (false, nil) when the daemon isn't reachable — the caller should fall
|
|
// back to embedded mode. Any other error is a real problem.
|
|
func runProxy(ctx context.Context, surface *gortexmcp.ToolSurface) (ran bool, err error) {
|
|
cwd, wdErr := resolveLaunchCWD()
|
|
if wdErr != nil {
|
|
return false, fmt.Errorf("cwd: %w", wdErr)
|
|
}
|
|
toolSpec, toolMode := clientToolPreference()
|
|
h := daemon.Handshake{
|
|
Mode: daemon.ModeMCP,
|
|
CWD: cwd,
|
|
ClientName: detectClientName(),
|
|
Tools: toolSpec,
|
|
ToolsMode: toolMode,
|
|
}
|
|
client, recoverable, err := dialDaemonWithRetry(ctx, h)
|
|
if err != nil && !recoverable {
|
|
return false, fmt.Errorf("dial daemon: %w", err)
|
|
}
|
|
if client == nil {
|
|
// The daemon isn't reachable (even after the retry window) or it's
|
|
// running a mismatched protocol version — both are recoverable by
|
|
// falling back to the embedded in-process server.
|
|
if errors.Is(err, daemon.ErrProtocolVersionMismatch) {
|
|
fmt.Fprintln(os.Stderr, "[gortex mcp] daemon protocol mismatch; falling back to embedded server")
|
|
} else {
|
|
fmt.Fprintln(os.Stderr, "[gortex mcp] daemon unreachable after retry window; falling back to embedded server")
|
|
}
|
|
return false, nil
|
|
}
|
|
defer client.Close()
|
|
|
|
// A daemon that is still warming up acks the handshake immediately and
|
|
// serves whatever the graph holds so far, filling in as warmup completes —
|
|
// so staying connected is strictly better than dead-ending on an empty
|
|
// embedded server. Surface the state so the launch log isn't misleading.
|
|
if client.Ack.Warming {
|
|
fmt.Fprintf(os.Stderr,
|
|
"[gortex mcp] proxying to daemon (session %s, daemon warming up — phase %q; graph still filling)\n",
|
|
client.Ack.SessionID, client.Ack.WarmupPhase)
|
|
} else {
|
|
fmt.Fprintf(os.Stderr,
|
|
"[gortex mcp] proxying to daemon (session %s, default_repo=%q)\n",
|
|
client.Ack.SessionID, client.Ack.DefaultRepo)
|
|
}
|
|
|
|
// Bidirectional pump:
|
|
// stdin → socket (MCP requests from the client)
|
|
// socket → stdout (MCP responses + notifications)
|
|
//
|
|
// We run both on goroutines and exit when either side hits EOF.
|
|
errCh := make(chan error, 2)
|
|
var wg sync.WaitGroup
|
|
var outMu sync.Mutex // serialises the two writers into os.Stdout
|
|
wg.Add(2)
|
|
|
|
if surface.Active() {
|
|
fmt.Fprintf(os.Stderr, "[gortex mcp] tool surface restricted (preset %q)\n", surface.Preset())
|
|
go func() {
|
|
defer wg.Done()
|
|
errCh <- pumpRequestsFiltered(os.Stdin, client.Conn, os.Stdout, &outMu, surface)
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
errCh <- pumpResponsesFiltered(client.Conn, os.Stdout, &outMu, surface)
|
|
}()
|
|
} else {
|
|
go func() {
|
|
defer wg.Done()
|
|
errCh <- pumpLines(os.Stdin, client.Conn)
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
errCh <- pumpLines(client.Conn, os.Stdout)
|
|
}()
|
|
}
|
|
|
|
// Orphan watchdog: if our parent (the MCP client) dies, stdin EOF is the
|
|
// normal shutdown signal — but a client that is SIGKILLed, or whose stdin
|
|
// pipe is inherited and held open elsewhere, can leave this proxy wedged
|
|
// forever, pinning a daemon session. Poll the parent PID and unblock the
|
|
// select when we get reparented (to init or a subreaper).
|
|
orphanCh := make(chan struct{}, 1)
|
|
watchCtx, cancelWatch := context.WithCancel(ctx)
|
|
defer cancelWatch()
|
|
go orphanWatch(watchCtx, orphanPollInterval, os.Getppid, func() {
|
|
fmt.Fprintln(os.Stderr, "[gortex mcp] parent process exited; closing proxy")
|
|
select {
|
|
case orphanCh <- struct{}{}:
|
|
default:
|
|
}
|
|
})
|
|
|
|
// Wait for first completion; exit on context cancellation or orphaning too.
|
|
select {
|
|
case pumpErr := <-errCh:
|
|
if pumpErr != nil && !errors.Is(pumpErr, io.EOF) {
|
|
return true, fmt.Errorf("proxy pump: %w", pumpErr)
|
|
}
|
|
case <-orphanCh:
|
|
case <-ctx.Done():
|
|
}
|
|
cancelWatch()
|
|
_ = client.Close()
|
|
// Bound the drain: a pump blocked reading a never-closing stdin (the exact
|
|
// orphan case) must not pin shutdown — the process is exiting regardless.
|
|
drained := make(chan struct{})
|
|
go func() { wg.Wait(); close(drained) }()
|
|
select {
|
|
case <-drained:
|
|
case <-time.After(proxyDrainTimeout):
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// orphanPollInterval is how often the proxy checks whether its parent
|
|
// process is still alive; proxyDrainTimeout bounds the post-close drain.
|
|
// Both are vars so tests can shorten them.
|
|
var (
|
|
orphanPollInterval = 5 * time.Second
|
|
proxyDrainTimeout = 2 * time.Second
|
|
)
|
|
|
|
// dialDaemon is the seam runProxy dials through. A package var so tests can
|
|
// substitute a fake without a real socket.
|
|
var dialDaemon = daemon.Dial
|
|
|
|
// proxyDialRetryWindow bounds how long dialDaemonWithRetry keeps retrying a
|
|
// recoverable "daemon unavailable" dial error before conceding to the embedded
|
|
// server; proxyDialRetryInterval is the gap between attempts. By the time the
|
|
// proxy dials, resolveDaemonDecision has already confirmed the socket is up
|
|
// (daemonReady) or waited for it (daemonAutostarted) — but under a CPU- and
|
|
// GC-saturated warmup the daemon's accept() can briefly exceed the 500ms dial
|
|
// timeout, surfacing as ErrDaemonUnavailable. Retrying rides that window out so
|
|
// a connecting session lands on the real daemon (and self-heals as warmup
|
|
// fills the graph) instead of dead-ending on an empty embedded graph. Vars so
|
|
// tests can shorten them.
|
|
var (
|
|
proxyDialRetryWindow = 20 * time.Second
|
|
proxyDialRetryInterval = 250 * time.Millisecond
|
|
)
|
|
|
|
// dialDaemonWithRetry dials the daemon, retrying transient "unavailable"
|
|
// errors (socket up but accept() starved by warmup) for a bounded window.
|
|
// Returns:
|
|
// - (client, false, nil) on success.
|
|
// - (nil, false, err) on a non-recoverable error — the caller surfaces it.
|
|
// - (nil, true, lastErr) when the window expires with the daemon still
|
|
// unreachable, or on a protocol-version mismatch (which never resolves by
|
|
// waiting) — the caller falls back to the embedded server. lastErr lets the
|
|
// caller distinguish the mismatch case for logging.
|
|
func dialDaemonWithRetry(ctx context.Context, h daemon.Handshake) (client *daemon.Client, recoverable bool, lastErr error) {
|
|
deadline := time.Now().Add(proxyDialRetryWindow)
|
|
for {
|
|
c, err := dialDaemon(h)
|
|
if err == nil {
|
|
return c, false, nil
|
|
}
|
|
if !daemon.ShouldFallBackToEmbedded(err) {
|
|
return nil, false, err
|
|
}
|
|
// A protocol-version mismatch is a stale daemon after an upgrade —
|
|
// waiting can't fix it, so concede to the embedded server now.
|
|
if errors.Is(err, daemon.ErrProtocolVersionMismatch) {
|
|
return nil, true, err
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return nil, true, err
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, true, err
|
|
case <-time.After(proxyDialRetryInterval):
|
|
}
|
|
}
|
|
}
|
|
|
|
// orphanWatch polls getppid every interval and invokes onOrphan exactly
|
|
// once when the proxy's parent process has gone away — detected as a change
|
|
// of parent PID (reparented to init=1 on classic Unix, or to the nearest
|
|
// subreaper). Watching for a *change* is strictly more robust than testing
|
|
// for PID 1 alone, which misses subreaper reparenting (containers, systemd
|
|
// user sessions, a wrapping CLI that calls prctl(PR_SET_CHILD_SUBREAPER)).
|
|
// It self-disarms when there is no meaningful parent to watch (orig <= 1),
|
|
// and is an inert no-op on platforms that never reparent — there the parent
|
|
// PID stays equal to orig for the whole process lifetime.
|
|
func orphanWatch(ctx context.Context, interval time.Duration, getppid func() int, onOrphan func()) {
|
|
orig := getppid()
|
|
if orig <= 1 || interval <= 0 {
|
|
return
|
|
}
|
|
t := time.NewTicker(interval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
if getppid() != orig {
|
|
onOrphan()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// pumpLines copies newline-delimited frames from src to dst. Uses a
|
|
// line-aware scanner so partial reads don't split a single MCP message
|
|
// between two writes (which would confuse the peer's parser).
|
|
func pumpLines(src io.Reader, dst io.Writer) error {
|
|
r := bufio.NewReaderSize(src, 1<<20) // 1 MB — some MCP replies are chunky
|
|
for {
|
|
line, err := r.ReadBytes('\n')
|
|
if len(line) > 0 {
|
|
if _, werr := dst.Write(line); werr != nil {
|
|
return werr
|
|
}
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// detectClientName makes a best-effort guess at which MCP client spawned
|
|
// us. Purely for the initial handshake telemetry — the authoritative
|
|
// answer comes from the MCP `initialize` request's clientInfo, which
|
|
// the daemon dispatcher (cmd/gortex/daemon_mcp.go::maybeSnoopInitialize)
|
|
// applies once the first frame arrives. The handshake-time guess
|
|
// only matters for the few hundred milliseconds before initialize
|
|
// reaches us.
|
|
//
|
|
// Env-var sniffing here favours the actual variables current MCP
|
|
// hosts set. Claude Code: CLAUDECODE=1 (current builds set this) plus
|
|
// CLAUDE_CODE_ENTRYPOINT=cli|sdk|... Other hosts kept best-effort.
|
|
func detectClientName() string {
|
|
switch {
|
|
case os.Getenv("CLAUDECODE") != "" || os.Getenv("CLAUDE_CODE_ENTRYPOINT") != "" || os.Getenv("CLAUDE_CODE_WORKSPACE") != "":
|
|
return "claude-code"
|
|
case os.Getenv("CURSOR_TRACE_ID") != "" || os.Getenv("CURSOR_WORKSPACE") != "":
|
|
return "cursor"
|
|
case os.Getenv("KIRO_WORKSPACE") != "":
|
|
return "kiro"
|
|
case os.Getenv("WINDSURF_WORKSPACE") != "":
|
|
return "windsurf"
|
|
case os.Getenv("CODEX_WORKSPACE") != "":
|
|
return "codex"
|
|
case os.Getenv("ANTIGRAVITY_AGENT") != "":
|
|
return "antigravity"
|
|
case os.Getenv("VSCODE_PID") != "" || os.Getenv("VSCODE_IPC_HOOK") != "":
|
|
// VS Code with the MCP extension. Coarse — Continue / Cline
|
|
// embedders run inside VS Code too, so this is just a hint
|
|
// until the MCP initialize frame lands and overrides it.
|
|
return "vscode"
|
|
case os.Getenv("ZED_TERM") != "" || os.Getenv("ZED_TERMINAL") != "":
|
|
return "zed"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// resolveLaunchCWD picks the most plausible project cwd for an MCP
|
|
// launch, defending against editors that spawn the MCP server with
|
|
// cwd unset or set to a non-project directory:
|
|
//
|
|
// - Antigravity sometimes spawns with cwd=`/`.
|
|
// - Cursor launches user-level `~/.cursor/mcp.json` entries with
|
|
// cwd=$HOME (see gortexhq/gortex#19).
|
|
//
|
|
// Resolution order:
|
|
// 1. os.Getwd() when it looks like a project root (not `/` or $HOME).
|
|
// 2. $PWD when it differs and isn't `/` or $HOME.
|
|
// 3. The first non-empty editor workspace env var (CURSOR_WORKSPACE,
|
|
// CLAUDE_CODE_WORKSPACE, WINDSURF_WORKSPACE, KIRO_WORKSPACE,
|
|
// CODEX_WORKSPACE, ANTIGRAVITY_WORKSPACE, VSCODE_WORKSPACE).
|
|
// 4. Fall through to whatever Getwd() returned — the daemon resolves
|
|
// it per session (ScopeForCWD) and surfaces a clear repo_not_tracked
|
|
// error when the cwd maps to no tracked repo, isolating the session
|
|
// to nothing rather than the whole graph.
|
|
func resolveLaunchCWD() (string, error) {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !isAmbiguousLaunchCWD(cwd) {
|
|
return cwd, nil
|
|
}
|
|
if pwd := os.Getenv("PWD"); pwd != cwd && !isAmbiguousLaunchCWD(pwd) {
|
|
return pwd, nil
|
|
}
|
|
for _, key := range []string{
|
|
"CURSOR_WORKSPACE",
|
|
"CLAUDE_CODE_WORKSPACE",
|
|
"WINDSURF_WORKSPACE",
|
|
"KIRO_WORKSPACE",
|
|
"CODEX_WORKSPACE",
|
|
"ANTIGRAVITY_WORKSPACE",
|
|
"VSCODE_WORKSPACE",
|
|
} {
|
|
if v := os.Getenv(key); !isAmbiguousLaunchCWD(v) {
|
|
return v, nil
|
|
}
|
|
}
|
|
return cwd, nil
|
|
}
|
|
|
|
// isAmbiguousLaunchCWD returns true when `p` is an editor-launch cwd
|
|
// we can't trust to point at the active project — empty, `/`, or the
|
|
// user's home directory.
|
|
//
|
|
// The home comparison goes through filepath.EvalSymlinks so the
|
|
// macOS `/var → /private/var` redirect (and similar symlinks) don't
|
|
// cause a false negative when an editor sets cwd via os.Chdir and
|
|
// then Getwd reports the resolved form.
|
|
func isAmbiguousLaunchCWD(p string) bool {
|
|
if p == "" || p == "/" {
|
|
return true
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil || home == "" {
|
|
return false
|
|
}
|
|
if p == home {
|
|
return true
|
|
}
|
|
resP, errP := filepath.EvalSymlinks(p)
|
|
resH, errH := filepath.EvalSymlinks(home)
|
|
return errP == nil && errH == nil && resP == resH
|
|
}
|
|
|
|
// The former shouldTryProxy stdin-TTY heuristic was removed: `gortex mcp`
|
|
// is now daemon-first via resolveDaemonDecision (ensure-daemon → relay,
|
|
// with an embedded fallback) regardless of whether stdin is a terminal
|
|
// or a pipe.
|