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
5 changes: 5 additions & 0 deletions .changeset/mcp-diagnostic-api-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

Give authorized MCP sessions the full diagnostic read API, including the energy ledger, raw series, logs, configuration, driver source and support exports. Keep technical names and addresses, list the connected box's routes, and page large answers without a total size limit. Stop reads when access ends.
3 changes: 2 additions & 1 deletion contract/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ scopes:
- { name: ftw.members.write, desc: Invite and revoke }
- { name: ftw.logs.read, desc: Log excerpts the box redacts }
- { name: ftw.config.read, desc: Configuration the box redacts }
- { name: ftw.diag.run, desc: Checks that change nothing }
- { name: ftw.diag.run, desc: Full technical diagnostics and read API }
- { name: ftw.dispatch.hold, desc: Hold energy assets for a capped time }
- { name: ftw.goals.request, desc: Ask an owner to change a durable goal }
# Reserved: no op asks for an update yet, and no role but owner carries it.
Expand Down Expand Up @@ -445,6 +445,7 @@ reads:
- { msg: logs.get, scope: ftw.logs.read, desc: A log excerpt the box redacts }
- { msg: config.get, scope: ftw.config.read, desc: A configuration section the box redacts }
- { msg: diag.run, scope: ftw.diag.run, desc: A check that changes nothing }
- { msg: api.read, const: MsgAPIRead, scope: ftw.diag.run, desc: Read the full technical diagnostic API }

# ---------------------------------------------------------------------------
# Cars. A car belongs to its owner, not to the home, and visits chargers. The
Expand Down
161 changes: 161 additions & 0 deletions go/internal/api/api_agent_archive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package api

import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"io"
"net/http"
"os"
"strings"
)

// Archive reads stay on the box. Spool a generated export to a private temp
// file, inspect entries without extracting paths, and remove it on return.
// The existing page writer keeps only the requested member's byte page.
func (s *Server) serveAgentArchive(w *agentReadBuffer, r *http.Request, member string) {
file, err := os.CreateTemp("", "ftw-diagnostic-*")
if err != nil {
writeJSON(w, 503, map[string]string{"error": "could not open diagnostic export"})
return
}
defer os.Remove(file.Name())
defer file.Close()
spool := &agentArchiveSpool{ctx: r.Context(), file: file, header: make(http.Header), status: 200}
s.serveAgentHTTP(spool, r)
if spool.err != nil || r.Context().Err() != nil {
writeJSON(w, 503, map[string]string{"error": "could not finish diagnostic export"})
return
}
if _, err = file.Seek(0, io.SeekStart); err != nil {
writeJSON(w, 500, map[string]string{"error": "could not read diagnostic export"})
return
}
// Preserve the handler's error answer rather than trying to open it as an archive.
if spool.status != 200 {
w.Header().Set("Content-Type", spool.header.Get("Content-Type"))
w.WriteHeader(spool.status)
_, _ = io.Copy(w, file)
return
}
var files []map[string]any
found := false
relative := func(name string) string {
_, rest, ok := strings.Cut(name, "/")
if ok && rest != "" {
return rest
}
return name
}
send := func(name string, size int64, open func() (io.ReadCloser, error)) error {
name = relative(name)
if member == "." {
files = append(files, map[string]any{"name": name, "bytes": size})
return nil
}
if name != member {
return nil
}
body, err := open()
if err != nil {
return err
}
defer body.Close()
found = true
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, err = io.Copy(w, body)
return err
}
switch strings.Split(spool.header.Get("Content-Type"), ";")[0] {
case "application/zip":
stat, e := file.Stat()
if e != nil {
err = e
break
}
archive, e := zip.NewReader(file, stat.Size())
if e != nil {
err = e
break
}
for _, entry := range archive.File {
if entry.FileInfo().IsDir() {
continue
}
entry := entry
err = send(entry.Name, int64(entry.UncompressedSize64), entry.Open)
if err != nil || found {
break
}
}
case "application/gzip", "application/x-gzip":
gz, e := gzip.NewReader(file)
if e != nil {
err = e
break
}
defer gz.Close()
archive := tar.NewReader(gz)
for {
header, e := archive.Next()
if e == io.EOF {
break
}
if e != nil {
err = e
break
}
if header.Typeflag != tar.TypeReg {
continue
}
err = send(header.Name, header.Size, func() (io.ReadCloser, error) { return io.NopCloser(archive), nil })
if err != nil || found {
break
}
}
default:
writeJSON(w, 415, map[string]string{"error": "archive_member requires a ZIP or tar.gz export; omit it to read the original bytes"})
return
}
if err != nil {
w.failed = err
return
}
if member == "." {
writeJSON(w, 200, map[string]any{"files": files})
return
}
if !found {
writeJSON(w, 404, map[string]string{"error": "archive member not found; use archive_member '.' to list files"})
}
}

