From 140977373c053e56e445dd1cee2b1e39bce80941 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Tue, 10 Mar 2026 08:32:18 +0300 Subject: [PATCH] feat(mcp): generate plugins tool from protobuf contract --- README.md | 30 +++++++- Taskfile.yml | 25 +++++++ api/generator/v1/generator.mcp.go | 41 +++++++++++ api/generator/v1/generator.pb.go | 95 +++++++++++++++++++----- api/generator/v1/generator.proto | 82 ++++++++++++++++++--- api/generator/v1/generator_grpc.pb.go | 2 +- config.local.yml | 22 ++++++ config.yml | 2 +- easyp.yaml | 5 ++ go.mod | 3 +- go.sum | 2 + internal/api/api.go | 30 +++++++- internal/mcpserver/server.go | 7 +- internal/mcpserver/tool_schemas.go | 80 --------------------- internal/mcpserver/tools_plugins.go | 100 +++++++++++--------------- sdk/client.go | 10 ++- 16 files changed, 361 insertions(+), 175 deletions(-) create mode 100644 api/generator/v1/generator.mcp.go create mode 100644 config.local.yml delete mode 100644 internal/mcpserver/tool_schemas.go diff --git a/README.md b/README.md index 3ae49ff..82441a0 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,32 @@ docker compose up -d docker compose logs -f service ``` +### Minimal Local Run + +For local `easyp generate`, gRPC testing and MCP smoke you do not need the full observability stack. + +```bash +# 1. Start only postgres and local registry +# If port 5432 is already occupied, the task uses 5433 by default. +task up-minimal + +# 2. Publish only the plugin images required by easyp.yaml +task local-push-required + +# 3. In a separate terminal run the service from source +# config.local.yml is tuned for this mode. +task run-local + +# 4. Generate code +easyp --cfg easyp.yaml mod download +easyp --cfg easyp.yaml generate + +# 5. Optional MCP smoke check +go run ./cmd/mcp-smoke --endpoint http://localhost:8083/mcp +``` + +If you want every plugin image in the local registry, use `task local-push-registry` instead of `task local-push-required`. + ### Health Check ```bash @@ -244,7 +270,7 @@ SERVER_HOST=0.0.0.0 SERVER_PORT_GRPC=8080 SERVER_PORT_METRIC=8081 SERVER_PORT_HEALTH=8082 -SERVER_PORT_GATEWAY=8083 +SERVER_PORT_MCP=8083 # Database DB_POSTGRES_DSN="postgres://user:pass@localhost/db" @@ -263,7 +289,7 @@ server: grpc: 8080 metric: 8081 health: 8082 - gateway: 8083 + mcp: 8083 db: migrate_dir: "migrate" driver: "postgres" diff --git a/Taskfile.yml b/Taskfile.yml index 918f9bd..e6805d7 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -9,6 +9,13 @@ tasks: cmds: - "docker compose up --build --remove-orphans --detach" + up-minimal: + dir: "{{.USER_WORKING_DIR}}" + preconditions: + - "test -f docker-compose.yml" + cmds: + - "sh -c 'EASYP_POSTGRES_PORT=${EASYP_POSTGRES_PORT:-5433} docker compose up -d postgres registry'" + down: dir: "{{.USER_WORKING_DIR}}" preconditions: @@ -23,6 +30,17 @@ tasks: cmds: - "./push.sh localhost:5005 --push" + local-push-required: + dir: "{{.USER_WORKING_DIR}}" + preconditions: + - "test -f registry/protocolbuffers/go/v1.36.10/Dockerfile" + - "test -f registry/grpc/go/v1.5.1/Dockerfile" + cmds: + - "docker build --platform linux/amd64 -t localhost:5005/protocolbuffers/go:v1.36.10 registry/protocolbuffers/go/v1.36.10" + - "docker push localhost:5005/protocolbuffers/go:v1.36.10" + - "docker build --platform linux/amd64 -t localhost:5005/grpc/go:v1.5.1 registry/grpc/go/v1.5.1" + - "docker push localhost:5005/grpc/go:v1.5.1" + run: dir: "{{.USER_WORKING_DIR}}" deps: @@ -32,6 +50,13 @@ tasks: cmds: - "docker compose logs -f service" + run-local: + dir: "{{.USER_WORKING_DIR}}" + preconditions: + - "test -f config.local.yml" + cmds: + - "go run ./cmd/main.go -cfg config.local.yml -log_level debug" + test-mcp: dir: "{{.USER_WORKING_DIR}}" cmds: diff --git a/api/generator/v1/generator.mcp.go b/api/generator/v1/generator.mcp.go new file mode 100644 index 0000000..ff9f61d --- /dev/null +++ b/api/generator/v1/generator.mcp.go @@ -0,0 +1,41 @@ +// Code generated by protoc-gen-mcp-go. DO NOT EDIT. +// source: api/generator/v1/generator.proto + +package generator + +import ( + context "context" + errors "errors" + mcpruntime "github.com/easyp-tech/protoc-gen-mcp/mcpruntime" + mcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ServiceAPIToolHandler defines the business logic required by generated MCP tools. +type ServiceAPIToolHandler interface { + Plugins(ctx context.Context, req *PluginsRequest) (*PluginsResponse, error) +} + +// RegisterServiceAPITools registers generated MCP tools for ServiceAPI. +func RegisterServiceAPITools(server *mcp.Server, impl ServiceAPIToolHandler, opts ...mcpruntime.RegisterOption) error { + if impl == nil { + return errors.New("RegisterServiceAPITools: impl is nil") + } + if err := mcpruntime.RegisterProtoTool(server, mcpruntime.ToolSpec[*PluginsRequest, *PluginsResponse]{ + Name: "plugins_list", + Title: "List plugins", + Description: "List available plugins with optional filters: group, name, version, tags.", + Namespace: "", + InputSchemaJSON: ServiceAPI_Plugins_ToolSpecInputSchemaJSON, + OutputSchemaJSON: ServiceAPI_Plugins_ToolSpecOutputSchemaJSON, + NewRequest: func() *PluginsRequest { return &PluginsRequest{} }, + NewResponse: func() *PluginsResponse { return &PluginsResponse{} }, + Handler: impl.Plugins, + }, opts...); err != nil { + return err + } + return nil +} + +const ServiceAPI_Plugins_ToolSpecInputSchemaJSON = "{\"type\":\"object\",\"properties\":{\"group\":{\"type\":[\"string\",\"null\"],\"description\":\"Filter by exact plugin group.\\nOmit to return plugins from all groups.\",\"examples\":[\"example\"]},\"name\":{\"type\":[\"string\",\"null\"],\"description\":\"Filter by exact plugin name.\\nOmit to return plugins with any name.\",\"examples\":[\"example\"]},\"tags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Filter by tags.\\nA plugin must contain all specified tags to match.\",\"examples\":[\"example\"]},\"description\":\"Filter by tags.\\nA plugin must contain all specified tags to match.\",\"examples\":[[\"example\"]]},\"version\":{\"type\":[\"string\",\"null\"],\"description\":\"Filter by exact plugin version.\\nOmit to return all versions.\",\"examples\":[\"example\"]}},\"description\":\"Request message for listing plugins.\",\"examples\":[\"{\\\"group\\\":\\\"grpc\\\",\\\"name\\\":\\\"go\\\"}\",\"{\\\"tags\\\":[\\\"stable\\\",\\\"go\\\"]}\"],\"additionalProperties\":false}" + +const ServiceAPI_Plugins_ToolSpecOutputSchemaJSON = "{\"type\":\"object\",\"properties\":{\"plugins\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"created_at\":{\"type\":\"string\",\"description\":\"Timestamp when the plugin was registered.\",\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"group\":{\"type\":\"string\",\"description\":\"Group to which the plugin belongs.\\nGroups organize plugins by maintainer or ecosystem.\\nCommon groups:\\n- `protocolbuffers` — Official Google protobuf plugins\\n- `grpc` — Official gRPC plugins\\n- `grpc-ecosystem` — gRPC ecosystem plugins (gateway, openapi)\\n- `community` — Community-maintained plugins\",\"examples\":[\"example\"]},\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for the plugin.\\nThis is an internal UUID assigned when the plugin is registered.\",\"examples\":[\"example\"]},\"name\":{\"type\":\"string\",\"description\":\"Name of the plugin.\\nThis is the plugin's identifier within its group.\",\"examples\":[\"`go`, `python`, `gateway`, `openapiv2`\"]},\"tags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Tags associated with the plugin for categorization.\\nTags help organize and filter plugins by category.\",\"examples\":[\"example\"]},\"description\":\"Tags associated with the plugin for categorization.\\nTags help organize and filter plugins by category.\",\"examples\":[\"`go`, `grpc`, `official`, `community`\"]},\"version\":{\"type\":\"string\",\"description\":\"Version of the plugin.\\nFollows semantic versioning (semver) format.\",\"examples\":[\"example\"]}},\"description\":\"List of available plugins matching the supplied filters.\\nPlugins are sorted by group, name, and version.\\n\\nInformation about a registered plugin.\",\"examples\":[{\"created_at\":\"2026-03-09T10:11:12Z\",\"group\":\"example\",\"id\":\"example\",\"name\":\"example\",\"tags\":[\"example\"],\"version\":\"example\"}],\"required\":[\"id\",\"group\",\"name\",\"version\",\"created_at\"],\"additionalProperties\":false},\"description\":\"List of available plugins matching the supplied filters.\\nPlugins are sorted by group, name, and version.\\n\\nInformation about a registered plugin.\",\"examples\":[[{\"created_at\":\"2026-03-09T10:11:12Z\",\"group\":\"example\",\"id\":\"example\",\"name\":\"example\",\"tags\":[\"example\"],\"version\":\"example\"}]]},\"total\":{\"type\":\"integer\",\"description\":\"Number of matching plugins in this response.\",\"examples\":[-1]}},\"description\":\"Response message for listing plugins.\",\"examples\":[{\"plugins\":[{\"created_at\":\"2026-03-09T10:11:12Z\",\"group\":\"example\",\"id\":\"example\",\"name\":\"example\",\"tags\":[\"example\"],\"version\":\"example\"}],\"total\":-1}],\"required\":[\"plugins\",\"total\"],\"additionalProperties\":false}" diff --git a/api/generator/v1/generator.pb.go b/api/generator/v1/generator.pb.go index 84f927f..562b888 100644 --- a/api/generator/v1/generator.pb.go +++ b/api/generator/v1/generator.pb.go @@ -1,13 +1,14 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.10 -// protoc v0.14.1-v0.14.0-bufbuild-protocompile-easyp-modified +// protoc v0.14.1-v0.16.0-bufbuild-protocompile-easyp // source: api/generator/v1/generator.proto package generator import ( _ "github.com/easyp-tech/protoc-gen-easydoc/doc/v1" + _ "github.com/easyp-tech/protoc-gen-mcp/api/mcp/options/v1" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" @@ -139,10 +140,24 @@ func (x *GenerateCodeResponse) GetCodeGeneratorResponse() *pluginpb.CodeGenerato } // Request message for listing plugins. -// -// Currently accepts no parameters. Future versions may add filtering options. type PluginsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // Filter by exact plugin group. + // + // Omit to return plugins from all groups. + Group string `protobuf:"bytes,1,opt,name=group,proto3" json:"group,omitempty"` + // Filter by exact plugin name. + // + // Omit to return plugins with any name. + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + // Filter by exact plugin version. + // + // Omit to return all versions. + Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + // Filter by tags. + // + // A plugin must contain all specified tags to match. + Tags []string `protobuf:"bytes,4,rep,name=tags,proto3" json:"tags,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -177,13 +192,43 @@ func (*PluginsRequest) Descriptor() ([]byte, []int) { return file_api_generator_v1_generator_proto_rawDescGZIP(), []int{2} } +func (x *PluginsRequest) GetGroup() string { + if x != nil { + return x.Group + } + return "" +} + +func (x *PluginsRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *PluginsRequest) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *PluginsRequest) GetTags() []string { + if x != nil { + return x.Tags + } + return nil +} + // Response message for listing plugins. type PluginsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - // List of available plugins. + // List of available plugins matching the supplied filters. // // Plugins are sorted by group, name, and version. - Plugins []*PluginInfo `protobuf:"bytes,1,rep,name=plugins,proto3" json:"plugins,omitempty"` + Plugins []*PluginInfo `protobuf:"bytes,1,rep,name=plugins,proto3" json:"plugins,omitempty"` + // Number of matching plugins in this response. + Total int32 `protobuf:"varint,2,opt,name=total,proto3" json:"total,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -225,6 +270,13 @@ func (x *PluginsResponse) GetPlugins() []*PluginInfo { return nil } +func (x *PluginsResponse) GetTotal() int32 { + if x != nil { + return x.Total + } + return 0 +} + // Information about a registered plugin. type PluginInfo struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -253,7 +305,7 @@ type PluginInfo struct { // Follows semantic versioning (semver) format. Version string `protobuf:"bytes,4,opt,name=version,proto3" json:"version,omitempty"` // Timestamp when the plugin was registered. - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=created_at,proto3" json:"created_at,omitempty"` // Tags associated with the plugin for categorization. // // Tags help organize and filter plugins by category. @@ -339,29 +391,36 @@ var File_api_generator_v1_generator_proto protoreflect.FileDescriptor const file_api_generator_v1_generator_proto_rawDesc = "" + "\n" + - " api/generator/v1/generator.proto\x12\x10api.generator.v1\x1a\x10doc/v1/doc.proto\x1a%google/protobuf/compiler/plugin.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x8e\x02\n" + + " api/generator/v1/generator.proto\x12\x10api.generator.v1\x1a api/mcp/options/v1/options.proto\x1a\x10doc/v1/doc.proto\x1a%google/protobuf/compiler/plugin.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x8e\x02\n" + "\x13GenerateCodeRequest\x12k\n" + "\x16code_generator_request\x18\x01 \x01(\v2..google.protobuf.compiler.CodeGeneratorRequestB\x05\xdaI\x02\b\x01R\x14codeGeneratorRequest\x12\x89\x01\n" + "\vplugin_name\x18\x02 \x01(\tBh\xdaIe\b\x01\xa2\x01\x1bprotocolbuffers/go:v1.36.10\x92\x02B^[a-z][a-z0-9-]*/[a-z][a-z0-9-]*:(v[0-9]+\\.[0-9]+\\.[0-9]+|latest)$R\n" + "pluginName\"\x86\x01\n" + "\x14GenerateCodeResponse\x12n\n" + - "\x17code_generator_response\x18\x01 \x01(\v2/.google.protobuf.compiler.CodeGeneratorResponseB\x05\xdaI\x02\x10\x01R\x15codeGeneratorResponse\"\x10\n" + - "\x0ePluginsRequest\"P\n" + - "\x0fPluginsResponse\x12=\n" + - "\aplugins\x18\x01 \x03(\v2\x1c.api.generator.v1.PluginInfoB\x05\xdaI\x02\x10\x01R\aplugins\"\xc1\x02\n" + + "\x17code_generator_response\x18\x01 \x01(\v2/.google.protobuf.compiler.CodeGeneratorResponseB\x05\xdaI\x02\x10\x01R\x15codeGeneratorResponse\"\xf0\x01\n" + + "\x0ePluginsRequest\x12:\n" + + "\x05group\x18\x01 \x01(\tB$\xdaI\x1b\xa2\x01\x04grpc\x92\x02\x11^[a-z][a-z0-9-]*$ڷ,\x02\x10\x01R\x05group\x126\n" + + "\x04name\x18\x02 \x01(\tB\"\xdaI\x19\xa2\x01\x02go\x92\x02\x11^[a-z][a-z0-9-]*$ڷ,\x02\x10\x01R\x04name\x12H\n" + + "\aversion\x18\x03 \x01(\tB.\xdaI%\xa2\x01\x06v1.5.1\x92\x02\x19^v[0-9]+\\.[0-9]+\\.[0-9]+$ڷ,\x02\x10\x01R\aversion\x12 \n" + + "\x04tags\x18\x04 \x03(\tB\f\xdaI\t\xa2\x01\x06stableR\x04tags\"s\n" + + "\x0fPluginsResponse\x12C\n" + + "\aplugins\x18\x01 \x03(\v2\x1c.api.generator.v1.PluginInfoB\v\xdaI\x02\x10\x01ڷ,\x02\b\x01R\aplugins\x12\x1b\n" + + "\x05total\x18\x02 \x01(\x05B\x05\xdaI\x02\x10\x01R\x05total\"\xc2\x02\n" + "\n" + "PluginInfo\x12\x17\n" + "\x02id\x18\x01 \x01(\tB\a\xdaI\x04\x10\x01P\x01R\x02id\x12A\n" + "\x05group\x18\x02 \x01(\tB+\xdaI(\x10\x01\xa2\x01\x0fprotocolbuffers\x92\x02\x11^[a-z][a-z0-9-]*$R\x05group\x122\n" + "\x04name\x18\x03 \x01(\tB\x1e\xdaI\x1b\x10\x01\xa2\x01\x02go\x92\x02\x11^[a-z][a-z0-9-]*$R\x04name\x12F\n" + - "\aversion\x18\x04 \x01(\tB,\xdaI)\x10\x01\xa2\x01\bv1.36.10\x92\x02\x19^v[0-9]+\\.[0-9]+\\.[0-9]+$R\aversion\x12@\n" + + "\aversion\x18\x04 \x01(\tB,\xdaI)\x10\x01\xa2\x01\bv1.36.10\x92\x02\x19^v[0-9]+\\.[0-9]+\\.[0-9]+$R\aversion\x12A\n" + "\n" + - "created_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x05\xdaI\x02\x10\x01R\tcreatedAt\x12\x19\n" + - "\x04tags\x18\x06 \x03(\tB\x05\xdaI\x02\x10\x01R\x04tags2\xbb\x01\n" + + "created_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x05\xdaI\x02\x10\x01R\n" + + "created_at\x12\x19\n" + + "\x04tags\x18\x06 \x03(\tB\x05\xdaI\x02\x10\x01R\x04tags2\xeb\x02\n" + "\n" + - "ServiceAPI\x12]\n" + - "\fGenerateCode\x12%.api.generator.v1.GenerateCodeRequest\x1a&.api.generator.v1.GenerateCodeResponse\x12N\n" + - "\aPlugins\x12 .api.generator.v1.PluginsRequest\x1a!.api.generator.v1.PluginsResponseB:Z8github.com/easyp-tech/service/api/generator/v1;generatorb\x06proto3" + "ServiceAPI\x12e\n" + + "\fGenerateCode\x12%.api.generator.v1.GenerateCodeRequest\x1a&.api.generator.v1.GenerateCodeResponse\"\x06ҷ,\x02 \x01\x12\xf5\x01\n" + + "\aPlugins\x12 .api.generator.v1.PluginsRequest\x1a!.api.generator.v1.PluginsResponse\"\xa4\x01ҷ,\x9f\x01\n" + + "\fplugins_list\x12\fList plugins\x1aIList available plugins with optional filters: group, name, version, tags.2\x1c{\"group\":\"grpc\",\"name\":\"go\"}2\x18{\"tags\":[\"stable\",\"go\"]}B:Z8github.com/easyp-tech/service/api/generator/v1;generatorb\x06proto3" var ( file_api_generator_v1_generator_proto_rawDescOnce sync.Once diff --git a/api/generator/v1/generator.proto b/api/generator/v1/generator.proto index 1c8c4ec..ffdb38b 100644 --- a/api/generator/v1/generator.proto +++ b/api/generator/v1/generator.proto @@ -2,6 +2,7 @@ syntax = "proto3"; package api.generator.v1; +import "api/mcp/options/v1/options.proto"; import "doc/v1/doc.proto"; import "google/protobuf/compiler/plugin.proto"; import "google/protobuf/timestamp.proto"; @@ -56,13 +57,25 @@ service ServiceAPI { // | `INVALID_ARGUMENT` | Invalid plugin name format | // | `INTERNAL` | Plugin execution failed | // | `DEADLINE_EXCEEDED` | Plugin execution timeout | - rpc GenerateCode(GenerateCodeRequest) returns (GenerateCodeResponse); + rpc GenerateCode(GenerateCodeRequest) returns (GenerateCodeResponse) { + option (api.mcp.options.v1.method) = { + hidden: true + }; + } // List available plugins. // // Returns a list of all plugins registered in the service. // Use this to discover available plugins and their versions. - rpc Plugins(PluginsRequest) returns (PluginsResponse); + rpc Plugins(PluginsRequest) returns (PluginsResponse) { + option (api.mcp.options.v1.method) = { + name: "plugins_list" + title: "List plugins" + description: "List available plugins with optional filters: group, name, version, tags." + examples: '{"group":"grpc","name":"go"}' + examples: '{"tags":["stable","go"]}' + }; + } } // Request message for code generation. @@ -102,16 +115,62 @@ message GenerateCodeResponse { } // Request message for listing plugins. -// -// Currently accepts no parameters. Future versions may add filtering options. -message PluginsRequest {} +message PluginsRequest { + // Filter by exact plugin group. + // + // Omit to return plugins from all groups. + string group = 1 [ + (api.mcp.options.v1.field).optional = true, + (doc.v1.field) = { + pattern: "^[a-z][a-z0-9-]*$" + example: "grpc" + } + ]; + + // Filter by exact plugin name. + // + // Omit to return plugins with any name. + string name = 2 [ + (api.mcp.options.v1.field).optional = true, + (doc.v1.field) = { + pattern: "^[a-z][a-z0-9-]*$" + example: "go" + } + ]; + + // Filter by exact plugin version. + // + // Omit to return all versions. + string version = 3 [ + (api.mcp.options.v1.field).optional = true, + (doc.v1.field) = { + pattern: "^v[0-9]+\\.[0-9]+\\.[0-9]+$" + example: "v1.5.1" + } + ]; + + // Filter by tags. + // + // A plugin must contain all specified tags to match. + repeated string tags = 4 [(doc.v1.field) = { + example: "stable" + }]; +} // Response message for listing plugins. message PluginsResponse { - // List of available plugins. + // List of available plugins matching the supplied filters. // // Plugins are sorted by group, name, and version. - repeated PluginInfo plugins = 1 [(doc.v1.field) = { + repeated PluginInfo plugins = 1 [ + (api.mcp.options.v1.field).required = true, + (doc.v1.field) = { + output_only: true + } + ]; + + // Number of matching plugins in this response. + int32 total = 2 [(doc.v1.field) = { output_only: true }]; } @@ -162,9 +221,12 @@ message PluginInfo { }]; // Timestamp when the plugin was registered. - google.protobuf.Timestamp created_at = 5 [(doc.v1.field) = { - output_only: true - }]; + google.protobuf.Timestamp created_at = 5 [ + json_name = "created_at", + (doc.v1.field) = { + output_only: true + } + ]; // Tags associated with the plugin for categorization. // diff --git a/api/generator/v1/generator_grpc.pb.go b/api/generator/v1/generator_grpc.pb.go index 736bbe7..f23a559 100644 --- a/api/generator/v1/generator_grpc.pb.go +++ b/api/generator/v1/generator_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v0.14.1-v0.14.0-bufbuild-protocompile-easyp-modified +// - protoc v0.14.1-v0.16.0-bufbuild-protocompile-easyp // source: api/generator/v1/generator.proto package generator diff --git a/config.local.yml b/config.local.yml new file mode 100644 index 0000000..c284307 --- /dev/null +++ b/config.local.yml @@ -0,0 +1,22 @@ +server: + host: "0.0.0.0" + port: + grpc: 8080 + metric: 8081 + health: 8082 + mcp: 8083 +db: + migrate_dir: "migrate" + driver: "postgres" + postgres: "postgres://easyp_svc:easyp_pass@localhost:5433/easyp_db?sslmode=disable" +registry: + domain: "localhost:5005" +telemetry: + otlp_endpoint: "localhost:4317" + pyroscope_endpoint: "http://localhost:4040" +worker_pool: + workers: 4 + queue_size: 16 + generation_timeout: 120s + max_retries: 3 + shutdown_timeout: 30s diff --git a/config.yml b/config.yml index 65f6a90..4a22bbe 100644 --- a/config.yml +++ b/config.yml @@ -4,7 +4,7 @@ server: grpc: 8080 metric: 8081 health: 8082 - gateway: 8083 + mcp: 8083 db: migrate_dir: "migrate" driver: "postgres" diff --git a/easyp.yaml b/easyp.yaml index a60ab77..d79dc03 100644 --- a/easyp.yaml +++ b/easyp.yaml @@ -3,6 +3,7 @@ deps: [ github.com/googleapis/googleapis, github.com/grpc-ecosystem/grpc-gateway, github.com/easyp-tech/protoc-gen-easydoc, + github.com/easyp-tech/protoc-gen-mcp, ] lint: @@ -79,6 +80,10 @@ generate: opts: paths: source_relative require_unimplemented_servers: false + - command: ["go", "run", "github.com/easyp-tech/protoc-gen-mcp/cmd/protoc-gen-mcp-go@v0.1.1"] + out: . + opts: + paths: source_relative # - remote: "api.beta.easyp.tech/easyp-tech/easydoc" # out: docs # opts: diff --git a/go.mod b/go.mod index 335a5cf..bc51ecd 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.26.0 require ( github.com/easyp-tech/easyp v0.14.1-0.20260301022854-21e6e9dbe91e github.com/easyp-tech/protoc-gen-easydoc v0.4.0 + github.com/easyp-tech/protoc-gen-mcp v0.1.1 github.com/gofrs/uuid/v5 v5.4.0 - github.com/google/jsonschema-go v0.4.2 github.com/grafana/pyroscope-go v1.2.7 github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 @@ -42,6 +42,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect diff --git a/go.sum b/go.sum index e4d685a..e6fc141 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/easyp-tech/easyp v0.14.1-0.20260301022854-21e6e9dbe91e h1:6bPKqVs7zRY github.com/easyp-tech/easyp v0.14.1-0.20260301022854-21e6e9dbe91e/go.mod h1:YIRgbpJBhj+txUP9EC/16L6QeZOgHhHgAYDeLpmWu6M= github.com/easyp-tech/protoc-gen-easydoc v0.4.0 h1:JJREL3C/+EKf9lzypTAGo6QeUWDtDKVxK1aq8EbRzzc= github.com/easyp-tech/protoc-gen-easydoc v0.4.0/go.mod h1:/NhDdfMihhuPWYUy74Hf0VaUtzOfEStv6UHHpPogQBg= +github.com/easyp-tech/protoc-gen-mcp v0.1.1 h1:/0Tgj0p+YhEWG+uB8/sP5lHAbKkXl20UazdIrx8XySw= +github.com/easyp-tech/protoc-gen-mcp v0.1.1/go.mod h1:Prp5IJPFVhDxuFbXNLtPQonsN32Z8SRs0bu9qrAgIHA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/internal/api/api.go b/internal/api/api.go index 0fcf5ac..66f2faf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "strings" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -51,15 +52,26 @@ func (api *API) GenerateCode(ctx context.Context, request *generator.GenerateCod }, nil } -// Plugins implements generator.ServiceAPIServer. -func (api *API) Plugins(ctx context.Context, _ *generator.PluginsRequest) (*generator.PluginsResponse, error) { - plugins, err := api.app.ListPlugins(ctx, core.PluginFilter{}) +func (api *API) Plugins(ctx context.Context, request *generator.PluginsRequest) (*generator.PluginsResponse, error) { + if request == nil { + request = &generator.PluginsRequest{} + } + + filter := core.PluginFilter{ + Group: strings.TrimSpace(request.GetGroup()), + Name: strings.TrimSpace(request.GetName()), + Version: strings.TrimSpace(request.GetVersion()), + Tags: compactStrings(request.GetTags()), + } + + plugins, err := api.app.ListPlugins(ctx, filter) if err != nil { return nil, fmt.Errorf("api.app.ListPlugins: %w", err) } response := &generator.PluginsResponse{ Plugins: make([]*generator.PluginInfo, 0, len(plugins)), + Total: int32(len(plugins)), } for _, p := range plugins { @@ -76,6 +88,18 @@ func (api *API) Plugins(ctx context.Context, _ *generator.PluginsRequest) (*gene return response, nil } +func compactStrings(values []string) []string { + out := make([]string, 0, len(values)) + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { + continue + } + out = append(out, v) + } + return out +} + // ErrorToStatus converts an application error to a gRPC status. // Compatible with grpchelper.GRPCCodesConverterHandler. func ErrorToStatus(err error) *status.Status { diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 54326ab..c4b3574 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -2,12 +2,15 @@ package mcpserver import ( "context" + "fmt" "log/slog" "net/http" easypmcp "github.com/easyp-tech/easyp/mcp/easypconfig" "github.com/modelcontextprotocol/go-sdk/mcp" + generator "github.com/easyp-tech/service/api/generator/v1" + "github.com/easyp-tech/service/internal/core" ) @@ -35,7 +38,9 @@ func New(pluginService PluginService, logger *slog.Logger) *Server { Version: "v1.0.0", }, opts) - registerPluginTools(srv, pluginService) + if err := generator.RegisterServiceAPITools(srv, newPluginToolHandler(pluginService)); err != nil { + panic(fmt.Errorf("register generator MCP tools: %w", err)) + } easypmcp.RegisterTool(srv) handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { diff --git a/internal/mcpserver/tool_schemas.go b/internal/mcpserver/tool_schemas.go deleted file mode 100644 index 82b06e2..0000000 --- a/internal/mcpserver/tool_schemas.go +++ /dev/null @@ -1,80 +0,0 @@ -package mcpserver - -import "github.com/google/jsonschema-go/jsonschema" - -func pluginsListInputSchema() *jsonschema.Schema { - return &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "group": { - Type: "string", - Description: "Filter by exact plugin group (optional)", - }, - "name": { - Type: "string", - Description: "Filter by exact plugin name (optional)", - }, - "version": { - Type: "string", - Description: "Filter by exact plugin version (optional)", - }, - "tags": { - Type: "array", - Description: "Filter by tags; plugin must contain all specified tags (optional)", - Items: &jsonschema.Schema{ - Type: "string", - }, - }, - }, - } -} - -func pluginsListOutputSchema() *jsonschema.Schema { - return &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "total": { - Type: "integer", - Description: "Number of plugins in this response", - }, - "plugins": { - Type: "array", - Description: "Matching plugins", - Items: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "id": { - Type: "string", - Description: "Plugin UUID", - }, - "group": { - Type: "string", - Description: "Plugin group", - }, - "name": { - Type: "string", - Description: "Plugin name", - }, - "version": { - Type: "string", - Description: "Plugin version", - }, - "tags": { - Type: "array", - Description: "Plugin tags", - Items: &jsonschema.Schema{ - Type: "string", - }, - }, - "created_at": { - Type: "string", - Description: "Creation timestamp in RFC3339 format", - }, - }, - Required: []string{"id", "group", "name", "version", "created_at"}, - }, - }, - }, - Required: []string{"total", "plugins"}, - } -} diff --git a/internal/mcpserver/tools_plugins.go b/internal/mcpserver/tools_plugins.go index 8b6b06c..1ce76e5 100644 --- a/internal/mcpserver/tools_plugins.go +++ b/internal/mcpserver/tools_plugins.go @@ -4,76 +4,62 @@ import ( "context" "fmt" "strings" - "time" - "github.com/modelcontextprotocol/go-sdk/mcp" + "google.golang.org/protobuf/types/known/timestamppb" + generator "github.com/easyp-tech/service/api/generator/v1" "github.com/easyp-tech/service/internal/core" ) -type pluginsListInput struct { - Group string `json:"group,omitempty"` - Name string `json:"name,omitempty"` - Version string `json:"version,omitempty"` - Tags []string `json:"tags,omitempty"` -} +const ( + pluginsListToolName = "plugins_list" +) -type pluginsListItem struct { - ID string `json:"id"` - Group string `json:"group"` - Name string `json:"name"` - Version string `json:"version"` - Tags []string `json:"tags,omitempty"` - CreatedAt string `json:"created_at"` +type pluginToolHandler struct { + pluginService PluginService } -type pluginsListOutput struct { - Total int `json:"total"` - Plugins []pluginsListItem `json:"plugins"` +func newPluginToolHandler(pluginService PluginService) *pluginToolHandler { + return &pluginToolHandler{pluginService: pluginService} } -const ( - pluginsListToolName = "plugins_list" -) +func (h *pluginToolHandler) Plugins(ctx context.Context, req *generator.PluginsRequest) (*generator.PluginsResponse, error) { + response := &generator.PluginsResponse{ + Plugins: make([]*generator.PluginInfo, 0), + } + if req == nil { + req = &generator.PluginsRequest{} + } + if h == nil || h.pluginService == nil { + return response, nil + } -func registerPluginTools(server *mcp.Server, pluginService PluginService) { - mcp.AddTool(server, &mcp.Tool{ - Name: pluginsListToolName, - Description: "List available plugins with optional filters: group, name, version, tags.", - InputSchema: pluginsListInputSchema(), - OutputSchema: pluginsListOutputSchema(), - }, func(ctx context.Context, _ *mcp.CallToolRequest, input pluginsListInput) (*mcp.CallToolResult, pluginsListOutput, error) { - filter := core.PluginFilter{ - Group: strings.TrimSpace(input.Group), - Name: strings.TrimSpace(input.Name), - Version: strings.TrimSpace(input.Version), - Tags: compactStrings(input.Tags), - } - if pluginService == nil { - return &mcp.CallToolResult{}, pluginsListOutput{}, nil - } - plugins, err := pluginService.ListPlugins(ctx, filter) - if err != nil { - return nil, pluginsListOutput{}, fmt.Errorf("list plugins: %w", err) - } + filter := core.PluginFilter{ + Group: strings.TrimSpace(req.GetGroup()), + Name: strings.TrimSpace(req.GetName()), + Version: strings.TrimSpace(req.GetVersion()), + Tags: compactStrings(req.GetTags()), + } - items := make([]pluginsListItem, 0, len(plugins)) - for _, p := range plugins { - items = append(items, pluginsListItem{ - ID: p.ID.String(), - Group: p.Group, - Name: p.Name, - Version: p.Version, - Tags: append([]string(nil), p.Tags...), - CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339), - }) - } + plugins, err := h.pluginService.ListPlugins(ctx, filter) + if err != nil { + return nil, fmt.Errorf("list plugins: %w", err) + } + + response.Total = int32(len(plugins)) + response.Plugins = make([]*generator.PluginInfo, 0, len(plugins)) + for _, p := range plugins { + response.Plugins = append(response.Plugins, &generator.PluginInfo{ + Id: p.ID.String(), + Group: p.Group, + Name: p.Name, + Version: p.Version, + CreatedAt: timestamppb.New(p.CreatedAt), + Tags: append([]string(nil), p.Tags...), + }) + } - return nil, pluginsListOutput{ - Total: len(items), - Plugins: items, - }, nil - }) + return response, nil } func compactStrings(values []string) []string { diff --git a/sdk/client.go b/sdk/client.go index b732ee2..48a6391 100644 --- a/sdk/client.go +++ b/sdk/client.go @@ -106,7 +106,15 @@ func (c *Client) ListPlugins(ctx context.Context, filter ...PluginFilter) ([]*ge ctx, cancel := c.withTimeout(ctx, c.cfg.listPluginsTimeout) defer cancel() - resp, err := c.genClient.Plugins(ctx, &generator.PluginsRequest{}) + req := &generator.PluginsRequest{} + if len(filter) > 0 { + req.Group = filter[0].Group + req.Name = filter[0].Name + req.Version = filter[0].Version + req.Tags = append([]string(nil), filter[0].Tags...) + } + + resp, err := c.genClient.Plugins(ctx, req) if err != nil { return nil, fmt.Errorf("c.genClient.Plugins: %w", err) }