Files
krow_backend/go-api/internal/mcpserver/tools.go
Aravind f2aa3b3ad8
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
mcp connection
2026-09-22 10:58:02 +05:30

146 lines
5.3 KiB
Go

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