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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ jobs:
# The local inspection route exists only under this tag; the suite
# must pass with it too, and TestNoRouteReadsAHome must pass without.
- run: go test -count=1 -tags ftwclouddev ./...
- name: Exact Core handlers for the measurement diagnostics integration
uses: actions/checkout@v5
with:
repository: srcfl/ftw
ref: bf80760fb4d11698ca09e76f22ce44cf347b78df
path: .core-integration
persist-credentials: false
- name: MCP measurement diagnostics through real Core handlers and SQLite
env:
FTW_CORE_CHECKOUT: ${{ github.workspace }}/.core-integration
run: go test -race -count=1 -run '^TestMeasurementDiagnosticsAgainstRealCoreHandlers$' ./internal/mcp
- name: FTW Cloud imports only the wire format from srcfl/ftw
run: bash scripts/check-imports.sh
- name: What a box can send never panics the service
Expand Down
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,13 +231,19 @@ to it yet.
clients of 2025-03-26 to 2025-11-25 that start with `initialize`, without
session ids or a GET stream. The read tools are `homes_list`, `status`,
`chargers_list`, `cars_list`, `plan_get`, `plan_explain`,
`history_query`, `health_get`, `events_list`, `logs_tail` (both with
before and after cursors), `drivers_status`, `config_get` and
`diag_run` (names any model API accepts), each with typed arguments, each one of the box's own reads for
agents (`live.get`, `loadpoints.get`, `cars.get`, `plan.get`, `price.get`,
`hist.query`, `health.get`, `events.get`, `logs.get`, `config.get`,
`diag.run`, `drivers.get`; srcfl/ftw `docs/agents.md`), under the scope
the registry names for it. `status` keeps the box's `live` body as it
`history_query`, `health_get`, `logs_tail` (with before/after cursors),
`drivers_status`, `config_get` and `measurement_diagnostics`, each with
typed arguments and the scope its box operation requires.
`measurement_diagnostics` needs the owner's diagnostic grant. Both MCP
profiles offer its fixed, read-only views of measurement reasons, saved
charger associations, energy provenance and stored series. Names and
hardware identifiers become opaque selections; other private fields stay
out. Refresh the catalogue after a service restart. No arbitrary path,
settings write, control or credential export is accepted by this tool.
`diag_run` and `events_list` are not listed until Core serves them. Cached
calls get an explicit unsupported result and never reach Core.
Full technical reads remain separate through `api_catalog` / `api_read`
on the technical profile. `status` keeps the box's `live` body as it
is, keyed by field id, and adds `readings`: each field by its registry
name (`grid_w`, `pv_w`, `battery_soc`, ...) with its unit, sources, state
and age, and any id the registry does not name under `unknownFields`.
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func apiTools() []*tool {
return []*tool{
{
name: "api_catalog", title: "Start technical troubleshooting",
description: "For technical troubleshooting, start here. Ask the connected box for every diagnostic read it supports, its running version, source-code links and a workflow for collecting bug-report evidence. Newer boxes include charger_connections with exact saved driver names and configured, missing or unverified_ocpp status; configured means a name matches, not verified equipment. Includes energy logs, original measurements, planner snapshots, configuration, driver source, logs, support reports and exports. The basic config_get and drivers_status tools redact names and cannot verify charger bindings. Ordinary status, history and control use the existing tools. The owner-approved diagnostic grant covers these reads; there are no extra endpoint permissions." + dataNote,
description: "For technical troubleshooting, start here. Ask the connected box for every diagnostic read it supports, its running version, source-code links and a workflow for collecting bug-report evidence. Newer boxes include charger_connections with exact saved driver names and configured, missing, disabled or unverified_ocpp status; configured means a name matches, not verified equipment. Includes energy logs, original measurements, planner snapshots, configuration, driver source, logs, support reports and exports. The basic config_get and drivers_status tools redact names and cannot verify charger bindings. Ordinary status, history and control use the existing tools. The owner-approved diagnostic grant covers these reads; there are no extra endpoint permissions." + dataNote,
scope: readScope(protocol.MsgAPIRead), input: object(nil), readOnly: true,
run: func(ctx context.Context, cc *callCtx, args json.RawMessage) (any, error) {
if err := decodeArgs(args, &struct{}{}); err != nil {
Expand Down
7 changes: 7 additions & 0 deletions internal/mcp/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ type stubBox struct {
delay time.Duration
cmds []protocol.Cmd
cmdReply func(protocol.Cmd) []any
apiReply func(protocol.APIRead) (int, []byte)
apiBadSequence bool
apiUnsupported bool
setupLoseReply bool
Expand Down Expand Up @@ -578,6 +579,12 @@ func (b *stubBox) handle(s *boxSession, env appwire.Envelope) {
if q.Path == "/api/test/metadata" {
data = []byte(`{"path":"device original path","status":"faulted","counter":9007199254740993}`)
}
b.mu.Lock()
apiReply := b.apiReply
b.mu.Unlock()
if apiReply != nil {
status, data = apiReply(q)
}
// This deliberately broken stub truncates a wire answer; real Core
// returns explicit byte pages within the wire bound instead.
truncated := q.MaxBytes > 0 && int64(len(data)) > q.MaxBytes
Expand Down
10 changes: 8 additions & 2 deletions internal/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const Instructions = "FTW Cloud reaches one home's FTW box as an assistant its o
"Ask small groups of questions, accept unknown answers, validate the exact draft and show changes before asking the user to confirm setup_commit. " +
"Setup edits need a separate owner-approved setup grant. Saving does not prove measurement or control on hardware. " +
"For ordinary status, history and control, use the existing tools. For technical troubleshooting, " +
"start with api_catalog: the box lists its full read API, running version, source-code links and " +
"use measurement_diagnostics for redacted quality, energy and source evidence; it needs the diagnostic grant. For full technical evidence start with api_catalog: the box lists its full read API, running version, source-code links and " +
"steps for gathering bug-report evidence. api_read keeps technical identifiers, addresses, logs and source code. " +
"For charger faults, check charger_connections when present, then read /api/config, /api/drivers and /api/loadpoints. " +
"Compare loadpoints[].driver_name with drivers[].name; when OCPP is enabled also read /api/ocpp/chargers. " +
Expand Down Expand Up @@ -158,7 +158,7 @@ func New(auth Authenticator, p *pool, log *slog.Logger) *Server {
if log == nil {
log = slog.Default()
}
return &Server{auth: auth, pool: p, tools: append(append(append(readTools(), apiTools()...), setupTools()...), controlTools()...), log: log, now: time.Now, inflight: make(chan struct{}, maxInflight)}
return &Server{auth: auth, pool: p, tools: append(append(append(append(readTools(), measurementTools()...), apiTools()...), setupTools()...), controlTools()...), log: log, now: time.Now, inflight: make(chan struct{}, maxInflight)}
}

// Path is where the server answers.
Expand Down Expand Up @@ -372,6 +372,12 @@ type callParams struct {
}

func (s *Server) call(w http.ResponseWriter, r *http.Request, id json.RawMessage, p callParams, g *oauth.Grant, modern bool) {
if unsupportedDiagnostic(p.Name) {
result := toolError("This connector does not provide this operation (E_UNSUPPORTED). Use measurement_diagnostics, health_get or logs_tail.")
result["structuredContent"] = map[string]any{"source": "service", "data": map[string]any{"available": false, "code": "E_UNSUPPORTED", "operation": p.Name, "alternatives": []string{"measurement_diagnostics", "health_get", "logs_tail"}}}
writeResult(w, id, modern, result)
return
}
t := s.tool(p.Name)
if publicProfile(r) && !publicTool(p.Name) {
t = nil
Expand Down
10 changes: 5 additions & 5 deletions internal/mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func TestAnAssistantReadsItsHome(t *testing.T) {
names = append(names, x.(map[string]any)["name"].(string))
}
for _, want := range []string{"homes_list", "status", "chargers_list", "cars_list", "plan_get", "plan_explain", "history_query",
"health_get", "events_list", "logs_tail", "drivers_status", "config_get", "diag_run"} {
"health_get", "measurement_diagnostics", "logs_tail", "drivers_status", "config_get"} {
if !slices.Contains(names, want) {
t.Fatalf("tools/list lacks %s: %v", want, names)
}
Expand Down Expand Up @@ -87,10 +87,10 @@ func TestAnAssistantReadsItsHome(t *testing.T) {
name string
args map[string]any
}{
{"plan_explain", nil}, {"health_get", nil}, {"events_list", map[string]any{"limit": 5}}, {"drivers_status", nil},
{"events_list", map[string]any{"limit": 5, "before": "2026-10-15T18:00:00+02:00"}}, {"chargers_list", nil}, {"cars_list", nil},
{"plan_explain", nil}, {"health_get", nil}, {"drivers_status", nil},
{"chargers_list", nil}, {"cars_list", nil},
{"logs_tail", map[string]any{"lines": 20, "level": "INFO", "after": "2026-10-15T17:00:00Z"}}, {"config_get", nil}, {"config_get", map[string]any{"section": "planner"}},
{"diag_run", map[string]any{"check": "clock"}}, {"diag_run", map[string]any{"check": "driver", "src": "s1"}}, {"homes_list", nil},
{"homes_list", nil},
} {
if _, isErr, text := a.call(c.name, c.args); isErr {
t.Fatalf("%s: %s", c.name, text)
Expand All @@ -112,7 +112,7 @@ func TestAnAssistantReadsItsHome(t *testing.T) {
}
joined := strings.Join(asked, "\n")
for _, want := range []string{"live.get", "loadpoints.get", "cars.get", "plan.get", "price.get", "hist.query", "health.get",
"events.get", "logs.get", "config.get", "diag.run", "drivers.get"} {
"logs.get", "config.get", "drivers.get"} {
if !strings.Contains(joined, want+" ") {
t.Fatalf("the box was not asked %s: %v", want, asked)
}
Expand Down
Loading
Loading