type agentArchiveSpool struct {
ctx context.Context
file *os.File
header http.Header
status int
wrote bool
err error
}

func (s *agentArchiveSpool) Header() http.Header { return s.header }
func (s *agentArchiveSpool) WriteHeader(status int) {
if !s.wrote {
s.status, s.wrote = status, true
}
}
func (s *agentArchiveSpool) Write(body []byte) (int, error) {
if s.err != nil {
return 0, s.err
}
if err := s.ctx.Err(); err != nil {
s.err = err
return 0, err
}
s.WriteHeader(200)
n, err := s.file.Write(body)
s.err = err
return n, err
}
156 changes: 156 additions & 0 deletions go/internal/api/api_agent_catalog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package api

import (
"net/http"
"net/url"
"regexp"
"runtime/debug"
"sort"
"strings"

"github.com/srcfl/ftw/go/internal/apiauth"
"github.com/srcfl/ftw/go/protocol"
)

// The router owns eligibility. These hints only describe queries; they never
// decide which Read routes a connected assistant can reach.
var agentAPIHints = []protocol.AgentAPIRoute{
{Path: "/api/status", Description: "Current measurements, control state and freshness"},
{Path: "/api/health", Description: "Box health"},
{Path: "/api/system/info", Description: "Runtime and host diagnostics, including host details"},
{Path: "/api/storage/inventory", Description: "Storage use and retention"},
{Path: "/api/components", Description: "Component versions and state"},
{Path: "/api/components/history", Description: "Component changes"},
{Path: "/api/drivers", Description: "Driver diagnostics by configured name"},
{Path: "/api/data-sources", Description: "Available data sources and freshness"},
{Path: "/api/battery_models", Description: "Learned battery model and efficiency"},
{Path: "/api/loadmodel", Description: "Load forecast model and learning"},
{Path: "/api/pvmodel", Description: "Solar forecast model and learning"},
{Path: "/api/forecast", Description: "Load and solar forecasts", Query: map[string]string{"range": "Time ahead, such as 24h"}},
{Path: "/api/prices", Description: "Stored import and export prices", Query: map[string]string{"range": "Time ahead, such as 24h", "since_ms": "Unix milliseconds", "until_ms": "Unix milliseconds"}},
{Path: "/api/planner/prefs", Description: "Planner preferences"},
{Path: "/api/mpc/plan", Description: "Full current plan and per-asset slots"},
{Path: "/api/mpc/diagnose", Description: "Current planner inputs, constraints and result"},
{Path: "/api/mpc/diagnose/history", Description: "Recorded planning decisions", Query: map[string]string{"since": "Unix milliseconds", "until": "Unix milliseconds", "limit": "Maximum snapshots, up to 5000"}},
{Path: "/api/mpc/diagnose/at", Description: "The planning snapshot active at an incident", Query: map[string]string{"ts": "Required incident time, Unix milliseconds"}},
{Path: "/api/history", Description: "Recorded site measurements", Query: map[string]string{"range": "Time back, such as 1h", "points": "Maximum rows"}},
{Path: "/api/energy/daily", Description: "Daily measured energy and costs", Query: map[string]string{"days": "Number of days"}},
{Path: "/api/energy/assets", Description: "Energy-ledger assets by configured name"},
{Path: "/api/energy/history", Description: "Energy-ledger entries in Wh, including flow, source, quality, provenance and sample count", Query: map[string]string{"scope": "system or asset", "asset_id": "Asset id from /api/energy/assets", "since": "Unix milliseconds", "until": "Unix milliseconds", "bucket": "Bucket duration, at least 5m", "limit": "Maximum buckets, up to 5000"}},
{Path: "/api/savings/daily", Description: "Daily cost comparison and coverage", Query: map[string]string{"days": "Number of days"}},
{Path: "/api/series/catalog", Description: "Stored drivers, metric names and units"},
{Path: "/api/series", Description: "One driver's measured series with min, max, sample count, resolution and bounds", Query: map[string]string{"driver": "Required driver name from /api/series/catalog", "metric": "Required metric name or comma-separated names from the catalogue", "range": "Time back, such as 6h", "since": "Unix milliseconds", "until": "Unix milliseconds", "points": "Maximum points per metric"}},
}

