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
338 lines
13 KiB
Go
338 lines
13 KiB
Go
package mcp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
)
|
|
|
|
// Warmup-aware fast path for tool calls.
|
|
//
|
|
// The daemon serves the MCP socket as soon as it binds — long before
|
|
// warmup (re-index every tracked repo, run the cross-repo resolve,
|
|
// derive the graph-wide passes) has finished. A graph-querying tool
|
|
// invoked in that window would otherwise either answer against a
|
|
// half-built graph with no signal that it is incomplete, or — for
|
|
// tools that genuinely need a finished graph — fail with a bare
|
|
// error the caller cannot reason about.
|
|
//
|
|
// `subscribe_workspace_readiness` already pushes phase transitions
|
|
// out-of-band; this is the IN-BAND counterpart. Every graph-querying
|
|
// tool call routed through wrapToolHandler is checked against the
|
|
// readiness broadcaster's last-published phase. While the daemon is
|
|
// warming up the call still runs (so the caller gets a best-effort
|
|
// partial answer from the portion of the graph already indexed) and
|
|
// the result is decorated with a structured `warming` block carrying
|
|
// a flag, a real progress percentage, the current phase, and a
|
|
// human-readable message. A caller can act on the partial answer or
|
|
// retry once `warming` clears.
|
|
//
|
|
// When ready (or in a single-process server with no warmup pipeline)
|
|
// this path is a transparent pass-through.
|
|
|
|
// warmupPhaseProgress maps each daemon warmup phase to a real
|
|
// completion percentage. The pipeline runs the phases in this strict
|
|
// order:
|
|
//
|
|
// snapshot_loaded → parallel_parse → parallel_parse_done → resolve →
|
|
// resolve_done → ready (queryable) → deferred_passes_all →
|
|
// deferred_passes_all_done → global_resolve → global_resolve_done →
|
|
// end_batch → end_batch_done → watcher_started → enrichment_complete
|
|
//
|
|
// The weights are cost-proportional rather than evenly spaced:
|
|
// parallel_parse (parsing every tracked repo off disk) dominates total
|
|
// warmup time, so it spans 15→55. `ready` fires at the resolve point —
|
|
// once references are queryable — so the warming envelope clears there;
|
|
// the phases past it report background-enrichment progress and carry
|
|
// ready:true. The numbers are derived from the phase the daemon actually
|
|
// reports — never a placeholder.
|
|
var warmupPhaseProgress = map[string]int{
|
|
"snapshot_loaded": 5,
|
|
"parallel_parse": 15,
|
|
"parallel_parse_done": 55,
|
|
"resolve": 57,
|
|
"resolve_done": 59,
|
|
"deferred_passes_all": 60,
|
|
"deferred_passes_all_done": 75,
|
|
"global_resolve": 78,
|
|
"global_resolve_done": 88,
|
|
"end_batch": 90,
|
|
"end_batch_done": 95,
|
|
"watcher_started": 98,
|
|
"ready": 100,
|
|
"enrichment_complete": 100,
|
|
}
|
|
|
|
// warmupExemptTools is the set of MCP tools that do NOT depend on the
|
|
// graph being fully warmed up, so the fast path leaves them entirely
|
|
// alone — no warming block, no decoration.
|
|
//
|
|
// Three groups, exhaustive:
|
|
//
|
|
// - Readiness / subscription tools: a caller polling warmup must
|
|
// reach these regardless of warmup state, or it could never learn
|
|
// the daemon is warming.
|
|
// - Session-control tools: planning-mode and session lifecycle
|
|
// never read the graph.
|
|
// - Discovery / indexing tools: tools_search walks the static tool
|
|
// catalog; index_repository / track_repository are the very
|
|
// operations that POPULATE the graph and must run during warmup.
|
|
//
|
|
// graph_stats is deliberately NOT exempt: it already surfaces a
|
|
// workspace_readiness block, and decorating it with the in-band
|
|
// warming flag keeps a single canonical signal across both shapes.
|
|
var warmupExemptTools = map[string]bool{
|
|
// Readiness / subscription channels.
|
|
"subscribe_workspace_readiness": true,
|
|
"unsubscribe_workspace_readiness": true,
|
|
"subscribe_diagnostics": true,
|
|
"unsubscribe_diagnostics": true,
|
|
"subscribe_daemon_health": true,
|
|
"unsubscribe_daemon_health": true,
|
|
"subscribe_stale_refs": true,
|
|
"unsubscribe_stale_refs": true,
|
|
"subscribe_graph_invalidated": true,
|
|
"unsubscribe_graph_invalidated": true,
|
|
// Session-control tools.
|
|
"set_planning_mode": true,
|
|
// Discovery / indexing tools — these run the graph build itself.
|
|
"tools_search": true,
|
|
"index_repository": true,
|
|
"track_repository": true,
|
|
}
|
|
|
|
// warmupState is the in-band snapshot of the daemon's warmup curve,
|
|
// derived from the readiness broadcaster's last-published phase.
|
|
type warmupState struct {
|
|
// known is false when no readiness phase has ever been published
|
|
// — a single-process `gortex server` (no warmup pipeline) or a
|
|
// daemon that has not yet reached its first phase. Callers treat
|
|
// !known as "not warming, proceed normally".
|
|
known bool
|
|
// ready is true once the daemon published the terminal `ready`
|
|
// phase. When ready, the fast path is a pass-through.
|
|
ready bool
|
|
// phase is the last-published warmup phase name.
|
|
phase string
|
|
// percent is the cost-proportional completion percentage in
|
|
// 0..100, derived from phase.
|
|
percent int
|
|
}
|
|
|
|
// warming reports whether a graph-querying tool should be decorated
|
|
// with the warming envelope — true only when a phase is known and the
|
|
// daemon has not yet reached `ready`.
|
|
func (w warmupState) warming() bool { return w.known && !w.ready }
|
|
|
|
// warmupStateFromSnapshot derives a warmupState from a readiness
|
|
// broadcaster payload (the `{phase, ready, ...}` map the broadcaster
|
|
// stores as last-known state). A nil snapshot means no phase has been
|
|
// published yet.
|
|
func warmupStateFromSnapshot(snap map[string]any) warmupState {
|
|
if snap == nil {
|
|
return warmupState{}
|
|
}
|
|
st := warmupState{known: true}
|
|
if phase, ok := snap["phase"].(string); ok {
|
|
st.phase = phase
|
|
}
|
|
if ready, ok := snap["ready"].(bool); ok {
|
|
st.ready = ready
|
|
}
|
|
// Percentage is derived from the published phase. An unrecognised
|
|
// phase (forward-compatibility: the daemon added a phase this
|
|
// build does not know) falls back to ready vs. mid-warmup
|
|
// extremes so the number is never a placeholder.
|
|
if pct, ok := warmupPhaseProgress[st.phase]; ok {
|
|
st.percent = pct
|
|
} else if st.ready {
|
|
st.percent = 100
|
|
} else {
|
|
st.percent = 1
|
|
}
|
|
// A daemon that published `ready` is fully warm regardless of
|
|
// which phase string carried the flag.
|
|
if st.ready {
|
|
st.percent = 100
|
|
}
|
|
return st
|
|
}
|
|
|
|
// warmupSnapshot returns the daemon's current warmup state as seen by
|
|
// the MCP server. nil broadcaster (single-process modes that never
|
|
// wire the publisher) yields a not-known state, so the fast path
|
|
// stays a pure pass-through there.
|
|
func (s *Server) warmupSnapshot() warmupState {
|
|
if s == nil || s.readinessBroadcaster == nil {
|
|
return warmupState{}
|
|
}
|
|
return warmupStateFromSnapshot(s.readinessBroadcaster.snapshot())
|
|
}
|
|
|
|
// warmupEnvelope is the structured `warming` block merged into a
|
|
// graph-querying tool's result while the daemon is still warming up.
|
|
// It is also the standalone body returned for tools that genuinely
|
|
// cannot produce a partial answer mid-warmup.
|
|
type warmupEnvelope struct {
|
|
// Warming is always true on an emitted envelope — it is the flag
|
|
// a caller branches on.
|
|
Warming bool `json:"warming"`
|
|
// Percent is the real warmup completion percentage (0..100).
|
|
Percent int `json:"percent"`
|
|
// Phase is the daemon's current warmup phase.
|
|
Phase string `json:"phase"`
|
|
// Retriable is always true: re-issuing the call once warmup
|
|
// completes yields the full answer.
|
|
Retriable bool `json:"retriable"`
|
|
// PartialResults is true when the decorated tool result still
|
|
// carries best-effort data from the portion of the graph already
|
|
// indexed; false on a standalone warming-only envelope.
|
|
PartialResults bool `json:"partial_results"`
|
|
// Message is a human-readable explanation for a caller that does
|
|
// not branch on the structured fields.
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// newWarmupEnvelope builds the warming block for a warmup state.
|
|
// partial flags whether the accompanying tool result still carries
|
|
// best-effort data (decoration path) or none (standalone path).
|
|
func newWarmupEnvelope(w warmupState, partial bool) warmupEnvelope {
|
|
var msg string
|
|
if partial {
|
|
msg = fmt.Sprintf(
|
|
"daemon is still warming up (phase %q, %d%% complete) — these results are partial, "+
|
|
"computed from the portion of the graph indexed so far; retry once warmup completes for the full answer",
|
|
w.phase, w.percent)
|
|
} else {
|
|
msg = fmt.Sprintf(
|
|
"daemon is still warming up (phase %q, %d%% complete) — retry this call once warmup completes",
|
|
w.phase, w.percent)
|
|
}
|
|
return warmupEnvelope{
|
|
Warming: true,
|
|
Percent: w.percent,
|
|
Phase: w.phase,
|
|
Retriable: true,
|
|
PartialResults: partial,
|
|
Message: msg,
|
|
}
|
|
}
|
|
|
|
// checkWarmupFastPath is the pre-handler hook wired into
|
|
// wrapToolHandler. It returns:
|
|
//
|
|
// - (envelope, true) when the caller should run the handler and
|
|
// then decorate its result with the warming block — the daemon is
|
|
// warming and the tool depends on the graph.
|
|
// - (zero, false) when the call should proceed untouched — the
|
|
// daemon is ready, no phase is known, or the tool is graph-
|
|
// independent.
|
|
//
|
|
// Unlike a hard gate, the warming case never blocks the handler: the
|
|
// handler runs against whatever the graph holds right now (a
|
|
// best-effort partial answer) and the decoration marks it partial.
|
|
func (s *Server) checkWarmupFastPath(toolName string) (warmupEnvelope, bool) {
|
|
if warmupExemptTools[toolName] {
|
|
return warmupEnvelope{}, false
|
|
}
|
|
w := s.warmupSnapshot()
|
|
if !w.warming() {
|
|
return warmupEnvelope{}, false
|
|
}
|
|
return newWarmupEnvelope(w, true), true
|
|
}
|
|
|
|
// decorateResultWithWarming merges the warming envelope into a tool
|
|
// result. The handler's result is a single JSON TextContent block (the
|
|
// shape every Gortex tool returns via respondJSONOrTOON / GCX
|
|
// encoders); decoration parses that JSON, attaches a top-level
|
|
// `warming` key, and re-marshals.
|
|
//
|
|
// Three result shapes are handled so no tool slips through unmarked:
|
|
//
|
|
// - JSON object → the `warming` key is added alongside existing keys.
|
|
// - JSON array / scalar → wrapped as {"warming": …, "results": <orig>}
|
|
// so the partial payload is preserved under a stable key.
|
|
// - empty / non-JSON / nil → replaced with a standalone warming-only
|
|
// envelope (the caller genuinely got nothing usable).
|
|
//
|
|
// An error result is decorated too: a structured tool error raised
|
|
// mid-warmup is very likely a consequence of the half-built graph, and
|
|
// the caller should see the retry signal rather than a bare failure.
|
|
func decorateResultWithWarming(res *mcp.CallToolResult, env warmupEnvelope) *mcp.CallToolResult {
|
|
text, ok := singleTextContent(res)
|
|
if !ok || text == "" {
|
|
// Nothing usable came back — emit a standalone warming-only
|
|
// envelope so the caller still gets flag + percent + phase.
|
|
empty := env
|
|
empty.PartialResults = false
|
|
empty.Message = fmt.Sprintf(
|
|
"daemon is still warming up (phase %q, %d%% complete) — no result could be produced yet; "+
|
|
"retry this call once warmup completes",
|
|
env.Phase, env.Percent)
|
|
body, mErr := json.Marshal(map[string]any{"warming": empty})
|
|
if mErr != nil {
|
|
return res
|
|
}
|
|
return rebuildTextResult(res, string(body))
|
|
}
|
|
|
|
var asObj map[string]any
|
|
if json.Unmarshal([]byte(text), &asObj) == nil {
|
|
// Don't clobber a `warming` key a handler set itself.
|
|
if _, exists := asObj["warming"]; !exists {
|
|
asObj["warming"] = env
|
|
}
|
|
body, mErr := json.Marshal(asObj)
|
|
if mErr != nil {
|
|
return res
|
|
}
|
|
return rebuildTextResult(res, string(body))
|
|
}
|
|
|
|
// Array / scalar / non-JSON (e.g. TOON, GCX) payload: wrap it so
|
|
// the original partial answer is preserved verbatim under a
|
|
// stable key while the warming flag rides alongside it.
|
|
var asAny any
|
|
if json.Unmarshal([]byte(text), &asAny) == nil {
|
|
body, mErr := json.Marshal(map[string]any{"warming": env, "results": asAny})
|
|
if mErr != nil {
|
|
return res
|
|
}
|
|
return rebuildTextResult(res, string(body))
|
|
}
|
|
body, mErr := json.Marshal(map[string]any{"warming": env, "results": text})
|
|
if mErr != nil {
|
|
return res
|
|
}
|
|
return rebuildTextResult(res, string(body))
|
|
}
|
|
|
|
// singleTextContent extracts the text of a result's single
|
|
// TextContent block. Returns ("", false) when the result is nil, has
|
|
// no content, or its first block is not text.
|
|
func singleTextContent(res *mcp.CallToolResult) (string, bool) {
|
|
if res == nil || len(res.Content) == 0 {
|
|
return "", false
|
|
}
|
|
tc, ok := mcp.AsTextContent(res.Content[0])
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return tc.Text, true
|
|
}
|
|
|
|
// rebuildTextResult returns a copy of res with its text content
|
|
// replaced by body, preserving the IsError flag. StructuredContent is
|
|
// dropped because it would otherwise disagree with the decorated text
|
|
// block — the text block is the canonical payload every Gortex tool
|
|
// ships.
|
|
func rebuildTextResult(res *mcp.CallToolResult, body string) *mcp.CallToolResult {
|
|
out := mcp.NewToolResultText(body)
|
|
if res != nil {
|
|
out.IsError = res.IsError
|
|
}
|
|
return out
|
|
}
|