Type the REST API's responses
Every tool now returns a Go struct (apitypes.go) instead of a map, and typeschema.go derives the OpenAPI response schemas from those types by reflection (json, doc and enum tags), so the document cannot drift from what is sent. Text results are documented as text/plain, settings in answers use the typed settings schema, and edits answer with EditResponse. TestResponsesMatchTheirSchemas checks real responses against the generated schemas. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
+45
-48
@@ -204,6 +204,7 @@ func (a *app) mcpDispatch(tok Token, req rpcRequest) (any, *rpcError) {
|
||||
type mcpTool struct {
|
||||
name, description, scope string
|
||||
schema map[string]any
|
||||
output any // a value of the result's type (nil: the result is text), for the OpenAPI document
|
||||
run func(a *app, args json.RawMessage) (any, error)
|
||||
}
|
||||
|
||||
@@ -247,10 +248,8 @@ func (a *app) runTool(tok Token, tool *mcpTool, args json.RawMessage) (any, erro
|
||||
}
|
||||
a.sys.audit(entry)
|
||||
if err == nil && tool.name == "update_settings" {
|
||||
if m, ok := res.(map[string]any); ok {
|
||||
if e, ok := m["etag"].(string); ok {
|
||||
a.sys.noteTokenEdit(tok.Name, e)
|
||||
}
|
||||
if r, ok := res.(EditResponse); ok {
|
||||
a.sys.noteTokenEdit(tok.Name, r.ETag)
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
@@ -270,54 +269,63 @@ func etag(data []byte) string {
|
||||
return hex.EncodeToString(h[:8])
|
||||
}
|
||||
|
||||
func finite(f float64) any {
|
||||
func finite(f float64) *float64 {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return nil
|
||||
}
|
||||
return math.Round(f*10) / 10
|
||||
r := math.Round(f*10) / 10
|
||||
return &r
|
||||
}
|
||||
|
||||
// orEmpty makes a nil list an empty one, so that it is [] in the JSON.
|
||||
func orEmpty[T any](l []T) []T {
|
||||
if l == nil {
|
||||
return []T{}
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
var emptySchema = map[string]any{"type": "object", "properties": map[string]any{}}
|
||||
|
||||
var mcpTools = []mcpTool{
|
||||
{
|
||||
name: "get_status", scope: scopeRead, schema: emptySchema,
|
||||
name: "get_status", scope: scopeRead, schema: emptySchema, output: StatusResponse{},
|
||||
description: "The router's state now: uplink (connected, addresses, gateway, pings), system (uptime, load, CPU, memory, disk, temperature, kernel), whether settings are unapplied, and whether an apply is running or waits for confirmation.",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) {
|
||||
snap := a.metrics.Snapshot()
|
||||
st := a.sys.Status()
|
||||
u, s := snap.Uplink, snap.System
|
||||
var pings []map[string]any
|
||||
pings := []PingInfo{}
|
||||
for _, p := range snap.Pings {
|
||||
pings = append(pings, map[string]any{"target": p.Target, "address": p.Address, "ok": p.OK, "rttMs": p.RTT.Milliseconds()})
|
||||
pings = append(pings, PingInfo{Target: p.Target, Address: p.Address, OK: p.OK, RttMs: p.RTT.Milliseconds()})
|
||||
}
|
||||
return map[string]any{
|
||||
"time": snap.Time,
|
||||
"uplink": map[string]any{"device": u.Device, "type": u.Type, "connected": u.Connected, "reachable": u.Reachable,
|
||||
"ipv4": u.IPv4, "ipv6": u.IPv6, "gateway": u.Gateway, "since": u.Since},
|
||||
"pings": pings,
|
||||
"system": map[string]any{"uptimeSeconds": int(s.Uptime.Seconds()), "load": s.Load, "kernel": s.Kernel,
|
||||
"rebootForKernel": s.KernelNext, "rebootForConfiguration": s.BootPending, "cpuPercent": finite(s.CPU),
|
||||
"memoryUsedBytes": s.MemUsed, "memoryTotalBytes": s.MemTotal, "temperatureC": finite(s.Temperature),
|
||||
"diskUsedBytes": s.DiskUsed, "diskTotalBytes": s.DiskTotal},
|
||||
"settings": map[string]any{"unapplied": st.Unapplied, "applying": st.Applying,
|
||||
"lastApplyResult": st.ApplyResult, "confirmationDueInSeconds": int(st.RollbackIn.Seconds())},
|
||||
return StatusResponse{
|
||||
Time: snap.Time,
|
||||
Uplink: UplinkInfo{Device: u.Device, Type: u.Type, Connected: u.Connected, Reachable: u.Reachable,
|
||||
IPv4: orEmpty(u.IPv4), IPv6: orEmpty(u.IPv6), Gateway: u.Gateway, Since: u.Since},
|
||||
Pings: pings,
|
||||
System: SystemInfo{UptimeSeconds: int(s.Uptime.Seconds()), Load: s.Load, Kernel: s.Kernel,
|
||||
RebootForKernel: s.KernelNext, RebootForConfiguration: s.BootPending, CPUPercent: finite(s.CPU),
|
||||
MemoryUsedBytes: s.MemUsed, MemoryTotalBytes: s.MemTotal, TemperatureC: finite(s.Temperature),
|
||||
DiskUsedBytes: s.DiskUsed, DiskTotalBytes: s.DiskTotal},
|
||||
Settings: SettingsInfo{Unapplied: st.Unapplied, Applying: st.Applying,
|
||||
LastApplyResult: st.ApplyResult, ConfirmationDueInSeconds: int(st.RollbackIn.Seconds())},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_settings", scope: scopeRead, schema: emptySchema,
|
||||
name: "get_settings", scope: scopeRead, schema: emptySchema, output: SettingsResponse{},
|
||||
description: "The saved settings (settings.json: `system` and `router`) with the etag to pass to update_settings, and whether they differ from the confirmed (running) system. Secrets are not in it, only paths of files that hold them.",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) {
|
||||
data, err := os.ReadFile(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var v any
|
||||
var v map[string]any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"etag": etag(data), "unapplied": a.sys.Status().Unapplied, "settings": v}, nil
|
||||
return SettingsResponse{ETag: etag(data), Unapplied: a.sys.Status().Unapplied, Settings: v}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -331,70 +339,60 @@ var mcpTools = []mcpTool{
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_interfaces", scope: scopeRead, schema: emptySchema,
|
||||
name: "list_interfaces", scope: scopeRead, schema: emptySchema, output: []InterfaceInfo{},
|
||||
description: "Network interfaces: physical cards (name now, name from the settings, MAC address) and VLAN devices, and how the settings use each (WAN, a LAN).",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) {
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []map[string]any
|
||||
out := []InterfaceInfo{}
|
||||
for _, d := range a.devices(s, s.form()) {
|
||||
e := map[string]any{"name": d.Name, "kind": d.Kind, "usedAs": d.Role}
|
||||
if d.Kernel != "" {
|
||||
e["kernelName"] = d.Kernel
|
||||
}
|
||||
if d.MAC != "" {
|
||||
e["mac"] = d.MAC
|
||||
}
|
||||
if d.Custom != "" {
|
||||
e["deviceName"] = d.Custom
|
||||
}
|
||||
out = append(out, e)
|
||||
out = append(out, InterfaceInfo{Name: d.Name, Kind: d.Kind, UsedAs: d.Role, KernelName: d.Kernel, MAC: d.MAC, DeviceName: d.Custom})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_dhcp_leases", scope: scopeRead, schema: emptySchema,
|
||||
name: "list_dhcp_leases", scope: scopeRead, schema: emptySchema, output: []LeaseInfo{},
|
||||
description: "The DHCP clients currently holding a lease: address, MAC, hostname, LAN, when the lease ends.",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) {
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []map[string]any
|
||||
out := []LeaseInfo{}
|
||||
for _, l := range readLeases(a.metrics.leasesPath, s.form().LANs, time.Now()) {
|
||||
out = append(out, map[string]any{"address": l.Address, "mac": l.MAC, "hostname": l.Hostname, "lan": l.LAN, "expires": l.Expires})
|
||||
out = append(out, LeaseInfo{Address: l.Address, MAC: l.MAC, Hostname: l.Hostname, LAN: l.LAN, Expires: l.Expires})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_update_status", scope: scopeRead, schema: emptySchema,
|
||||
name: "get_update_status", scope: scopeRead, schema: emptySchema, output: UpdateStatusResponse{},
|
||||
description: "The installed release (version, schema) and the state of the last update run.",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) {
|
||||
st := a.sys.UpdateStatus()
|
||||
return map[string]any{"installed": a.sys.installedRelease(), "running": st.Running, "lastResult": st.Result, "log": st.Log}, nil
|
||||
return UpdateStatusResponse{Installed: a.sys.installedRelease(), Running: st.Running, LastResult: st.Result, Log: st.Log}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_apply_log", scope: scopeRead, schema: emptySchema,
|
||||
name: "get_apply_log", scope: scopeRead, schema: emptySchema, output: ApplyLogResponse{},
|
||||
description: "The log of the last apply (build and activation) and its result.",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) {
|
||||
st := a.sys.Status()
|
||||
return map[string]any{"applying": st.Applying, "result": st.ApplyResult, "log": st.Log}, nil
|
||||
return ApplyLogResponse{Applying: st.Applying, Result: st.ApplyResult, Log: st.Log}, nil
|
||||
},
|
||||
},
|
||||
logsTool,
|
||||
{
|
||||
name: "check_settings", scope: scopeRead,
|
||||
schema: emptySchema,
|
||||
schema: emptySchema, output: CheckResponse{},
|
||||
description: "Evaluates the saved settings the way an apply would (the router's assertions and the option types) without building or activating anything. Takes about a minute; if it is still running, call again to get the result. Reports whether the result is for the current saved settings.",
|
||||
run: func(a *app, _ json.RawMessage) (any, error) { return a.checkSettings() },
|
||||
},
|
||||
{
|
||||
name: "update_settings", scope: scopeChange,
|
||||
name: "update_settings", scope: scopeChange, output: EditResponse{},
|
||||
schema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
@@ -535,7 +533,6 @@ func (a *app) editSettings(wantEtag string, edit func(next Settings) (Settings,
|
||||
return nil, err
|
||||
}
|
||||
after, _ := os.ReadFile(a.sys.settingsPath())
|
||||
return map[string]any{"saved": true, "applied": false, "etag": etag(after),
|
||||
"changes": diffLines(pretty(data), pretty(after)),
|
||||
"next": "Run check_settings (GET /api/v1/settings/check); a person applies the changes in the web UI."}, nil
|
||||
return EditResponse{Saved: true, ETag: etag(after), Changes: diffLines(pretty(data), pretty(after)),
|
||||
Next: "Run check_settings (GET /api/v1/settings/check); a person applies the changes in the web UI."}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user