mcp connection
This commit is contained in:
145
go-api/internal/mcpserver/tools.go
Normal file
145
go-api/internal/mcpserver/tools.go
Normal file
@@ -0,0 +1,145 @@
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user