From 22137e7ebeab3f29571738d09597f415b31f2fcc Mon Sep 17 00:00:00 2001 From: Ryan D McGuire Date: Thu, 16 Jul 2026 15:02:17 -0400 Subject: [PATCH] move to generated mcp for econet --- AGENTS.md | 12 +- .../v1alpha1/v1alpha1mcp/econet.pb.mcp.go | 316 ++++++++++++++++++ buf.gen.yaml | 4 + buf.lock | 4 +- go.mod | 6 +- go.sum | 10 + pkg/econet/econet.go | 6 +- pkg/econet/econetmcpgen/server.go | 72 ++++ 8 files changed, 424 insertions(+), 6 deletions(-) create mode 100644 api/econet/v1alpha1/v1alpha1mcp/econet.pb.mcp.go create mode 100644 pkg/econet/econetmcpgen/server.go diff --git a/AGENTS.md b/AGENTS.md index 847ed31..377b3a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,8 +24,14 @@ Exporter for Rheem EcoNet / Rheemcloud water heaters, built on the **No MQTT**, so `WiFiSignal` and `Running`/`RunningState` stay zero. - `pkg/econet/econetgrpc/` — `EconetService` gRPC impl (`ListDevices`, `GetDevice`) + `deviceToProto`. Does **not** own a client; it's injected. -- `pkg/econet/econetmcp/` — MCP server at `/api/mcp`; the `list_water_heaters` - tool delegates to the gRPC server. +- `pkg/econet/econetmcpgen/` — **active** MCP server at `/api/mcp`. Registers + the tools generated by protoc-gen-go-mcp (`api/econet/v1alpha1/v1alpha1mcp/`) + against our go-sdk `*mcp.Server` via the `runtime/gosdk` adapter, forwarding + in-process to the gRPC server. Exposes the same package API as `econetmcp`. +- `pkg/econet/econetmcp/` — the older hand-written MCP server; a single + `list_water_heaters` tool delegating to the gRPC server. Kept in place as a + fallback. **Switch between the two** with the aliased `econetmcp` import in + `pkg/econet/econet.go` (one line — both packages export identical symbols). - `pkg/econet/econetmetrics/` — OTEL observable gauges (read the client snapshot at scrape time; no poller of its own). @@ -68,6 +74,8 @@ that's all it takes — no extra wiring. ## Workflow - Regenerate proto: `make proto` (buf; `PROTO_DIRS=proto/econet/v1alpha1/*`). + This also runs the `protoc-gen-go-mcp` plugin (see `buf.gen.yaml`), emitting + the MCP registrars under `api/econet/v1alpha1/v1alpha1mcp/`. - Regenerate config schema after config changes: `make schema`. - Build/test: `go build ./... && go test ./...`. - `econetclient.Device` is a plain struct, so `deviceToProto` and the datapoint diff --git a/api/econet/v1alpha1/v1alpha1mcp/econet.pb.mcp.go b/api/econet/v1alpha1/v1alpha1mcp/econet.pb.mcp.go new file mode 100644 index 0000000..6421eeb --- /dev/null +++ b/api/econet/v1alpha1/v1alpha1mcp/econet.pb.mcp.go @@ -0,0 +1,316 @@ +// Code generated by protoc-gen-mcp-go. DO NOT EDIT. +// source: econet/v1alpha1/econet.proto + +package v1alpha1mcp + +import ( + v1alpha1 "gitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1" +) + +import ( + "context" + "encoding/json" + "google.golang.org/protobuf/encoding/protojson" + "connectrpc.com/connect" + grpc "google.golang.org/grpc" + "github.com/redpanda-data/protoc-gen-go-mcp/pkg/runtime" +) + +var ( + EconetService_GetDeviceTool = runtime.Tool{Name: "econet_v1alpha1_EconetService_GetDevice", Description: "", RawInputSchema: json.RawMessage{0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x22, 0x3a, 0x7b, 0x22, 0x6d, 0x69, 0x6e, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x22, 0x3a, 0x31, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x7d, 0x2c, 0x22, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x7d}, RawOutputSchema: json.RawMessage{0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x61, 0x77, 0x61, 0x79, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x66, 0x72, 0x69, 0x65, 0x6e, 0x64, 0x6c, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x69, 0x63, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x68, 0x6f, 0x74, 0x5f, 0x77, 0x61, 0x74, 0x65, 0x72, 0x5f, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x22, 0x3a, 0x22, 0x64, 0x61, 0x74, 0x65, 0x2d, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x5b, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x2c, 0x22, 0x6e, 0x75, 0x6c, 0x6c, 0x22, 0x5d, 0x7d, 0x2c, 0x22, 0x6d, 0x6f, 0x64, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x74, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x74, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x78, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x74, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x6d, 0x69, 0x6e, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x77, 0x69, 0x66, 0x69, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x7d, 0x2c, 0x22, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x7d, 0x7d, 0x2c, 0x22, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x7d}} + EconetService_ListDevicesTool = runtime.Tool{Name: "econet_v1alpha1_EconetService_ListDevices", Description: "", RawInputSchema: json.RawMessage{0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x7d, 0x2c, 0x22, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x7d}, RawOutputSchema: json.RawMessage{0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x61, 0x77, 0x61, 0x79, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x66, 0x72, 0x69, 0x65, 0x6e, 0x64, 0x6c, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x69, 0x63, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x68, 0x6f, 0x74, 0x5f, 0x77, 0x61, 0x74, 0x65, 0x72, 0x5f, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x22, 0x3a, 0x22, 0x64, 0x61, 0x74, 0x65, 0x2d, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x5b, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x2c, 0x22, 0x6e, 0x75, 0x6c, 0x6c, 0x22, 0x5d, 0x7d, 0x2c, 0x22, 0x6d, 0x6f, 0x64, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x74, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x74, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x78, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x74, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x6d, 0x69, 0x6e, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x77, 0x69, 0x66, 0x69, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x7d, 0x2c, 0x22, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x7d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x61, 0x72, 0x72, 0x61, 0x79, 0x22, 0x7d, 0x7d, 0x2c, 0x22, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x7d}} +) + +// EconetServiceServer is compatible with the grpc-go server interface. +type EconetServiceServer interface { + GetDevice(ctx context.Context, req *v1alpha1.GetDeviceRequest) (*v1alpha1.GetDeviceResponse, error) + ListDevices(ctx context.Context, req *v1alpha1.ListDevicesRequest) (*v1alpha1.ListDevicesResponse, error) +} + +// RegisterEconetServiceHandler registers standard MCP handlers for EconetService +func RegisterEconetServiceHandler(s runtime.MCPServer, srv EconetServiceServer, opts ...runtime.Option) { + config := runtime.NewConfig() + for _, opt := range opts { + opt(config) + } + GetDeviceTool := EconetService_GetDeviceTool + GetDeviceTool = runtime.ApplyConfig(GetDeviceTool, config) + + if config.ShouldRegister(GetDeviceTool.Name) { + s.AddTool(GetDeviceTool, func(ctx context.Context, request *runtime.CallToolRequest) (*runtime.CallToolResult, error) { + var req v1alpha1.GetDeviceRequest + + message := request.Arguments + + // Extract extra properties if configured + for _, prop := range config.ExtraProperties { + if propVal, ok := message[prop.Name]; ok { + ctx = context.WithValue(ctx, prop.ContextKey, propVal) + } + } + + // Rewrite oneof discriminated wrappers and recursion placeholders into the + // protojson-native shape. Errors are model-readable for self-correction. + if err := runtime.DecodeArguments(req.ProtoReflect().Descriptor(), message); err != nil { + return runtime.NewToolResultError(err.Error()), nil + } + + marshaled, err := json.Marshal(message) + if err != nil { + return nil, err + } + + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(marshaled, &req); err != nil { + return nil, err + } + + resp, err := srv.GetDevice(ctx, &req) + if err != nil { + return runtime.HandleError(err) + } + + structured, err := runtime.EncodeMessage(resp) + if err != nil { + return nil, err + } + + return runtime.NewToolResultJSON(structured), nil + }) + } + ListDevicesTool := EconetService_ListDevicesTool + ListDevicesTool = runtime.ApplyConfig(ListDevicesTool, config) + + if config.ShouldRegister(ListDevicesTool.Name) { + s.AddTool(ListDevicesTool, func(ctx context.Context, request *runtime.CallToolRequest) (*runtime.CallToolResult, error) { + var req v1alpha1.ListDevicesRequest + + message := request.Arguments + + // Extract extra properties if configured + for _, prop := range config.ExtraProperties { + if propVal, ok := message[prop.Name]; ok { + ctx = context.WithValue(ctx, prop.ContextKey, propVal) + } + } + + // Rewrite oneof discriminated wrappers and recursion placeholders into the + // protojson-native shape. Errors are model-readable for self-correction. + if err := runtime.DecodeArguments(req.ProtoReflect().Descriptor(), message); err != nil { + return runtime.NewToolResultError(err.Error()), nil + } + + marshaled, err := json.Marshal(message) + if err != nil { + return nil, err + } + + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(marshaled, &req); err != nil { + return nil, err + } + + resp, err := srv.ListDevices(ctx, &req) + if err != nil { + return runtime.HandleError(err) + } + + structured, err := runtime.EncodeMessage(resp) + if err != nil { + return nil, err + } + + return runtime.NewToolResultJSON(structured), nil + }) + } +} + +// EconetServiceClient is compatible with the grpc-go client interface. +type EconetServiceClient interface { + GetDevice(ctx context.Context, req *v1alpha1.GetDeviceRequest, opts ...grpc.CallOption) (*v1alpha1.GetDeviceResponse, error) + ListDevices(ctx context.Context, req *v1alpha1.ListDevicesRequest, opts ...grpc.CallOption) (*v1alpha1.ListDevicesResponse, error) +} + +// ConnectEconetServiceClient is compatible with the connectrpc-go client interface. +type ConnectEconetServiceClient interface { + GetDevice(ctx context.Context, req *connect.Request[v1alpha1.GetDeviceRequest]) (*connect.Response[v1alpha1.GetDeviceResponse], error) + ListDevices(ctx context.Context, req *connect.Request[v1alpha1.ListDevicesRequest]) (*connect.Response[v1alpha1.ListDevicesResponse], error) +} + +// ForwardToConnectEconetServiceClient registers a connectrpc client, to forward MCP calls to it. +func ForwardToConnectEconetServiceClient(s runtime.MCPServer, client ConnectEconetServiceClient, opts ...runtime.Option) { + config := runtime.NewConfig() + for _, opt := range opts { + opt(config) + } + GetDeviceTool := EconetService_GetDeviceTool + GetDeviceTool = runtime.ApplyConfig(GetDeviceTool, config) + + if config.ShouldRegister(GetDeviceTool.Name) { + s.AddTool(GetDeviceTool, func(ctx context.Context, request *runtime.CallToolRequest) (*runtime.CallToolResult, error) { + var req v1alpha1.GetDeviceRequest + + message := request.Arguments + + // Extract extra properties if configured + for _, prop := range config.ExtraProperties { + if propVal, ok := message[prop.Name]; ok { + ctx = context.WithValue(ctx, prop.ContextKey, propVal) + } + } + + if err := runtime.DecodeArguments(req.ProtoReflect().Descriptor(), message); err != nil { + return runtime.NewToolResultError(err.Error()), nil + } + + marshaled, err := json.Marshal(message) + if err != nil { + return nil, err + } + + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(marshaled, &req); err != nil { + return nil, err + } + + resp, err := client.GetDevice(ctx, connect.NewRequest(&req)) + if err != nil { + return runtime.HandleError(err) + } + + structured, err := runtime.EncodeMessage(resp.Msg) + if err != nil { + return nil, err + } + return runtime.NewToolResultJSON(structured), nil + }) + } + ListDevicesTool := EconetService_ListDevicesTool + ListDevicesTool = runtime.ApplyConfig(ListDevicesTool, config) + + if config.ShouldRegister(ListDevicesTool.Name) { + s.AddTool(ListDevicesTool, func(ctx context.Context, request *runtime.CallToolRequest) (*runtime.CallToolResult, error) { + var req v1alpha1.ListDevicesRequest + + message := request.Arguments + + // Extract extra properties if configured + for _, prop := range config.ExtraProperties { + if propVal, ok := message[prop.Name]; ok { + ctx = context.WithValue(ctx, prop.ContextKey, propVal) + } + } + + if err := runtime.DecodeArguments(req.ProtoReflect().Descriptor(), message); err != nil { + return runtime.NewToolResultError(err.Error()), nil + } + + marshaled, err := json.Marshal(message) + if err != nil { + return nil, err + } + + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(marshaled, &req); err != nil { + return nil, err + } + + resp, err := client.ListDevices(ctx, connect.NewRequest(&req)) + if err != nil { + return runtime.HandleError(err) + } + + structured, err := runtime.EncodeMessage(resp.Msg) + if err != nil { + return nil, err + } + return runtime.NewToolResultJSON(structured), nil + }) + } +} + +// ForwardToEconetServiceClient registers a gRPC client, to forward MCP calls to it. +func ForwardToEconetServiceClient(s runtime.MCPServer, client EconetServiceClient, opts ...runtime.Option) { + config := runtime.NewConfig() + for _, opt := range opts { + opt(config) + } + GetDeviceTool := EconetService_GetDeviceTool + GetDeviceTool = runtime.ApplyConfig(GetDeviceTool, config) + + if config.ShouldRegister(GetDeviceTool.Name) { + s.AddTool(GetDeviceTool, func(ctx context.Context, request *runtime.CallToolRequest) (*runtime.CallToolResult, error) { + var req v1alpha1.GetDeviceRequest + + message := request.Arguments + + // Extract extra properties if configured + for _, prop := range config.ExtraProperties { + if propVal, ok := message[prop.Name]; ok { + ctx = context.WithValue(ctx, prop.ContextKey, propVal) + } + } + + if err := runtime.DecodeArguments(req.ProtoReflect().Descriptor(), message); err != nil { + return runtime.NewToolResultError(err.Error()), nil + } + + marshaled, err := json.Marshal(message) + if err != nil { + return nil, err + } + + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(marshaled, &req); err != nil { + return nil, err + } + + resp, err := client.GetDevice(ctx, &req) + if err != nil { + return runtime.HandleError(err) + } + + structured, err := runtime.EncodeMessage(resp) + if err != nil { + return nil, err + } + return runtime.NewToolResultJSON(structured), nil + }) + } + ListDevicesTool := EconetService_ListDevicesTool + ListDevicesTool = runtime.ApplyConfig(ListDevicesTool, config) + + if config.ShouldRegister(ListDevicesTool.Name) { + s.AddTool(ListDevicesTool, func(ctx context.Context, request *runtime.CallToolRequest) (*runtime.CallToolResult, error) { + var req v1alpha1.ListDevicesRequest + + message := request.Arguments + + // Extract extra properties if configured + for _, prop := range config.ExtraProperties { + if propVal, ok := message[prop.Name]; ok { + ctx = context.WithValue(ctx, prop.ContextKey, propVal) + } + } + + if err := runtime.DecodeArguments(req.ProtoReflect().Descriptor(), message); err != nil { + return runtime.NewToolResultError(err.Error()), nil + } + + marshaled, err := json.Marshal(message) + if err != nil { + return nil, err + } + + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(marshaled, &req); err != nil { + return nil, err + } + + resp, err := client.ListDevices(ctx, &req) + if err != nil { + return runtime.HandleError(err) + } + + structured, err := runtime.EncodeMessage(resp) + if err != nil { + return nil, err + } + return runtime.NewToolResultJSON(structured), nil + }) + } +} diff --git a/buf.gen.yaml b/buf.gen.yaml index a71dfae..a45e4a7 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -24,5 +24,9 @@ plugins: opt: - merge_file_name=econet-exporter - allow_merge=true + - local: ["go", "run", "github.com/redpanda-data/protoc-gen-go-mcp/cmd/protoc-gen-go-mcp@latest"] + out: api + opt: + - paths=source_relative inputs: - directory: proto diff --git a/buf.lock b/buf.lock index b09d86d..7f43039 100644 --- a/buf.lock +++ b/buf.lock @@ -2,8 +2,8 @@ version: v2 deps: - name: buf.build/bufbuild/protovalidate - commit: 50325440f8f24053b047484a6bf60b76 - digest: b5:74cb6f5c0853c3c10aafc701614194bbd63326bdb8ef4068214454b8894b03ba4113e04b3a33a8321cdf05336e37db4dc14a5e2495db8462566914f36086ba31 + commit: 435963d1631043e694e56e6bcc3c79c3 + digest: b5:f4ea07ad2dd94bd7243562f9908b9fb104feef8076040c89d9f7c1dedc074de4d4ce2b997686ef4400f3eccb765a7cfc20ed4acdd70b9a3699351245c61dba97 - name: buf.build/googleapis/googleapis commit: c17df5b2beca46928cc87d5656bd5343 digest: b5:648a01e0170d4512dea7d564016165decd1ed6e34bef79fe54753e51ad7e27545709ad9157d7551270147d551155c595a2fb0bf5bb33b1c83040ddbce915c604 diff --git a/go.mod b/go.mod index 1595724..69d5563 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,14 @@ module gitea.libretechconsulting.com/rmcguire/econet-exporter -go 1.26 +go 1.26.1 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 + connectrpc.com/connect v1.20.0 gitea.libretechconsulting.com/rmcguire/go-app v0.17.1 github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 github.com/modelcontextprotocol/go-sdk v1.6.1 + github.com/redpanda-data/protoc-gen-go-mcp v0.0.0-20260709214304-372b2d56e470 github.com/rs/zerolog v1.35.1 go.opentelemetry.io/otel/metric v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 @@ -36,6 +38,7 @@ require ( ) require ( + buf.build/gen/go/redpandadata/common/protocolbuffers/go v1.34.2-20240917150400-3f349e63f44a.2 // indirect buf.build/go/protovalidate v1.2.0 // indirect cel.dev/expr v0.25.2 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect @@ -55,6 +58,7 @@ require ( github.com/prometheus/common v0.69.0 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.21.1 // indirect + github.com/redpanda-data/common-go/api v0.0.0-20250801174835-9eea07f1ea06 // indirect github.com/rs/cors v1.11.1 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect diff --git a/go.sum b/go.sum index 5e56a0b..cef56c1 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,13 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/gen/go/redpandadata/common/protocolbuffers/go v1.34.2-20240917150400-3f349e63f44a.2 h1:JyGBchZNUPlQ7/qjieeKq/Cy+/i1vc0H+cIniGZNSFg= +buf.build/gen/go/redpandadata/common/protocolbuffers/go v1.34.2-20240917150400-3f349e63f44a.2/go.mod h1:wThyg02xJx4K/DA5fg0QlKts8XVPyTT86JC8hPfEzno= buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0= buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4= cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= gitea.libretechconsulting.com/rmcguire/go-app v0.17.1 h1:OdUNAXRoDpB5sC3KdaJC7Rav8MmxiRZC0fRx9YNH1Ks= gitea.libretechconsulting.com/rmcguire/go-app v0.17.1/go.mod h1:YBygVa9JBYAP+ibF8FevN1KUe3h5GHWxAdFyU6I8QgA= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= @@ -65,6 +69,8 @@ github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/u github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= 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/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -77,6 +83,10 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/redpanda-data/common-go/api v0.0.0-20250801174835-9eea07f1ea06 h1:9Ecc+Cg1EyqSTIQ6wQKoKk8BqDlBQmR74bJui4qIqsM= +github.com/redpanda-data/common-go/api v0.0.0-20250801174835-9eea07f1ea06/go.mod h1:klAmWfc8Q3hEZk8geFTMu6f2sk3VUKRS7cv/LvB05ig= +github.com/redpanda-data/protoc-gen-go-mcp v0.0.0-20260709214304-372b2d56e470 h1:gBvQBXc+MtBa8aGjXTOFLPu+1a+7I0UMu6Np9ofY1Ow= +github.com/redpanda-data/protoc-gen-go-mcp v0.0.0-20260709214304-372b2d56e470/go.mod h1:1Ulz0OS7NpChjyMu6NWZucui6SEbIsOzAshGfpqWCV8= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/pkg/econet/econet.go b/pkg/econet/econet.go index e8ca802..37761f2 100644 --- a/pkg/econet/econet.go +++ b/pkg/econet/econet.go @@ -16,7 +16,11 @@ import ( "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetclient" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetgrpc" - "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetmcp" + // MCP server. Aliased so switching implementations is a one-line change. + // Generated (protoc-gen-go-mcp) tools: + econetmcp "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetmcpgen" + // Hand-written tools (switch back by using this import instead): + // econetmcp "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetmcp" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetmetrics" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/service" ) diff --git a/pkg/econet/econetmcpgen/server.go b/pkg/econet/econetmcpgen/server.go new file mode 100644 index 0000000..536a5a5 --- /dev/null +++ b/pkg/econet/econetmcpgen/server.go @@ -0,0 +1,72 @@ +/* +Package econetmcpgen exposes an MCP server, mounted as an HTTP handler at +/api/mcp, whose tools are generated from the EconetService proto by +protoc-gen-go-mcp and forwarded to the in-process gRPC server. + +It is the generated-code counterpart to econetmcp (which hand-writes a single +list_water_heaters tool). Both expose an identical package API, so switching +between them is a one-line import change in pkg/econet/econet.go. +*/ +package econetmcpgen + +import ( + "context" + "net/http" + + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv/http/opts" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/redpanda-data/protoc-gen-go-mcp/pkg/runtime/gosdk" + "github.com/rs/zerolog" + + v1alpha1mcp "gitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1/v1alpha1mcp" + "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config" + "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetgrpc" +) + +var EconetMCPImpl = &mcp.Implementation{ + Name: "Econet MCP Server", + Title: "Econet Exporter MCP", +} + +type EconetMCPServer struct { + ctx context.Context + cfg *config.ServiceConfig + log *zerolog.Logger + server *mcp.Server + econetGRPC *econetgrpc.EconetGRPCServer +} + +func NewEconetMCPServer(ctx context.Context, cfg *config.ServiceConfig, + grpc *econetgrpc.EconetGRPCServer, +) *EconetMCPServer { + return &EconetMCPServer{ + ctx: ctx, + cfg: cfg, + log: zerolog.Ctx(ctx), + econetGRPC: grpc, + server: mcp.NewServer(EconetMCPImpl, &mcp.ServerOptions{ + Instructions: "Use these tools to inspect Rheem EcoNet water heaters: " + + "current mode, setpoint, hot water availability, connectivity, and alerts.", + HasTools: true, + }), + } +} + +func (m *EconetMCPServer) GetHandlers() []opts.HTTPHandler { + // Register the generated tools against the in-process gRPC server via the + // go-sdk adapter, so the generated code shares our configured *mcp.Server. + v1alpha1mcp.RegisterEconetServiceHandler(gosdk.Wrap(m.server), m.econetGRPC) + + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return m.server + }, &mcp.StreamableHTTPOptions{}) + + m.log.Debug().Msg("Econet MCP tools ready (generated)") + + return []opts.HTTPHandler{ + { + Prefix: "/api/mcp", + Handler: handler, + }, + } +}