207 lines
6.9 KiB
Go
207 lines
6.9 KiB
Go
package wasmguest
|
|
|
|
import (
|
|
"encoding/json"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
|
|
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
|
"git.dev.alexdunmow.com/block/pluginsdk/rbac"
|
|
"google.golang.org/protobuf/reflect/protoreflect"
|
|
"google.golang.org/protobuf/reflect/protoregistry"
|
|
)
|
|
|
|
// captureMCPTools resolves the plugin's registered Connect procedures through
|
|
// the protobuf registry compiled into the guest. It emits only unary methods
|
|
// that have an explicit RBAC mapping; the host repeats that RBAC intersection
|
|
// before adding the definitions to its live MCP catalogue.
|
|
func captureMCPTools(methodRoles map[string]rbac.Role) []*abiv1.MCPToolDescriptor {
|
|
procedures := make([]string, 0, len(methodRoles))
|
|
for procedure := range methodRoles {
|
|
procedures = append(procedures, procedure)
|
|
}
|
|
sort.Strings(procedures)
|
|
|
|
tools := make([]*abiv1.MCPToolDescriptor, 0, len(procedures))
|
|
for _, procedure := range procedures {
|
|
serviceName, methodName, ok := splitConnectProcedure(procedure)
|
|
if !ok {
|
|
continue
|
|
}
|
|
desc, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(serviceName))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
service, ok := desc.(protoreflect.ServiceDescriptor)
|
|
if !ok {
|
|
continue
|
|
}
|
|
method := service.Methods().ByName(protoreflect.Name(methodName))
|
|
if method == nil || method.IsStreamingClient() || method.IsStreamingServer() {
|
|
continue
|
|
}
|
|
|
|
schema, err := json.Marshal(schemaForMessage(method.Input(), map[protoreflect.FullName]bool{}))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
description, documentation := methodDocumentation(method)
|
|
tools = append(tools, &abiv1.MCPToolDescriptor{
|
|
Procedure: procedure,
|
|
ServiceFullName: serviceName,
|
|
MethodName: methodName,
|
|
Description: description,
|
|
Documentation: documentation,
|
|
InputSchemaJson: schema,
|
|
})
|
|
}
|
|
return tools
|
|
}
|
|
|
|
func splitConnectProcedure(procedure string) (serviceName, methodName string, ok bool) {
|
|
trimmed := strings.TrimPrefix(strings.TrimSpace(procedure), "/")
|
|
serviceName, methodName, ok = strings.Cut(trimmed, "/")
|
|
return serviceName, methodName, ok && serviceName != "" && methodName != "" && !strings.Contains(methodName, "/")
|
|
}
|
|
|
|
func methodDocumentation(method protoreflect.MethodDescriptor) (description, documentation string) {
|
|
location := method.ParentFile().SourceLocations().ByDescriptor(method)
|
|
documentation = strings.TrimSpace(location.LeadingComments)
|
|
paragraph := documentation
|
|
if before, _, ok := strings.Cut(paragraph, "\n\n"); ok {
|
|
paragraph = before
|
|
}
|
|
paragraph = strings.TrimSpace(strings.ReplaceAll(paragraph, "\n", " "))
|
|
methodName := string(method.Name())
|
|
if after, ok := strings.CutPrefix(paragraph, methodName+" "); ok {
|
|
paragraph = strings.TrimSpace(after)
|
|
if paragraph != "" {
|
|
runes := []rune(paragraph)
|
|
runes[0] = unicode.ToUpper(runes[0])
|
|
paragraph = string(runes)
|
|
}
|
|
}
|
|
if paragraph == "" {
|
|
paragraph = "Calls " + string(method.FullName()) + "."
|
|
}
|
|
return paragraph, documentation
|
|
}
|
|
|
|
func schemaForMessage(message protoreflect.MessageDescriptor, visited map[protoreflect.FullName]bool) map[string]any {
|
|
object := map[string]any{"type": "object"}
|
|
if message == nil || visited[message.FullName()] {
|
|
return object
|
|
}
|
|
visited[message.FullName()] = true
|
|
defer delete(visited, message.FullName())
|
|
|
|
properties := make(map[string]any, message.Fields().Len())
|
|
for i := range message.Fields().Len() {
|
|
field := message.Fields().Get(i)
|
|
properties[string(field.Name())] = fieldSchema(field, visited)
|
|
}
|
|
if len(properties) > 0 {
|
|
object["properties"] = properties
|
|
}
|
|
return object
|
|
}
|
|
|
|
func fieldSchema(field protoreflect.FieldDescriptor, visited map[protoreflect.FullName]bool) map[string]any {
|
|
var schema map[string]any
|
|
switch {
|
|
case field.IsMap():
|
|
schema = map[string]any{
|
|
"type": "object",
|
|
"additionalProperties": singularFieldSchema(field.MapValue(), visited),
|
|
}
|
|
case field.IsList():
|
|
schema = map[string]any{"type": "array", "items": singularFieldSchema(field, visited)}
|
|
default:
|
|
schema = singularFieldSchema(field, visited)
|
|
}
|
|
|
|
location := field.ParentFile().SourceLocations().ByDescriptor(field)
|
|
if comment := strings.TrimSpace(strings.ReplaceAll(location.LeadingComments, "\n", " ")); comment != "" {
|
|
schema["description"] = comment
|
|
}
|
|
return schema
|
|
}
|
|
|
|
func singularFieldSchema(field protoreflect.FieldDescriptor, visited map[protoreflect.FullName]bool) map[string]any {
|
|
switch field.Kind() {
|
|
case protoreflect.MessageKind, protoreflect.GroupKind:
|
|
return wellKnownOrMessage(field.Message(), visited)
|
|
case protoreflect.EnumKind:
|
|
return enumSchema(field.Enum())
|
|
default:
|
|
return scalarSchema(field.Kind())
|
|
}
|
|
}
|
|
|
|
func scalarSchema(kind protoreflect.Kind) map[string]any {
|
|
switch kind {
|
|
case protoreflect.BoolKind:
|
|
return map[string]any{"type": "boolean"}
|
|
case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind,
|
|
protoreflect.Uint32Kind, protoreflect.Fixed32Kind,
|
|
protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind,
|
|
protoreflect.Uint64Kind, protoreflect.Fixed64Kind:
|
|
return map[string]any{"type": "integer"}
|
|
case protoreflect.FloatKind, protoreflect.DoubleKind:
|
|
return map[string]any{"type": "number"}
|
|
case protoreflect.StringKind:
|
|
return map[string]any{"type": "string"}
|
|
case protoreflect.BytesKind:
|
|
return map[string]any{"type": "string", "contentEncoding": "base64"}
|
|
default:
|
|
return map[string]any{}
|
|
}
|
|
}
|
|
|
|
func enumSchema(enum protoreflect.EnumDescriptor) map[string]any {
|
|
schema := map[string]any{"type": "string"}
|
|
if enum == nil {
|
|
return schema
|
|
}
|
|
values := make([]any, 0, enum.Values().Len())
|
|
for i := range enum.Values().Len() {
|
|
values = append(values, string(enum.Values().Get(i).Name()))
|
|
}
|
|
if len(values) > 0 {
|
|
schema["enum"] = values
|
|
}
|
|
return schema
|
|
}
|
|
|
|
func wellKnownOrMessage(message protoreflect.MessageDescriptor, visited map[protoreflect.FullName]bool) map[string]any {
|
|
if message == nil {
|
|
return map[string]any{"type": "object"}
|
|
}
|
|
switch message.FullName() {
|
|
case "google.protobuf.Timestamp":
|
|
return map[string]any{"type": "string", "format": "date-time"}
|
|
case "google.protobuf.Duration", "google.protobuf.FieldMask":
|
|
return map[string]any{"type": "string"}
|
|
case "google.protobuf.Struct":
|
|
return map[string]any{"type": "object"}
|
|
case "google.protobuf.ListValue":
|
|
return map[string]any{"type": "array"}
|
|
case "google.protobuf.Value", "google.protobuf.Any":
|
|
return map[string]any{}
|
|
case "google.protobuf.Empty":
|
|
return map[string]any{"type": "object"}
|
|
case "google.protobuf.StringValue", "google.protobuf.BytesValue":
|
|
return map[string]any{"type": "string"}
|
|
case "google.protobuf.BoolValue":
|
|
return map[string]any{"type": "boolean"}
|
|
case "google.protobuf.Int32Value", "google.protobuf.UInt32Value",
|
|
"google.protobuf.Int64Value", "google.protobuf.UInt64Value":
|
|
return map[string]any{"type": "integer"}
|
|
case "google.protobuf.FloatValue", "google.protobuf.DoubleValue":
|
|
return map[string]any{"type": "number"}
|
|
default:
|
|
return schemaForMessage(message, visited)
|
|
}
|
|
}
|