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
223 lines
7.5 KiB
Go
223 lines
7.5 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
|
|
"github.com/zzet/gortex/internal/savings"
|
|
)
|
|
|
|
// sessionCtxKey is the private context key under which a caller
|
|
// (typically the daemon's MCP dispatcher) stashes the session ID for
|
|
// the current request. The value is read by `Server.sessionFor` so
|
|
// tool handlers resolve to the correct per-client state.
|
|
//
|
|
// Unexported so external packages can't inject one accidentally — use
|
|
// WithSessionID / SessionIDFromContext.
|
|
type sessionCtxKey struct{}
|
|
|
|
// WithSessionID returns a context carrying id. The daemon's MCP
|
|
// dispatcher wraps each inbound frame's context with this before
|
|
// calling MCPServer.HandleMessage, giving every tool handler access
|
|
// to the per-session state without touching the handler signature.
|
|
//
|
|
// An empty id is treated as "no session" and returns ctx unchanged —
|
|
// that's the path the embedded stdio server takes, where there's only
|
|
// one implicit session.
|
|
func WithSessionID(ctx context.Context, id string) context.Context {
|
|
if id == "" {
|
|
return ctx
|
|
}
|
|
return context.WithValue(ctx, sessionCtxKey{}, id)
|
|
}
|
|
|
|
// SessionIDFromContext returns the session ID attached via
|
|
// WithSessionID, or "" when none is present. Callers treat "" as
|
|
// "default shared session" — the same state the embedded server uses.
|
|
func SessionIDFromContext(ctx context.Context) string {
|
|
if ctx == nil {
|
|
return ""
|
|
}
|
|
if id, ok := ctx.Value(sessionCtxKey{}).(string); ok {
|
|
return id
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// sessionCWDCtxKey carries the session's working directory. The
|
|
// daemon's MCP dispatcher stashes it alongside the session ID so tool
|
|
// handlers can resolve — and enforce — the workspace boundary for the
|
|
// session (Server.sessionScope). Unexported: external packages must
|
|
// use WithSessionCWD / SessionCWDFromContext.
|
|
type sessionCWDCtxKey struct{}
|
|
|
|
// WithSessionCWD returns a context carrying the session's working
|
|
// directory. The daemon dispatcher wraps each inbound frame with this
|
|
// before calling MCPServer.HandleMessage, giving every tool handler
|
|
// the cwd needed to resolve the session's workspace scope.
|
|
//
|
|
// An empty cwd returns ctx unchanged — that's the embedded stdio path
|
|
// (one implicit session, no cwd) and control clients; both fall back
|
|
// to the server-default scope.
|
|
func WithSessionCWD(ctx context.Context, cwd string) context.Context {
|
|
if cwd == "" {
|
|
return ctx
|
|
}
|
|
return context.WithValue(ctx, sessionCWDCtxKey{}, cwd)
|
|
}
|
|
|
|
// SessionCWDFromContext returns the session cwd attached via
|
|
// WithSessionCWD, or "" when none is present.
|
|
func SessionCWDFromContext(ctx context.Context) string {
|
|
if ctx == nil {
|
|
return ""
|
|
}
|
|
if cwd, ok := ctx.Value(sessionCWDCtxKey{}).(string); ok {
|
|
return cwd
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// repoAllowCtxKey carries the per-request repo allow-set resolved by
|
|
// handleAnalyze (resolveScope → ResolvedScope.RepoAllow). The scoped-
|
|
// node accessors (scopedNodes / scopedNodesByKinds / scopedNodeSlice)
|
|
// read it to narrow within the workspace ceiling without threading a
|
|
// param through their ~40 call sites. Unexported: only handleAnalyze
|
|
// ever sets it, on the per-request ctx — use withRepoAllow /
|
|
// repoAllowFromContext.
|
|
type repoAllowCtxKey struct{}
|
|
|
|
// withRepoAllow returns a context carrying the per-request repo
|
|
// allow-set resolved by handleAnalyze. An empty/nil allow-set returns
|
|
// ctx unchanged (the common no-narrowing case), so non-analyze callers
|
|
// and unnarrowed analyze calls are byte-for-byte unaffected.
|
|
func withRepoAllow(ctx context.Context, allow map[string]bool) context.Context {
|
|
if len(allow) == 0 {
|
|
return ctx
|
|
}
|
|
return context.WithValue(ctx, repoAllowCtxKey{}, allow)
|
|
}
|
|
|
|
// repoAllowFromContext returns the repo allow-set attached via
|
|
// withRepoAllow, or nil when none is present.
|
|
func repoAllowFromContext(ctx context.Context) map[string]bool {
|
|
if ctx == nil {
|
|
return nil
|
|
}
|
|
if a, ok := ctx.Value(repoAllowCtxKey{}).(map[string]bool); ok {
|
|
return a
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sessionLocal bundles the per-client state that should not aggregate
|
|
// across sessions: recent agent activity (viewed/modified files and
|
|
// symbols), and session-scoped token-savings counters. Shared pieces —
|
|
// the graph, feedback store, the cumulative savings store on disk —
|
|
// stay on *Server directly or are referenced via pointers that all
|
|
// sessions share.
|
|
type sessionLocal struct {
|
|
session *sessionState
|
|
tokenStats *tokenStats
|
|
}
|
|
|
|
// newSessionLocal constructs a fresh per-session state container. The
|
|
// persistent savings store pointer is threaded in so per-session
|
|
// record() calls still contribute to cumulative totals on disk — each
|
|
// session's in-memory counters are isolated but the file they flush to
|
|
// is shared. parent, when non-nil, is the process-wide tokenStats
|
|
// aggregate; every per-session record() call also bumps it so the
|
|
// shared default reflects daemon-wide live activity.
|
|
func newSessionLocal(id string, persistent *savings.Store, repoPath string, parent *tokenStats) *sessionLocal {
|
|
return &sessionLocal{
|
|
session: newSessionState(),
|
|
tokenStats: &tokenStats{
|
|
persistent: persistent,
|
|
repoPath: repoPath,
|
|
parent: parent,
|
|
sessionID: id,
|
|
},
|
|
}
|
|
}
|
|
|
|
// sessionMap is a thread-safe string→*sessionLocal registry. Used by
|
|
// *Server to multiplex session-scoped state when running inside the
|
|
// daemon. The embedded / stdio server path doesn't consult this map;
|
|
// it reads *Server.session directly.
|
|
//
|
|
// The map also holds a pointer to the shared persistent savings store,
|
|
// so per-session tokenStats created by lazy get() calls inherit it
|
|
// automatically. Updating it via setPersistent propagates to every
|
|
// existing entry as well.
|
|
type sessionMap struct {
|
|
mu sync.Mutex
|
|
sessions map[string]*sessionLocal
|
|
persistent *savings.Store
|
|
repoPath string
|
|
// parent is the process-wide tokenStats aggregate. Each per-session
|
|
// counter created by get() inherits it as its parent so record()
|
|
// calls fan out to the daemon-wide totals.
|
|
parent *tokenStats
|
|
}
|
|
|
|
func newSessionMap() *sessionMap {
|
|
return &sessionMap{sessions: make(map[string]*sessionLocal)}
|
|
}
|
|
|
|
// setParentTokenStats installs the process-wide tokenStats so every
|
|
// session created here aggregates into it. Called once at server
|
|
// construction (Server.attachSessionMap) before any client connects.
|
|
func (m *sessionMap) setParentTokenStats(parent *tokenStats) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.parent = parent
|
|
for _, sl := range m.sessions {
|
|
if sl.tokenStats == nil {
|
|
continue
|
|
}
|
|
sl.tokenStats.mu.Lock()
|
|
sl.tokenStats.parent = parent
|
|
sl.tokenStats.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// get returns the session state for id, creating it if absent. Never
|
|
// returns nil — a missing entry is created lazily. Thread-safe.
|
|
func (m *sessionMap) get(id string) *sessionLocal {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
sl, ok := m.sessions[id]
|
|
if !ok {
|
|
sl = newSessionLocal(id, m.persistent, m.repoPath, m.parent)
|
|
m.sessions[id] = sl
|
|
}
|
|
return sl
|
|
}
|
|
|
|
// release drops the session entry for id. Called when the daemon's
|
|
// accept loop sees a proxy disconnect.
|
|
func (m *sessionMap) release(id string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.sessions, id)
|
|
}
|
|
|
|
// setPersistent updates the shared savings store pointer and
|
|
// propagates it into every live session so no existing client flushes
|
|
// savings to a stale (or nil) store.
|
|
func (m *sessionMap) setPersistent(store *savings.Store, repoPath string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.persistent = store
|
|
m.repoPath = repoPath
|
|
for _, sl := range m.sessions {
|
|
if sl.tokenStats == nil {
|
|
continue
|
|
}
|
|
sl.tokenStats.mu.Lock()
|
|
sl.tokenStats.persistent = store
|
|
sl.tokenStats.repoPath = repoPath
|
|
sl.tokenStats.mu.Unlock()
|
|
}
|
|
}
|