package main import ( "encoding/json" "fmt" "regexp" "strconv" "strings" ) // get_logs: the system journal, read-only, through journalctl with its // arguments built here (no shell), so that nothing a caller sends can become // an option. var ( unitRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9@:._-]{0,79}$`) timeRe = regexp.MustCompile(`^[0-9A-Za-z :.+/_,-]{1,40}$`) prioRe = regexp.MustCompile(`^([0-7]|emerg|alert|crit|err|warning|notice|info|debug)$`) maxLogs = 1000 logSize = 256 << 10 ) // runJournal runs journalctl (replaced in tests). var runJournal = command type logQuery struct { Unit string `json:"unit"` Lines int `json:"lines"` Since string `json:"since"` Until string `json:"until"` Priority string `json:"priority"` Grep string `json:"grep"` Boot *int `json:"boot"` } // journalArgs turns a query into journalctl's arguments. func journalArgs(q logQuery) ([]string, error) { if q.Lines == 0 { q.Lines = 100 } if q.Lines < 1 || q.Lines > maxLogs { return nil, argError(fmt.Sprintf("lines must be 1 to %d", maxLogs)) } args := []string{"--no-pager", "-o", "short-iso", "--lines=" + strconv.Itoa(q.Lines)} switch { case q.Unit == "kernel": args = append(args, "-k") case q.Unit != "": if !unitRe.MatchString(q.Unit) { return nil, argError("unit: a unit name like router-webui or router-pppoe.service, or kernel") } args = append(args, "--unit="+q.Unit) } for _, t := range []struct{ flag, v string }{{"--since=", q.Since}, {"--until=", q.Until}} { if t.v == "" { continue } if !timeRe.MatchString(t.v) { return nil, argError(strings.TrimPrefix(strings.TrimSuffix(t.flag, "="), "--") + ": a time like \"1 hour ago\", \"-2h\", \"yesterday\" or \"2026-10-31 10:00:00\"") } args = append(args, t.flag+t.v) } if q.Priority != "" { if !prioRe.MatchString(q.Priority) { return nil, argError("priority: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (that level and worse)") } args = append(args, "--priority="+q.Priority) } if q.Grep != "" { if len(q.Grep) > 200 { return nil, argError("grep: at most 200 characters") } args = append(args, "--grep="+q.Grep, "--case-sensitive=false") } if q.Boot != nil { if *q.Boot > 0 || *q.Boot < -50 { return nil, argError("boot: 0 (this boot), -1 (the one before), ...") } args = append(args, "--boot="+strconv.Itoa(*q.Boot)) } return args, nil } var logsTool = mcpTool{ name: "get_logs", scope: scopeRead, description: "Lines of the system journal, oldest first (the last `lines` that match). Without `unit` all units. Useful units: router-pppoe (the PPPoE connection: connects and disconnects), router-webui, router-apply, systemd-networkd, kea-dhcp4-server, nftables, wireguard-*; `kernel` for the kernel log. Search a text with `grep`, narrow by `since` (\"1 hour ago\", \"2026-10-31 10:00:00\"), `priority` (err = errors and worse) or `boot` (0 this boot, -1 the previous).", schema: map[string]any{ "type": "object", "properties": map[string]any{ "unit": map[string]any{"type": "string", "description": "A systemd unit (router-pppoe, router-webui.service, ...) or `kernel`."}, "lines": map[string]any{"type": "integer", "minimum": 1, "maximum": maxLogs, "default": 100, "description": "How many lines at most."}, "since": map[string]any{"type": "string", "description": "Only entries since then: \"1 hour ago\", \"yesterday\", \"2026-10-31 10:00:00\"."}, "until": map[string]any{"type": "string", "description": "Only entries until then."}, "priority": map[string]any{"type": "string", "description": "This level and worse: 0-7 or emerg, alert, crit, err, warning, notice, info, debug."}, "grep": map[string]any{"type": "string", "description": "Only entries matching this regular expression (case insensitive)."}, "boot": map[string]any{"type": "integer", "maximum": 0, "description": "0 is the current boot, -1 the one before, ... Without it all boots the journal holds."}, }, }, run: func(a *app, raw json.RawMessage) (any, error) { var q logQuery if err := json.Unmarshal(raw, &q); err != nil { return nil, argError("bad arguments: " + err.Error()) } args, err := journalArgs(q) if err != nil { return nil, err } out, err := runJournal("journalctl", args...) if err != nil && out == "" { return nil, fmt.Errorf("journalctl: %w", err) } if len(out) > logSize { out = "[…]\n" + out[len(out)-logSize:] } if out == "" || strings.HasPrefix(out, "-- No entries --") { return "No matching entries.", nil } return out, nil }, }