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

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()
}
}