diff --git a/abi/proto/v1/manifest.proto b/abi/proto/v1/manifest.proto index 15a564d..325799b 100644 --- a/abi/proto/v1/manifest.proto +++ b/abi/proto/v1/manifest.proto @@ -156,6 +156,26 @@ message PluginManifest { // HTTP handler) and merges them into the site sitemap. Requires // has_http_handler. bool sitemap = 37; + + // mcp_tools is the descriptor-derived MCP surface for the plugin's own + // unary ConnectRPC methods. The Go guest binding captures these during + // DESCRIBE from the compiled protobuf descriptors; plugin authors do not + // duplicate schemas or procedure metadata by hand. The host still checks + // every procedure against rbac_method_roles before advertising or invoking + // it, so descriptor metadata cannot widen the plugin's authorization map. + repeated MCPToolDescriptor mcp_tools = 38; +} + +// MCPToolDescriptor is one unary ConnectRPC method prepared for the host MCP +// catalogue. Procedure, service, method, documentation, and the request JSON +// schema are all derived from protobuf reflection during DESCRIBE. +message MCPToolDescriptor { + string procedure = 1; + string service_full_name = 2; + string method_name = 3; + string description = 4; + string documentation = 5; + bytes input_schema_json = 6; } // PublicRoute is one site-root path claim (see PluginManifest.public_routes). diff --git a/abi/v1/manifest.pb.go b/abi/v1/manifest.pb.go index 7583f2d..1547af6 100644 --- a/abi/v1/manifest.pb.go +++ b/abi/v1/manifest.pb.go @@ -149,7 +149,14 @@ type PluginManifest struct { // entries from the plugin's well-known sitemap endpoint (served by its // HTTP handler) and merges them into the site sitemap. Requires // has_http_handler. - Sitemap bool `protobuf:"varint,37,opt,name=sitemap,proto3" json:"sitemap,omitempty"` + Sitemap bool `protobuf:"varint,37,opt,name=sitemap,proto3" json:"sitemap,omitempty"` + // mcp_tools is the descriptor-derived MCP surface for the plugin's own + // unary ConnectRPC methods. The Go guest binding captures these during + // DESCRIBE from the compiled protobuf descriptors; plugin authors do not + // duplicate schemas or procedure metadata by hand. The host still checks + // every procedure against rbac_method_roles before advertising or invoking + // it, so descriptor metadata cannot widen the plugin's authorization map. + McpTools []*MCPToolDescriptor `protobuf:"bytes,38,rep,name=mcp_tools,json=mcpTools,proto3" json:"mcp_tools,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -443,6 +450,100 @@ func (x *PluginManifest) GetSitemap() bool { return false } +func (x *PluginManifest) GetMcpTools() []*MCPToolDescriptor { + if x != nil { + return x.McpTools + } + return nil +} + +// MCPToolDescriptor is one unary ConnectRPC method prepared for the host MCP +// catalogue. Procedure, service, method, documentation, and the request JSON +// schema are all derived from protobuf reflection during DESCRIBE. +type MCPToolDescriptor struct { + state protoimpl.MessageState `protogen:"open.v1"` + Procedure string `protobuf:"bytes,1,opt,name=procedure,proto3" json:"procedure,omitempty"` + ServiceFullName string `protobuf:"bytes,2,opt,name=service_full_name,json=serviceFullName,proto3" json:"service_full_name,omitempty"` + MethodName string `protobuf:"bytes,3,opt,name=method_name,json=methodName,proto3" json:"method_name,omitempty"` + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` + Documentation string `protobuf:"bytes,5,opt,name=documentation,proto3" json:"documentation,omitempty"` + InputSchemaJson []byte `protobuf:"bytes,6,opt,name=input_schema_json,json=inputSchemaJson,proto3" json:"input_schema_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MCPToolDescriptor) Reset() { + *x = MCPToolDescriptor{} + mi := &file_v1_manifest_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MCPToolDescriptor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MCPToolDescriptor) ProtoMessage() {} + +func (x *MCPToolDescriptor) ProtoReflect() protoreflect.Message { + mi := &file_v1_manifest_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MCPToolDescriptor.ProtoReflect.Descriptor instead. +func (*MCPToolDescriptor) Descriptor() ([]byte, []int) { + return file_v1_manifest_proto_rawDescGZIP(), []int{1} +} + +func (x *MCPToolDescriptor) GetProcedure() string { + if x != nil { + return x.Procedure + } + return "" +} + +func (x *MCPToolDescriptor) GetServiceFullName() string { + if x != nil { + return x.ServiceFullName + } + return "" +} + +func (x *MCPToolDescriptor) GetMethodName() string { + if x != nil { + return x.MethodName + } + return "" +} + +func (x *MCPToolDescriptor) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *MCPToolDescriptor) GetDocumentation() string { + if x != nil { + return x.Documentation + } + return "" +} + +func (x *MCPToolDescriptor) GetInputSchemaJson() []byte { + if x != nil { + return x.InputSchemaJson + } + return nil +} + // PublicRoute is one site-root path claim (see PluginManifest.public_routes). type PublicRoute struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -459,7 +560,7 @@ type PublicRoute struct { func (x *PublicRoute) Reset() { *x = PublicRoute{} - mi := &file_v1_manifest_proto_msgTypes[1] + mi := &file_v1_manifest_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -471,7 +572,7 @@ func (x *PublicRoute) String() string { func (*PublicRoute) ProtoMessage() {} func (x *PublicRoute) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[1] + mi := &file_v1_manifest_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -484,7 +585,7 @@ func (x *PublicRoute) ProtoReflect() protoreflect.Message { // Deprecated: Use PublicRoute.ProtoReflect.Descriptor instead. func (*PublicRoute) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{1} + return file_v1_manifest_proto_rawDescGZIP(), []int{2} } func (x *PublicRoute) GetPath() string { @@ -513,7 +614,7 @@ type Dependency struct { func (x *Dependency) Reset() { *x = Dependency{} - mi := &file_v1_manifest_proto_msgTypes[2] + mi := &file_v1_manifest_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -525,7 +626,7 @@ func (x *Dependency) String() string { func (*Dependency) ProtoMessage() {} func (x *Dependency) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[2] + mi := &file_v1_manifest_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -538,7 +639,7 @@ func (x *Dependency) ProtoReflect() protoreflect.Message { // Deprecated: Use Dependency.ProtoReflect.Descriptor instead. func (*Dependency) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{2} + return file_v1_manifest_proto_rawDescGZIP(), []int{3} } func (x *Dependency) GetPlugin() string { @@ -581,7 +682,7 @@ type BlockMeta struct { func (x *BlockMeta) Reset() { *x = BlockMeta{} - mi := &file_v1_manifest_proto_msgTypes[3] + mi := &file_v1_manifest_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -593,7 +694,7 @@ func (x *BlockMeta) String() string { func (*BlockMeta) ProtoMessage() {} func (x *BlockMeta) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[3] + mi := &file_v1_manifest_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -606,7 +707,7 @@ func (x *BlockMeta) ProtoReflect() protoreflect.Message { // Deprecated: Use BlockMeta.ProtoReflect.Descriptor instead. func (*BlockMeta) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{3} + return file_v1_manifest_proto_rawDescGZIP(), []int{4} } func (x *BlockMeta) GetKey() string { @@ -672,7 +773,7 @@ type BlockTemplateOverride struct { func (x *BlockTemplateOverride) Reset() { *x = BlockTemplateOverride{} - mi := &file_v1_manifest_proto_msgTypes[4] + mi := &file_v1_manifest_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -684,7 +785,7 @@ func (x *BlockTemplateOverride) String() string { func (*BlockTemplateOverride) ProtoMessage() {} func (x *BlockTemplateOverride) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[4] + mi := &file_v1_manifest_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -697,7 +798,7 @@ func (x *BlockTemplateOverride) ProtoReflect() protoreflect.Message { // Deprecated: Use BlockTemplateOverride.ProtoReflect.Descriptor instead. func (*BlockTemplateOverride) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{4} + return file_v1_manifest_proto_rawDescGZIP(), []int{5} } func (x *BlockTemplateOverride) GetTemplateKey() string { @@ -733,7 +834,7 @@ type SystemTemplateMeta struct { func (x *SystemTemplateMeta) Reset() { *x = SystemTemplateMeta{} - mi := &file_v1_manifest_proto_msgTypes[5] + mi := &file_v1_manifest_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -745,7 +846,7 @@ func (x *SystemTemplateMeta) String() string { func (*SystemTemplateMeta) ProtoMessage() {} func (x *SystemTemplateMeta) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[5] + mi := &file_v1_manifest_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -758,7 +859,7 @@ func (x *SystemTemplateMeta) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemTemplateMeta.ProtoReflect.Descriptor instead. func (*SystemTemplateMeta) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{5} + return file_v1_manifest_proto_rawDescGZIP(), []int{6} } func (x *SystemTemplateMeta) GetKey() string { @@ -797,7 +898,7 @@ type PageTemplateMeta struct { func (x *PageTemplateMeta) Reset() { *x = PageTemplateMeta{} - mi := &file_v1_manifest_proto_msgTypes[6] + mi := &file_v1_manifest_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -809,7 +910,7 @@ func (x *PageTemplateMeta) String() string { func (*PageTemplateMeta) ProtoMessage() {} func (x *PageTemplateMeta) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[6] + mi := &file_v1_manifest_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -822,7 +923,7 @@ func (x *PageTemplateMeta) ProtoReflect() protoreflect.Message { // Deprecated: Use PageTemplateMeta.ProtoReflect.Descriptor instead. func (*PageTemplateMeta) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{6} + return file_v1_manifest_proto_rawDescGZIP(), []int{7} } func (x *PageTemplateMeta) GetSystemKey() string { @@ -876,7 +977,7 @@ type AdminPage struct { func (x *AdminPage) Reset() { *x = AdminPage{} - mi := &file_v1_manifest_proto_msgTypes[7] + mi := &file_v1_manifest_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -888,7 +989,7 @@ func (x *AdminPage) String() string { func (*AdminPage) ProtoMessage() {} func (x *AdminPage) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[7] + mi := &file_v1_manifest_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -901,7 +1002,7 @@ func (x *AdminPage) ProtoReflect() protoreflect.Message { // Deprecated: Use AdminPage.ProtoReflect.Descriptor instead. func (*AdminPage) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{7} + return file_v1_manifest_proto_rawDescGZIP(), []int{8} } func (x *AdminPage) GetKey() string { @@ -953,7 +1054,7 @@ type AiAction struct { func (x *AiAction) Reset() { *x = AiAction{} - mi := &file_v1_manifest_proto_msgTypes[8] + mi := &file_v1_manifest_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -965,7 +1066,7 @@ func (x *AiAction) String() string { func (*AiAction) ProtoMessage() {} func (x *AiAction) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[8] + mi := &file_v1_manifest_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -978,7 +1079,7 @@ func (x *AiAction) ProtoReflect() protoreflect.Message { // Deprecated: Use AiAction.ProtoReflect.Descriptor instead. func (*AiAction) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{8} + return file_v1_manifest_proto_rawDescGZIP(), []int{9} } func (x *AiAction) GetKey() string { @@ -1028,7 +1129,7 @@ type CssManifest struct { func (x *CssManifest) Reset() { *x = CssManifest{} - mi := &file_v1_manifest_proto_msgTypes[9] + mi := &file_v1_manifest_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1040,7 +1141,7 @@ func (x *CssManifest) String() string { func (*CssManifest) ProtoMessage() {} func (x *CssManifest) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[9] + mi := &file_v1_manifest_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1053,7 +1154,7 @@ func (x *CssManifest) ProtoReflect() protoreflect.Message { // Deprecated: Use CssManifest.ProtoReflect.Descriptor instead. func (*CssManifest) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{9} + return file_v1_manifest_proto_rawDescGZIP(), []int{10} } func (x *CssManifest) GetNpmPackages() map[string]string { @@ -1094,7 +1195,7 @@ type DirectoryExtensions struct { func (x *DirectoryExtensions) Reset() { *x = DirectoryExtensions{} - mi := &file_v1_manifest_proto_msgTypes[10] + mi := &file_v1_manifest_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1106,7 +1207,7 @@ func (x *DirectoryExtensions) String() string { func (*DirectoryExtensions) ProtoMessage() {} func (x *DirectoryExtensions) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[10] + mi := &file_v1_manifest_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1119,7 +1220,7 @@ func (x *DirectoryExtensions) ProtoReflect() protoreflect.Message { // Deprecated: Use DirectoryExtensions.ProtoReflect.Descriptor instead. func (*DirectoryExtensions) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{10} + return file_v1_manifest_proto_rawDescGZIP(), []int{11} } func (x *DirectoryExtensions) GetBooleanFilterFields() []string { @@ -1168,7 +1269,7 @@ type BadgeLabel struct { func (x *BadgeLabel) Reset() { *x = BadgeLabel{} - mi := &file_v1_manifest_proto_msgTypes[11] + mi := &file_v1_manifest_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1180,7 +1281,7 @@ func (x *BadgeLabel) String() string { func (*BadgeLabel) ProtoMessage() {} func (x *BadgeLabel) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[11] + mi := &file_v1_manifest_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1193,7 +1294,7 @@ func (x *BadgeLabel) ProtoReflect() protoreflect.Message { // Deprecated: Use BadgeLabel.ProtoReflect.Descriptor instead. func (*BadgeLabel) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{11} + return file_v1_manifest_proto_rawDescGZIP(), []int{12} } func (x *BadgeLabel) GetPositive() string { @@ -1223,7 +1324,7 @@ type MasterPageDefinition struct { func (x *MasterPageDefinition) Reset() { *x = MasterPageDefinition{} - mi := &file_v1_manifest_proto_msgTypes[12] + mi := &file_v1_manifest_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1235,7 +1336,7 @@ func (x *MasterPageDefinition) String() string { func (*MasterPageDefinition) ProtoMessage() {} func (x *MasterPageDefinition) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[12] + mi := &file_v1_manifest_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1248,7 +1349,7 @@ func (x *MasterPageDefinition) ProtoReflect() protoreflect.Message { // Deprecated: Use MasterPageDefinition.ProtoReflect.Descriptor instead. func (*MasterPageDefinition) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{12} + return file_v1_manifest_proto_rawDescGZIP(), []int{13} } func (x *MasterPageDefinition) GetKey() string { @@ -1295,7 +1396,7 @@ type MasterPageBlock struct { func (x *MasterPageBlock) Reset() { *x = MasterPageBlock{} - mi := &file_v1_manifest_proto_msgTypes[13] + mi := &file_v1_manifest_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1307,7 +1408,7 @@ func (x *MasterPageBlock) String() string { func (*MasterPageBlock) ProtoMessage() {} func (x *MasterPageBlock) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[13] + mi := &file_v1_manifest_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1320,7 +1421,7 @@ func (x *MasterPageBlock) ProtoReflect() protoreflect.Message { // Deprecated: Use MasterPageBlock.ProtoReflect.Descriptor instead. func (*MasterPageBlock) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{13} + return file_v1_manifest_proto_rawDescGZIP(), []int{14} } func (x *MasterPageBlock) GetBlockKey() string { @@ -1377,7 +1478,7 @@ type CoreServiceBinding struct { func (x *CoreServiceBinding) Reset() { *x = CoreServiceBinding{} - mi := &file_v1_manifest_proto_msgTypes[14] + mi := &file_v1_manifest_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1389,7 +1490,7 @@ func (x *CoreServiceBinding) String() string { func (*CoreServiceBinding) ProtoMessage() {} func (x *CoreServiceBinding) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[14] + mi := &file_v1_manifest_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1402,7 +1503,7 @@ func (x *CoreServiceBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use CoreServiceBinding.ProtoReflect.Descriptor instead. func (*CoreServiceBinding) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{14} + return file_v1_manifest_proto_rawDescGZIP(), []int{15} } func (x *CoreServiceBinding) GetServiceName() string { @@ -1423,7 +1524,7 @@ var File_v1_manifest_proto protoreflect.FileDescriptor const file_v1_manifest_proto_rawDesc = "" + "\n" + - "\x11v1/manifest.proto\x12\x06abi.v1\"\xaf\x0e\n" + + "\x11v1/manifest.proto\x12\x06abi.v1\"\xe7\x0e\n" + "\x0ePluginManifest\x12\x1f\n" + "\vabi_version\x18\x01 \x01(\rR\n" + "abiVersion\x12\x12\n" + @@ -1465,10 +1566,19 @@ const file_v1_manifest_proto_rawDesc = "" + "\rallowed_hosts\x18\" \x03(\tR\fallowedHosts\x12&\n" + "\x0fmax_response_mb\x18# \x01(\rR\rmaxResponseMb\x128\n" + "\rpublic_routes\x18$ \x03(\v2\x13.abi.v1.PublicRouteR\fpublicRoutes\x12\x18\n" + - "\asitemap\x18% \x01(\bR\asitemap\x1aB\n" + + "\asitemap\x18% \x01(\bR\asitemap\x126\n" + + "\tmcp_tools\x18& \x03(\v2\x19.abi.v1.MCPToolDescriptorR\bmcpTools\x1aB\n" + "\x14RbacMethodRolesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"9\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xf2\x01\n" + + "\x11MCPToolDescriptor\x12\x1c\n" + + "\tprocedure\x18\x01 \x01(\tR\tprocedure\x12*\n" + + "\x11service_full_name\x18\x02 \x01(\tR\x0fserviceFullName\x12\x1f\n" + + "\vmethod_name\x18\x03 \x01(\tR\n" + + "methodName\x12 \n" + + "\vdescription\x18\x04 \x01(\tR\vdescription\x12$\n" + + "\rdocumentation\x18\x05 \x01(\tR\rdocumentation\x12*\n" + + "\x11input_schema_json\x18\x06 \x01(\fR\x0finputSchemaJson\"9\n" + "\vPublicRoute\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12\x16\n" + "\x06prefix\x18\x02 \x01(\bR\x06prefix\"a\n" + @@ -1566,52 +1676,54 @@ func file_v1_manifest_proto_rawDescGZIP() []byte { return file_v1_manifest_proto_rawDescData } -var file_v1_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 19) +var file_v1_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 20) var file_v1_manifest_proto_goTypes = []any{ (*PluginManifest)(nil), // 0: abi.v1.PluginManifest - (*PublicRoute)(nil), // 1: abi.v1.PublicRoute - (*Dependency)(nil), // 2: abi.v1.Dependency - (*BlockMeta)(nil), // 3: abi.v1.BlockMeta - (*BlockTemplateOverride)(nil), // 4: abi.v1.BlockTemplateOverride - (*SystemTemplateMeta)(nil), // 5: abi.v1.SystemTemplateMeta - (*PageTemplateMeta)(nil), // 6: abi.v1.PageTemplateMeta - (*AdminPage)(nil), // 7: abi.v1.AdminPage - (*AiAction)(nil), // 8: abi.v1.AiAction - (*CssManifest)(nil), // 9: abi.v1.CssManifest - (*DirectoryExtensions)(nil), // 10: abi.v1.DirectoryExtensions - (*BadgeLabel)(nil), // 11: abi.v1.BadgeLabel - (*MasterPageDefinition)(nil), // 12: abi.v1.MasterPageDefinition - (*MasterPageBlock)(nil), // 13: abi.v1.MasterPageBlock - (*CoreServiceBinding)(nil), // 14: abi.v1.CoreServiceBinding - nil, // 15: abi.v1.PluginManifest.RbacMethodRolesEntry - nil, // 16: abi.v1.CssManifest.NpmPackagesEntry - nil, // 17: abi.v1.DirectoryExtensions.BadgeLabelsEntry - nil, // 18: abi.v1.CoreServiceBinding.MethodRolesEntry + (*MCPToolDescriptor)(nil), // 1: abi.v1.MCPToolDescriptor + (*PublicRoute)(nil), // 2: abi.v1.PublicRoute + (*Dependency)(nil), // 3: abi.v1.Dependency + (*BlockMeta)(nil), // 4: abi.v1.BlockMeta + (*BlockTemplateOverride)(nil), // 5: abi.v1.BlockTemplateOverride + (*SystemTemplateMeta)(nil), // 6: abi.v1.SystemTemplateMeta + (*PageTemplateMeta)(nil), // 7: abi.v1.PageTemplateMeta + (*AdminPage)(nil), // 8: abi.v1.AdminPage + (*AiAction)(nil), // 9: abi.v1.AiAction + (*CssManifest)(nil), // 10: abi.v1.CssManifest + (*DirectoryExtensions)(nil), // 11: abi.v1.DirectoryExtensions + (*BadgeLabel)(nil), // 12: abi.v1.BadgeLabel + (*MasterPageDefinition)(nil), // 13: abi.v1.MasterPageDefinition + (*MasterPageBlock)(nil), // 14: abi.v1.MasterPageBlock + (*CoreServiceBinding)(nil), // 15: abi.v1.CoreServiceBinding + nil, // 16: abi.v1.PluginManifest.RbacMethodRolesEntry + nil, // 17: abi.v1.CssManifest.NpmPackagesEntry + nil, // 18: abi.v1.DirectoryExtensions.BadgeLabelsEntry + nil, // 19: abi.v1.CoreServiceBinding.MethodRolesEntry } var file_v1_manifest_proto_depIdxs = []int32{ - 2, // 0: abi.v1.PluginManifest.dependencies:type_name -> abi.v1.Dependency - 3, // 1: abi.v1.PluginManifest.blocks:type_name -> abi.v1.BlockMeta - 4, // 2: abi.v1.PluginManifest.block_template_overrides:type_name -> abi.v1.BlockTemplateOverride - 5, // 3: abi.v1.PluginManifest.system_templates:type_name -> abi.v1.SystemTemplateMeta - 6, // 4: abi.v1.PluginManifest.page_templates:type_name -> abi.v1.PageTemplateMeta - 7, // 5: abi.v1.PluginManifest.admin_pages:type_name -> abi.v1.AdminPage - 12, // 6: abi.v1.PluginManifest.master_pages:type_name -> abi.v1.MasterPageDefinition - 8, // 7: abi.v1.PluginManifest.ai_actions:type_name -> abi.v1.AiAction - 15, // 8: abi.v1.PluginManifest.rbac_method_roles:type_name -> abi.v1.PluginManifest.RbacMethodRolesEntry - 9, // 9: abi.v1.PluginManifest.css_manifest:type_name -> abi.v1.CssManifest - 10, // 10: abi.v1.PluginManifest.directory_extensions:type_name -> abi.v1.DirectoryExtensions - 14, // 11: abi.v1.PluginManifest.core_service_bindings:type_name -> abi.v1.CoreServiceBinding - 1, // 12: abi.v1.PluginManifest.public_routes:type_name -> abi.v1.PublicRoute - 16, // 13: abi.v1.CssManifest.npm_packages:type_name -> abi.v1.CssManifest.NpmPackagesEntry - 17, // 14: abi.v1.DirectoryExtensions.badge_labels:type_name -> abi.v1.DirectoryExtensions.BadgeLabelsEntry - 13, // 15: abi.v1.MasterPageDefinition.blocks:type_name -> abi.v1.MasterPageBlock - 18, // 16: abi.v1.CoreServiceBinding.method_roles:type_name -> abi.v1.CoreServiceBinding.MethodRolesEntry - 11, // 17: abi.v1.DirectoryExtensions.BadgeLabelsEntry.value:type_name -> abi.v1.BadgeLabel - 18, // [18:18] is the sub-list for method output_type - 18, // [18:18] is the sub-list for method input_type - 18, // [18:18] is the sub-list for extension type_name - 18, // [18:18] is the sub-list for extension extendee - 0, // [0:18] is the sub-list for field type_name + 3, // 0: abi.v1.PluginManifest.dependencies:type_name -> abi.v1.Dependency + 4, // 1: abi.v1.PluginManifest.blocks:type_name -> abi.v1.BlockMeta + 5, // 2: abi.v1.PluginManifest.block_template_overrides:type_name -> abi.v1.BlockTemplateOverride + 6, // 3: abi.v1.PluginManifest.system_templates:type_name -> abi.v1.SystemTemplateMeta + 7, // 4: abi.v1.PluginManifest.page_templates:type_name -> abi.v1.PageTemplateMeta + 8, // 5: abi.v1.PluginManifest.admin_pages:type_name -> abi.v1.AdminPage + 13, // 6: abi.v1.PluginManifest.master_pages:type_name -> abi.v1.MasterPageDefinition + 9, // 7: abi.v1.PluginManifest.ai_actions:type_name -> abi.v1.AiAction + 16, // 8: abi.v1.PluginManifest.rbac_method_roles:type_name -> abi.v1.PluginManifest.RbacMethodRolesEntry + 10, // 9: abi.v1.PluginManifest.css_manifest:type_name -> abi.v1.CssManifest + 11, // 10: abi.v1.PluginManifest.directory_extensions:type_name -> abi.v1.DirectoryExtensions + 15, // 11: abi.v1.PluginManifest.core_service_bindings:type_name -> abi.v1.CoreServiceBinding + 2, // 12: abi.v1.PluginManifest.public_routes:type_name -> abi.v1.PublicRoute + 1, // 13: abi.v1.PluginManifest.mcp_tools:type_name -> abi.v1.MCPToolDescriptor + 17, // 14: abi.v1.CssManifest.npm_packages:type_name -> abi.v1.CssManifest.NpmPackagesEntry + 18, // 15: abi.v1.DirectoryExtensions.badge_labels:type_name -> abi.v1.DirectoryExtensions.BadgeLabelsEntry + 14, // 16: abi.v1.MasterPageDefinition.blocks:type_name -> abi.v1.MasterPageBlock + 19, // 17: abi.v1.CoreServiceBinding.method_roles:type_name -> abi.v1.CoreServiceBinding.MethodRolesEntry + 12, // 18: abi.v1.DirectoryExtensions.BadgeLabelsEntry.value:type_name -> abi.v1.BadgeLabel + 19, // [19:19] is the sub-list for method output_type + 19, // [19:19] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name } func init() { file_v1_manifest_proto_init() } @@ -1619,14 +1731,14 @@ func file_v1_manifest_proto_init() { if File_v1_manifest_proto != nil { return } - file_v1_manifest_proto_msgTypes[13].OneofWrappers = []any{} + file_v1_manifest_proto_msgTypes[14].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_manifest_proto_rawDesc), len(file_v1_manifest_proto_rawDesc)), NumEnums: 0, - NumMessages: 19, + NumMessages: 20, NumExtensions: 0, NumServices: 0, }, diff --git a/plugin/wasmguest/describe.go b/plugin/wasmguest/describe.go index f20cec3..7ec7144 100644 --- a/plugin/wasmguest/describe.go +++ b/plugin/wasmguest/describe.go @@ -247,12 +247,14 @@ func (g *guest) captureServiceRegistration(m *abiv1.PluginManifest) { if coreBound[svc.Name()] { continue // declared via core_service_bindings, host mounts it } - for method, role := range svc.MethodRoles() { + methodRoles := svc.MethodRoles() + for method, role := range methodRoles { if m.RbacMethodRoles == nil { m.RbacMethodRoles = make(map[string]string) } m.RbacMethodRoles[method] = string(role) } + m.McpTools = append(m.McpTools, captureMCPTools(methodRoles)...) } } diff --git a/plugin/wasmguest/mcp.go b/plugin/wasmguest/mcp.go new file mode 100644 index 0000000..f37445a --- /dev/null +++ b/plugin/wasmguest/mcp.go @@ -0,0 +1,206 @@ +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 strings.HasPrefix(paragraph, methodName+" ") { + paragraph = strings.TrimSpace(strings.TrimPrefix(paragraph, methodName+" ")) + 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) + } +} diff --git a/plugin/wasmguest/mcp_test.go b/plugin/wasmguest/mcp_test.go new file mode 100644 index 0000000..0c8e6e3 --- /dev/null +++ b/plugin/wasmguest/mcp_test.go @@ -0,0 +1,100 @@ +package wasmguest + +import ( + "encoding/json" + "testing" + + "git.dev.alexdunmow.com/block/pluginsdk/rbac" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/descriptorpb" + _ "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestCaptureMCPToolsUsesCompiledProtobufDescriptor(t *testing.T) { + registerMCPFixture(t) + procedure := "/mcpfixture.v1.WikiService/ListArticles" + tools := captureMCPTools(map[string]rbac.Role{procedure: rbac.RoleAdmin}) + if len(tools) != 1 { + t.Fatalf("captureMCPTools() returned %d tools, want 1", len(tools)) + } + tool := tools[0] + if tool.GetProcedure() != procedure || tool.GetServiceFullName() != "mcpfixture.v1.WikiService" || tool.GetMethodName() != "ListArticles" { + t.Fatalf("unexpected procedure metadata: %#v", tool) + } + if tool.GetDescription() != "Lists Wiki articles." || tool.GetDocumentation() != "ListArticles lists Wiki articles.\n\nSupports pagination." { + t.Fatalf("unexpected descriptor documentation: description=%q doc=%q", tool.GetDescription(), tool.GetDocumentation()) + } + var schema map[string]any + if err := json.Unmarshal(tool.GetInputSchemaJson(), &schema); err != nil { + t.Fatalf("schema is not JSON: %v", err) + } + properties, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("schema properties = %#v", schema["properties"]) + } + assertSchemaType(t, properties, "search", "string") + assertSchemaType(t, properties, "page", "integer") + assertSchemaType(t, properties, "published_after", "string") +} + +func TestCaptureMCPToolsSkipsUnresolvableAndStreamingProcedures(t *testing.T) { + registerMCPFixture(t) + tools := captureMCPTools(map[string]rbac.Role{ + "/mcpfixture.v1.WikiService/StreamArticles": rbac.RoleAdmin, + "/missing.v1.WikiService/ListArticles": rbac.RoleAdmin, + "not-a-procedure": rbac.RoleAdmin, + }) + if len(tools) != 0 { + t.Fatalf("captureMCPTools() returned %d tools, want fail-closed omission", len(tools)) + } +} + +func assertSchemaType(t *testing.T, properties map[string]any, field, expected string) { + t.Helper() + property, ok := properties[field].(map[string]any) + if !ok || property["type"] != expected { + t.Fatalf("schema field %q = %#v, want type %q", field, properties[field], expected) + } +} + +func registerMCPFixture(t *testing.T) { + t.Helper() + if _, err := protoregistry.GlobalFiles.FindFileByPath("test/mcpfixture.proto"); err == nil { + return + } + file, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: proto.String("test/mcpfixture.proto"), + Package: proto.String("mcpfixture.v1"), + Syntax: proto.String("proto3"), + Dependency: []string{"google/protobuf/timestamp.proto"}, + MessageType: []*descriptorpb.DescriptorProto{{ + Name: proto.String("ListArticlesRequest"), + Field: []*descriptorpb.FieldDescriptorProto{ + {Name: proto.String("search"), Number: proto.Int32(1), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum()}, + {Name: proto.String("page"), Number: proto.Int32(2), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum()}, + {Name: proto.String("published_after"), Number: proto.Int32(3), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum(), TypeName: proto.String(".google.protobuf.Timestamp")}, + }, + }, { + Name: proto.String("ListArticlesResponse"), + }}, + Service: []*descriptorpb.ServiceDescriptorProto{{ + Name: proto.String("WikiService"), + Method: []*descriptorpb.MethodDescriptorProto{{ + Name: proto.String("ListArticles"), InputType: proto.String(".mcpfixture.v1.ListArticlesRequest"), OutputType: proto.String(".mcpfixture.v1.ListArticlesResponse"), + }, { + Name: proto.String("StreamArticles"), InputType: proto.String(".mcpfixture.v1.ListArticlesRequest"), OutputType: proto.String(".mcpfixture.v1.ListArticlesResponse"), ServerStreaming: proto.Bool(true), + }}, + }}, + SourceCodeInfo: &descriptorpb.SourceCodeInfo{Location: []*descriptorpb.SourceCodeInfo_Location{{ + Path: []int32{6, 0, 2, 0}, LeadingComments: proto.String("ListArticles lists Wiki articles.\n\nSupports pagination."), + }}}, + }, protoregistry.GlobalFiles) + if err != nil { + t.Fatalf("create fixture descriptor: %v", err) + } + if err := protoregistry.GlobalFiles.RegisterFile(file); err != nil { + t.Fatalf("register fixture descriptor: %v", err) + } +}