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() }