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

264 lines
9.9 KiB
Go

package mcp
import (
"context"
"fmt"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/daemon"
)
// Planning mode and the federation write-gate share one canonical
// write-tool set — daemon.MutatingTools — so a tool is hidden/blocked
// in a read-only session by exactly the same list that refuses to route
// it to a remote. See internal/daemon/mutating.go.
// sessionPlanningMode reports whether the request's session is in
// planning mode (no writes permitted).
func (s *Server) sessionPlanningMode(ctx context.Context) bool {
sess := s.sessionFor(ctx)
if sess == nil {
return false
}
sess.mu.Lock()
defer sess.mu.Unlock()
return sess.planningMode
}
// toolSurfaceFilter is the per-session tools/list filter wired into the
// MCP server. In planning mode it drops every editing tool so the agent
// never sees a tool it is not allowed to call.
// editingToolsHidden reports whether editing tools must be removed from
// this session's tool surface — either planning mode or a block-mode
// workflow currently in a non-editing phase.
func (s *Server) editingToolsHidden(ctx context.Context) bool {
return s.sessionPlanningMode(ctx) || s.workflowHidesEdits(ctx)
}
func (s *Server) toolSurfaceFilter(ctx context.Context, tools []mcp.Tool) []mcp.Tool {
if s.editingToolsHidden(ctx) {
kept := make([]mcp.Tool, 0, len(tools))
for _, t := range tools {
if daemon.MutatingTools[t.Name] {
continue
}
kept = append(kept, t)
}
tools = kept
}
// Per-session tool-surface preset: narrow (and, for a client asking for
// a wider surface than the daemon's eager set, widen) the list to the
// policy resolved for THIS connection (forwarded GORTEX_TOOLS / --tools,
// else the client-aware default, else the server's global preset).
tools = s.applySessionPreset(ctx, tools)
// Per-host adaptation: drop tools the host duplicates and apply any
// host-specific description overrides (see host_context.go).
return s.sessionHostContext(ctx).apply(tools)
}
// applySessionPreset shapes the tool list to the surface in force for this
// session. Two regimes:
//
// - No per-session override (the session's effective policy IS the
// server's global one): preserve the server's own behaviour — a defer
// preset leaves the already-lean eager set untouched (so promote-on-
// demand tools stay visible), a hide preset removes non-allowed tools.
// - A per-session override (a client forwarded GORTEX_TOOLS / --tools, or
// the client-aware default applied): this session chose its own
// surface, so narrow to exactly the tools its policy allows AND widen to
// any tools the daemon deferred that the policy allows — a client asking
// for `full`/`nav` over a `core` daemon must still see them. Widened
// tools stay callable because a tools/call promotes a deferred tool by
// name before dispatch.
func (s *Server) applySessionPreset(ctx context.Context, tools []mcp.Tool) []mcp.Tool {
p := s.effectiveSessionPolicy(ctx)
shaped := s.shapeToolSurface(tools, p)
// The lean (`agent`) preset additionally compacts every parameter
// description on THIS session's view so the coding-agent tools/list
// stays inside its byte ceiling. Deep-copied — the shared schema is
// never mutated.
if p.lean {
shaped = leanizeAgentTools(shaped)
}
return shaped
}
// shapeToolSurface narrows / widens the tool list to the session policy
// (see applySessionPreset for the two regimes).
func (s *Server) shapeToolSurface(tools []mcp.Tool, p *toolPolicy) []mcp.Tool {
override := p != s.toolPolicy
// A non-lean global preset preserves the server's own behaviour: a defer
// preset leaves the eager set alone (so promote-on-demand tools stay
// visible), a hide preset removes non-allowed tools. The lean `agent`
// preset always presents a strict roster — it drops even tools that were
// force-registered live outside the lazy registry (preview_edit,
// simulate_chain), so a coding agent's cold surface is exactly its set.
if !override && !p.lean {
if !p.hideMode() {
return tools
}
return narrowToPolicy(tools, p)
}
kept := make([]mcp.Tool, 0, len(tools))
for _, t := range tools {
if s.sessionAllows(p, t.Name) {
kept = append(kept, t)
}
}
// Widen with the deferred catalogue's finished (scrubbed + budget-
// annotated) schemas for the tools this policy allows but the daemon held
// back under its global preset.
if s.lazy.Enabled() {
present := make(map[string]bool, len(tools))
for _, t := range tools {
present[t.Name] = true
}
widened := false
for _, name := range s.lazy.DeferredNames() {
if present[name] || !s.sessionAllows(p, name) {
continue
}
if dt, ok := s.lazy.DeferredTool(name); ok {
kept = append(kept, dt)
widened = true
}
}
if widened {
sort.Slice(kept, func(i, j int) bool { return kept[i].Name < kept[j].Name })
}
}
return kept
}
// sessionAllows reports whether a tool belongs on this session's surface:
// the policy's own allow-set, plus — on the lean agent surface — any tool in
// the per-workspace LEARNED set (a deferred tool the team promoted through
// use, which would otherwise be narrowed out of the strict agent roster).
func (s *Server) sessionAllows(p *toolPolicy, name string) bool {
if p.allows(name) {
return true
}
return p.lean && s.isLearnedPromoted(name)
}
// narrowToPolicy keeps only the tools the policy allows, preserving order.
func narrowToPolicy(tools []mcp.Tool, p *toolPolicy) []mcp.Tool {
kept := make([]mcp.Tool, 0, len(tools))
for _, t := range tools {
if p.allows(t.Name) {
kept = append(kept, t)
}
}
return kept
}
// checkToolGate returns a structured error result when toolName must not
// run given the session's runtime mode, or nil when the call may
// proceed. This is the hard guarantee behind planning mode: even a
// client that never re-read tools/list cannot slip an edit through.
func (s *Server) checkToolGate(ctx context.Context, toolName string) *mcp.CallToolResult {
if blocked := s.checkPlanningModeGate(ctx, toolName); blocked != nil {
return blocked
}
if blocked := s.checkWorkflowGate(ctx, toolName); blocked != nil {
return blocked
}
if blocked := s.checkToolPresetGate(ctx, toolName); blocked != nil {
return blocked
}
return nil
}
// checkToolPresetGate hard-blocks calls to tools outside the active
// hide-mode preset, so a client that hard-codes a hidden tool name can't
// bypass the restricted surface. The preset is resolved per session
// (forwarded GORTEX_TOOLS / --tools, else the client-aware default, else
// the server global) so a client that scoped its own pipe to a hide-mode
// preset is enforced on ITS calls without affecting other sessions. Defer
// mode needs no gate — non-allowed tools simply aren't registered live
// until a call by name (or tools_search) promotes them.
func (s *Server) checkToolPresetGate(ctx context.Context, toolName string) *mcp.CallToolResult {
p := s.effectiveSessionPolicy(ctx)
if !p.hideMode() || p.allows(toolName) {
return nil
}
return NewStructuredErrorResult(StructuredError{
ErrorCode: ErrCodeToolBlockedByMode,
Message: fmt.Sprintf("%q is not part of the active tool preset %q — it has been removed from this "+
"server's tool surface. Call tool_profile to see the available tools.", toolName, p.preset),
Data: map[string]any{"tool": toolName, "preset": p.preset},
})
}
// checkPlanningModeGate blocks editing tools while the session is in
// planning mode.
func (s *Server) checkPlanningModeGate(ctx context.Context, toolName string) *mcp.CallToolResult {
if !daemon.IsMutating(toolName) {
return nil
}
if !s.sessionPlanningMode(ctx) {
return nil
}
return NewStructuredErrorResult(StructuredError{
ErrorCode: ErrCodeToolBlockedByMode,
Message: fmt.Sprintf("%q is an editing tool and this session is in planning mode — no writes are "+
"permitted. Call set_planning_mode with mode \"editing\" to enable edits.", toolName),
Retriable: true,
Data: map[string]any{"tool": toolName, "mode": "planning"},
})
}
// registerPlanningModeTool registers set_planning_mode — the runtime
// switch between a guaranteed no-writes planning phase and normal editing.
func (s *Server) registerPlanningModeTool() {
s.addTool(mcp.NewTool("set_planning_mode",
mcp.WithDescription("Switch this session between \"planning\" mode — every editing tool "+
"(edit_file, edit_symbol, write_file, rename_symbol) is removed from the tool surface and "+
"hard-blocked, a guaranteed no-writes phase — and \"editing\" mode, where edits are enabled. "+
"Use planning mode while exploring or drafting a change so no accidental writes happen."),
mcp.WithString("mode", mcp.Required(),
mcp.Description("\"planning\" (editing tools removed and blocked) or \"editing\" (edits enabled)")),
), s.handleSetPlanningMode)
}
func (s *Server) handleSetPlanningMode(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
raw, err := req.RequireString("mode")
if err != nil {
return mcp.NewToolResultError("mode is required (\"planning\" or \"editing\")"), nil
}
var planning bool
switch strings.ToLower(strings.TrimSpace(raw)) {
case "planning", "plan", "read-only", "readonly":
planning = true
case "editing", "edit", "write":
planning = false
default:
return NewStructuredErrorResult(StructuredError{
ErrorCode: ErrCodeInvalidArgument,
Message: fmt.Sprintf("unknown mode %q — want \"planning\" or \"editing\"", raw),
}), nil
}
sess := s.sessionFor(ctx)
sess.mu.Lock()
sess.planningMode = planning
sess.mu.Unlock()
mode := "editing"
note := "Editing tools are enabled."
if planning {
mode = "planning"
note = "Editing tools are removed from the tool surface and hard-blocked. " +
"Re-read tools/list to refresh the surface."
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"mode": mode,
"editing_enabled": !planning,
"editing_tools": daemon.SortedMutatingTools(),
"note": note,
})
}