// These GET handlers return diagnostic data despite their Local tier for
// phone passthrough. OAuth GETs start or complete a credential exchange and
// are not reads. Configure and Actuate handlers keep their command path.
var agentLocalReads = map[string]bool{
"/api/config": true,
"/api/drivers/{id}/source": true,
"/api/drivers/{name}/logs": true,
"/api/logs": true,
"/api/support/dump": true,
"/api/support/report": true,
"/api/ocpp/chargers": true,
"/api/backups/{id}": true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep full backups outside diagnostic reads

An agent granted only ftw.diag.run can enumerate /api/backups and then read /api/backups/{id} through this allowlist. That endpoint was deliberately classified Local because the archive contains the unmasked configuration and state.db; unlike /api/config, the backup path does not redact stored credentials. This turns a diagnostic grant into a complete secrets and persistent-state export, so backups should remain unavailable through this scope or require a separate explicit backup permission.

Useful? React with 👍 / 👎.

"/api/agents": true,
"/api/agents/audit": true,
}

func agentReadMark(pattern string, mark routeMark) bool {
fields := strings.Fields(pattern)
return len(fields) == 2 && fields[0] == http.MethodGet && !mark.static &&
(mark.tier == apiauth.TierRead || mark.tier == apiauth.TierLocal && agentLocalReads[fields[1]])
}

func (s *Server) agentReadAllowed(r *http.Request) bool {
if r.Method != http.MethodGet {
return false
}
_, pattern := s.mux.Handler(r)
mark, ok := s.marks[pattern]
return ok && agentReadMark(pattern, mark)
}

func (s *Server) agentReadCatalog() []protocol.AgentAPIRoute {
routes := make([]protocol.AgentAPIRoute, 0, len(s.marks))
for pattern, mark := range s.marks {
if !agentReadMark(pattern, mark) {
continue
}
path := strings.Fields(pattern)[1]
route := protocol.AgentAPIRoute{Path: path, Scope: protocol.ScopeDiagRun, Description: "Read " + path}
for _, hint := range agentAPIHints {
if hint.Path == path {
route.Description, route.Query = hint.Description, hint.Query
break
}
}
if query, ok := agentAPIQueryHints[path]; ok {
route.Query = query
}
routes = append(routes, route)
}
sort.Slice(routes, func(i, j int) bool { return routes[i].Path < routes[j].Path })
return routes
}

