161 lines
5.0 KiB
Go
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()
|
|
}
|