diff --git a/cmd/app/main.go b/cmd/app/main.go index 6a5f531..15dadc2 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -16,12 +16,14 @@ import ( grpcHandler "github.com/ChargePi/oecs-hub/internal/grpc" "github.com/ChargePi/oecs-hub/internal/grpc/adminserver" "github.com/ChargePi/oecs-hub/internal/manufacturer" + "github.com/ChargePi/oecs-hub/internal/mcp" "github.com/ChargePi/oecs-hub/internal/oecsspec" postgresStorage "github.com/ChargePi/oecs-hub/internal/storage/postgres" redisStorage "github.com/ChargePi/oecs-hub/internal/storage/redis" grpc_zap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap" grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/recovery" "github.com/improbable-eng/grpc-web/go/grpcweb" + "github.com/mark3labs/mcp-go/server" redisotel "github.com/redis/go-redis/extra/redisotel-native/v9" "github.com/redis/go-redis/v9" "github.com/spf13/cobra" @@ -130,6 +132,10 @@ var ( chargerCache := redisStorage.NewChargerCache(redisClient, cfg.Redis.CacheTTL) chargerSvc := charger.NewService(chargerRepo, chargerCache, validator, manufacturerSvc, graphClient) + mcpSrv := server.NewMCPServer(serviceName, serviceVersion) + mcp.RegisterTools(mcpSrv, chargerSvc) + mcpHandler := server.NewStreamableHTTPServer(mcpSrv) + recoveryHandler := func(p any) error { logger.Error("recovered from panic", zap.Any("panic", p), zap.String("stack", string(debug.Stack()))) @@ -162,7 +168,13 @@ var ( httpServer := &http.Server{ Addr: cfg.GRPC.Address, Handler: h2c.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if wrappedGrpc.IsGrpcWebRequest(r) || wrappedGrpc.IsAcceptableGrpcCorsRequest(r) { + switch { + case r.URL.Path == "/healthz": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) + case r.URL.Path == "/mcp": + mcpHandler.ServeHTTP(w, r) + case wrappedGrpc.IsGrpcWebRequest(r) || wrappedGrpc.IsAcceptableGrpcCorsRequest(r): wrappedGrpc.ServeHTTP(w, r) return } diff --git a/go.mod b/go.mod index a7d3796..2e9861d 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/improbable-eng/grpc-web v0.15.0 github.com/jackc/pgx/v5 v5.10.0 github.com/lib/pq v1.12.3 + github.com/mark3labs/mcp-go v0.58.0 github.com/neo4j/neo4j-go-driver/v5 v5.28.4 github.com/pressly/goose/v3 v3.27.3 github.com/redis/go-redis/extra/redisotel-native/v9 v9.22.0 @@ -65,6 +66,7 @@ require ( github.com/goccy/go-yaml v1.19.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect @@ -108,6 +110,7 @@ require ( github.com/rs/cors v1.7.0 // indirect github.com/sagikazarmark/crypt v0.31.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sethvargo/go-retry v0.4.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -119,6 +122,7 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.etcd.io/etcd/api/v3 v3.6.4 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.4 // indirect go.etcd.io/etcd/client/v2 v2.305.22 // indirect @@ -150,7 +154,7 @@ require ( golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect - golang.org/x/time v0.12.0 // indirect + golang.org/x/time v0.15.0 // indirect google.golang.org/api v0.248.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect diff --git a/go.sum b/go.sum index 9b01a39..300e7e0 100644 --- a/go.sum +++ b/go.sum @@ -96,6 +96,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f h1:U5y3Y5UE0w7amNe7Z5G/twsBW0KEalRQXZzf8ufSh9I= github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f/go.mod h1:xH/i4TFMt8koVQZ6WFms69WAsDWr2XsYL3Hkl7jkoLE= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= @@ -216,6 +218,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= @@ -363,6 +367,8 @@ github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= +github.com/mark3labs/mcp-go v0.58.0 h1:AWfBk8lgRR0KZYve7PaLbR2MIjpw1oK2eGpBApaNS+Q= +github.com/mark3labs/mcp-go v0.58.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -518,6 +524,8 @@ github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeH github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +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/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= @@ -591,6 +599,8 @@ github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtX github.com/xBlaz3kx/DevX v0.5.0 h1:2F1BttJqclljXg/yAE6YFuMcbLhl5d0CXCBfz3agDeg= github.com/xBlaz3kx/DevX v0.5.0/go.mod h1:9+QM5q7Wr1Mqok7JILxtKlFt6yscKLm/GiV3HKCxK1Q= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +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= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= @@ -814,8 +824,8 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/charger/repository.go b/internal/charger/repository.go index 998ae58..f1a23c2 100644 --- a/internal/charger/repository.go +++ b/internal/charger/repository.go @@ -17,6 +17,16 @@ const ( MaxPageSize = 200 ) +// FieldFilter matches chargers whose OECS spec has Field (a dot-separated path into the +// spec document, e.g. "hardware.housing.material" or "hardware.connectors.type") equal to +// any one of Values. Array-valued fields along the path are matched element-wise, so a +// path through a repeated node (e.g. "hardware.connectors.type") matches if any element +// has one of the given values. +type FieldFilter struct { + Field string + Values []string +} + // SearchFilters holds the optional filters accepted by Repository.List. A nil/empty // field matches "any" for that filter. ConnectorTypes/Protocols are OR-matched. type SearchFilters struct { @@ -31,11 +41,27 @@ type SearchFilters struct { Statuses []Status } +// FieldSearchFilters holds the filters accepted by Repository.SearchByFields: an optional +// free-text query, charger type and manufacturer, plus any number of generic OECS field +// filters. FieldFilters are AND-matched against each other and against Query/ChargerType/ +// ManufacturerID; within one FieldFilter, Values are OR-matched. Used by the MCP +// search_chargers tool, which - unlike the public gRPC search - needs to filter on +// arbitrary OECS spec fields rather than a fixed set of denormalized columns. +type FieldSearchFilters struct { + Query *string + ManufacturerID *uuid.UUID + ChargerType *string + Statuses []Status + FieldFilters []FieldFilter +} + type Repository interface { Get(ctx context.Context, id uuid.UUID) (*Charger, error) GetForReview(ctx context.Context, id uuid.UUID) (*Charger, error) Create(ctx context.Context, c *Charger) error List(ctx context.Context, filters SearchFilters, limit, offset uint32) ([]*Charger, int64, error) + // SearchByFields returns chargers matching filters, paginated - see FieldSearchFilters. + SearchByFields(ctx context.Context, filters FieldSearchFilters, limit, offset uint32) ([]*Charger, int64, error) // ListByIDs silently omits missing/unverified IDs rather than erroring. ListByIDs(ctx context.Context, ids []uuid.UUID) ([]*Charger, error) UpdateStatus(ctx context.Context, id uuid.UUID, status Status, manufacturerID *uuid.UUID) (*Charger, error) diff --git a/internal/charger/service.go b/internal/charger/service.go index 2a13f10..4198445 100644 --- a/internal/charger/service.go +++ b/internal/charger/service.go @@ -146,6 +146,24 @@ func (s *Service) List(ctx context.Context, filters SearchFilters, limit, offset return chargers, total, nil } +// SearchByFields returns chargers matching filters, paginated - see FieldSearchFilters. +// Used by the MCP search_chargers tool, which needs to filter on arbitrary OECS spec +// fields rather than the fixed set List's SearchFilters supports. +func (s *Service) SearchByFields(ctx context.Context, filters FieldSearchFilters, limit, offset uint32) ([]*Charger, int64, error) { + ctx, span := tracer.Start(ctx, "charger.SearchByFields") + defer span.End() + + chargers, total, err := s.repo.SearchByFields(ctx, filters, clampPageSize(limit), offset) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return nil, 0, fmt.Errorf("search chargers by fields: %w", err) + } + + return chargers, total, nil +} + // GetMany returns the verified chargers among ids, used to hydrate full variant detail // for a graph traversal (see internal/graph.GetManufacturerGraph, which only returns // IDs). Missing/unverified IDs are silently omitted. diff --git a/internal/mcp/search_chargers.go b/internal/mcp/search_chargers.go new file mode 100644 index 0000000..d8ba6c7 --- /dev/null +++ b/internal/mcp/search_chargers.go @@ -0,0 +1,144 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/ChargePi/oecs-hub/internal/charger" + "github.com/ChargePi/oecs-hub/internal/pagination" + "github.com/google/uuid" + "github.com/mark3labs/mcp-go/mcp" +) + +// FieldFilterInput matches charger.FieldFilter: it lets a caller filter on any field of +// the OECS charger spec, not just the ones with a dedicated input below. +type FieldFilterInput struct { + Field string `json:"field" jsonschema:"dot-path to an OECS spec field, e.g. hardware.housing.material, hardware.connectors.type, or software.protocols.name"` + Values []string `json:"values" jsonschema:"candidate values for the field; matches if the field equals any one of them"` +} + +type SearchChargersInput struct { + Query string `json:"query,omitempty" jsonschema:"free-text search across manufacturer name, model name, and product series"` + ManufacturerID string `json:"manufacturerId,omitempty" jsonschema:"restrict results to one manufacturer, by UUID - not part of the OECS spec, so it can't be expressed via fields"` + ChargerType string `json:"chargerType,omitempty" jsonschema:"AC, DC, portable-evse, or wireless"` + Fields []FieldFilterInput `json:"fields,omitempty" jsonschema:"generic filters over any OECS spec field, by dot-path and candidate values (e.g. field \"hardware.connectors.type\" values [\"CCS2_Combo2\"], or \"manufacturer.country\" values [\"DE\"]); distinct entries are AND-matched together"` + PageSize int `json:"pageSize,omitempty" jsonschema:"max results to return (default 50, max 200)"` + PageToken string `json:"pageToken,omitempty" jsonschema:"opaque pagination cursor from a previous response's nextPageToken"` +} + +type ChargerSummaryOutput struct { + ID string `json:"id"` + ManufacturerID string `json:"manufacturerId,omitempty"` + ManufacturerName string `json:"manufacturerName"` + ManufacturerCountry string `json:"manufacturerCountry,omitempty"` + // Spec is the full OECS charger spec document (https://github.com/xBlaz3kx/oecs). + Spec any `json:"spec"` +} + +type SearchChargersOutput struct { + Chargers []ChargerSummaryOutput `json:"chargers"` + TotalSize int64 `json:"totalSize"` + NextPageToken string `json:"nextPageToken,omitempty"` +} + +type searchChargersHandler struct { + chargers ChargerSearcher +} + +func newSearchChargersHandler(chargers ChargerSearcher) *searchChargersHandler { + return &searchChargersHandler{chargers: chargers} +} + +func (h *searchChargersHandler) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var in SearchChargersInput + if err := req.BindArguments(&in); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to bind arguments: %v", err)), nil + } + + filters, err := searchChargersFilters(in) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + offset, err := pagination.DecodeOffset(in.PageToken) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid pageToken: %v", err)), nil + } + + limit := pagination.ClampPageSize(in.PageSize, charger.DefaultPageSize, charger.MaxPageSize) + + results, total, err := h.chargers.SearchByFields(ctx, filters, limit, offset) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + out := SearchChargersOutput{ + Chargers: make([]ChargerSummaryOutput, len(results)), + TotalSize: total, + NextPageToken: pagination.NextToken(offset, len(results), total), + } + + for i, c := range results { + co, err := chargerToOutput(c) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + out.Chargers[i] = co + } + + return &mcp.CallToolResult{Content: []mcp.Content{}, StructuredContent: out}, nil +} + +func searchChargersFilters(in SearchChargersInput) (charger.FieldSearchFilters, error) { + filters := charger.FieldSearchFilters{ + Statuses: []charger.Status{charger.StatusVerified}, + } + + if in.Query != "" { + filters.Query = &in.Query + } + + if in.ChargerType != "" { + filters.ChargerType = &in.ChargerType + } + + if in.ManufacturerID != "" { + id, err := uuid.Parse(in.ManufacturerID) + if err != nil { + return charger.FieldSearchFilters{}, fmt.Errorf("invalid manufacturerId: %w", err) + } + + filters.ManufacturerID = &id + } + + for _, f := range in.Fields { + filters.FieldFilters = append(filters.FieldFilters, charger.FieldFilter{ + Field: f.Field, + Values: f.Values, + }) + } + + return filters, nil +} + +func chargerToOutput(c *charger.Charger) (ChargerSummaryOutput, error) { + manufacturerID := "" + if c.ManufacturerID != nil { + manufacturerID = c.ManufacturerID.String() + } + + var spec any + if err := json.Unmarshal(c.Spec, &spec); err != nil { + return ChargerSummaryOutput{}, fmt.Errorf("unmarshal spec for charger %s: %w", c.ID, err) + } + + return ChargerSummaryOutput{ + ID: c.ID.String(), + ManufacturerID: manufacturerID, + ManufacturerName: c.ManufacturerName, + ManufacturerCountry: c.ManufacturerCountry, + Spec: spec, + }, nil +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..dc297a0 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,35 @@ +// Package mcp exposes the OECS Hub registry to MCP clients (e.g. LLM agents) as a +// set of read-only tools. +package mcp + +import ( + "context" + + "github.com/ChargePi/oecs-hub/internal/charger" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// ChargerSearcher is the subset of charger.Service the search_chargers tool depends on. +type ChargerSearcher interface { + SearchByFields(ctx context.Context, filters charger.FieldSearchFilters, limit, offset uint32) ([]*charger.Charger, int64, error) +} + +// searchChargersDescription documents search_chargers' filter semantics for the calling +// model: all filters are AND-matched, so each one only narrows the result set further. +const searchChargersDescription = `Search verified EV charger specs in the OECS Hub registry.Supports a free-text query, +charger type, and manufacturer ID directly,plus a generic 'fields' filter that can match any field of the OECS charger spec +(https://github.com/ChargePi/oecs) by dot-path, e.g. field "hardware.housing.material" with values ["aluminum"], "hardware.connectors.type" with values ["CCS2_Combo2"], +or "manufacturer.country" with values ["DE"]. All filters - query, chargerType, manufacturerId, and every entry in fields - are AND-matched: +each one only narrows the result set further, so add more filters to narrow a search, never to broaden it. Results are paginated.` + +// RegisterTools adds every MCP tool the registry exposes to s. +func RegisterTools(s *server.MCPServer, chargers ChargerSearcher) { + tool := mcp.NewTool("search_chargers", + mcp.WithDescription(searchChargersDescription), + mcp.WithInputSchema[SearchChargersInput](), + mcp.WithOutputSchema[SearchChargersOutput](), + ) + + s.AddTool(tool, newSearchChargersHandler(chargers).Handle) +} diff --git a/internal/storage/postgres/charger_field_filter.go b/internal/storage/postgres/charger_field_filter.go new file mode 100644 index 0000000..cc994ec --- /dev/null +++ b/internal/storage/postgres/charger_field_filter.go @@ -0,0 +1,67 @@ +package postgres + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/ChargePi/oecs-hub/internal/charger" +) + +// fieldSegmentRe restricts field path segments to safe identifiers, since segments are +// interpolated directly into a jsonpath expression - values are never interpolated, they +// travel as jsonpath variables (see fieldFilterPredicate). +var fieldSegmentRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// fieldFilterPredicate builds a Postgres jsonpath expression (and its bound variables, +// as a jsonb object) matching FieldFilter.Field against any of FieldFilter.Values. Lax +// mode makes the path auto-unwrap arrays it passes through, so a path segment that is +// actually a repeated node (e.g. "hardware.connectors.type") matches if any element does. +func fieldFilterPredicate(f charger.FieldFilter) (path string, vars []byte, err error) { + segments := strings.Split(f.Field, ".") + for _, seg := range segments { + if !fieldSegmentRe.MatchString(seg) { + return "", nil, fmt.Errorf("invalid field %q: bad path segment %q", f.Field, seg) + } + } + + if len(f.Values) == 0 { + return "", nil, fmt.Errorf("field %q: at least one value is required", f.Field) + } + + varValues := make(map[string]any, len(f.Values)) + conds := make([]string, len(f.Values)) + + for i, v := range f.Values { + name := fmt.Sprintf("v%d", i) + varValues[name] = typedJSONValue(v) + conds[i] = "@ == $" + name + } + + varsJSON, err := json.Marshal(varValues) + if err != nil { + return "", nil, fmt.Errorf("marshal field filter values: %w", err) + } + + path = fmt.Sprintf("lax $.%s ? (%s)", strings.Join(segments, "."), strings.Join(conds, " || ")) + + return path, varsJSON, nil +} + +// typedJSONValue best-effort coerces a user-supplied filter value to the JSON type it's +// likely comparing against, since jsonpath equality is type-sensitive (a JSON boolean +// never equals the string "true"). OECS enum/string fields don't collide with this - they +// aren't literal "true"/"false"/numbers. +func typedJSONValue(s string) any { + if b, err := strconv.ParseBool(s); err == nil { + return b + } + + if f, err := strconv.ParseFloat(s, 64); err == nil { + return f + } + + return s +} diff --git a/internal/storage/postgres/charger_repository.go b/internal/storage/postgres/charger_repository.go index 518fbd5..13aab3d 100644 --- a/internal/storage/postgres/charger_repository.go +++ b/internal/storage/postgres/charger_repository.go @@ -68,6 +68,24 @@ func (r *ChargerRepository) Create(ctx context.Context, c *charger.Charger) erro func (r *ChargerRepository) List(ctx context.Context, filters charger.SearchFilters, limit, offset uint32) ([]*charger.Charger, int64, error) { query := r.applyFilters(r.db.WithContext(ctx).Model(&chargerVariantEntity{}), filters) + return runSearch(query, limit, offset) +} + +// SearchByFields returns chargers matching filters, paginated. Unlike List, it supports +// generic filtering over arbitrary OECS spec fields via filters.FieldFilters - see +// charger.FieldSearchFilters. +func (r *ChargerRepository) SearchByFields(ctx context.Context, filters charger.FieldSearchFilters, limit, offset uint32) ([]*charger.Charger, int64, error) { + query, err := r.applyFieldSearchFilters(r.db.WithContext(ctx).Model(&chargerVariantEntity{}), filters) + if err != nil { + return nil, 0, err + } + + return runSearch(query, limit, offset) +} + +// runSearch counts and fetches the given query's matches, applying the shared List/ +// SearchByFields ordering and page bounds. +func runSearch(query *gorm.DB, limit, offset uint32) ([]*charger.Charger, int64, error) { var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("count chargers: %w", err) @@ -132,6 +150,43 @@ func (r *ChargerRepository) applyFilters(query *gorm.DB, filters charger.SearchF return query } +// applyFieldSearchFilters is the FieldSearchFilters counterpart to applyFilters, used by +// SearchByFields. +func (r *ChargerRepository) applyFieldSearchFilters(query *gorm.DB, filters charger.FieldSearchFilters) (*gorm.DB, error) { + if filters.Query != nil && *filters.Query != "" { + q := "%" + *filters.Query + "%" + query = query.Where("(manufacturer_name ILIKE ? OR model_name ILIKE ? OR series ILIKE ?)", q, q, q) + } + + if filters.ManufacturerID != nil { + query = query.Where("manufacturer_id = ?", *filters.ManufacturerID) + } + + if filters.ChargerType != nil && *filters.ChargerType != "" { + query = query.Where("charger_type = ?", *filters.ChargerType) + } + + if len(filters.Statuses) > 0 { + statuses := make([]string, len(filters.Statuses)) + for i, s := range filters.Statuses { + statuses[i] = string(s) + } + + query = query.Where("status IN ?", statuses) + } + + for _, f := range filters.FieldFilters { + path, vars, err := fieldFilterPredicate(f) + if err != nil { + return nil, fmt.Errorf("field filter: %w", err) + } + + query = query.Where("jsonb_path_exists(spec, ?::jsonpath, ?::jsonb)", path, string(vars)) + } + + return query, nil +} + // ListByIDs returns the verified chargers among ids. func (r *ChargerRepository) ListByIDs(ctx context.Context, ids []uuid.UUID) ([]*charger.Charger, error) { if len(ids) == 0 { diff --git a/internal/storage/postgres/charger_search_by_fields_integration_test.go b/internal/storage/postgres/charger_search_by_fields_integration_test.go new file mode 100644 index 0000000..60b27b3 --- /dev/null +++ b/internal/storage/postgres/charger_search_by_fields_integration_test.go @@ -0,0 +1,129 @@ +package postgres_test + +import ( + "context" + "os" + "testing" + + "github.com/ChargePi/oecs-hub/internal/charger" + postgresStorage "github.com/ChargePi/oecs-hub/internal/storage/postgres" + "github.com/google/uuid" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// TestSearchByFields_Integration exercises Repository.SearchByFields' generic OECS-field +// filter against a real Postgres instance. It's skipped unless OECS_HUB_DATABASE_DSN is +// set (see the docker run + goose migrate steps used to stand one up for manual +// verification), since it's not part of the normal `go test ./...` run. +func TestSearchByFields_Integration(t *testing.T) { + dsn := os.Getenv("OECS_HUB_DATABASE_DSN") + if dsn == "" { + t.Skip("OECS_HUB_DATABASE_DSN not set") + } + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("connect: %v", err) + } + + repo := postgresStorage.NewChargerRepository(db) + ctx := context.Background() + + specA := []byte(`{ + "version": "1.1.0", + "manufacturer": {"name": "Acme", "country": "US"}, + "model": {"name": "Bolt-9000", "type": "AC"}, + "hardware": { + "housing": {"material": "aluminum"}, + "connectors": [{"type": "CCS2_Combo2", "currentType": "DC"}] + } + }`) + + specB := []byte(`{ + "version": "1.1.0", + "manufacturer": {"name": "Zenith", "country": "DE"}, + "model": {"name": "Volt-1", "type": "DC"}, + "hardware": { + "housing": {"material": "composite"}, + "connectors": [{"type": "Type2_Mennekes", "currentType": "AC"}] + } + }`) + + a := &charger.Charger{ID: uuid.New(), ManufacturerName: "Acme", ModelName: "Bolt-9000", ChargerType: "AC", ConnectorTypes: []string{"CCS2_Combo2"}, Protocols: []string{}, SchemaVersion: "1.1.0", Spec: specA, Status: charger.StatusVerified} + b := &charger.Charger{ID: uuid.New(), ManufacturerName: "Zenith", ModelName: "Volt-1", ChargerType: "DC", ConnectorTypes: []string{"Type2_Mennekes"}, Protocols: []string{}, SchemaVersion: "1.1.0", Spec: specB, Status: charger.StatusVerified} + + if err := repo.Create(ctx, a); err != nil { + t.Fatalf("create a: %v", err) + } + + if err := repo.Create(ctx, b); err != nil { + t.Fatalf("create b: %v", err) + } + + t.Run("scalar field match", func(t *testing.T) { + results, total, err := repo.SearchByFields(ctx, charger.FieldSearchFilters{ + Statuses: []charger.Status{charger.StatusVerified}, + FieldFilters: []charger.FieldFilter{{Field: "hardware.housing.material", Values: []string{"aluminum"}}}, + }, 50, 0) + if err != nil { + t.Fatalf("list: %v", err) + } + + if total != 1 || len(results) != 1 || results[0].ID != a.ID { + t.Fatalf("expected only charger a, got total=%d results=%+v", total, results) + } + }) + + t.Run("array field match via lax mode", func(t *testing.T) { + results, total, err := repo.SearchByFields(ctx, charger.FieldSearchFilters{ + Statuses: []charger.Status{charger.StatusVerified}, + FieldFilters: []charger.FieldFilter{{Field: "hardware.connectors.type", Values: []string{"Type2_Mennekes"}}}, + }, 50, 0) + if err != nil { + t.Fatalf("list: %v", err) + } + + if total != 1 || len(results) != 1 || results[0].ID != b.ID { + t.Fatalf("expected only charger b, got total=%d results=%+v", total, results) + } + }) + + t.Run("multiple values OR-matched", func(t *testing.T) { + _, total, err := repo.SearchByFields(ctx, charger.FieldSearchFilters{ + Statuses: []charger.Status{charger.StatusVerified}, + FieldFilters: []charger.FieldFilter{{Field: "hardware.housing.material", Values: []string{"aluminum", "composite"}}}, + }, 50, 0) + if err != nil { + t.Fatalf("list: %v", err) + } + + if total != 2 { + t.Fatalf("expected both chargers, got total=%d", total) + } + }) + + t.Run("no match", func(t *testing.T) { + _, total, err := repo.SearchByFields(ctx, charger.FieldSearchFilters{ + Statuses: []charger.Status{charger.StatusVerified}, + FieldFilters: []charger.FieldFilter{{Field: "hardware.housing.material", Values: []string{"titanium"}}}, + }, 50, 0) + if err != nil { + t.Fatalf("list: %v", err) + } + + if total != 0 { + t.Fatalf("expected no match, got total=%d", total) + } + }) + + t.Run("invalid field path is rejected", func(t *testing.T) { + _, _, err := repo.SearchByFields(ctx, charger.FieldSearchFilters{ + Statuses: []charger.Status{charger.StatusVerified}, + FieldFilters: []charger.FieldFilter{{Field: "hardware; DROP TABLE charger_variants;--", Values: []string{"x"}}}, + }, 50, 0) + if err == nil { + t.Fatal("expected an error for an invalid field path") + } + }) +}