Repository navigation
Expose all technical diagnostics over MCP #1723
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| "/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.", | ||
| }, | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
An agent granted only
ftw.diag.runcan enumerate/api/backupsand then read/api/backups/{id}through this allowlist. That endpoint was deliberately classified Local because the archive contains the unmasked configuration andstate.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 👍 / 👎.