var agentAPIQueryHints = map[string]map[string]string{
"/api/components/history": {"id": "Component id", "kind": "Component kind", "limit": "Maximum changes"},
"/api/data-sources": {"lat": "Latitude", "lon": "Longitude"},
"/api/device_repository/catalog": {"channel": "Driver channel"},
"/api/notifications/history": {"limit": "Maximum notifications"},
"/api/logs": {"limit": "Maximum log entries, up to 5000"},
"/api/drivers/{name}/logs": {"limit": "Maximum log entries, up to 2000"},
"/api/ev/status": {"driver": "Configured driver name"},
"/api/research/load/dump": {"days": "Days of original observations, 1 to 365; tar.gz export"},
"/api/agents/audit": {"before": "Audit sequence before which to page", "n": "Maximum audit rows"},
"/api/series": {"driver": "Configured driver name", "metric": "Metric or comma-separated metrics from /api/series/catalog", "range": "Time back, such as 6h", "since": "Unix milliseconds", "until": "Unix milliseconds", "points": "Maximum points per metric; 0 returns stored rows", "format": "json (default) or csv"},
"/api/energy/history.csv": {"scope": "system or asset", "asset_id": "Asset id from /api/energy/assets", "since": "Unix milliseconds", "until": "Unix milliseconds", "bucket": "Bucket duration, at least 5m", "limit": "Maximum buckets, up to 5000"},
}

// This context is requested for technical debugging, not for routine status,
// history or control. Source links name a recorded build or a declared
// release; a default-branch fallback says explicitly that it may differ.
func (s *Server) agentDebugContext() map[string]any {
ref, kind, revision, modified := "master", "default branch; may differ from the running build", "", false
if info, ok := debug.ReadBuildInfo(); ok {
for _, setting := range info.Settings {
if setting.Key == "vcs.revision" {
revision = setting.Value
}
if setting.Key == "vcs.modified" {
modified = setting.Value == "true"
}
}
}
if revision != "" {
ref, kind = revision, "recorded build revision"
}
if regexp.MustCompile(`^v?0\.[0-9]+\.[0-9]+(?:-beta\.[0-9]+)?$`).MatchString(s.deps.Version) {
ref, kind = "v"+strings.TrimPrefix(s.deps.Version, "v"), "declared release tag"
}
tree := "https://github.com/srcfl/ftw/tree/" + url.PathEscape(ref)
return map[string]any{
"core": map[string]any{"version": s.deps.Version, "revision": revision, "modified": modified, "source_ref_kind": kind,
"repository": "https://github.com/srcfl/ftw", "source": tree,
"api": tree + "/go/internal/api", "energy_history": tree + "/go/internal/state", "planner": tree + "/go/internal/mpc",
"driver_host": tree + "/go/internal/drivers", "bundled_driver_pin": tree + "/drivers/BUNDLED_SOURCE.json", "issues": "https://github.com/srcfl/ftw/issues"},
"related_sources": []map[string]string{
{"part": "Editable Lua drivers", "repository": "https://github.com/srcfl/device-drivers", "running_source": "Read /api/drivers/catalog, /api/drivers/{id}/source and /api/device_repository/drivers/{id}/versions for installed code and hashes"},
{"part": "FTW Cloud and MCP", "repository": "https://github.com/srcfl/ftw-cloud", "running_source": "https://cloud.ftw.energy/source", "revision": "The Cloud HTTP response names X-Source-Revision"},
{"part": "Web client", "repository": "https://github.com/srcfl/ftw-webapp"},
{"part": "Native apps", "repository": "https://github.com/srcfl/ftw-app"},
{"part": "Compiled Energyplan solver", "repository": "https://github.com/srcfl/energyplan", "access": "Private repository; Core carries binaries and validates their plans"},
},
"workflow": []string{
"Record the symptom, expected behavior, incident time and timezone. Separate measured facts from guesses.",
"Read /api/health, /api/support/report and /api/components for versions, clock, freshness and failures.",
"Use /api/energy/history for Wh and provenance, /api/series for original measurements, and /api/mpc/diagnose/history then /api/mpc/diagnose/at for the plan active at the incident.",
"Inspect configuration, driver details, driver source and logs as needed. Compare with source for the running version; default-branch code may differ.",
"For a bug report, include the Core version and source reference, equipment and driver versions, reproduction steps, expected and observed results, incident time, exact API queries and relevant logs or snapshots. Mark data gaps and whether evidence came from real equipment, a simulator or a test.",
"Use the existing control and goal tools when an action is needed. Reading a diagnostic view does not issue a command. Prepare a bug report for the user; do not publish it without their request.",
},
}
}
Loading
Loading