146 lines
5.3 KiB
Go
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),
|
|
},
|
|
}
|
|
}
|