Files
nixos-router/pkgs/router-webui/mcp.go
T
nilsandClaude Sonnet 5.5 98b0ebc130 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
2026-11-05 08:10:59 +01:00

539 lines
20 KiB
Go

package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// An MCP server (Model Context Protocol, Streamable HTTP transport) on
// POST /mcp, so an assistant can read the router's state and edit its
// settings. It is a thin layer over what the web UI does:
//
// - tokens (tokens.go) instead of the browser session; scope read or change;
// - changes only edit settings.json, like the Advanced page: they stay
// unapplied until a person applies them in the web UI (commit-confirm);
// - secrets are never returned (the settings hold only paths to them), and
// file paths in the settings must stay under the state directory;
// - SSH keys, the release schema and the state version are out of reach;
// - every call is written to the audit log.
//
// Only requests and responses of JSON-RPC 2.0 over POST are implemented: no
// server-initiated messages, no streaming.
var mcpVersions = []string{"2025-06-18", "2025-03-26", "2024-11-05"}
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
const mcpInstructions = `This is the management interface of a nixos-router.
Read the state with get_status, list_interfaces, list_dhcp_leases, get_settings and get_update_status;
get_logs reads the system journal (a unit, a time range, a priority, a text to search for).
Settings are one JSON document (the resource router://settings; router://options describes every option).
Change them with update_settings (a JSON merge patch; pass the etag from get_settings), then run check_settings,
which evaluates the configuration and reports the router's own assertions. Changes are saved but not applied:
a person reviews them (get_pending_changes shows the difference) and applies them in the web UI.
Never put secrets into the settings: they refer to files (passwordFile, secretFile, ...) that the person provides.`
// mcp is the HTTP handler of the endpoint.
func (a *app) mcp(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "this endpoint takes POST (JSON-RPC)", http.StatusMethodNotAllowed)
return
}
tok, ok := a.bearer(w, r)
if !ok {
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
return
}
var req rpcRequest
if err := json.Unmarshal(body, &req); err != nil || req.JSONRPC != "2.0" || req.Method == "" {
a.rpcReply(w, rpcResponse{ID: json.RawMessage("null"), Error: &rpcError{-32700, "not a JSON-RPC 2.0 request"}})
return
}
if len(req.ID) == 0 { // a notification
w.WriteHeader(http.StatusAccepted)
return
}
resp := rpcResponse{ID: req.ID}
result, rerr := a.mcpDispatch(tok, req)
if rerr != nil {
resp.Error = rerr
} else {
resp.Result = result
}
a.rpcReply(w, resp)
}
// bearer authenticates an API request (MCP or REST) by its token; it has
// answered the request itself when it returns false.
func (a *app) bearer(w http.ResponseWriter, r *http.Request) (Token, bool) {
// A browser page must not be able to talk to the router with a token it
// does not have, but DNS rebinding could present a foreign Origin.
if o := r.Header.Get("Origin"); o != "" {
u, err := url.Parse(o)
if err != nil || u.Host != r.Host {
http.Error(w, "forbidden origin", http.StatusForbidden)
return Token{}, false
}
}
secret, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
tok, valid := a.tokens.Lookup(strings.TrimSpace(secret))
if !ok || !valid {
time.Sleep(time.Second) // a guess costs a second
w.Header().Set("WWW-Authenticate", `Bearer realm="router"`)
http.Error(w, "a valid token is required", http.StatusUnauthorized)
return Token{}, false
}
return tok, true
}
func (a *app) rpcReply(w http.ResponseWriter, resp rpcResponse) {
resp.JSONRPC = "2.0"
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func (a *app) mcpDispatch(tok Token, req rpcRequest) (any, *rpcError) {
switch req.Method {
case "initialize":
var p struct {
ProtocolVersion string `json:"protocolVersion"`
}
_ = json.Unmarshal(req.Params, &p)
version := mcpVersions[0]
for _, v := range mcpVersions {
if v == p.ProtocolVersion {
version = v
}
}
return map[string]any{
"protocolVersion": version,
"capabilities": map[string]any{"tools": map[string]any{}, "resources": map[string]any{}},
"serverInfo": map[string]any{"name": "nixos-router", "version": a.sys.installedRelease().Version},
"instructions": mcpInstructions,
}, nil
case "ping":
return map[string]any{}, nil
case "tools/list":
var out []map[string]any
for _, t := range mcpTools {
if tok.allows(t.scope) {
out = append(out, map[string]any{"name": t.name, "description": t.description, "inputSchema": t.schema})
}
}
return map[string]any{"tools": out}, nil
case "tools/call":
var p struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &p); err != nil {
return nil, &rpcError{-32602, "bad parameters"}
}
return a.mcpCall(tok, p.Name, p.Arguments)
case "resources/list":
res := []map[string]any{
{"uri": "router://settings", "name": "settings", "description": "The saved settings (settings.json).", "mimeType": "application/json"},
}
if a.optionsFile != "" {
res = append(res, map[string]any{"uri": "router://options", "name": "options",
"description": "Every router option: type, default, description (what settings.json may contain).", "mimeType": "application/json"})
}
return map[string]any{"resources": res}, nil
case "resources/read":
var p struct {
URI string `json:"uri"`
}
if err := json.Unmarshal(req.Params, &p); err != nil {
return nil, &rpcError{-32602, "bad parameters"}
}
var text string
switch p.URI {
case "router://settings":
data, err := os.ReadFile(a.sys.settingsPath())
if err != nil {
return nil, &rpcError{-32603, err.Error()}
}
text = string(data)
case "router://options":
data, err := os.ReadFile(a.optionsFile)
if a.optionsFile == "" || err != nil {
return nil, &rpcError{-32002, "no such resource"}
}
text = string(data)
default:
return nil, &rpcError{-32002, "no such resource"}
}
return map[string]any{"contents": []map[string]any{{"uri": p.URI, "mimeType": "application/json", "text": text}}}, nil
}
return nil, &rpcError{-32601, "method not found: " + req.Method}
}
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)
}
// mcpCall runs a tool. A failing tool is not a protocol error: the assistant
// is told what went wrong and can correct itself.
func (a *app) mcpCall(tok Token, name string, args json.RawMessage) (any, *rpcError) {
var tool *mcpTool
for i := range mcpTools {
if mcpTools[i].name == name {
tool = &mcpTools[i]
}
}
if tool == nil || !tok.allows(tool.scope) {
return nil, &rpcError{-32602, "unknown tool: " + name}
}
res, err := a.runTool(tok, tool, args)
if err != nil {
return map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": err.Error()}}}, nil
}
text, ok := res.(string)
if !ok {
data, merr := json.MarshalIndent(res, "", " ")
if merr != nil {
return nil, &rpcError{-32603, merr.Error()}
}
text = string(data)
}
return map[string]any{"content": []map[string]any{{"type": "text", "text": text}}}, nil
}
// runTool runs a tool for a token (the scope is checked by the caller) and
// writes the call to the audit log.
func (a *app) runTool(tok Token, tool *mcpTool, args json.RawMessage) (any, error) {
if len(args) == 0 {
args = json.RawMessage("{}")
}
res, err := tool.run(a, args)
entry := AuditEntry{Time: time.Now().UTC(), Token: tok.Name, Tool: tool.name, Args: string(args)}
if err != nil {
entry.Error = err.Error()
}
a.sys.audit(entry)
if err == nil && tool.name == "update_settings" {
if r, ok := res.(EditResponse); ok {
a.sys.noteTokenEdit(tok.Name, r.ETag)
}
}
return res, err
}
// errStale: the settings changed since the caller read them.
var errStale = errors.New("the settings changed since you read them (someone else edited, or the etag is wrong): call get_settings again")
// argError is a tool's complaint about its arguments (HTTP 400 in the REST API).
type argError string
func (e argError) Error() string { return string(e) }
// etag identifies a version of the settings file.
func etag(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:8])
}
func finite(f float64) *float64 {
if math.IsNaN(f) || math.IsInf(f, 0) {
return nil
}
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, 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
pings := []PingInfo{}
for _, p := range snap.Pings {
pings = append(pings, PingInfo{Target: p.Target, Address: p.Address, OK: p.OK, RttMs: p.RTT.Milliseconds()})
}
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, 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 map[string]any
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return SettingsResponse{ETag: etag(data), Unapplied: a.sys.Status().Unapplied, Settings: v}, nil
},
},
{
name: "get_pending_changes", scope: scopeRead, schema: emptySchema,
description: "The difference between the confirmed settings and the saved ones (lines starting with - are removed, + added); empty when there is none.",
run: func(a *app, _ json.RawMessage) (any, error) {
if d := a.sys.pendingChanges(); d != "" {
return d, nil
}
return "No changes: the saved settings are the confirmed ones.", nil
},
},
{
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
}
out := []InterfaceInfo{}
for _, d := range a.devices(s, s.form()) {
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, 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
}
out := []LeaseInfo{}
for _, l := range readLeases(a.metrics.leasesPath, s.form().LANs, time.Now()) {
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, 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 UpdateStatusResponse{Installed: a.sys.installedRelease(), Running: st.Running, LastResult: st.Result, Log: st.Log}, nil
},
},
{
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 ApplyLogResponse{Applying: st.Applying, Result: st.ApplyResult, Log: st.Log}, nil
},
},
logsTool,
{
name: "check_settings", scope: scopeRead,
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, output: EditResponse{},
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"etag": map[string]any{"type": "string", "description": "The etag from get_settings: the change is refused if the settings changed since."},
"patch": map[string]any{"type": "object", "description": "A JSON merge patch (RFC 7386) on the settings: objects merge, a value replaces, null removes a key, arrays are replaced whole."},
},
"required": []string{"etag", "patch"},
},
description: "Edit the saved settings with a JSON merge patch. The settings are saved but not applied: a person applies them in the web UI. Not allowed: ssh keys, the schema version, the state version, and file paths outside the router's state directory. Run check_settings afterwards.",
run: func(a *app, args json.RawMessage) (any, error) { return a.updateSettings(args) },
},
{
name: "discard_changes", scope: scopeChange, schema: emptySchema,
description: "Throw away the unapplied changes: the settings go back to the confirmed ones.",
run: func(a *app, _ json.RawMessage) (any, error) {
if err := a.sys.Discard(); err != nil {
return nil, err
}
return "The settings are the confirmed ones again.", nil
},
},
}
// mergePatch applies a JSON merge patch (RFC 7386).
func mergePatch(target, patch any) any {
p, ok := patch.(map[string]any)
if !ok {
return patch
}
t, ok := target.(map[string]any)
if !ok {
t = map[string]any{}
}
for k, v := range p {
if v == nil {
delete(t, k)
} else {
t[k] = mergePatch(t[k], v)
}
}
return t
}
// checkMCPSettings refuses what an assistant may not change.
func (s System) checkMCPSettings(old, next Settings) error {
for k := range next {
if k != "version" && k != "system" && k != "router" {
return fmt.Errorf("unknown top-level key %q (only system and router)", k)
}
}
same := func(a, b any) bool { x, _ := json.Marshal(a); y, _ := json.Marshal(b); return string(x) == string(y) }
if !same(old["version"], next["version"]) {
return errors.New("`version` (the settings schema) cannot be changed")
}
oldSys, newSys := obj(Settings{"system": old["system"]}, "system"), obj(Settings{"system": next["system"]}, "system")
for _, k := range []string{"sshKeys", "stateVersion"} {
if !same(oldSys[k], newSys[k]) {
return fmt.Errorf("`system.%s` cannot be changed through this interface", k)
}
}
for k := range newSys {
if k != "hostName" && k != "timeZone" && !same(oldSys[k], newSys[k]) {
return fmt.Errorf("`system.%s` cannot be changed through this interface (only hostName and timeZone)", k)
}
}
var walk func(path string, v any) error
walk = func(path string, v any) error {
switch x := v.(type) {
case map[string]any:
for k, e := range x {
if str, ok := e.(string); ok && strings.HasSuffix(k, "File") {
if !strings.HasPrefix(str, s.StateDir+"/") || strings.Contains(str, "..") {
return fmt.Errorf("%s.%s: files must be under %s (the person who runs the router puts secrets there)", path, k, s.StateDir)
}
}
if err := walk(path+"."+k, e); err != nil {
return err
}
}
case []any:
for i, e := range x {
if err := walk(fmt.Sprintf("%s[%d]", path, i), e); err != nil {
return err
}
}
}
return nil
}
return walk("router", next["router"])
}
func (a *app) updateSettings(args json.RawMessage) (any, error) {
var in struct {
Etag string `json:"etag"`
Patch any `json:"patch"`
}
if err := json.Unmarshal(args, &in); err != nil || in.Patch == nil {
return nil, errors.New("give `etag` and `patch` (an object)")
}
if _, ok := in.Patch.(map[string]any); !ok {
return nil, errors.New("`patch` must be an object")
}
return a.editSettings(in.Etag, func(next Settings) (Settings, error) {
patched, _ := mergePatch(map[string]any(next), in.Patch).(map[string]any)
return Settings(patched), nil
})
}
// editSettings saves what edit makes of the settings, if etag is the current
// version of them (so nobody's edit is lost) and the result stays within what
// the API may change. The settings stay unapplied.
func (a *app) editSettings(wantEtag string, edit func(next Settings) (Settings, error)) (any, error) {
a.settingsMu.Lock()
defer a.settingsMu.Unlock()
data, err := os.ReadFile(a.sys.settingsPath())
if err != nil {
return nil, err
}
if wantEtag != etag(data) {
return nil, errStale
}
old, err := parseSettings(data)
if err != nil {
return nil, err
}
next, err := parseSettings(data) // a copy to edit
if err != nil {
return nil, err
}
next, err = edit(next)
if err != nil {
return nil, err
}
if err := a.sys.checkMCPSettings(old, next); err != nil {
return nil, err
}
if err := next.save(a.sys.settingsPath()); err != nil {
return nil, err
}
after, _ := os.ReadFile(a.sys.settingsPath())
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
}