cli/internal/bnp/mcp_tools.go
2026-08-10 22:49:09 +08:00

161 lines
5.0 KiB
Go

package bnp
import (
"encoding/json"
"fmt"
"strings"
"unicode"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
)
// ValidateMCPManifest enforces the descriptor/runtime contract shared with the
// CMS before an artifact can be packed or accepted by `ninja plugin verify`.
func ValidateMCPManifest(manifest *abiv1.PluginManifest, registryScope, pluginName string) error {
descriptors := manifest.GetMcpTools()
if len(descriptors) == 0 {
return nil
}
if !manifest.GetHasHttpHandler() {
return fmt.Errorf("bnp: MCP descriptors require has_http_handler so tools are callable at runtime")
}
seenProcedures := make(map[string]bool, len(descriptors))
seenToolNames := make(map[string]bool, len(descriptors))
for _, descriptor := range descriptors {
if descriptor == nil {
return fmt.Errorf("bnp: MCP descriptor is nil")
}
procedure := strings.TrimSpace(descriptor.GetProcedure())
serviceName, methodName, ok := splitConnectProcedure(procedure)
if !ok || serviceName != descriptor.GetServiceFullName() || methodName != descriptor.GetMethodName() {
return fmt.Errorf("bnp: MCP descriptor has inconsistent procedure %q", procedure)
}
if seenProcedures[procedure] {
return fmt.Errorf("bnp: duplicate MCP procedure %q", procedure)
}
seenProcedures[procedure] = true
role, mapped := manifest.GetRbacMethodRoles()[procedure]
if !mapped || !supportedMCPRole(role) {
return fmt.Errorf("bnp: MCP procedure %q has no supported RBAC role", procedure)
}
if !validMCPObjectSchema(descriptor.GetInputSchemaJson()) {
return fmt.Errorf("bnp: MCP procedure %q has an invalid input schema", procedure)
}
toolName, err := mcpToolName(registryScope, pluginName, serviceName, methodName)
if err != nil {
return fmt.Errorf("bnp: MCP procedure %q: %w", procedure, err)
}
if seenToolNames[toolName] {
return fmt.Errorf("bnp: MCP procedures produce duplicate tool name %q", toolName)
}
seenToolNames[toolName] = true
}
return nil
}
func supportedMCPRole(role string) bool {
switch role {
case "", "public", "viewer", "admin", "superadmin":
return true
default:
return false
}
}
func validMCPObjectSchema(raw []byte) bool {
var schema map[string]any
if json.Unmarshal(raw, &schema) != nil {
return false
}
typeName, _ := schema["type"].(string)
return typeName == "object"
}
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 mcpToolName(registryScope, pluginName, serviceFullName, methodName string) (string, error) {
scope := mcpIdentifier(strings.TrimPrefix(strings.TrimSpace(registryScope), "@"))
plugin := mcpIdentifier(pluginName)
if scope == "" || plugin == "" {
return "", fmt.Errorf("registry scope and plugin name must be non-empty MCP identifiers")
}
parts := strings.Split(serviceFullName, ".")
if len(parts) < 2 {
return "", fmt.Errorf("service %q has no protobuf package", serviceFullName)
}
apiMajor := parts[len(parts)-2]
if !validAPIMajor(apiMajor) {
return "", fmt.Errorf("service %q protobuf package must end in an API major such as v1", serviceFullName)
}
service := mcpIdentifier(strings.TrimSuffix(parts[len(parts)-1], "Service"))
if service == "" {
return "", fmt.Errorf("service %q has no service qualifier", serviceFullName)
}
domain := "plugin_" + scope + "_" + plugin + "_" + apiMajor
if service != plugin {
domain += "_" + service
}
toolName := domain + "_" + mcpIdentifier(methodName)
if len(toolName) > 128 {
return "", fmt.Errorf("tool name %q exceeds MCP's 128-byte limit", toolName)
}
return toolName, nil
}
func validAPIMajor(value string) bool {
if len(value) < 2 || value[0] != 'v' {
return false
}
for _, character := range value[1:] {
if character < '0' || character > '9' {
return false
}
}
return true
}
func mcpIdentifier(value string) string {
value = snakeIdentifier(strings.TrimSpace(value))
var builder strings.Builder
previousSeparator := false
for _, character := range value {
if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') {
builder.WriteRune(character)
previousSeparator = false
continue
}
if builder.Len() > 0 && !previousSeparator {
builder.WriteByte('_')
previousSeparator = true
}
}
return strings.TrimSuffix(builder.String(), "_")
}
func snakeIdentifier(value string) string {
var builder strings.Builder
runes := []rune(value)
for index, current := range runes {
if unicode.IsUpper(current) {
previousLower := index > 0 && unicode.IsLower(runes[index-1])
nextLower := index+1 < len(runes) && unicode.IsLower(runes[index+1])
if index > 0 && (previousLower || nextLower) {
builder.WriteByte('_')
}
builder.WriteRune(unicode.ToLower(current))
continue
}
builder.WriteRune(current)
}
return builder.String()
}