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
431 lines
16 KiB
Go
431 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/zzet/gortex/internal/config"
|
|
"github.com/zzet/gortex/internal/indexer"
|
|
"github.com/zzet/gortex/internal/llm/conversationlog"
|
|
"github.com/zzet/gortex/internal/persistence"
|
|
"github.com/zzet/gortex/internal/platform"
|
|
"github.com/zzet/gortex/internal/server"
|
|
"github.com/zzet/gortex/internal/server/hub"
|
|
"github.com/zzet/gortex/internal/serverstack"
|
|
)
|
|
|
|
var (
|
|
mcpIndex string
|
|
mcpTransport string
|
|
mcpPort int
|
|
mcpBind string
|
|
mcpAuthToken string
|
|
mcpWatch bool
|
|
mcpServerAPI bool
|
|
mcpCORSOrigin string
|
|
mcpDebounce int
|
|
mcpTrack []string
|
|
mcpProject string
|
|
mcpCacheDir string
|
|
mcpNoCache bool
|
|
mcpEmbeddings bool
|
|
mcpEmbeddingsURL string
|
|
mcpEmbeddingsModel string
|
|
mcpSemantic bool
|
|
mcpNoSemantic bool
|
|
mcpSemanticMode string
|
|
mcpNoDaemon bool
|
|
mcpForceProxy bool
|
|
mcpTools string
|
|
mcpToolsMode string
|
|
)
|
|
|
|
var mcpCmd = &cobra.Command{
|
|
Use: "mcp",
|
|
Short: "Start the MCP server (stdio transport)",
|
|
RunE: runMCP,
|
|
}
|
|
|
|
func init() {
|
|
mcpCmd.Flags().StringVar(&mcpIndex, "index", "", "repository path to index on startup")
|
|
mcpCmd.Flags().StringVar(&mcpTransport, "transport", "stdio", "transport: stdio")
|
|
mcpCmd.Flags().IntVar(&mcpPort, "port", 8765, "port for HTTP server API when --server is set")
|
|
mcpCmd.Flags().BoolVar(&mcpWatch, "watch", false, "keep graph in sync with filesystem changes")
|
|
mcpCmd.Flags().IntVar(&mcpDebounce, "debounce", 150, "debounce delay in ms")
|
|
mcpCmd.Flags().BoolVar(&mcpServerAPI, "server", false, "start HTTP server API alongside MCP stdio")
|
|
mcpCmd.Flags().StringVar(&mcpBind, "bind", "127.0.0.1", "bind address for --server; requires --auth-token when not localhost")
|
|
mcpCmd.Flags().StringVar(&mcpAuthToken, "auth-token", "", "bearer token for --server's /v1/* requests (fallback: $GORTEX_SERVER_TOKEN)")
|
|
mcpCmd.Flags().StringVar(&mcpCORSOrigin, "cors-origin", "*", "allowed CORS origin for server API")
|
|
mcpCmd.Flags().StringSliceVar(&mcpTrack, "track", nil, "additional repository paths to track")
|
|
mcpCmd.Flags().StringVar(&mcpProject, "project", "", "active project name")
|
|
mcpCmd.Flags().StringVar(&mcpCacheDir, "cache-dir", "", "graph cache directory (default ~/.gortex/cache/)")
|
|
mcpCmd.Flags().BoolVar(&mcpNoCache, "no-cache", false, "disable graph caching")
|
|
mcpCmd.Flags().BoolVar(&mcpEmbeddings, "embeddings", false, "enable semantic search (built-in word vectors or transformer if compiled in)")
|
|
mcpCmd.Flags().StringVar(&mcpEmbeddingsURL, "embeddings-url", "", "embedding API URL (e.g. http://localhost:11434 for Ollama)")
|
|
mcpCmd.Flags().StringVar(&mcpEmbeddingsModel, "embeddings-model", "", "embedding model name (default: auto-detect)")
|
|
mcpCmd.Flags().BoolVar(&mcpSemantic, "semantic", false, "enable semantic enrichment (SCIP, go/types, LSP)")
|
|
mcpCmd.Flags().BoolVar(&mcpNoSemantic, "no-semantic", false, "disable semantic enrichment")
|
|
mcpCmd.Flags().StringVar(&mcpSemanticMode, "semantic-mode", "typecheck", "Go analysis mode: typecheck or callgraph")
|
|
mcpCmd.Flags().BoolVar(&mcpNoDaemon, "no-daemon", false, "deprecated no-op (warns when set); the embedded server is used automatically when no daemon is available")
|
|
mcpCmd.Flags().BoolVar(&mcpForceProxy, "proxy", false, "require a running daemon and proxy through it (error if unavailable)")
|
|
mcpCmd.Flags().StringVar(&mcpTools, "tools", "", "restrict the published MCP tool surface to a preset: core (default)|full|readonly|edit|nav (optionally with ,+tool / ,-tool deltas). GORTEX_TOOLS overrides this")
|
|
mcpCmd.Flags().StringVar(&mcpToolsMode, "tools-mode", "", "how a --tools preset hides tools: hide (remove from tools/list + block calls) or defer (keep reachable via tools_search). Default hide")
|
|
rootCmd.AddCommand(mcpCmd)
|
|
}
|
|
|
|
var legacyMCPFlagsWarned bool
|
|
|
|
// warnLegacyMCPFlags emits one stderr line per explicitly-set legacy flag
|
|
// (--index/--watch/--proxy/--no-daemon). These are permanent no-op compat
|
|
// shims for un-migrated on-disk editor configs. stderr only — stdout is
|
|
// the MCP JSON-RPC stream and a stray byte corrupts the protocol.
|
|
func warnLegacyMCPFlags(cmd *cobra.Command) {
|
|
if legacyMCPFlagsWarned {
|
|
return
|
|
}
|
|
legacyMCPFlagsWarned = true
|
|
for _, name := range []string{"index", "watch", "proxy", "no-daemon"} {
|
|
if f := cmd.Flags().Lookup(name); f != nil && f.Changed {
|
|
fmt.Fprintf(os.Stderr, "[gortex] note: --%s is deprecated and ignored on the proxy path; "+
|
|
"`gortex mcp` proxies to the daemon (auto-starting it) and falls back to an embedded server.\n", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func runMCP(cmd *cobra.Command, args []string) error {
|
|
warnLegacyMCPFlags(cmd)
|
|
|
|
// Daemon-first: ensure a daemon is up (auto-starting it under a
|
|
// single-flight lock when GORTEX_AUTOSTART allows), then relay stdio
|
|
// over its socket. The old stdin-TTY heuristic is gone — behavior is
|
|
// identical from a terminal or a pipe given the same daemon state. The
|
|
// legacy --no-daemon flag is an inert no-op (warned above): whether we
|
|
// proxy or fall back to the embedded server is decided purely by daemon
|
|
// presence + GORTEX_AUTOSTART, never by the flag.
|
|
switch resolveDaemonDecision() {
|
|
case daemonReady, daemonAutostarted:
|
|
ran, proxyErr := runProxy(cmd.Context(), proxyToolSurface())
|
|
if proxyErr != nil {
|
|
return proxyErr
|
|
}
|
|
if ran {
|
|
return nil
|
|
}
|
|
// Lost the daemon between ensure and dial (rare) — fall
|
|
// through to the embedded server.
|
|
}
|
|
|
|
logger := newLogger()
|
|
defer func() { _ = logger.Sync() }()
|
|
|
|
// We only reach here when no daemon is available (or autostart is off).
|
|
// A bare `gortex mcp` with no --index used to serve an EMPTY embedded
|
|
// graph — every tool returned nothing, which reads to the user as "gortex
|
|
// is broken." Default the embedded index to the launch cwd so the fallback
|
|
// serves the user's actual repo instead of nothing. An explicit --index
|
|
// still wins; an ambiguous cwd (`/`, $HOME) is left unset rather than
|
|
// indexing the world.
|
|
if mcpIndex == "" {
|
|
if cwd, cwdErr := resolveLaunchCWD(); cwdErr == nil && !isAmbiguousLaunchCWD(cwd) {
|
|
mcpIndex = cwd
|
|
fmt.Fprintf(os.Stderr, "[gortex] no daemon available; embedded server indexing %s\n", mcpIndex)
|
|
}
|
|
}
|
|
|
|
// The embedded server runs in single-repo mode over --index: it
|
|
// indexes that tree and serves the whole graph (no marker handshake,
|
|
// no .gortex/workspace.toml). Multi-repo scoping is the daemon's job.
|
|
cfg, err := config.Load(cfgFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Resolve the embedded semantic decision: --no-semantic forces off,
|
|
// --semantic forces on, otherwise semantic.enabled in config decides.
|
|
cfg.Semantic.Enabled = !mcpNoSemantic && (mcpSemantic || cfg.Semantic.Enabled)
|
|
// Fold --tools / --tools-mode into the mcp.tools config the server
|
|
// stack reads (flag overrides config; GORTEX_TOOLS still overrides).
|
|
applyToolPresetFlags(cfg, mcpTools, mcpToolsMode)
|
|
|
|
// Side-store layout for the embedded path: every store partitions
|
|
// per-repo under the indexed repo path, and the notebook is repo-local
|
|
// (committed to git). When --cache-dir is unset, notes / memories still
|
|
// persist via the shared cache dir's sidecar.
|
|
sideStoreCacheDir := mcpCacheDir
|
|
if sideStoreCacheDir == "" {
|
|
sideStoreCacheDir = platform.CacheDir()
|
|
}
|
|
// The savings ledger is machine-global — the same sidecar database
|
|
// every entry point writes and the `gortex savings` CLI reads.
|
|
// --cache-dir deliberately does NOT relocate it: users set that flag
|
|
// to move the graph cache, and quietly splitting the ledger away
|
|
// from the dashboard's default read path recreates the
|
|
// empty-dashboard failure mode. Isolation (tests, sandboxes) comes
|
|
// from XDG_DATA_HOME / XDG_CACHE_HOME, which both ledger paths
|
|
// honour.
|
|
|
|
ss, err := serverstack.NewSharedServer(serverstack.SharedServerConfig{
|
|
Lifecycle: serverstack.LifecycleOneshot,
|
|
Index: mcpIndex,
|
|
Config: cfg,
|
|
Logger: logger,
|
|
Version: version,
|
|
Embedder: serverstack.EmbedderRequest{
|
|
FlagChanged: cmd.Flags().Changed("embeddings"),
|
|
FlagEnabled: mcpEmbeddings,
|
|
FlagURL: mcpEmbeddingsURL,
|
|
FlagModel: mcpEmbeddingsModel,
|
|
},
|
|
ActiveProject: mcpProject,
|
|
SemanticMode: mcpSemanticMode,
|
|
SideStores: serverstack.SideStores{
|
|
NotesDir: sideStoreCacheDir,
|
|
NotesRepo: mcpIndex,
|
|
FeedbackDir: mcpCacheDir,
|
|
FeedbackRepo: mcpIndex,
|
|
NotebookPath: mcpIndex,
|
|
},
|
|
SavingsRepo: mcpIndex,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("build server stack: %w", err)
|
|
}
|
|
defer ss.Close()
|
|
|
|
g := ss.Graph
|
|
idx := ss.Indexer
|
|
cm := ss.ConfigMgr
|
|
mi := ss.MultiIndexer
|
|
srv := ss.MCP
|
|
|
|
// Persist the resolved active project so the MCP server and
|
|
// set_active_project agree on the default scope.
|
|
activeProject := mcpProject
|
|
if activeProject == "" && cm != nil {
|
|
activeProject = cm.Global().ActiveProject
|
|
}
|
|
if cm != nil {
|
|
cm.Global().ActiveProject = activeProject
|
|
}
|
|
|
|
// Register --track repos for the background watch / track loop.
|
|
if cm != nil {
|
|
for _, trackPath := range mcpTrack {
|
|
absPath, aerr := filepath.Abs(trackPath)
|
|
if aerr != nil {
|
|
fmt.Fprintf(os.Stderr, "[gortex] warning: could not resolve --track path %s: %v\n", trackPath, aerr)
|
|
continue
|
|
}
|
|
if aerr := cm.Global().AddRepo(config.RepoEntry{Path: absPath}); aerr != nil {
|
|
fmt.Fprintf(os.Stderr, "[gortex] warning: could not add --track repo %s: %v\n", absPath, aerr)
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "[gortex] MCP server ready (transport: %s)\n", mcpTransport)
|
|
|
|
// Start server HTTP API if requested.
|
|
if mcpServerAPI {
|
|
authToken := mcpAuthToken
|
|
if authToken == "" {
|
|
authToken = os.Getenv("GORTEX_SERVER_TOKEN")
|
|
}
|
|
if authToken == "" {
|
|
if !isLocalhostBind(mcpBind) {
|
|
return fmt.Errorf("--bind %q requires --auth-token (or $GORTEX_SERVER_TOKEN); refusing to expose unauthenticated server on external interface", mcpBind)
|
|
}
|
|
fmt.Fprintln(os.Stderr, "[gortex] server: unauthenticated mode; localhost only")
|
|
}
|
|
|
|
serverHandler := server.NewHandler(srv.MCPServer(), g, version, logger)
|
|
if cm != nil {
|
|
serverHandler.SetConfigManager(cm)
|
|
}
|
|
if id, err := resolveServerID(mcpCacheDir); err == nil {
|
|
serverHandler.SetServerID(id)
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "[gortex] server: id persistence disabled: %v\n", err)
|
|
}
|
|
// Conversation-log inspector: enable the /v1/conversations* routes
|
|
// (opt-in sink) with the route-scoped DNS-rebind guard cooperating
|
|
// with this server's auth token.
|
|
serverHandler.SetConversationDir(conversationlog.DirFromEnv())
|
|
authTokenForGuard := authToken
|
|
serverHandler.SetConversationGuard(nil, func() string { return authTokenForGuard })
|
|
handler := server.WithAuth(serverHandler, authToken)
|
|
corsOpts := server.CORSOptions{AllowOrigins: []string{mcpCORSOrigin}}
|
|
handler = server.WithCORS(handler, corsOpts)
|
|
go func() {
|
|
serverAddr := fmt.Sprintf("%s:%d", mcpBind, mcpPort)
|
|
fmt.Fprintf(os.Stderr, "[gortex] server API at http://%s\n", serverAddr)
|
|
if err := http.ListenAndServe(serverAddr, handler); err != nil && err != http.ErrServerClosed {
|
|
fmt.Fprintf(os.Stderr, "[gortex] server API error: %v\n", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Start MCP stdio in a goroutine so we can do background init.
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- srv.ServeStdio()
|
|
}()
|
|
|
|
// Create persistence store.
|
|
var store persistence.Store
|
|
if mcpNoCache {
|
|
store = persistence.NopStore{}
|
|
} else {
|
|
var err error
|
|
store, err = persistence.NewFileStore(mcpCacheDir, version)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[gortex] warning: cache disabled: %v\n", err)
|
|
store = persistence.NopStore{}
|
|
}
|
|
}
|
|
|
|
// Background: index, watch, analyze — graph populates while MCP is live.
|
|
go func() {
|
|
if mcpIndex != "" {
|
|
commitHash := gitCommitHash(mcpIndex)
|
|
branch := gitBranch(mcpIndex)
|
|
repoKey := canonicalRepo(mcpIndex)
|
|
cached := false
|
|
|
|
if commitHash != "" && store.Check(repoKey, branch, commitHash) && store.Validate(repoKey, branch, commitHash) {
|
|
snap, err := store.Load(repoKey, branch, commitHash)
|
|
if err == nil {
|
|
for _, n := range snap.Nodes {
|
|
g.AddNode(n)
|
|
}
|
|
for _, e := range snap.Edges {
|
|
g.AddEdge(e)
|
|
}
|
|
idx.SetFileMtimes(snap.FileMtimes)
|
|
idx.SetRootPath(mcpIndex)
|
|
|
|
// Restore vector index if available.
|
|
if len(snap.VectorIndex) > 0 && snap.VectorDims > 0 {
|
|
if err := idx.ImportVectorIndex(snap.VectorIndex, snap.VectorDims, snap.VectorCount); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[gortex] vector index restore failed: %v\n", err)
|
|
}
|
|
}
|
|
|
|
result, err := idx.IncrementalReindex(mcpIndex)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[gortex] incremental reindex failed: %v\n", err)
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "[gortex] restored graph (%d nodes, %d edges), re-indexed %d stale files in %dms\n",
|
|
result.NodeCount, result.EdgeCount, result.FileCount, result.DurationMs)
|
|
}
|
|
cached = true
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "[gortex] cache load failed, will re-index: %v\n", err)
|
|
}
|
|
}
|
|
|
|
if !cached {
|
|
fmt.Fprintf(os.Stderr, "[gortex] indexing %s...\n", mcpIndex)
|
|
result, err := idx.Index(mcpIndex)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[gortex] indexing failed: %v\n", err)
|
|
return
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[gortex] indexed %d files (%d nodes, %d edges) in %dms\n",
|
|
result.FileCount, result.NodeCount, result.EdgeCount, result.DurationMs)
|
|
}
|
|
}
|
|
|
|
// Search backend is auto-updated via SearchProvider (idx.Search)
|
|
|
|
// Pass contract registry to MCP server.
|
|
// In multi-repo mode, merge all per-repo registries.
|
|
if mi != nil {
|
|
srv.SetContractRegistry(mi.MergedContractRegistry())
|
|
} else if cr := idx.ContractRegistry(); cr != nil {
|
|
srv.SetContractRegistry(cr)
|
|
}
|
|
|
|
// Start watcher if requested.
|
|
if mcpWatch {
|
|
wcfg := cfg.Watch
|
|
wcfg.Enabled = true
|
|
if mcpDebounce > 0 {
|
|
wcfg.DebounceMs = mcpDebounce
|
|
}
|
|
|
|
watcher, err := indexer.NewWatcher(idx, wcfg, logger)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[gortex] watcher setup failed: %v\n", err)
|
|
return
|
|
}
|
|
|
|
watchPaths := wcfg.Paths
|
|
if len(watchPaths) == 0 && mcpIndex != "" {
|
|
watchPaths = []string{mcpIndex}
|
|
}
|
|
if len(watchPaths) == 0 {
|
|
watchPaths = []string{"."}
|
|
}
|
|
|
|
if err := watcher.Start(watchPaths); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[gortex] watcher start failed: %v\n", err)
|
|
return
|
|
}
|
|
srv.SetWatcher(watcher)
|
|
|
|
// Create hub for fan-out of watcher events.
|
|
eventHub := hub.New()
|
|
go eventHub.Run(watcher.Events())
|
|
|
|
srv.WatchForReanalysis(eventHub, 500)
|
|
fmt.Fprintf(os.Stderr, "[gortex] watch mode active\n")
|
|
}
|
|
|
|
// Run initial analysis.
|
|
srv.RunAnalysis()
|
|
}()
|
|
|
|
// Handle graceful shutdown.
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, platform.ShutdownSignals()...)
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
return err
|
|
case sig := <-sigCh:
|
|
fmt.Fprintf(os.Stderr, "\n[gortex] received %s, shutting down\n", sig)
|
|
|
|
// Persist graph snapshot on shutdown.
|
|
if mcpIndex != "" {
|
|
commitHash := gitCommitHash(mcpIndex)
|
|
if commitHash != "" {
|
|
snap := &persistence.Snapshot{
|
|
Version: version,
|
|
RepoPath: canonicalRepo(mcpIndex),
|
|
CommitHash: commitHash,
|
|
Branch: gitBranch(mcpIndex),
|
|
IndexedAt: time.Now(),
|
|
Nodes: g.AllNodes(),
|
|
Edges: g.AllEdges(),
|
|
FileMtimes: idx.FileMtimes(),
|
|
}
|
|
// Include vector index if available.
|
|
snap.VectorIndex, snap.VectorDims, snap.VectorCount = idx.ExportVectorIndex()
|
|
if err := store.Save(snap); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[gortex] cache save failed: %v\n", err)
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "[gortex] saved graph snapshot (%d nodes, %d edges)\n",
|
|
len(snap.Nodes), len(snap.Edges))
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|