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
14 changes: 13 additions & 1 deletion cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())))

Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
26 changes: 26 additions & 0 deletions internal/charger/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions internal/charger/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,24 @@
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.
Expand All @@ -168,7 +186,7 @@
// necessary) the manufacturer the spec references and upserts the Memgraph
// projection; rejecting a previously-verified record removes it from the projection.
// Either way the cached copy is evicted so the next Get reflects the new status.
func (s *Service) ChangeStatus(ctx context.Context, id uuid.UUID, status Status) (*Charger, error) {

Check failure on line 189 in internal/charger/service.go

View workflow job for this annotation

GitHub Actions / Lint

calculated cyclomatic complexity for function ChangeStatus is 11, max is 10 (cyclop)
ctx, span := tracer.Start(ctx, "charger.ChangeStatus", trace.WithAttributes(idAttr(id), statusAttr(status)))
defer span.End()

Expand Down
144 changes: 144 additions & 0 deletions internal/mcp/search_chargers.go
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 35 additions & 0 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading