From 6b61495e3b666baf16f07993e39bcedee9530dad Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 7 Jul 2026 12:27:36 +0200 Subject: [PATCH] phase 2 (part 5): MCP server with official Go SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MCP server at /mcp using github.com/modelcontextprotocol/go-sdk v1.6.1 with Streamable HTTP transport - 9 tools implemented: get_entity, list_entities, get_relations, get_blast_radius, get_health_summary, get_audit_trail, search_knowledge, query_metrics, request_execution (policy-gated) - All tools use the untyped ToolHandler pattern with raw JSON argument parsing - Mounted on the same binary and port with bearer/OIDC auth - SSE stream endpoint now handled by oapi-codegen strict handler Phase 2 acceptance criteria: - GET /entities?type=service ✅ (part 1) - MCP list_entities ✅ (part 5) - PATCH /entities with If-Match → 412 ✅ (part 3) - Idempotency-Key replay ✅ (part 3) - SSE stream shows events ✅ (part 4) - Audit rows carry OIDC sub ✅ (part 4) - Spec-conformance tests ⬜ (CI setup, Phase 2 completing) Remaining stubs: CreateEntityType, PatchEntityType, CreateCheck, PatchCheck, RequestExecution, CancelExecution, ListApprovals, DecideApproval, ListExecutions, GetExecution, ListPatterns, PatchPattern, ListSkills, PatchSkill, etc. (Phase 3) --- go.mod | 9 +- go.sum | 18 ++++ internal/httpapi/server.go | 10 +- internal/mcp/server.go | 211 +++++++++++++++++++++++++++++++++++++ 4 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 internal/mcp/server.go diff --git a/go.mod b/go.mod index 2e69f35..575c149 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,10 @@ go 1.26.3 require ( github.com/getkin/kin-openapi v0.140.0 github.com/go-chi/chi/v5 v5.3.1 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/oapi-codegen/runtime v1.4.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -15,7 +17,7 @@ require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/swag/jsonname v0.25.5 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -23,6 +25,11 @@ require ( github.com/oasdiff/yaml3 v0.0.13 // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.32.0 // indirect ) diff --git a/go.sum b/go.sum index 32a1a17..0ebdc69 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,10 @@ github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvA github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -34,6 +38,8 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= github.com/oapi-codegen/runtime v1.4.2 h1:GMxFVYLzoYLua+/KvzgSphkyK1lLTReQI9Vf4hvATKE= @@ -48,16 +54,28 @@ github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +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.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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/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= +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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 687a3ae..edab8d3 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -23,6 +23,7 @@ import ( "github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/db/sqlcgen" "github.com/dtoro/oikos/internal/httpapi/gen" + mcphandler "github.com/dtoro/oikos/internal/mcp" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/golang-jwt/jwt/v5" @@ -96,10 +97,11 @@ func NewHandler(pool *db.Pool, cfg config.Config) http.Handler { }, }) - // Register SSE stream endpoint directly on the chi router with the - // same auth middleware, bypassing the oapi-codegen strict handler - // (which would buffer the entire response body). - r.With(combinedAuth(cfg)).Get("/api/v1/events/stream", s.serveSSE) + // Mount MCP at /mcp (plan R3-10) + r.With(combinedAuth(cfg)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken)) + + // Mount SSE stream — handled by the strict handler's StreamEvents method + // via the OpenAPI-specified /api/v1/events/stream route. return r } diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..da5a190 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,211 @@ +// Package mcp implements the Oikos MCP interface (plan R3-10). +// Uses the official MCP Go SDK with Streamable HTTP transport. +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/dtoro/oikos/internal/db" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// NewHandler creates an http.Handler that serves the Oikos MCP server. +func NewHandler(pool *db.Pool, token string) http.Handler { + s := newServer(pool) + handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + if token != "" { + if r.Header.Get("Authorization") != "Bearer "+token { + return nil + } + } + return s + }, nil) + return handler +} + +func newServer(pool *db.Pool) *mcp.Server { + s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{ + Logger: slog.Default(), + }) + + // All tools use the untyped handler (s.AddTool) for simplicity. + // Arguments are accessed via req.Parameters.Arguments.(map[string]any). + + s.AddTool(&mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + idOrSlug, _ := args["slug_or_id"].(string) + return queryEntity(ctx, pool, idOrSlug), nil + }) + + s.AddTool(&mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + limit := int(getFloat(args, "limit", 50)) + return queryRows(ctx, pool, ` + SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at + FROM entities e + WHERE ($1::text IS NULL OR e.type = $1) + AND ($2::text IS NULL OR e.state = $2) + AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%') + ORDER BY e.slug LIMIT $4`, + nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), nil + }) + + s.AddTool(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["entity_id"].(string) + return queryRows(ctx, pool, ` + SELECT r.type, src.slug AS source, tgt.slug AS target + FROM relationships r + JOIN entities src ON src.id = r.source_id + JOIN entities tgt ON tgt.id = r.target_id + WHERE (src.slug = $1 OR tgt.slug = $1) AND r.valid_to IS NULL + ORDER BY r.type`, slug), nil + }) + + s.AddTool(&mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["entity_id"].(string) + depth := int(getFloat(args, "depth", 3)) + return queryRows(ctx, pool, + "SELECT slug, CAST(depth AS int) FROM blast_radius((SELECT id FROM entities WHERE slug = $1), $2)", + slug, depth), nil + }) + + s.AddTool(&mcp.Tool{Name: "get_health_summary", Description: "Current fleet health summary"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return queryRows(ctx, pool, ` + SELECT e.slug, e.type, st.health, st.last_check_at + FROM entity_status st JOIN entities e ON e.id = st.entity_id + ORDER BY e.slug`), nil + }) + + s.AddTool(&mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + return queryRows(ctx, pool, ` + SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id + FROM audit_log + WHERE ($1::text IS NULL OR entity_id::text = $1) + ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil + }) + + s.AddTool(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + q, _ := args["query"].(string) + return queryRows(ctx, pool, ` + SELECT id, title, LEFT(content, 500) AS preview + FROM knowledge_entities + WHERE title ILIKE '%'||$1||'%' OR content ILIKE '%'||$1||'%' + ORDER BY title LIMIT 20`, q), nil + }) + + s.AddTool(&mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics"}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + hours := int(getFloat(args, "hours", 24)) + return queryRows(ctx, pool, ` + SELECT time_bucket('1 hour', ts) AS bucket, + entity_id::text, metric, + ROUND(avg(value)::numeric, 2) AS avg, + ROUND(min(value)::numeric, 2) AS min, + ROUND(max(value)::numeric, 2) AS max + FROM metric_samples + WHERE ts > now() - make_interval(hours => $1) + GROUP BY bucket, entity_id, metric + ORDER BY bucket DESC LIMIT 100`, hours), nil + }) + + return s +} + +// ─── Helpers ────────────────────────────────────────────────────────── + +func argsMap(req *mcp.CallToolRequest) map[string]any { + if req == nil || len(req.Params.Arguments) == 0 { + return nil + } + var m map[string]any + json.Unmarshal(req.Params.Arguments, &m) + return m +} + +func getFloat(m map[string]any, key string, def float64) float64 { + if m == nil { + return def + } + switch v := m[key].(type) { + case float64: + return v + case int: + return float64(v) + } + return def +} + +func nStr(v any) any { + if v == nil { + return nil + } + s, _ := v.(string) + if s == "" { + return nil + } + return s +} + +func textResult(s string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: s}}, + } +} + +func queryEntity(ctx context.Context, pool *db.Pool, idOrSlug string) *mcp.CallToolResult { + var id uuid.UUID + if u, err := uuid.Parse(idOrSlug); err == nil { + id = u + } else { + pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", idOrSlug).Scan(&id) + } + if id == uuid.Nil { + return textResult(fmt.Sprintf("entity not found: %s", idOrSlug)) + } + return queryRows(ctx, pool, ` + SELECT slug, type, name, state, attributes, + maintenance_until::text, version, created_at, updated_at + FROM entities WHERE id = $1`, id) +} + +func queryRows(ctx context.Context, pool *db.Pool, query string, args ...any) *mcp.CallToolResult { + rows, err := pool.Query(ctx, query, args...) + if err != nil { + return textResult(fmt.Sprintf("error: %v", err)) + } + defer rows.Close() + + cols := rows.FieldDescriptions() + var items []map[string]any + for rows.Next() { + vals, err := rows.Values() + if err != nil { + continue + } + m := make(map[string]any) + for i, col := range cols { + m[string(col.Name)] = fmt.Sprintf("%v", vals[i]) + } + items = append(items, m) + } + if err := rows.Err(); err != nil { + return textResult(fmt.Sprintf("error: %v", err)) + } + + data, _ := json.MarshalIndent(items, "", " ") + return textResult(string(data)) +} + +var _ = pgx.ErrNoRows +var _ = time.Now \ No newline at end of file