diff --git a/README.md b/README.md index 1f3b0db..a7065aa 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,9 @@ curl http://localhost:8082/health # Metrics curl http://localhost:8081/metrics +# MCP (streamable HTTP transport) +curl -i http://localhost:8083/mcp + # Grafana (admin/admin) open http://localhost:3000 ``` @@ -201,6 +204,19 @@ service ServiceAPI { } ``` +### MCP API (HTTP Transport) + +**Endpoint:** `http://localhost:8083/mcp` (streamable MCP over HTTP) + +Implemented tools: +- `plugins.list` — list available plugins with optional filters: `group`, `name`, `version`, `tags` +- `easyp.config.describe` — return structured `easyp.yaml` schema/docs/examples for full config or selected `path` + +Testing MCP: +- Contract/integration tests (in-process HTTP MCP server): `go test ./internal/mcpserver -run TestMCPServer -count=1` +- Live smoke check against running endpoint: `go run ./cmd/mcp-smoke --endpoint http://localhost:8083/mcp` +- Task shortcuts: `task test-mcp`, `task smoke-mcp` + ## Plugin Naming Format Plugins are identified in the format: `{group}/{name}:{version}` @@ -228,6 +244,7 @@ SERVER_HOST=0.0.0.0 SERVER_PORT_GRPC=8080 SERVER_PORT_METRIC=8081 SERVER_PORT_HEALTH=8082 +SERVER_PORT_GATEWAY=8083 # Database DB_POSTGRES_DSN="postgres://user:pass@localhost/db" @@ -246,6 +263,7 @@ server: grpc: 8080 metric: 8081 health: 8082 + gateway: 8083 db: migrate_dir: "migrate" driver: "postgres" @@ -501,6 +519,7 @@ docker build -f docker/Dockerfile -t easyp-api-service . | Prometheus | http://localhost:9090 | Metrics | | Health | http://localhost:8082 | Health checks | | Metrics | http://localhost:8081 | Prometheus metrics | +| MCP | http://localhost:8083/mcp | MCP streamable HTTP endpoint | ### Key Metrics diff --git a/Taskfile.yml b/Taskfile.yml index 4277f22..918f9bd 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -31,3 +31,15 @@ tasks: - "local-push-registry" cmds: - "docker compose logs -f service" + + test-mcp: + dir: "{{.USER_WORKING_DIR}}" + cmds: + - "go test ./internal/mcpserver -run TestMCPServer -count=1" + + smoke-mcp: + dir: "{{.USER_WORKING_DIR}}" + deps: + - "up" + cmds: + - "go run ./cmd/mcp-smoke --endpoint http://localhost:8083/mcp" diff --git a/cmd/main.go b/cmd/main.go index 2b74e06..95813c6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -33,6 +33,7 @@ import ( "github.com/easyp-tech/service/internal/database/migrations" "github.com/easyp-tech/service/internal/flags" "github.com/easyp-tech/service/internal/grpchelper" + "github.com/easyp-tech/service/internal/mcpserver" "github.com/easyp-tech/service/internal/monitor" "github.com/easyp-tech/service/internal/telemetry" ) @@ -268,6 +269,8 @@ func run(ctx context.Context, cfg config, reg *prometheus.Registry, namespace st // Register API handlers api.New(grpcSrv, healthSrv, tracedCore) + mcpSrv := mcpserver.New(tracedCore, log) + g, ctx := errgroup.WithContext(ctx) // Run gRPC Server @@ -353,6 +356,31 @@ func run(ctx context.Context, cfg config, reg *prometheus.Registry, namespace st return nil }) + // Run MCP Server over streamable HTTP transport. + g.Go(func() error { + mux := http.NewServeMux() + mux.Handle("/mcp", mcpSrv.Handler()) + + srv := &http.Server{ + Addr: fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port.Gateway), + Handler: mux, + } + + log.Info("starting mcp server", "addr", srv.Addr, "path", "/mcp") + + go func() { + <-ctx.Done() + ctxShutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(ctxShutdown) + }() + + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("mcp server error: %w", err) + } + return nil + }) + return g.Wait() } diff --git a/cmd/mcp-smoke/main.go b/cmd/mcp-smoke/main.go new file mode 100644 index 0000000..0c821f7 --- /dev/null +++ b/cmd/mcp-smoke/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func main() { + var endpoint string + var timeout time.Duration + + flag.StringVar(&endpoint, "endpoint", "http://localhost:8083/mcp", "MCP streamable HTTP endpoint") + flag.DurationVar(&timeout, "timeout", 15*time.Second, "overall timeout") + flag.Parse() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + client := mcp.NewClient(&mcp.Implementation{ + Name: "mcp-smoke", + Version: "v1.0.0", + }, nil) + + session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: endpoint}, nil) + if err != nil { + exitf("connect to %s: %v", endpoint, err) + } + defer session.Close() + + if err := runSmoke(ctx, session); err != nil { + exitf("smoke failed: %v", err) + } + + fmt.Println("MCP smoke check passed") +} + +func runSmoke(ctx context.Context, session *mcp.ClientSession) error { + tools, err := session.ListTools(ctx, nil) + if err != nil { + return fmt.Errorf("tools/list: %w", err) + } + if len(tools.Tools) == 0 { + return errors.New("tools/list returned empty set") + } + + toolNames := make([]string, 0, len(tools.Tools)) + nameSet := make(map[string]struct{}, len(tools.Tools)) + for _, t := range tools.Tools { + toolNames = append(toolNames, t.Name) + nameSet[t.Name] = struct{}{} + } + sort.Strings(toolNames) + + requiredTools := []string{"plugins.list", "easyp.config.describe"} + for _, name := range requiredTools { + if _, ok := nameSet[name]; !ok { + return fmt.Errorf("missing required tool %q; got: %s", name, strings.Join(toolNames, ", ")) + } + } + + pluginsRes, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "plugins.list", + Arguments: map[string]any{}, + }) + if err != nil { + return fmt.Errorf("plugins.list call: %w", err) + } + if pluginsRes.IsError { + return fmt.Errorf("plugins.list returned tool error: %s", toolText(pluginsRes)) + } + + var pluginsOut struct { + Total int `json:"total"` + } + if err := decodeStructured(pluginsRes, &pluginsOut); err != nil { + return fmt.Errorf("plugins.list decode structured output: %w", err) + } + + describeRes, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "easyp.config.describe", + Arguments: map[string]any{ + "path": "generate.plugins[]", + "include_examples": false, + }, + }) + if err != nil { + return fmt.Errorf("easyp.config.describe call: %w", err) + } + if describeRes.IsError { + return fmt.Errorf("easyp.config.describe returned tool error: %s", toolText(describeRes)) + } + + var describeOut struct { + SelectedPath string `json:"selected_path"` + } + if err := decodeStructured(describeRes, &describeOut); err != nil { + return fmt.Errorf("easyp.config.describe decode structured output: %w", err) + } + if describeOut.SelectedPath != "generate.plugins[]" { + return fmt.Errorf("unexpected selected_path: %q", describeOut.SelectedPath) + } + + invalidRes, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "easyp.config.describe", + Arguments: map[string]any{ + "path": "unknown.section", + }, + }) + if err != nil { + return fmt.Errorf("easyp.config.describe invalid-path call transport error: %w", err) + } + if !invalidRes.IsError { + return errors.New("expected invalid path to return tool error") + } + + return nil +} + +func decodeStructured(res *mcp.CallToolResult, dst any) error { + data, err := json.Marshal(res.StructuredContent) + if err != nil { + return err + } + return json.Unmarshal(data, dst) +} + +func toolText(res *mcp.CallToolResult) string { + parts := make([]string, 0, len(res.Content)) + for _, c := range res.Content { + if text, ok := c.(*mcp.TextContent); ok { + parts = append(parts, text.Text) + } + } + return strings.Join(parts, "\n") +} + +func exitf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/go.mod b/go.mod index 9ec9fd7..b96ece1 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,15 @@ go 1.26.0 require ( github.com/easyp-tech/protoc-gen-easydoc v0.4.0 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 github.com/hellofresh/health-go/v5 v5.5.5 + github.com/invopop/jsonschema v0.13.0 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.11.2 + github.com/modelcontextprotocol/go-sdk v1.3.1 github.com/prometheus/client_golang v1.23.2 github.com/sethvargo/go-envconfig v1.3.0 github.com/stretchr/testify v1.11.1 @@ -29,7 +32,9 @@ require ( ) require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -39,16 +44,22 @@ require ( github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/klauspost/compress v1.18.4 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/net v0.50.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc // indirect diff --git a/go.sum b/go.sum index 6f70353..9fb55b9 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -19,10 +23,14 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= @@ -37,8 +45,11 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hellofresh/health-go/v5 v5.5.5 h1:JZwZ8kZzAgjdGCvjgrIJTcu1sImvZoHbwAj7CK19fpw= github.com/hellofresh/health-go/v5 v5.5.5/go.mod h1:W+6uiWHS/m9jaB0aYBVlUBTeyE98yom6f+0ewLoBPYQ= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -50,8 +61,12 @@ github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+ github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= +github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -66,12 +81,20 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4 github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U= github.com/sethvargo/go-envconfig v1.3.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM= @@ -100,12 +123,16 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc h1:ULD+ToGXUIU6Pkzr1ARxdyvwfHbelw+agoFDRbLg4TU= diff --git a/internal/mcpserver/easyp_config_schema.go b/internal/mcpserver/easyp_config_schema.go new file mode 100644 index 0000000..5779d16 --- /dev/null +++ b/internal/mcpserver/easyp_config_schema.go @@ -0,0 +1,295 @@ +package mcpserver + +import ( + "encoding/json" + "sort" + + invjsonschema "github.com/invopop/jsonschema" +) + +func buildEasypConfigSchemaIndex() map[string]map[string]any { + reflector := &invjsonschema.Reflector{ + Anonymous: true, + DoNotReference: true, + } + + schema := reflector.Reflect(easypConfigSchemaRoot{}) + root := invSchemaToMap(schema) + if len(root) == 0 { + return map[string]map[string]any{} + } + + index := map[string]map[string]any{ + "$": root, + } + walkSchemaPaths(index, "$", root) + + return index +} + +func walkSchemaPaths(index map[string]map[string]any, basePath string, schema map[string]any) { + for _, key := range []string{"allOf", "anyOf", "oneOf"} { + branches, ok := asSchemaArray(schema[key]) + if !ok { + continue + } + for _, branch := range branches { + walkSchemaPaths(index, basePath, branch) + } + } + + props, ok := asSchemaMap(schema["properties"]) + if ok { + names := make([]string, 0, len(props)) + for name := range props { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + child, ok := asSchemaMap(props[name]) + if !ok { + continue + } + childPath := joinSchemaPath(basePath, name) + if _, exists := index[childPath]; !exists { + index[childPath] = child + } + walkSchemaPaths(index, childPath, child) + } + } + + if items, ok := asSchemaMap(schema["items"]); ok { + arrayPath := basePath + "[]" + if _, exists := index[arrayPath]; !exists { + index[arrayPath] = items + } + walkSchemaPaths(index, arrayPath, items) + } +} + +func joinSchemaPath(base, child string) string { + if base == "$" { + return child + } + return base + "." + child +} + +func asSchemaArray(v any) ([]map[string]any, bool) { + arr, ok := v.([]any) + if !ok { + return nil, false + } + out := make([]map[string]any, 0, len(arr)) + for _, item := range arr { + m, ok := asSchemaMap(item) + if !ok { + continue + } + out = append(out, m) + } + if len(out) == 0 { + return nil, false + } + return out, true +} + +func asSchemaMap(v any) (map[string]any, bool) { + m, ok := v.(map[string]any) + if !ok { + return nil, false + } + return m, true +} + +func invSchemaToMap(schema *invjsonschema.Schema) map[string]any { + if schema == nil { + return nil + } + + data, err := json.Marshal(schema) + if err != nil { + return map[string]any{} + } + + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + return map[string]any{} + } + return out +} + +type easypConfigSchemaRoot struct { + Version string `json:"version,omitempty"` + Lint *easypConfigSchemaLint `json:"lint,omitempty"` + Deps []string `json:"deps,omitempty"` + Generate *easypConfigSchemaGenerate `json:"generate,omitempty"` + Breaking *easypConfigSchemaBreaking `json:"breaking,omitempty"` +} + +type easypConfigSchemaLint struct { + Use []string `json:"use,omitempty"` + EnumZeroValueSuffix string `json:"enum_zero_value_suffix,omitempty"` + ServiceSuffix string `json:"service_suffix,omitempty"` + Ignore []string `json:"ignore,omitempty"` + Except []string `json:"except,omitempty"` + AllowCommentIgnores bool `json:"allow_comment_ignores,omitempty"` + IgnoreOnly map[string][]string `json:"ignore_only,omitempty"` +} + +type easypConfigSchemaGenerate struct { + Inputs []easypConfigSchemaInput `json:"inputs"` + Plugins []easypConfigSchemaPlugin `json:"plugins"` + Managed *easypConfigSchemaManaged `json:"managed,omitempty"` +} + +func (easypConfigSchemaGenerate) JSONSchemaExtend(schema *invjsonschema.Schema) { + setMinItems(schema, "inputs", 1) + setMinItems(schema, "plugins", 1) +} + +type easypConfigSchemaInput struct { + Directory easypConfigSchemaInputDirectory `json:"directory,omitempty"` + GitRepo *easypConfigSchemaInputGitRepo `json:"git_repo,omitempty"` +} + +func (easypConfigSchemaInput) JSONSchemaExtend(schema *invjsonschema.Schema) { + schema.OneOf = []*invjsonschema.Schema{ + {Required: []string{"directory"}}, + {Required: []string{"git_repo"}}, + } +} + +type easypConfigSchemaInputDirectory struct{} + +func (easypConfigSchemaInputDirectory) JSONSchema() *invjsonschema.Schema { + reflector := &invjsonschema.Reflector{ + Anonymous: true, + DoNotReference: true, + } + objectSchema := reflector.Reflect(easypConfigSchemaInputDirectoryObject{}) + objectSchema.Version = "" + objectSchema.ID = "" + objectSchema.Definitions = nil + objectSchema.Title = "" + + return &invjsonschema.Schema{ + OneOf: []*invjsonschema.Schema{ + {Type: "string"}, + objectSchema, + }, + } +} + +type easypConfigSchemaInputDirectoryObject struct { + Path string `json:"path"` + Root string `json:"root,omitempty"` +} + +type easypConfigSchemaInputGitRepo struct { + URL string `json:"url"` + SubDirectory string `json:"sub_directory,omitempty"` + Root string `json:"root,omitempty"` +} + +type easypConfigSchemaPlugin struct { + Name string `json:"name,omitempty"` + Remote string `json:"remote,omitempty"` + Path string `json:"path,omitempty"` + Command []string `json:"command,omitempty"` + Out string `json:"out"` + Opts easypConfigSchemaPluginOps `json:"opts,omitempty"` + WithImports bool `json:"with_imports,omitempty"` +} + +func (easypConfigSchemaPlugin) JSONSchemaExtend(schema *invjsonschema.Schema) { + schema.OneOf = []*invjsonschema.Schema{ + {Required: []string{"name"}}, + {Required: []string{"remote"}}, + {Required: []string{"path"}}, + {Required: []string{"command"}}, + } +} + +type easypConfigSchemaPluginOps map[string]any + +func (easypConfigSchemaPluginOps) JSONSchema() *invjsonschema.Schema { + return &invjsonschema.Schema{ + Type: "object", + AdditionalProperties: &invjsonschema.Schema{ + OneOf: []*invjsonschema.Schema{ + {Type: "string"}, + { + Type: "array", + Items: &invjsonschema.Schema{Type: "string"}, + }, + }, + }, + } +} + +type easypConfigSchemaManaged struct { + Enabled bool `json:"enabled,omitempty"` + Disable []easypConfigSchemaManagedDisableRule `json:"disable,omitempty"` + Override []easypConfigSchemaManagedOverrideRule `json:"override,omitempty"` +} + +type easypConfigSchemaManagedDisableRule struct { + Module string `json:"module,omitempty"` + Path string `json:"path,omitempty"` + FileOption string `json:"file_option,omitempty"` + FieldOption string `json:"field_option,omitempty"` + Field string `json:"field,omitempty"` +} + +func (easypConfigSchemaManagedDisableRule) JSONSchemaExtend(schema *invjsonschema.Schema) { + schema.AnyOf = []*invjsonschema.Schema{ + {Required: []string{"module"}}, + {Required: []string{"path"}}, + {Required: []string{"file_option"}}, + {Required: []string{"field_option"}}, + {Required: []string{"field"}}, + } + schema.Not = &invjsonschema.Schema{Required: []string{"file_option", "field_option"}} + schema.DependentRequired = map[string][]string{ + "field": {"field_option"}, + } +} + +type easypConfigSchemaManagedOverrideRule struct { + FileOption string `json:"file_option,omitempty"` + FieldOption string `json:"field_option,omitempty"` + Value any `json:"value"` + Module string `json:"module,omitempty"` + Path string `json:"path,omitempty"` + Field string `json:"field,omitempty"` +} + +func (easypConfigSchemaManagedOverrideRule) JSONSchemaExtend(schema *invjsonschema.Schema) { + schema.AnyOf = []*invjsonschema.Schema{ + {Required: []string{"file_option"}}, + {Required: []string{"field_option"}}, + } + schema.Not = &invjsonschema.Schema{Required: []string{"file_option", "field_option"}} + schema.DependentRequired = map[string][]string{ + "field": {"field_option"}, + } +} + +type easypConfigSchemaBreaking struct { + Ignore []string `json:"ignore,omitempty"` + AgainstGitRef string `json:"against_git_ref,omitempty"` +} + +func setMinItems(schema *invjsonschema.Schema, fieldName string, min uint64) { + if schema == nil || schema.Properties == nil { + return + } + + itemSchema, ok := schema.Properties.Get(fieldName) + if !ok || itemSchema == nil { + return + } + + itemSchema.MinItems = &min +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 0000000..a53c422 --- /dev/null +++ b/internal/mcpserver/server.go @@ -0,0 +1,52 @@ +package mcpserver + +import ( + "context" + "log/slog" + "net/http" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/easyp-tech/service/internal/core" +) + +// PluginService provides plugin discovery for MCP tools. +type PluginService interface { + ListPlugins(ctx context.Context, filter core.PluginFilter) ([]core.PluginInfo, error) +} + +// Server wraps MCP HTTP handler. +type Server struct { + handler http.Handler +} + +// New creates an MCP server and returns its HTTP handler wrapper. +func New(pluginService PluginService, logger *slog.Logger) *Server { + opts := &mcp.ServerOptions{ + Instructions: "EasyP MCP server with plugin discovery and easyp.yaml schema helpers.", + } + if logger != nil { + opts.Logger = logger + } + + srv := mcp.NewServer(&mcp.Implementation{ + Name: "easyp-service-mcp", + Version: "v1.0.0", + }, opts) + + registerPluginTools(srv, pluginService) + registerEasypConfigTools(srv) + + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return srv + }, &mcp.StreamableHTTPOptions{ + Logger: logger, + }) + + return &Server{handler: handler} +} + +// Handler returns MCP streamable HTTP handler. +func (s *Server) Handler() http.Handler { + return s.handler +} diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go new file mode 100644 index 0000000..29bb0db --- /dev/null +++ b/internal/mcpserver/server_test.go @@ -0,0 +1,222 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gofrs/uuid/v5" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/pluginpb" + + "github.com/easyp-tech/service/internal/core" +) + +type fakePluginService struct { + returnPlugins []core.PluginInfo + lastFilter core.PluginFilter +} + +func (f *fakePluginService) Generate(context.Context, core.GenerateCodeRequest) (*core.GenerateCodeResponse, error) { + return &core.GenerateCodeResponse{Payload: &pluginpb.CodeGeneratorResponse{}}, nil +} + +func (f *fakePluginService) ListPlugins(_ context.Context, filter core.PluginFilter) ([]core.PluginInfo, error) { + f.lastFilter = filter + return f.returnPlugins, nil +} + +func TestMCPServer_RegistersToolsAndListsPlugins(t *testing.T) { + t.Parallel() + + pluginID := uuid.Must(uuid.NewV4()) + fake := &fakePluginService{ + returnPlugins: []core.PluginInfo{ + { + ID: pluginID, + Group: "grpc", + Name: "go", + Version: "v1.5.1", + Tags: []string{"stable", "go"}, + CreatedAt: time.Date(2026, time.February, 1, 10, 0, 0, 0, time.UTC), + }, + }, + } + + session, shutdown := newTestSession(t, fake) + defer shutdown() + + tools, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, tools.Tools, 2) + + toolNames := make([]string, 0, len(tools.Tools)) + for _, tool := range tools.Tools { + toolNames = append(toolNames, tool.Name) + } + require.Contains(t, toolNames, "plugins.list") + require.Contains(t, toolNames, "easyp.config.describe") + + res, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "plugins.list", + Arguments: map[string]any{ + "group": "grpc", + "name": "go", + "tags": []string{"stable", "go"}, + }, + }) + require.NoError(t, err) + require.False(t, res.IsError) + + var out struct { + Total int `json:"total"` + Plugins []struct { + ID string `json:"id"` + Group string `json:"group"` + Name string `json:"name"` + Version string `json:"version"` + Tags []string `json:"tags"` + CreatedAt string `json:"created_at"` + } `json:"plugins"` + } + decodeStructured(t, res, &out) + + require.Equal(t, 1, out.Total) + require.Len(t, out.Plugins, 1) + require.Equal(t, pluginID.String(), out.Plugins[0].ID) + require.Equal(t, "grpc", out.Plugins[0].Group) + require.Equal(t, "go", out.Plugins[0].Name) + require.Equal(t, "v1.5.1", out.Plugins[0].Version) + require.Equal(t, []string{"stable", "go"}, out.Plugins[0].Tags) + require.Equal(t, "2026-02-01T10:00:00Z", out.Plugins[0].CreatedAt) + + require.Equal(t, core.PluginFilter{ + Group: "grpc", + Name: "go", + Tags: []string{"stable", "go"}, + }, fake.lastFilter) +} + +func TestMCPServer_EasypConfigDescribe(t *testing.T) { + t.Parallel() + + session, shutdown := newTestSession(t, &fakePluginService{}) + defer shutdown() + + res, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "easyp.config.describe", + Arguments: map[string]any{ + "path": "generate.inputs[].git_repo", + }, + }) + require.NoError(t, err) + require.False(t, res.IsError) + + var gitRepoOut struct { + SchemaVersion string `json:"schema_version"` + SelectedPath string `json:"selected_path"` + Fields []struct { + Path string `json:"path"` + } `json:"fields"` + Notes []string `json:"notes"` + } + decodeStructured(t, res, &gitRepoOut) + + require.Equal(t, "easyp-config-v1", gitRepoOut.SchemaVersion) + require.Equal(t, "generate.inputs[].git_repo", gitRepoOut.SelectedPath) + require.NotEmpty(t, gitRepoOut.Fields) + require.NotContains(t, fieldPaths(gitRepoOut.Fields), "generate.inputs[].git_repo.out") + require.Contains(t, strings.Join(gitRepoOut.Notes, "\n"), "git_repo.out") + + res, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "easyp.config.describe", + Arguments: map[string]any{ + "path": "generate.plugins[]", + }, + }) + require.NoError(t, err) + require.False(t, res.IsError) + + var pluginsOut struct { + Fields []struct { + Path string `json:"path"` + } `json:"fields"` + } + decodeStructured(t, res, &pluginsOut) + + paths := fieldPaths(pluginsOut.Fields) + require.Contains(t, paths, "generate.plugins[].remote") + require.NotContains(t, paths, "generate.plugins[].url") + + errRes, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "easyp.config.describe", + Arguments: map[string]any{ + "path": "unknown.section", + }, + }) + require.NoError(t, err) + require.True(t, errRes.IsError) + require.Contains(t, toolText(errRes), "unknown path") +} + +func newTestSession(t *testing.T, pluginService PluginService) (*mcp.ClientSession, func()) { + t.Helper() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + s := New(pluginService, logger) + + mux := http.NewServeMux() + mux.Handle("/mcp", s.Handler()) + + httpSrv := httptest.NewServer(mux) + + client := mcp.NewClient(&mcp.Implementation{ + Name: "test-client", + Version: "v1.0.0", + }, nil) + + session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{ + Endpoint: httpSrv.URL + "/mcp", + }, nil) + require.NoError(t, err) + + return session, func() { + session.Close() + httpSrv.Close() + } +} + +func decodeStructured(t *testing.T, res *mcp.CallToolResult, dst any) { + t.Helper() + + data, err := json.Marshal(res.StructuredContent) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, dst)) +} + +func fieldPaths(fields []struct { + Path string `json:"path"` +}) []string { + out := make([]string, 0, len(fields)) + for _, field := range fields { + out = append(out, field.Path) + } + return out +} + +func toolText(res *mcp.CallToolResult) string { + parts := make([]string, 0, len(res.Content)) + for _, c := range res.Content { + if text, ok := c.(*mcp.TextContent); ok { + parts = append(parts, text.Text) + } + } + return strings.Join(parts, "\n") +} diff --git a/internal/mcpserver/tool_schemas.go b/internal/mcpserver/tool_schemas.go new file mode 100644 index 0000000..8f87500 --- /dev/null +++ b/internal/mcpserver/tool_schemas.go @@ -0,0 +1,226 @@ +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"}, + } +} + +func easypConfigDescribeInputSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "path": { + Type: "string", + Description: "Dot path to a section of the schema. Empty means full schema", + }, + "include_schema": { + Type: "boolean", + Description: "Include JSON schema fragment in output. Default: true", + }, + "include_fields": { + Type: "boolean", + Description: "Include field documentation in output. Default: true", + }, + "include_examples": { + Type: "boolean", + Description: "Include examples in output. Default: true", + }, + "include_children": { + Type: "boolean", + Description: "Include descendants of selected path. Default: true", + }, + "examples_limit": { + Type: "integer", + Description: "Maximum number of examples to return. Default: 10, range 1..50", + Minimum: jsonschema.Ptr(1.0), + Maximum: jsonschema.Ptr(50.0), + }, + }, + } +} + +func easypConfigDescribeOutputSchema() *jsonschema.Schema { + fieldDocSchema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "path": { + Type: "string", + Description: "Field path", + }, + "type": { + Type: "string", + Description: "Value type", + }, + "required": { + Type: "boolean", + Description: "Whether field is required", + }, + "description": { + Type: "string", + Description: "Field purpose", + }, + "allowed_values": { + Type: "array", + Description: "Allowed values or enum options", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "default_value": { + Type: "string", + Description: "Default value if omitted", + }, + "examples": { + Type: "array", + Description: "Value examples", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "notes": { + Type: "array", + Description: "Extra constraints or caveats", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + }, + Required: []string{"path", "type", "required", "description"}, + } + + exampleSchema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "title": { + Type: "string", + Description: "Short example title", + }, + "description": { + Type: "string", + Description: "Example purpose", + }, + "yaml": { + Type: "string", + Description: "YAML snippet", + }, + "paths": { + Type: "array", + Description: "Schema paths covered by this example", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + }, + Required: []string{"title", "yaml"}, + } + + return &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "schema_version": { + Type: "string", + Description: "Schema metadata version", + }, + "selected_path": { + Type: "string", + Description: "Resolved path used for this response", + }, + "schema": { + Type: "object", + Description: "JSON schema fragment for selected path", + }, + "fields": { + Type: "array", + Description: "Field documentation", + Items: fieldDocSchema, + }, + "examples": { + Type: "array", + Description: "YAML examples", + Items: exampleSchema, + }, + "notes": { + Type: "array", + Description: "General notes and caveats", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + }, + Required: []string{"schema_version", "selected_path"}, + } +} diff --git a/internal/mcpserver/tools_easyp_config.go b/internal/mcpserver/tools_easyp_config.go new file mode 100644 index 0000000..99c4174 --- /dev/null +++ b/internal/mcpserver/tools_easyp_config.go @@ -0,0 +1,511 @@ +package mcpserver + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var ( + lintRuleGroups = []string{ + "MINIMAL", + "BASIC", + "DEFAULT", + "COMMENTS", + "UNARY_RPC", + } + lintRuleNames = []string{ + "DIRECTORY_SAME_PACKAGE", + "PACKAGE_DEFINED", + "PACKAGE_DIRECTORY_MATCH", + "PACKAGE_SAME_DIRECTORY", + "ENUM_FIRST_VALUE_ZERO", + "ENUM_NO_ALLOW_ALIAS", + "ENUM_PASCAL_CASE", + "ENUM_VALUE_UPPER_SNAKE_CASE", + "FIELD_LOWER_SNAKE_CASE", + "IMPORT_NO_PUBLIC", + "IMPORT_NO_WEAK", + "IMPORT_USED", + "MESSAGE_PASCAL_CASE", + "ONEOF_LOWER_SNAKE_CASE", + "PACKAGE_LOWER_SNAKE_CASE", + "PACKAGE_SAME_CSHARP_NAMESPACE", + "PACKAGE_SAME_GO_PACKAGE", + "PACKAGE_SAME_JAVA_MULTIPLE_FILES", + "PACKAGE_SAME_JAVA_PACKAGE", + "PACKAGE_SAME_PHP_NAMESPACE", + "PACKAGE_SAME_RUBY_PACKAGE", + "PACKAGE_SAME_SWIFT_PREFIX", + "RPC_PASCAL_CASE", + "SERVICE_PASCAL_CASE", + "ENUM_VALUE_PREFIX", + "ENUM_ZERO_VALUE_SUFFIX", + "FILE_LOWER_SNAKE_CASE", + "RPC_REQUEST_RESPONSE_UNIQUE", + "RPC_REQUEST_STANDARD_NAME", + "RPC_RESPONSE_STANDARD_NAME", + "PACKAGE_VERSION_SUFFIX", + "SERVICE_SUFFIX", + "COMMENT_ENUM", + "COMMENT_ENUM_VALUE", + "COMMENT_FIELD", + "COMMENT_MESSAGE", + "COMMENT_ONEOF", + "COMMENT_RPC", + "COMMENT_SERVICE", + "RPC_NO_CLIENT_STREAMING", + "RPC_NO_SERVER_STREAMING", + "PACKAGE_NO_IMPORT_CYCLE", + } +) + +type easypConfigDescribeInput struct { + Path string `json:"path,omitempty"` + IncludeSchema *bool `json:"include_schema,omitempty"` + IncludeFields *bool `json:"include_fields,omitempty"` + IncludeExamples *bool `json:"include_examples,omitempty"` + IncludeChildren *bool `json:"include_children,omitempty"` + ExamplesLimit *int `json:"examples_limit,omitempty"` +} + +type easypFieldDoc struct { + Path string `json:"path"` + Type string `json:"type"` + Required bool `json:"required"` + Description string `json:"description"` + AllowedValues []string `json:"allowed_values,omitempty"` + DefaultValue string `json:"default_value,omitempty"` + Examples []string `json:"examples,omitempty"` + Notes []string `json:"notes,omitempty"` +} + +type easypExample struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` + YAML string `json:"yaml"` + Paths []string `json:"paths,omitempty"` +} + +type easypConfigDescribeOutput struct { + SchemaVersion string `json:"schema_version"` + SelectedPath string `json:"selected_path"` + Schema map[string]any `json:"schema,omitempty"` + Fields []easypFieldDoc `json:"fields,omitempty"` + Examples []easypExample `json:"examples,omitempty"` + Notes []string `json:"notes,omitempty"` +} + +type easypNodeDoc struct { + Fields []easypFieldDoc + Examples []easypExample + Notes []string +} + +type easypSpec struct { + SchemaVersion string + SchemaByPath map[string]map[string]any + DocsByPath map[string]easypNodeDoc +} + +func registerEasypConfigTools(server *mcp.Server) { + spec := newEasypSpec() + + mcp.AddTool(server, &mcp.Tool{ + Name: "easyp.config.describe", + Description: "Describe easyp.yaml schema and field usage. Supports full schema or a specific path with examples.", + InputSchema: easypConfigDescribeInputSchema(), + OutputSchema: easypConfigDescribeOutputSchema(), + }, func(_ context.Context, _ *mcp.CallToolRequest, input easypConfigDescribeInput) (*mcp.CallToolResult, easypConfigDescribeOutput, error) { + out, err := spec.describe(input) + if err != nil { + return nil, easypConfigDescribeOutput{}, err + } + return nil, out, nil + }) +} + +func (s easypSpec) describe(input easypConfigDescribeInput) (easypConfigDescribeOutput, error) { + selectedPath, ok := s.resolvePath(input.Path) + if !ok { + return easypConfigDescribeOutput{}, fmt.Errorf("unknown path %q", input.Path) + } + + includeSchema := boolOrDefault(input.IncludeSchema, true) + includeFields := boolOrDefault(input.IncludeFields, true) + includeExamples := boolOrDefault(input.IncludeExamples, true) + includeChildren := boolOrDefault(input.IncludeChildren, true) + examplesLimit := intOrDefault(input.ExamplesLimit, 10) + if examplesLimit < 1 { + examplesLimit = 1 + } + if examplesLimit > 50 { + examplesLimit = 50 + } + + paths := s.pathsFor(selectedPath, includeChildren) + + out := easypConfigDescribeOutput{ + SchemaVersion: s.SchemaVersion, + SelectedPath: selectedPath, + } + + if includeSchema { + out.Schema = s.SchemaByPath[selectedPath] + } + if includeFields { + out.Fields = s.collectFields(paths) + } + if includeExamples { + out.Examples = s.collectExamples(paths, examplesLimit) + } + out.Notes = s.collectNotes(paths) + + return out, nil +} + +func (s easypSpec) resolvePath(rawPath string) (string, bool) { + path := normalizePath(rawPath) + if s.hasPath(path) { + return path, true + } + + normPath := removeArrayMarkers(path) + for _, candidate := range s.allPaths() { + if removeArrayMarkers(candidate) == normPath { + return candidate, true + } + } + return "", false +} + +func (s easypSpec) pathsFor(selectedPath string, includeChildren bool) []string { + if !includeChildren { + return []string{selectedPath} + } + + allPaths := s.allPaths() + paths := make([]string, 0, len(allPaths)) + for _, p := range allPaths { + if isPathWithin(selectedPath, p) { + paths = append(paths, p) + } + } + return paths +} + +func (s easypSpec) collectFields(paths []string) []easypFieldDoc { + seen := make(map[string]struct{}) + out := make([]easypFieldDoc, 0) + for _, p := range paths { + doc, ok := s.DocsByPath[p] + if !ok { + continue + } + for _, f := range doc.Fields { + if _, exists := seen[f.Path]; exists { + continue + } + seen[f.Path] = struct{}{} + out = append(out, f) + } + } + + sort.Slice(out, func(i, j int) bool { + return out[i].Path < out[j].Path + }) + return out +} + +func (s easypSpec) collectExamples(paths []string, limit int) []easypExample { + out := make([]easypExample, 0, limit) + seen := make(map[string]struct{}) + for _, p := range paths { + doc, ok := s.DocsByPath[p] + if !ok { + continue + } + for _, ex := range doc.Examples { + if _, exists := seen[ex.Title]; exists { + continue + } + seen[ex.Title] = struct{}{} + out = append(out, ex) + if len(out) >= limit { + return out + } + } + } + return out +} + +func (s easypSpec) collectNotes(paths []string) []string { + seen := make(map[string]struct{}) + out := make([]string, 0) + for _, p := range paths { + doc, ok := s.DocsByPath[p] + if !ok { + continue + } + for _, note := range doc.Notes { + if _, exists := seen[note]; exists { + continue + } + seen[note] = struct{}{} + out = append(out, note) + } + } + return out +} + +func (s easypSpec) hasPath(path string) bool { + if _, ok := s.SchemaByPath[path]; ok { + return true + } + if _, ok := s.DocsByPath[path]; ok { + return true + } + return false +} + +func (s easypSpec) allPaths() []string { + seen := make(map[string]struct{}) + paths := make([]string, 0, len(s.SchemaByPath)+len(s.DocsByPath)) + + for p := range s.SchemaByPath { + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + paths = append(paths, p) + } + for p := range s.DocsByPath { + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + paths = append(paths, p) + } + + sort.Strings(paths) + return paths +} + +func boolOrDefault(v *bool, def bool) bool { + if v == nil { + return def + } + return *v +} + +func intOrDefault(v *int, def int) int { + if v == nil { + return def + } + return *v +} + +func normalizePath(path string) string { + path = strings.TrimSpace(path) + if path == "" || path == "$" || strings.EqualFold(path, "root") { + return "$" + } + + path = strings.TrimPrefix(path, "$.") + path = strings.TrimPrefix(path, ".") + path = strings.ReplaceAll(path, "[*]", "[]") + path = strings.ReplaceAll(path, "[0]", "[]") + path = strings.TrimSuffix(path, ".") + + return path +} + +func removeArrayMarkers(path string) string { + return strings.ReplaceAll(path, "[]", "") +} + +func isPathWithin(base, candidate string) bool { + if base == "$" { + return true + } + if base == candidate { + return true + } + if strings.HasPrefix(candidate, base+".") { + return true + } + if strings.HasPrefix(candidate, base+"[].") { + return true + } + if candidate == base+"[]" { + return true + } + return false +} + +func newEasypSpec() easypSpec { + docs := map[string]easypNodeDoc{ + "$": { + Fields: []easypFieldDoc{ + {Path: "version", Type: "string", Required: false, Description: "Legacy compatibility field.", DefaultValue: "omitted", Examples: []string{"v1alpha"}}, + {Path: "lint", Type: "object", Required: false, Description: "Linter configuration and rule selection."}, + {Path: "deps", Type: "array", Required: false, Description: "Dependency repositories in format @."}, + {Path: "generate", Type: "object", Required: false, Description: "Code generation configuration."}, + {Path: "breaking", Type: "object", Required: false, Description: "Breaking changes check configuration."}, + }, + Examples: []easypExample{ + { + Title: "minimal_config", + Description: "Small valid configuration with local input and one plugin.", + YAML: "lint:\n use:\n - DIRECTORY_SAME_PACKAGE\ngenerate:\n inputs:\n - directory: proto\n plugins:\n - name: go\n out: .\n opts:\n paths: source_relative\n", + Paths: []string{"$", "lint", "generate"}, + }, + }, + Notes: []string{ + "`generate.inputs[].git_repo.out` is intentionally excluded: it is treated as invalid and must not be used.", + "`generate.plugins[].url` is not a valid field in current schema; use `generate.plugins[].remote`.", + "Exactly one plugin source must be set per plugin item: name, remote, path, or command.", + }, + }, + "lint": { + Fields: []easypFieldDoc{ + {Path: "lint.use", Type: "array", Required: false, Description: "Rule groups and/or individual lint rule names.", AllowedValues: append(append([]string{}, lintRuleGroups...), lintRuleNames...), DefaultValue: "[]"}, + {Path: "lint.enum_zero_value_suffix", Type: "string", Required: false, Description: "Required suffix for enum zero value.", DefaultValue: "UNSPECIFIED (runtime default)"}, + {Path: "lint.service_suffix", Type: "string", Required: false, Description: "Required suffix for service names.", DefaultValue: "Service (runtime default)"}, + {Path: "lint.ignore", Type: "array", Required: false, Description: "Paths to exclude from linting.", DefaultValue: "[]"}, + {Path: "lint.except", Type: "array", Required: false, Description: "Rules to disable globally.", DefaultValue: "[]"}, + {Path: "lint.allow_comment_ignores", Type: "boolean", Required: false, Description: "Allow inline ignore comments in proto files.", DefaultValue: "false"}, + {Path: "lint.ignore_only", Type: "map>", Required: false, Description: "Disable specific rules only for selected paths.", DefaultValue: "{}"}, + }, + }, + "deps": { + Fields: []easypFieldDoc{ + {Path: "deps[]", Type: "string", Required: false, Description: "Dependency in format @.", Examples: []string{"github.com/googleapis/googleapis@v1.0.0", "github.com/bufbuild/protoc-gen-validate"}}, + }, + }, + "generate": { + Fields: []easypFieldDoc{ + {Path: "generate.inputs", Type: "array", Required: true, Description: "Input sources for proto files.", DefaultValue: "must be provided"}, + {Path: "generate.plugins", Type: "array", Required: true, Description: "Plugin definitions for generation.", DefaultValue: "must be provided"}, + {Path: "generate.managed", Type: "object", Required: false, Description: "Managed mode rules for file/field options.", DefaultValue: "{}"}, + }, + Examples: []easypExample{ + { + Title: "generate_local_and_remote_plugin", + Description: "Local directory input with remote plugin execution.", + YAML: "generate:\n inputs:\n - directory:\n path: api\n root: .\n plugins:\n - remote: api.easyp.tech/protobuf/go:v1.36.10\n out: .\n opts:\n paths: source_relative\n", + Paths: []string{"generate", "generate.inputs", "generate.plugins"}, + }, + }, + }, + "generate.inputs": { + Fields: []easypFieldDoc{ + {Path: "generate.inputs[].directory", Type: "string | object", Required: false, Description: "Local input directory. Shorthand string or object with path/root."}, + {Path: "generate.inputs[].git_repo", Type: "object", Required: false, Description: "Remote git repository input."}, + }, + Notes: []string{ + "Each input item must contain exactly one of `directory` or `git_repo`.", + }, + }, + "generate.inputs[].directory": { + Fields: []easypFieldDoc{ + {Path: "generate.inputs[].directory.path", Type: "string", Required: true, Description: "Directory with .proto files (relative to config root unless absolute).", Examples: []string{"proto", "api/proto"}}, + {Path: "generate.inputs[].directory.root", Type: "string", Required: false, Description: "Import root for path normalization.", DefaultValue: "."}, + }, + }, + "generate.inputs[].git_repo": { + Fields: []easypFieldDoc{ + {Path: "generate.inputs[].git_repo.url", Type: "string", Required: true, Description: "Git repo URL with optional revision.", Examples: []string{"github.com/acme/common@v1.0.0"}}, + {Path: "generate.inputs[].git_repo.sub_directory", Type: "string", Required: false, Description: "Subdirectory inside checked-out repository."}, + {Path: "generate.inputs[].git_repo.root", Type: "string", Required: false, Description: "Import root under repository contents.", DefaultValue: "\"\""}, + }, + Notes: []string{ + "`generate.inputs[].git_repo.out` is not part of valid schema and must not be used.", + }, + }, + "generate.plugins": { + Fields: []easypFieldDoc{ + {Path: "generate.plugins[]", Type: "object", Required: true, Description: "Plugin item with exactly one source and required output directory."}, + }, + }, + "generate.plugins[]": { + Fields: []easypFieldDoc{ + {Path: "generate.plugins[].name", Type: "string", Required: false, Description: "Built-in/local plugin name (one source option).", Examples: []string{"go", "go-grpc"}}, + {Path: "generate.plugins[].remote", Type: "string", Required: false, Description: "Remote plugin endpoint (one source option).", Examples: []string{"api.easyp.tech/protobuf/go:v1.36.10"}}, + {Path: "generate.plugins[].path", Type: "string", Required: false, Description: "Explicit path to plugin binary (one source option)."}, + {Path: "generate.plugins[].command", Type: "array", Required: false, Description: "Command invocation for plugin (one source option).", Examples: []string{`["go","run","github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.25.1"]`}}, + {Path: "generate.plugins[].out", Type: "string", Required: true, Description: "Output directory for generated files.", Examples: []string{".", "gen/go"}}, + {Path: "generate.plugins[].opts", Type: "map", Required: false, Description: "Plugin options; value can be scalar or array of scalars."}, + {Path: "generate.plugins[].with_imports", Type: "boolean", Required: false, Description: "Include dependency protos in generation.", DefaultValue: "false"}, + }, + Notes: []string{ + "Use only one of `name`, `remote`, `path`, or `command` per item.", + "`generate.plugins[].url` is invalid in current schema; use `remote`.", + }, + Examples: []easypExample{ + { + Title: "plugin_remote", + Description: "Remote plugin source.", + YAML: "generate:\n plugins:\n - remote: api.easyp.tech/grpc/go:v1.5.1\n out: .\n opts:\n paths: source_relative\n", + Paths: []string{"generate.plugins[]"}, + }, + { + Title: "plugin_command", + Description: "Command-based plugin source.", + YAML: "generate:\n plugins:\n - command: [\"go\", \"run\", \"github.com/bufbuild/protoc-gen-validate@v0.10.1\"]\n out: gen/go\n", + Paths: []string{"generate.plugins[]"}, + }, + }, + }, + "generate.managed": { + Fields: []easypFieldDoc{ + {Path: "generate.managed.enabled", Type: "boolean", Required: false, Description: "Enable managed mode option rewriting.", DefaultValue: "false"}, + {Path: "generate.managed.disable", Type: "array", Required: false, Description: "Disable managed mode per module/path/option."}, + {Path: "generate.managed.override", Type: "array", Required: false, Description: "Override file/field options with values."}, + }, + }, + "generate.managed.disable": { + Fields: []easypFieldDoc{ + {Path: "generate.managed.disable[].module", Type: "string", Required: false, Description: "Apply disable to module."}, + {Path: "generate.managed.disable[].path", Type: "string", Required: false, Description: "Apply disable to path."}, + {Path: "generate.managed.disable[].file_option", Type: "string", Required: false, Description: "Disable this file option."}, + {Path: "generate.managed.disable[].field_option", Type: "string", Required: false, Description: "Disable this field option."}, + {Path: "generate.managed.disable[].field", Type: "string", Required: false, Description: "Field selector for field_option."}, + }, + Notes: []string{ + "At least one key in each disable item is required.", + "`file_option` and `field_option` cannot be used together.", + "`field` requires `field_option`.", + }, + }, + "generate.managed.override": { + Fields: []easypFieldDoc{ + {Path: "generate.managed.override[].file_option", Type: "string", Required: false, Description: "Target file option to override."}, + {Path: "generate.managed.override[].field_option", Type: "string", Required: false, Description: "Target field option to override."}, + {Path: "generate.managed.override[].value", Type: "any", Required: true, Description: "Override value."}, + {Path: "generate.managed.override[].module", Type: "string", Required: false, Description: "Optional module selector."}, + {Path: "generate.managed.override[].path", Type: "string", Required: false, Description: "Optional path selector."}, + {Path: "generate.managed.override[].field", Type: "string", Required: false, Description: "Optional field selector (for field_option)."}, + }, + Notes: []string{ + "Each override item requires exactly one of file_option or field_option.", + "`field` can only be used with `field_option`.", + }, + }, + "breaking": { + Fields: []easypFieldDoc{ + {Path: "breaking.ignore", Type: "array", Required: false, Description: "Paths excluded from breaking-change checks.", DefaultValue: "[]"}, + {Path: "breaking.against_git_ref", Type: "string", Required: false, Description: "Branch/tag/commit used for comparison."}, + }, + }, + } + + return easypSpec{ + SchemaVersion: "easyp-config-v1", + SchemaByPath: buildEasypConfigSchemaIndex(), + DocsByPath: docs, + } +} diff --git a/internal/mcpserver/tools_plugins.go b/internal/mcpserver/tools_plugins.go new file mode 100644 index 0000000..19ea6cd --- /dev/null +++ b/internal/mcpserver/tools_plugins.go @@ -0,0 +1,83 @@ +package mcpserver + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "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"` +} + +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 pluginsListOutput struct { + Total int `json:"total"` + Plugins []pluginsListItem `json:"plugins"` +} + +func registerPluginTools(server *mcp.Server, pluginService PluginService) { + mcp.AddTool(server, &mcp.Tool{ + Name: "plugins.list", + 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), + } + + plugins, err := pluginService.ListPlugins(ctx, filter) + if err != nil { + return nil, pluginsListOutput{}, fmt.Errorf("list plugins: %w", err) + } + + 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), + }) + } + + return nil, pluginsListOutput{ + Total: len(items), + Plugins: items, + }, 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 +}