Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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}`
Expand Down Expand Up @@ -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"
Expand All @@ -246,6 +263,7 @@ server:
grpc: 8080
metric: 8081
health: 8082
gateway: 8083
db:
migrate_dir: "migrate"
driver: "postgres"
Expand Down Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
28 changes: 28 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}

Expand Down
149 changes: 149 additions & 0 deletions cmd/mcp-smoke/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
11 changes: 11 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading