package mcpserver import ( "sort" "github.com/krow/krow-backend/go-api/internal/tools" ) // Which tools this surface publishes, and why it is a rule rather than a list. // // The set is DERIVED from the registry on every call, not enumerated. A list // would be a promise someone has to keep: register a write tool tomorrow, // forget to update the list, and it ships to every connected client. A // derivation cannot forget. The only hand-maintained part is `deferred` below, // which names tools held back for a reason other than their effect — and // holding something back is the safe direction to be wrong in. // // Three conditions, all required: // // 1. Effect is read. I4's whole point is that a write is gated; a write // reachable over a surface with no confirmation round-trip is I4 defeated. // 2. RequiresConfirmation is false. Belt and braces: Register already forces // it true for a write, so this catches a READ tool that opted in — some // reads are expensive enough to be worth asking about — which this surface // has no way to ask about yet. // 3. Not in `deferred`. // deferred names tools held back for a reason that is not their effect. // // knowledge_search is read-only and still cannot ship. Its corpora come from // tools.Context.KnowledgeSources, which the agent loop fills from the running // agent's SPEC — deliberately, so that which documents may be read is not // something a model can choose. An MCP call has no spec, so the field is empty, // and retrieval refuses an empty source list rather than treating it as "all of // them". The tool would therefore fail every call; publishing it would advertise // a capability that cannot work. // // Making it work is a design decision, not an omission: either the connection // binds to an agent spec whose sources it inherits, or sources are derived from // the caller's org ACL (which needs a reindex). Passing them as a tool argument // is the one option that is ruled out, because that is exactly what the field's // placement in the spec exists to prevent. var deferred = map[string]string{ "knowledge_search": "corpora come from an agent spec, which an MCP call does not have", } // exposed returns the tools this surface publishes, sorted by name. // // Sorted because tools/list is a set, and a stable order makes it diffable in a // test and in a log. func exposed(reg *tools.Registry) []tools.ToolInfo { out := make([]tools.ToolInfo, 0, 16) for _, info := range reg.Catalogue() { if !isExposable(info) { continue } out = append(out, info) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // isExposable is the rule, in one place, so the list and the check cannot // disagree. func isExposable(info tools.ToolInfo) bool { if info.Effect != string(tools.EffectRead) { return false } if info.RequiresConfirmation { return false } if _, held := deferred[info.Name]; held { return false } return true } // isExposedName reports whether a tool may be called by name over this surface. // // tools/call consults this BEFORE the registry, so an unexposed tool answers // exactly as an unknown one does. The alternative — dispatching and letting // authorization refuse — would make "this tool exists but you may not reach it // here" distinguishable from "no such tool", which is an inventory of the // surface's own blind spots. func isExposedName(reg *tools.Registry, name string) bool { t, ok := reg.Get(name) if !ok { return false } return isExposable(tools.ToolInfo{ Name: t.Name, Effect: string(t.Effect), RequiresConfirmation: t.RequiresConfirmation, }) } /* ── MCP shapes ─────────────────────────────────────────────────────────── */ // mcpTool is one entry in a tools/list result. // // Every field is copied from the registry rather than restated. The annotations // are hints a client may show a person before approving a call; they are // derived from Effect so they cannot contradict it. type mcpTool struct { Name string `json:"name"` Description string `json:"description"` InputSchema map[string]any `json:"inputSchema"` Annotations *annotations `json:"annotations,omitempty"` } // annotations are the advisory hints from the MCP tool definition. type annotations struct { ReadOnlyHint bool `json:"readOnlyHint"` DestructiveHint bool `json:"destructiveHint"` } // emptySchema is what a tool with no declared schema publishes. // // tools/list requires an inputSchema per tool, and a client given `null` may // reasonably refuse the whole list. An object that accepts nothing is the // honest rendering of "this tool takes no arguments". func emptySchema() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{}, "additionalProperties": false, } } // toMCPTool converts a registry entry into its wire form. func toMCPTool(info tools.ToolInfo) mcpTool { schema := info.InputSchema if schema == nil { schema = emptySchema() } return mcpTool{ Name: info.Name, Description: info.Description, InputSchema: schema, Annotations: &annotations{ ReadOnlyHint: info.Effect == string(tools.EffectRead), DestructiveHint: info.Effect == string(tools.EffectWrite), }, } }