From a14edd919d522bc45c80e98e5e2437fc589d4a1d Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:04:47 -0700 Subject: [PATCH 1/4] feat(macos): bring the MCP server to the 2026-07-28 spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server advertised protocolVersion 2024-11-05 and implemented four methods. This moves it to the stateless revision without dropping the clients that still expect the old handshake. Protocol core: server/discover, per-request _meta (protocol version, client capabilities, client info, log level), resultType on every result, serverInfo in every result's _meta, and the renumbered error codes (-32022 unsupported version, resource-not-found -32002 -> -32602). initialize still negotiates for pre-2026 clients, and ping / logging/setLevel are still answered rather than 404'd at them. Result quality: ttlMs + cacheScope on every cacheable list, deterministic tool ordering, annotations (the read-only/destructive split is now machine-readable instead of prose in a description), and outputSchema + structuredContent on all but burrow_report, which is Markdown and so declares neither. Tasks: the io.modelcontextprotocol/tasks extension for the nine long-running tools, opt-in on both sides. A multi-minute clean or analyze returns a handle and reports progress instead of blocking and then coming back as timed_out: true, which reads to a model like "nothing to clean". MRTR: a missing required argument becomes an input_required elicitation when the client can ask a human, rather than an argument error. It is deliberately not used as a confirmation gate — an agent answers its own elicitation, so MoActions.decide and the Settings opt-ins remain the only authority, pinned by tests. New surface: resources (10) + templates (3) over the read tools, five prompts encoding the tool orderings that avoid wrong answers, argument completion, and two tools that close real gaps — burrow_agent_audit (the audit trail was write-only) and burrow_anomalies (the detector had no agent surface). Not implemented, on purpose: Roots, Sampling and Logging (deprecated in this same revision), legacy HTTP+SSE, and Streamable HTTP — the last is gated on a security design, since opening a listener on a process that drives a privileged helper is not a transport swap. Verified with 36 new unit tests (1042 total, 0 failures) and a 93-check end-to-end harness driving the signed binary over stdio. --- docs/agent-tools.md | 72 +++- macos/Sources/MCP.swift | 253 +++++-------- macos/Sources/MCPInputRequests.swift | 204 ++++++++++ macos/Sources/MCPProtocol.swift | 234 ++++++++++++ macos/Sources/MCPResources.swift | 375 ++++++++++++++++++ macos/Sources/MCPServer.swift | 508 +++++++++++++++++++++++++ macos/Sources/MCPTasks.swift | 264 +++++++++++++ macos/Sources/MCPToolMetadata.swift | 414 ++++++++++++++++++++ macos/Tests/MCPConformanceTests.swift | 524 ++++++++++++++++++++++++++ macos/Tests/MCPTests.swift | 3 +- 10 files changed, 2691 insertions(+), 160 deletions(-) create mode 100644 macos/Sources/MCPInputRequests.swift create mode 100644 macos/Sources/MCPProtocol.swift create mode 100644 macos/Sources/MCPResources.swift create mode 100644 macos/Sources/MCPServer.swift create mode 100644 macos/Sources/MCPTasks.swift create mode 100644 macos/Sources/MCPToolMetadata.swift create mode 100644 macos/Tests/MCPConformanceTests.swift diff --git a/docs/agent-tools.md b/docs/agent-tools.md index ca9b4db8..ff81f343 100644 --- a/docs/agent-tools.md +++ b/docs/agent-tools.md @@ -9,9 +9,13 @@ reference + when to reach for each one**. Setup is in the Everything is **local** (`127.0.0.1` / stdio only), reads a shared on-disk history Burrow samples continuously, and every actuating call is **dry-run by default**. +The server speaks MCP **2026-07-28** — the stateless revision, with `server/discover`, tasks, +and cache hints — and still answers the older `initialize` handshake, so it works with clients +of either era. See [Protocol surface](#protocol-surface) at the bottom. + ## The two kinds of tools -- **Read-only (21)** — observe and diagnose. Always safe; call these proactively whenever a +- **Read-only (23)** — observe and diagnose. Always safe; call these proactively whenever a question is about *this machine's* state, history, or health. - **Actuating, gated (5)** — clean / optimize / uninstall / purge / installer. **Preview by default** (`--dry-run`); a real run needs `confirm: true` **and** the user's Settings @@ -39,6 +43,8 @@ samples continuously, and every actuating call is **dry-run by default**. | **burrow_ports** | "What's listening on my machine / what's using port 3000?" Lists listening TCP/UDP ports with the owning process (pid, name, uid). Read-only — to free a port, tell the user which pid to kill. | — | | **burrow_report** | "Give me a weekly digest." Returns a Markdown system report over `days`: disk forecast, top energy users, cleanup summary. | `days` | | **burrow_info** | Meta/diagnostic: "is Burrow actually recording data?" Shows data prefixes + row counts + staleness, retention, sample interval, decode-skip count. Use when other tools return empty/stale data to explain why. | — | +| **burrow_anomalies** | "Is anything behaving *unusually*?" Processes whose last-24h CPU has regressed against their own 14-day baseline. This is per-process, not a leaderboard: a process that always sits at 40% isn't flagged, one that went 2% → 15% is. Empty when there isn't enough history. | — | +| **burrow_agent_audit** | "What has an agent already done to this machine?" One row per mutating call with the exact arguments, dry-run flag, outcome, and duration. Check it before assuming a cleanup didn't run — and to re-read your own earlier calls in a long session. | `minutes`, `limit` | ## Cleanup history (read-only) @@ -86,7 +92,55 @@ it. Real cleans run at user level (not elevated). | **burrow_purge** | Finds dev build artifacts (`node_modules`, `target/`, …). | **Preview-only over MCP** — returns the dry-run list; the real purge is an interactive flow in the app. | `confirm` (reserved) | | **burrow_installer** | Finds leftover installers (`.dmg`/`.pkg`/…). | **Preview-only over MCP**, like purge. | `confirm` (reserved) | -Every actuating call is recorded to Burrow's audit log, so the user can see what an agent did. +Every actuating call is recorded to Burrow's audit log, so the user can see what an agent did — +and `burrow_agent_audit` reads that log back, so you can see it too. + +--- + +## Beyond tools + +**Resources** — the read-only answers agents re-poll most, attachable instead of re-called. +`burrow://snapshot/latest`, `burrow://doctor`, `burrow://ports`, `burrow://info`, +`burrow://forecast/disk`, `burrow://cleanup/history`, `burrow://cleanup/deleted-files`, +`burrow://agent-audit`, `burrow://anomalies`, `burrow://report/weekly`, plus templates +`burrow://history/{minutes}`, `burrow://processes/{metric}`, and `burrow://report/{days}`. +Each read carries a `ttlMs` telling you how long it stays honest — five seconds for a live +snapshot, a minute for a digest. + +**Prompts** — `diagnose_slow_mac`, `reclaim_disk_space`, `explain_last_cleanup`, +`investigate_process`, `pre_uninstall_check`. Each encodes the tool ordering that avoids wrong +answers, so a host can offer the right investigation as one click. + +**Tasks** — a client that declares `io.modelcontextprotocol/tasks` gets a task handle instead +of a blocking call for the slow tools (`burrow_analyze`, `burrow_dupes`, `burrow_photos`, +`burrow_orphans`, and the five actuating ones). Poll `tasks/get` until terminal; pass a +`progressToken` if you want `notifications/progress` while it runs. Without the extension the +same call behaves exactly as before. This is what stops a multi-minute clean coming back as +`timed_out: true`, which reads like "nothing to clean" when it means "we gave up". + +**Missing arguments** — a client that supports elicitation gets an `input_required` result +asking for the argument (which apps to uninstall, which directory to scan) rather than an +error. Answer it by re-issuing the call with `inputResponses` and the `requestState` you were +given. Note what this is *not*: answering an elicitation cannot authorise a destructive run. +The Settings opt-in is the only thing that can, and an agent cannot set it. + +--- + +## Protocol surface + +| Method | Notes | +|---|---| +| `server/discover` | Supported versions, capabilities, and the usage instructions. No handshake needed. | +| `initialize` | Still answered for pre-2026 clients; negotiates down to their revision. | +| `tools/list` / `tools/call` | Deterministically ordered, annotated, `outputSchema` + `structuredContent` on all but `burrow_report` (Markdown). | +| `resources/list`, `resources/templates/list`, `resources/read` | Cache hints on every result. | +| `prompts/list`, `prompts/get`, `completion/complete` | Completion covers metric names and recently-seen process names. | +| `tasks/get`, `tasks/update`, `tasks/cancel` | The tasks extension. Cancellation is cooperative — it stops us reporting, not the engine subprocess. | + +Deliberately not implemented: Roots, Sampling, and Logging (all deprecated in this revision), +the legacy HTTP+SSE transport, and Streamable HTTP. The last one is a security design question +rather than a transport swap — Burrow shells out to a privileged helper and deletes files, so +opening a listener needs its own auth model first. --- @@ -105,12 +159,16 @@ Every actuating call is recorded to Burrow's audit log, so the user can see what - **"Is anything insecure / what's listening?"** → `burrow_doctor` (SIP/Gatekeeper/FileVault/ firewall) + `burrow_ports`. - **"What did Burrow change?"** → `burrow_cleanup_history` + `burrow_deleted_files`. +- **"Did an agent already do this?"** → `burrow_agent_audit` before repeating a cleanup, and + after a call you're unsure completed. +- **"Something's off but nothing looks high"** → `burrow_anomalies`, which compares each + process against its own history rather than against the others. - **Empty/stale results?** → `burrow_info` to confirm data is flowing. ## Not yet exposed over MCP -The 0.9 app has features that don't yet have agent tools (tracked for a follow-up): the -per-process **inspector** (code signature, Mach-O arch, deep metrics, open connections), the -**process tree**, table **filter/suspend/resume/export**, the **CPU watchdog**, and **Get -Online** (speed test, nearby Wi-Fi scan, captive-portal tips, connection history). Until then, -use `burrow_snapshot` / `burrow_top_processes` / `burrow_process_usage` for process questions. +The app still has features without agent tools (tracked for a follow-up): the per-process +**inspector** (code signature, Mach-O arch, deep metrics, open connections), the **process +tree**, table **filter/suspend/resume/export**, the **CPU watchdog**, and **Get Online** (speed +test, nearby Wi-Fi scan, captive-portal tips, connection history). Until then, use +`burrow_snapshot` / `burrow_top_processes` / `burrow_process_usage` for process questions. diff --git a/macos/Sources/MCP.swift b/macos/Sources/MCP.swift index c3c674b7..fafb5581 100644 --- a/macos/Sources/MCP.swift +++ b/macos/Sources/MCP.swift @@ -13,16 +13,25 @@ // app writes to. SQLite WAL means the spawn can read concurrently // with the GUI's sampler write loop. // +// The server speaks the 2026-07-28 revision — the one that retired the +// `initialize` handshake in favour of per-request `_meta` — while still +// answering a pre-2026 client that expects the handshake. The envelope +// layer lives in MCPProtocol.swift; the routing lives in MCPServer.swift; +// this file keeps the process entry point and the tool catalogue. +// // Protocol surface implemented: -// * initialize → server info + capabilities -// * notifications/initialized → no-op (notification, no response) -// * tools/list → fixed set, see ToolCatalog -// * tools/call → dispatched to the catalog +// * server/discover → supported versions, capabilities, instructions +// * initialize / notifications/initialized → legacy handshake, still served +// * tools/list, tools/call → the catalogue below, with MRTR and tasks +// * resources/list, resources/templates/list, resources/read +// * prompts/list, prompts/get, completion/complete +// * tasks/get, tasks/update, tasks/cancel (io.modelcontextprotocol/tasks) // // All other methods return JSON-RPC error -32601 (method not found). // Tool results are wrapped as `{content: [{type: "text", text: "..."}]}` // per MCP convention — the text payload is the actual JSON we want -// the agent to read. +// the agent to read, mirrored into `structuredContent` for the tools +// that declare an output schema. // // Wire it up in your Claude Code config (`~/.claude/settings.json`): // @@ -64,153 +73,6 @@ enum MCP { } } -// MARK: - Server - -final class MCPServer { - private let db: DB - private let dec = JSONDecoder() - private let enc = JSONEncoder() - private let catalog: ToolCatalog - private let serverVersion: String - - init(db: DB, serverVersion: String = RuntimeEnvironment.current.appVersion) { - self.db = db - self.catalog = ToolCatalog(db: db) - self.serverVersion = serverVersion - self.enc.outputFormatting = [.withoutEscapingSlashes] - } - - /// Drive the loop. Reads line by line from `input`; one JSON-RPC - /// message per line is the de-facto standard for stdio MCP. Exits - /// cleanly on EOF. - func serve(input: FileHandle, output: FileHandle) { - var buffer = Data() - while true { - let chunk = input.availableData - if chunk.isEmpty { break } // EOF — peer closed - buffer.append(chunk) - while let nl = buffer.firstIndex(of: 0x0A) { - let line = buffer.subdata(in: buffer.startIndex.. [String: Any]? { - // Decode the JSON-RPC envelope loosely — we only care about - // jsonrpc/id/method/params. Use a flexible decode so we can - // tell notifications (no id) from requests (with id). - guard let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return MCPServer.errorResponse(id: nil, code: -32700, message: "parse error") - } - let method = (raw["method"] as? String) ?? "" - let id = raw["id"] // may be nil for notifications - - switch method { - case "initialize": - return self.initializeResponse(id: id) - case "notifications/initialized": - // Notification — no response. The client is just telling us - // it processed our initialize response. - return nil - case "tools/list": - return self.toolsListResponse(id: id) - case "tools/call": - return self.toolsCallResponse(raw: raw, id: id) - default: - // Notifications have no id; don't reply with an error to - // them, that would be malformed JSON-RPC. - guard id != nil else { return nil } - return MCPServer.errorResponse(id: id, code: -32601, - message: "method not found: \(method)") - } - } - - // MARK: - Method handlers - - private func initializeResponse(id: Any?) -> [String: Any] { - return [ - "jsonrpc": "2.0", - "id": id as Any, - "result": [ - "protocolVersion": "2024-11-05", - "capabilities": ["tools": [String: Any]()], - "serverInfo": [ - "name": "burrow", - "version": self.serverVersion, - ], - ], - ] - } - - private func toolsListResponse(id: Any?) -> [String: Any] { - return [ - "jsonrpc": "2.0", - "id": id as Any, - "result": ["tools": self.catalog.descriptors()], - ] - } - - private func toolsCallResponse(raw: [String: Any], id: Any?) -> [String: Any] { - let params = raw["params"] as? [String: Any] ?? [:] - let name = params["name"] as? String ?? "" - let args = params["arguments"] as? [String: Any] ?? [:] - - do { - let resultText = try self.catalog.call(name: name, arguments: args) - return [ - "jsonrpc": "2.0", - "id": id as Any, - "result": [ - "content": [ - ["type": "text", "text": resultText], - ], - ], - ] - } catch let MCPToolError.unknown(toolName) { - return MCPServer.errorResponse(id: id, code: -32602, - message: "unknown tool: \(toolName)") - } catch let MCPToolError.badArguments(reason) { - return MCPServer.errorResponse(id: id, code: -32602, - message: "bad arguments: \(reason)") - } catch { - return MCPServer.errorResponse(id: id, code: -32603, - message: "internal error: \(error.localizedDescription)") - } - } - - // MARK: - Plumbing - - private func write(_ fh: FileHandle, _ object: [String: Any]) { - guard var data = try? JSONSerialization.data(withJSONObject: object, - options: [.withoutEscapingSlashes]) else { - return - } - data.append(0x0A) - try? fh.write(contentsOf: data) - } - - static func errorResponse(id: Any?, code: Int, message: String) -> [String: Any] { - return [ - "jsonrpc": "2.0", - "id": id as Any, - "error": ["code": code, "message": message], - ] - } -} - // MARK: - Tool catalog enum MCPToolError: Error { @@ -523,6 +385,27 @@ struct ToolCatalog { "additionalProperties": false, ] as [String: Any], ], + [ + "name": "burrow_agent_audit", + "description": "What AI agents have already done through this MCP server: one row per mutating tool call with the tool name, the exact arguments, whether it was a dry run, whether it succeeded, and how long it took. `minutes` selects the window (default 10080 = 7 days), `limit` caps rows (default 50, max 500). Read-only. Use it to answer 'what has an agent changed on this machine?' before assuming a cleanup didn't happen — and to check your own earlier calls in a long session.", + "inputSchema": [ + "type": "object", + "properties": [ + "minutes": ["type": "integer", "minimum": 1, "description": "How far back to look. Default 10080 (7 days)."], + "limit": ["type": "integer", "minimum": 1, "maximum": 500], + ], + "additionalProperties": false, + ] as [String: Any], + ], + [ + "name": "burrow_anomalies", + "description": "Processes whose CPU over the last 24h has regressed against their own 14-day baseline, worst first. This is a per-process comparison, not a leaderboard: a process that always uses 40% CPU is not an anomaly, one that jumped from 2% to 15% is. Returns an empty list when there isn't enough history to compare. Read-only.", + "inputSchema": [ + "type": "object", + "properties": [String: Any](), + "additionalProperties": false, + ] as [String: Any], + ], ] } @@ -626,6 +509,10 @@ struct ToolCatalog { return self.runAction(.purge, confirm: (arguments["confirm"] as? Bool) ?? false) case "burrow_installer": return self.runAction(.installer, confirm: (arguments["confirm"] as? Bool) ?? false) + case "burrow_agent_audit": + return try self.callAgentAudit(arguments) + case "burrow_anomalies": + return self.callAnomalies() default: throw MCPToolError.unknown(name) } @@ -663,6 +550,68 @@ struct ToolCatalog { return "{\"count\":\(rows.count),\"rows\":[\(pieces.joined(separator: ","))]}" } + /// `burrow_agent_audit` — read back the rows `recordAudit` writes. The + /// audit trail was write-only until now: the GUI could show it and the + /// MCP process could append to it, but an agent had no way to see what + /// it (or another agent) had already done. Read-only, and never audited + /// itself — reading the log isn't an action worth logging. + private func callAgentAudit(_ args: [String: Any]) throws -> String { + let minutes = (args["minutes"] as? Int) ?? 10_080 + // Same overflow bound as the other windowed tools. + guard minutes > 0, minutes <= 1_000_000 else { + throw MCPToolError.badArguments("minutes must be between 1 and 1000000") + } + let limit = max(1, min((args["limit"] as? Int) ?? 50, 500)) + + let now = Int(Date().timeIntervalSince1970) + let rows = self.metrics.rawRows(prefix: AgentAudit.prefix, + MetricsStore.Window(since: now - minutes * 60, until: now), + maxPoints: nil) + var entries: [[String: Any]] = [] + for row in rows.suffix(limit).reversed() { // newest first + guard let e = AgentAudit.decode(row.json) else { continue } + var item: [String: Any] = [ + "ts": row.ts, + "tool": e.tool, + "client": e.client, + "dry_run": e.dryRun, + "duration_ms": e.durationMs, + "ok": e.ok, + "summary": e.summary, + ] + // Args were stored as a JSON string; hand them back as an object + // so the agent doesn't have to parse a string inside JSON. + if let d = e.argsJSON.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: d) as? [String: Any] { + item["args"] = parsed + } else { + item["args_raw"] = e.argsJSON + } + entries.append(item) + } + return Self.jsonString(["count": entries.count, + "window_minutes": minutes, + "entries": entries]) + } + + /// `burrow_anomalies` — per-process CPU regressions against each + /// process's own baseline. The detector already backed a GUI card; this + /// is the same call with no new logic behind it. + private func callAnomalies() -> String { + let findings = AnomalyScan.scan(metrics: self.metrics, + now: Int(Date().timeIntervalSince1970)) + let items: [[String: Any]] = findings.map { + ["process": $0.process, + "recent_median_cpu": $0.recentMedian, + "baseline_median_cpu": $0.baselineMedian] + } + return Self.jsonString([ + "count": items.count, + "findings": items, + "basis": "last 24h vs the prior 14 days, per process", + ]) + } + private func callTopProcesses(_ args: [String: Any]) throws -> String { let minutes = (args["minutes"] as? Int) ?? 60 let limit = max(1, min((args["limit"] as? Int) ?? 10, 100)) diff --git a/macos/Sources/MCPInputRequests.swift b/macos/Sources/MCPInputRequests.swift new file mode 100644 index 00000000..11a5140c --- /dev/null +++ b/macos/Sources/MCPInputRequests.swift @@ -0,0 +1,204 @@ +// +// MCPInputRequests.swift +// Burrow +// +// Multi Round-Trip Requests (MRTR) — the 2026-07-28 replacement for +// server-initiated requests. +// +// A server that needs something mid-call can't just send a request any +// more: it returns `resultType: "input_required"` with the questions in +// `inputRequests`, and the client re-issues the original call with the +// answers in `inputResponses`. Correlation across the retry is the +// server's job, which is what `requestState` carries. +// +// IMPORTANT — this is ergonomics, not consent. An `input_required` round +// trip is answered by the *agent*, which is free to answer its own +// question without a human ever seeing it. So MRTR is used here only +// where the server is genuinely missing information it cannot invent (an +// app name, a directory to scan). It is deliberately NOT used as a +// confirmation gate for the destructive tools: those stay governed by +// `MoActions.decide` and the user's Settings opt-ins, which an agent +// cannot answer on the user's behalf. +// + +import Foundation + +enum MCPInputRequests { + /// A single argument the server can't proceed without. + struct MissingArgument { + let key: String + let message: String + /// Whether the answer is a comma-separated list that expands into an + /// array argument. Elicitation form values are primitives, so a list + /// arrives as one string. + let isList: Bool + let placeholder: String + } + + /// Which required argument each tool would otherwise reject. Tools with + /// sensible defaults (burrow_analyze defaults to the home folder) are + /// absent on purpose — asking would be worse than defaulting. + static func missingArgument(tool: String, arguments: [String: Any]) -> MissingArgument? { + func blankString(_ key: String) -> Bool { + let v = (arguments[key] as? String)?.trimmingCharacters(in: .whitespaces) + return v == nil || v!.isEmpty + } + func blankList(_ key: String) -> Bool { + let v = (arguments[key] as? [String])? + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + return v == nil || v!.isEmpty + } + + switch tool { + case "burrow_uninstall" where blankList("apps"): + return MissingArgument( + key: "apps", + message: "Which applications should be uninstalled? Use the exact names burrow_list_apps reports. Separate several with commas.", + isList: true, placeholder: "Slack, Zoom") + case "burrow_dupes" where blankList("paths"): + return MissingArgument( + key: "paths", + message: "Which directories should be scanned for duplicate files? Absolute paths, separated by commas.", + isList: true, placeholder: "/Users/you/Downloads, /Users/you/Documents") + case "burrow_orphans" where blankString("path"): + return MissingArgument( + key: "path", + message: "Which directory should be scanned for orphaned files?", + isList: false, placeholder: "/Users/you/Library/Application Support") + case "burrow_photos" where blankString("path"): + return MissingArgument( + key: "path", + message: "Which directory should be scanned for near-duplicate photos?", + isList: false, placeholder: "/Users/you/Pictures") + case "burrow_rules_dryrun" where blankString("dir"): + return MissingArgument( + key: "dir", + message: "Which rules directory should be previewed? No rules ship with the app, so there is no default.", + isList: false, placeholder: "/Users/you/burrow-rules") + case "burrow_slim_check" where blankString("binary"): + return MissingArgument( + key: "binary", + message: "Which Mach-O binary should be measured? Usually an app's main executable.", + isList: false, placeholder: "/Applications/Some.app/Contents/MacOS/Some") + default: + return nil + } + } + + /// The `inputRequests` map for one missing argument. Keys are + /// server-assigned; we use the argument name so the retry is readable. + static func elicitation(for missing: MissingArgument) -> [String: Any] { + let field: [String: Any] = [ + "type": "string", + "title": missing.key, + "description": "\(missing.message) Example: \(missing.placeholder)", + "minLength": 1, + ] + return [ + missing.key: [ + "method": "elicitation/create", + "params": [ + "mode": "form", + "message": missing.message, + "requestedSchema": [ + "type": "object", + "properties": [missing.key: field], + "required": [missing.key], + ] as [String: Any], + ] as [String: Any], + ] as [String: Any], + ] + } + + /// Encode what the retry needs to know. The spec types `requestState` as + /// an opaque string, so this is our own JSON in it. + static func encodeState(tool: String, arguments: [String: Any], asking key: String) -> String { + let payload: [String: Any] = ["tool": tool, "arguments": arguments, "asking": key] + guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]), + let s = String(data: data, encoding: .utf8) else { + return "{}" + } + return s + } + + struct State { + let tool: String + let arguments: [String: Any] + let asking: String + } + + static func decodeState(_ raw: String?) -> State? { + guard let raw, let data = raw.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tool = obj["tool"] as? String, + let asking = obj["asking"] as? String else { return nil } + return State(tool: tool, + arguments: (obj["arguments"] as? [String: Any]) ?? [:], + asking: asking) + } + + /// What came back from the client. + enum Resolution { + /// The user answered; these are the arguments to run with. + case proceed([String: Any]) + /// The user declined or dismissed. Not an error — the tool reports it. + case declined(String) + /// The answer didn't contain what we asked for. + case unusable(String) + } + + /// Fold `inputResponses` back into the original arguments. + static func resolve(state: State, inputResponses: [String: Any]) -> Resolution { + guard let response = inputResponses[state.asking] as? [String: Any] else { + return .unusable("no answer for \"\(state.asking)\" in inputResponses") + } + let action = response["action"] as? String ?? "" + switch action { + case "accept": + break + case "decline": + return .declined("the user declined to provide \(state.asking)") + case "cancel": + return .declined("the user dismissed the request for \(state.asking)") + default: + return .unusable("unrecognised elicitation action \"\(action)\"") + } + + let content = (response["content"] as? [String: Any]) ?? [:] + var arguments = state.arguments + + // The client may hand back a list directly, or the comma-separated + // string the form asked for. Accept either. + if let list = content[state.asking] as? [String] { + let cleaned = list.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + guard !cleaned.isEmpty else { return .unusable("the answer for \(state.asking) was empty") } + arguments[state.asking] = cleaned + return .proceed(arguments) + } + + guard let raw = content[state.asking] as? String else { + return .unusable("the answer for \(state.asking) was not a string") + } + let trimmed = raw.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return .unusable("the answer for \(state.asking) was empty") } + + if Self.isListArgument(tool: state.tool, key: state.asking) { + let parts = trimmed.split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard !parts.isEmpty else { return .unusable("the answer for \(state.asking) was empty") } + arguments[state.asking] = parts + } else { + arguments[state.asking] = trimmed + } + return .proceed(arguments) + } + + /// Whether the tool wants an array for this argument. Derived from the + /// same table the question came from, so the two can't disagree. + private static func isListArgument(tool: String, key: String) -> Bool { + Self.missingArgument(tool: tool, arguments: [:])?.isList == true + && Self.missingArgument(tool: tool, arguments: [:])?.key == key + } +} diff --git a/macos/Sources/MCPProtocol.swift b/macos/Sources/MCPProtocol.swift new file mode 100644 index 00000000..fd97045c --- /dev/null +++ b/macos/Sources/MCPProtocol.swift @@ -0,0 +1,234 @@ +// +// MCPProtocol.swift +// Burrow +// +// The wire vocabulary of MCP's 2026-07-28 revision — the one that +// retired the `initialize` handshake and made every request carry its +// own context. +// +// Two eras have to coexist here. A pre-2026 client negotiates once at +// `initialize` and then sends bare requests; a 2026-07-28 client sends +// no handshake at all and repeats its protocol version, capabilities, +// and identity in `params._meta` on every single request. Burrow serves +// both: `MCPRequestContext.parse` reads the per-request `_meta` when it +// is there and falls back to whatever the legacy handshake negotiated +// when it isn't. +// +// Nothing in here talks to the DB or the engine — it is the envelope +// layer only, which is what makes the conformance tests cheap. +// + +import Foundation + +enum MCPProtocol { + /// The newest revision we speak, and what `server/discover` leads with. + static let latest = "2026-07-28" + + /// Every revision we can serve, newest first. Old entries are kept + /// deliberately: the spec's deprecation policy gives removed features a + /// twelve-month window, and clients still probe downward. + static let supported = [latest, "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"] + + /// The revision that made the protocol stateless. Dates are ISO-8601, so + /// lexicographic order is chronological order. + static let statelessEra = "2026-07-28" + + static func isStatelessEra(_ version: String) -> Bool { version >= statelessEra } + + /// The version we assume for a client that never told us — a pre-2026 + /// client that skipped `initialize` entirely. + static let legacyFallback = "2024-11-05" + + /// Reserved `_meta` keys. The `io.modelcontextprotocol/` prefix is + /// reserved by the spec for protocol-level metadata. + enum Meta { + static let protocolVersion = "io.modelcontextprotocol/protocolVersion" + static let clientCapabilities = "io.modelcontextprotocol/clientCapabilities" + static let clientInfo = "io.modelcontextprotocol/clientInfo" + static let logLevel = "io.modelcontextprotocol/logLevel" + static let serverInfo = "io.modelcontextprotocol/serverInfo" + static let relatedTask = "io.modelcontextprotocol/related-task" + static let progressToken = "progressToken" + } + + /// Extension identifiers we care about, in both directions. + enum Extensions { + static let tasks = "io.modelcontextprotocol/tasks" + } + + /// JSON-RPC error codes. -32020…-32099 is the range the 2026-07-28 + /// revision reserved for the spec itself; the three below are the ones + /// it defines. -32000…-32019 stays implementation-defined. + enum ErrorCode { + static let parse = -32700 + static let invalidRequest = -32600 + static let methodNotFound = -32601 + static let invalidParams = -32602 + static let internalError = -32603 + static let headerMismatch = -32020 + static let missingRequiredClientCapability = -32021 + static let unsupportedProtocolVersion = -32022 + } + + /// `resultType` discriminates an ordinary result from an interim one. + enum ResultType { + static let complete = "complete" + static let inputRequired = "input_required" + static let task = "task" + } + + /// Cache hints. `ttlMs`/`cacheScope` are required on every list result + /// in this revision, so the numbers are policy, not decoration. + /// + /// "public" is only correct for responses with no machine-specific data + /// in them — the tool and prompt catalogues are identical for every + /// install of a given build. Anything derived from this Mac is + /// "private", and short-lived on top of that. + enum Cache { + /// Static catalogues: fixed for the lifetime of the binary. + static let catalogTTL = 3_600_000 + /// The resource *list* is static; the resource *contents* are not. + static let resourceListTTL = 300_000 + /// Live metric reads. Roughly one sampler tick — re-reading sooner + /// than that just returns the same row. + static let liveTTL = 5_000 + /// Reads that summarise a long window and move slowly. + static let digestTTL = 60_000 + + static let publicScope = "public" + static let privateScope = "private" + } + + /// Our own identity, echoed into every result's `_meta`. + static func serverInfo(version: String) -> [String: Any] { + return [ + "name": "burrow", + "title": "Burrow", + "version": version, + "description": "Local Mac system telemetry, disk analysis, and gated cleanup.", + "websiteUrl": "https://burrow.henryzh.dev", + ] + } + + /// Natural-language guidance handed to the model alongside the tool + /// list. Kept to the things tool descriptions can't say — cross-tool + /// ordering and the safety model. + static let instructions = """ + Burrow reports on the Mac it is running on: system metrics sampled over time, \ + disk usage, listening ports, duplicate files, and the cleanup history of the \ + bundled engine. + + Ordering that matters: call burrow_doctor first when something is wrong — it \ + tells you whether the data is even flowing before you trust the rest. Use \ + burrow_process_usage (cumulative CPU-seconds) rather than burrow_top_processes \ + (a single peak sample) when the question is "what used my computer", and call \ + burrow_list_apps for the exact name burrow_uninstall accepts. + + Safety: every tool that can delete something is preview-by-default. Passing \ + confirm:true is necessary but never sufficient — the user must also have \ + switched the matching opt-in on in Burrow's Settings, and uninstall needs a \ + second one. A refusal comes back as a normal result explaining the missing \ + opt-in; that is the user's decision to change, not something to work around. + """ +} + +// MARK: - Per-request context + +/// What a 2026-07-28 request carries in `params._meta`, plus the shims that +/// let a legacy client work without it. +struct MCPRequestContext { + let protocolVersion: String + let clientCapabilities: [String: Any] + let clientInfo: [String: Any]? + let logLevel: String? + let progressToken: Any? + /// True when the request actually declared a version — as opposed to us + /// falling back to the handshake. Lets the server tell the eras apart. + let declaredVersion: Bool + + /// Extensions the client opted into, by identifier. + var clientExtensions: [String: Any] { + (self.clientCapabilities["extensions"] as? [String: Any]) ?? [:] + } + + /// Never hand a task handle to a client that didn't ask for one — it + /// would read as a malformed tool result. + var supportsTasks: Bool { + self.clientExtensions[MCPProtocol.Extensions.tasks] != nil + } + + /// MRTR elicitation is only reachable when the client can render a form. + var supportsElicitation: Bool { + guard let e = self.clientCapabilities["elicitation"] as? [String: Any] else { return false } + // An empty object still means "supported"; `form` is the mode we use. + return e.isEmpty || e["form"] != nil + } + + var isStatelessEra: Bool { MCPProtocol.isStatelessEra(self.protocolVersion) } + + /// Read the context out of a request's params. `fallbackVersion` is what + /// the legacy `initialize` handshake settled on for this connection. + static func parse(params: [String: Any], fallbackVersion: String) -> MCPRequestContext { + let meta = (params["_meta"] as? [String: Any]) ?? [:] + let declared = meta[MCPProtocol.Meta.protocolVersion] as? String + return MCPRequestContext( + protocolVersion: declared ?? fallbackVersion, + clientCapabilities: (meta[MCPProtocol.Meta.clientCapabilities] as? [String: Any]) ?? [:], + clientInfo: meta[MCPProtocol.Meta.clientInfo] as? [String: Any], + logLevel: meta[MCPProtocol.Meta.logLevel] as? String, + progressToken: meta[MCPProtocol.Meta.progressToken], + declaredVersion: declared != nil) + } + + /// A context for code paths with no request behind them (tests, the + /// legacy handshake itself). + static func legacy(version: String = MCPProtocol.legacyFallback) -> MCPRequestContext { + MCPRequestContext(protocolVersion: version, clientCapabilities: [:], clientInfo: nil, + logLevel: nil, progressToken: nil, declaredVersion: false) + } +} + +// MARK: - Result envelopes + +/// Builders for the fields the revision made mandatory. Every result the +/// server emits goes through one of these, so `resultType` and the server +/// identity can't be forgotten on a new method. +enum MCPResult { + /// An ordinary, finished result. + static func complete(_ body: [String: Any], serverVersion: String) -> [String: Any] { + var out = body + out["resultType"] = MCPProtocol.ResultType.complete + return Self.stamped(out, serverVersion: serverVersion) + } + + /// A finished result that clients are allowed to cache. + static func cacheable(_ body: [String: Any], ttlMs: Int, scope: String, + serverVersion: String) -> [String: Any] { + var out = body + out["ttlMs"] = ttlMs + out["cacheScope"] = scope + return Self.complete(out, serverVersion: serverVersion) + } + + /// An interim result: the server needs something before it can finish. + static func inputRequired(inputRequests: [String: Any], requestState: String, + serverVersion: String) -> [String: Any] { + var out: [String: Any] = [ + "resultType": MCPProtocol.ResultType.inputRequired, + "inputRequests": inputRequests, + "requestState": requestState, + ] + out = Self.stamped(out, serverVersion: serverVersion) + return out + } + + /// Attach `io.modelcontextprotocol/serverInfo` without clobbering any + /// `_meta` the caller already built. + static func stamped(_ body: [String: Any], serverVersion: String) -> [String: Any] { + var out = body + var meta = (out["_meta"] as? [String: Any]) ?? [:] + meta[MCPProtocol.Meta.serverInfo] = MCPProtocol.serverInfo(version: serverVersion) + out["_meta"] = meta + return out + } +} diff --git a/macos/Sources/MCPResources.swift b/macos/Sources/MCPResources.swift new file mode 100644 index 00000000..4eb16fcb --- /dev/null +++ b/macos/Sources/MCPResources.swift @@ -0,0 +1,375 @@ +// +// MCPResources.swift +// Burrow +// +// Resources, resource templates, prompts, and argument completion. +// +// Burrow is a data-rich app that, until now, exposed all of it through +// tool calls only. Resources fit the read-mostly half of that data better: +// an agent can attach `burrow://doctor` to its context once instead of +// re-calling a tool, and the `ttlMs` hints tell it how long the attachment +// stays honest — five seconds for a live snapshot, a minute for a digest. +// +// Everything here is read-only by construction. Resources map onto the +// same read tools the catalogue already exposes, so there is exactly one +// implementation of each answer; nothing that deletes is reachable from a +// resource URI or a prompt. +// + +import Foundation + +struct MCPResources { + let catalog: ToolCatalog + let db: DB + + private var metrics: MetricsStore { MetricsStore(db: db) } + + // MARK: - Fixed resources + + /// One row of the resource table: how to describe it, and which read + /// tool produces it. + struct Fixed { + let uri: String + let name: String + let title: String + let description: String + let mimeType: String + let tool: String + let arguments: [String: Any] + let ttlMs: Int + } + + static let fixed: [Fixed] = [ + Fixed(uri: "burrow://snapshot/latest", name: "latest_snapshot", + title: "Latest system snapshot", + description: "The most recent sample: CPU, memory, disk, network, thermal, and top processes.", + mimeType: "application/json", tool: "burrow_snapshot", arguments: [:], + ttlMs: MCPProtocol.Cache.liveTTL), + + Fixed(uri: "burrow://doctor", name: "doctor", + title: "Diagnostics", + description: "Engine presence, Full Disk Access, memory pressure, disk headroom, and decode errors as ok/warn/fail checks.", + mimeType: "application/json", tool: "burrow_doctor", arguments: [:], + ttlMs: MCPProtocol.Cache.digestTTL), + + Fixed(uri: "burrow://ports", name: "listening_ports", + title: "Listening ports", + description: "Every listening TCP/UDP socket with the process that owns it.", + mimeType: "application/json", tool: "burrow_ports", arguments: [:], + ttlMs: MCPProtocol.Cache.liveTTL), + + Fixed(uri: "burrow://info", name: "burrow_state", + title: "Burrow's own state", + description: "Which data streams are recording, how stale each one is, and the retention setting. Read this before trusting the others.", + mimeType: "application/json", tool: "burrow_info", arguments: [:], + ttlMs: MCPProtocol.Cache.liveTTL), + + Fixed(uri: "burrow://forecast/disk", name: "disk_forecast", + title: "Disk-full forecast", + description: "When the largest volume runs out of space, fitted from free-space history.", + mimeType: "application/json", tool: "burrow_disk_forecast", arguments: [:], + ttlMs: MCPProtocol.Cache.digestTTL), + + Fixed(uri: "burrow://cleanup/history", name: "cleanup_history", + title: "Cleanup history", + description: "Past clean/optimize/purge/uninstall sessions with bytes freed and item counts.", + mimeType: "application/json", tool: "burrow_cleanup_history", arguments: [:], + ttlMs: MCPProtocol.Cache.digestTTL), + + Fixed(uri: "burrow://cleanup/deleted-files", name: "deleted_files", + title: "Deleted file paths", + description: "The exact paths past cleanups removed or trashed, newest first.", + mimeType: "application/json", tool: "burrow_deleted_files", arguments: [:], + ttlMs: MCPProtocol.Cache.digestTTL), + + Fixed(uri: "burrow://agent-audit", name: "agent_audit", + title: "What agents have done", + description: "Every mutating tool call an agent has made through this server in the last week, with its arguments and outcome.", + mimeType: "application/json", tool: "burrow_agent_audit", arguments: [:], + ttlMs: MCPProtocol.Cache.liveTTL), + + Fixed(uri: "burrow://anomalies", name: "anomalies", + title: "CPU anomalies", + description: "Processes whose recent CPU use has regressed against their own 14-day baseline.", + mimeType: "application/json", tool: "burrow_anomalies", arguments: [:], + ttlMs: MCPProtocol.Cache.digestTTL), + + Fixed(uri: "burrow://report/weekly", name: "weekly_report", + title: "Weekly digest", + description: "The seven-day system digest as Markdown — disk forecast, top energy users, cleanup summary.", + mimeType: "text/markdown", tool: "burrow_report", arguments: [:], + ttlMs: MCPProtocol.Cache.digestTTL), + ] + + /// Resource descriptors for `resources/list`. + static func listing() -> [[String: Any]] { + Self.fixed.map { r in + [ + "uri": r.uri, + "name": r.name, + "title": r.title, + "description": r.description, + "mimeType": r.mimeType, + "annotations": ["audience": ["assistant"], "priority": 0.5], + ] + } + } + + // MARK: - Templates + + static let templates: [[String: Any]] = [ + [ + "uriTemplate": "burrow://history/{minutes}", + "name": "metric_history", + "title": "Metric history window", + "description": "Sampled snapshots over the last {minutes} minutes, e.g. burrow://history/120.", + "mimeType": "application/json", + ], + [ + "uriTemplate": "burrow://processes/{metric}", + "name": "process_ranking", + "title": "Process ranking", + "description": "Processes over the last hour ranked by {metric}: cpu_time, peak_cpu, avg_cpu, or peak_mem.", + "mimeType": "application/json", + ], + [ + "uriTemplate": "burrow://report/{days}", + "name": "report_window", + "title": "Digest over N days", + "description": "The system digest as Markdown over the last {days} days, e.g. burrow://report/30.", + "mimeType": "text/markdown", + ], + ] + + // MARK: - Reading + + struct Contents { + let text: String + let mimeType: String + let ttlMs: Int + } + + enum ReadError: Error { + /// Maps to -32602 in this revision — the resource-not-found code was + /// realigned with JSON-RPC's Invalid Params. + case notFound(String) + } + + func read(uri: String) throws -> Contents { + if let fixed = Self.fixed.first(where: { $0.uri == uri }) { + let text = (try? self.catalog.call(name: fixed.tool, arguments: fixed.arguments)) + ?? "{\"error\":\"read failed\"}" + return Contents(text: text, mimeType: fixed.mimeType, ttlMs: fixed.ttlMs) + } + + if let minutes = Self.parameter(of: uri, prefix: "burrow://history/") { + guard let m = Int(minutes), m > 0, m <= 1_000_000 else { + throw ReadError.notFound("burrow://history/{minutes} needs a positive integer, got \"\(minutes)\"") + } + let text = (try? self.catalog.call(name: "burrow_history", arguments: ["minutes": m])) + ?? "{\"error\":\"read failed\"}" + return Contents(text: text, mimeType: "application/json", ttlMs: MCPProtocol.Cache.liveTTL) + } + + if let metric = Self.parameter(of: uri, prefix: "burrow://processes/") { + guard MetricsStore.ProcessRank(rawValue: metric) != nil else { + let allowed = MetricsStore.ProcessRank.allCases.map(\.rawValue).joined(separator: ", ") + throw ReadError.notFound("burrow://processes/{metric} takes one of: \(allowed)") + } + let text = (try? self.catalog.call(name: "burrow_process_usage", + arguments: ["metric": metric, "minutes": 60])) + ?? "{\"error\":\"read failed\"}" + return Contents(text: text, mimeType: "application/json", ttlMs: MCPProtocol.Cache.digestTTL) + } + + if let days = Self.parameter(of: uri, prefix: "burrow://report/") { + // `burrow://report/weekly` is a fixed resource and was matched above. + guard let d = Int(days), d >= 1, d <= 90 else { + throw ReadError.notFound("burrow://report/{days} takes 1-90, got \"\(days)\"") + } + let text = (try? self.catalog.call(name: "burrow_report", arguments: ["days": d])) + ?? "# report unavailable" + return Contents(text: text, mimeType: "text/markdown", ttlMs: MCPProtocol.Cache.digestTTL) + } + + throw ReadError.notFound("unknown resource: \(uri)") + } + + /// The single path segment after `prefix`, or nil when the URI doesn't + /// match. Rejects nested paths so `burrow://history/1/2` isn't silently + /// read as `1`. + private static func parameter(of uri: String, prefix: String) -> String? { + guard uri.hasPrefix(prefix) else { return nil } + let rest = String(uri.dropFirst(prefix.count)) + guard !rest.isEmpty, !rest.contains("/") else { return nil } + return rest.removingPercentEncoding ?? rest + } + + // MARK: - Prompts + + static let prompts: [[String: Any]] = [ + [ + "name": "diagnose_slow_mac", + "title": "Diagnose a slow Mac", + "description": "Work out why this Mac is slow right now, in the order that avoids wrong answers.", + "arguments": [ + ["name": "minutes", "description": "How far back to look. Defaults to 60.", "required": false], + ], + ], + [ + "name": "reclaim_disk_space", + "title": "Reclaim disk space", + "description": "Find the safest large wins on disk and preview them without deleting anything.", + "arguments": [ + ["name": "target_gb", "description": "How many gigabytes to try to free.", "required": false], + ], + ], + [ + "name": "explain_last_cleanup", + "title": "Explain the last cleanup", + "description": "Say exactly what the most recent cleanup removed, path by path.", + "arguments": [], + ], + [ + "name": "investigate_process", + "title": "Investigate a process", + "description": "Build a picture of one process: how much it has used, when, and what it is listening on.", + "arguments": [ + ["name": "name", "description": "Process name, e.g. \"Google Chrome Helper\".", "required": true], + ], + ], + [ + "name": "pre_uninstall_check", + "title": "Check before uninstalling", + "description": "Everything worth knowing before removing an app, without removing it.", + "arguments": [ + ["name": "app", "description": "App name as burrow_list_apps reports it.", "required": true], + ], + ], + ] + + /// Render a prompt into messages. Returns nil for an unknown name. + static func prompt(name: String, arguments: [String: Any]) -> [String: Any]? { + func arg(_ key: String) -> String? { + if let s = arguments[key] as? String, !s.isEmpty { return s } + if let n = arguments[key] as? Int { return String(n) } + return nil + } + + let text: String + let description: String + switch name { + case "diagnose_slow_mac": + let minutes = arg("minutes") ?? "60" + description = "Diagnose slowness over the last \(minutes) minutes." + text = """ + This Mac feels slow. Work out why, using Burrow's tools in this order: + + 1. Call burrow_doctor first. If the engine is missing or data is stale, say so — \ + every later answer would be built on nothing. + 2. Call burrow_process_usage with minutes=\(minutes) and metric=cpu_time. Cumulative \ + CPU-seconds is the honest answer to "what used my computer"; peak CPU just crowns \ + whatever spiked for one sample. + 3. Call burrow_snapshot for memory pressure and thermal state right now. + 4. If disk is tight, call burrow_disk_forecast. + + Then give me the single most likely cause and what to do about it. Don't clean \ + anything — say what you'd clean and let me decide. + """ + case "reclaim_disk_space": + let target = arg("target_gb") + description = target.map { "Find \($0) GB to reclaim." } ?? "Find space to reclaim." + text = """ + I need to free up disk space\(target.map { " — about \($0) GB" } ?? ""). + + Start with burrow_disk_forecast to see how urgent this is, then burrow_analyze on \ + the home folder to find where the weight actually is. Pass min_size so small \ + entries don't drown the result, and analyze a specific subdirectory rather than \ + rescanning everything. + + Then preview the safe wins: burrow_clean without confirm (that's a dry run), \ + burrow_purge for build artifacts, and burrow_dupes on the directories that looked \ + heavy. Rank what you found by bytes-per-risk and show me the list. + + Do not pass confirm:true to anything. I'll decide what gets deleted. + """ + case "explain_last_cleanup": + description = "Explain what the last cleanup actually removed." + text = """ + What did the last cleanup actually do? Call burrow_cleanup_history for the session \ + summary, then burrow_deleted_files for the exact paths. Group the paths by what \ + they belonged to, tell me the total reclaimed, and flag anything that looks like it \ + shouldn't have been touched. + """ + case "investigate_process": + guard let process = arg("name") else { return nil } + description = "Investigate \(process)." + text = """ + Tell me about "\(process)" on this Mac. + + Use burrow_process_usage over a few windows (an hour, a day) so I can see whether \ + this is a spike or a pattern, burrow_ports to see whether it's listening on \ + anything, and burrow_net for what it's moving over the network. Say whether its \ + usage looks normal for what it is, and whether it's worth doing something about. + """ + case "pre_uninstall_check": + guard let app = arg("app") else { return nil } + description = "Pre-uninstall check for \(app)." + text = """ + I'm thinking about uninstalling "\(app)". Before anything is removed: + + Call burrow_list_apps and confirm the exact name the uninstaller would match — if \ + it's ambiguous, stop and tell me. Then call burrow_uninstall WITHOUT confirm to get \ + the dry-run list of what would go, and burrow_orphans on ~/Library/Application \ + Support to see what this app has already left lying around. + + Show me the file list and what it totals. Don't uninstall. + """ + default: + return nil + } + + return [ + "description": description, + "messages": [ + ["role": "user", "content": ["type": "text", "text": text]], + ], + ] + } + + // MARK: - Completion + + /// `completion/complete`. Two things are worth completing here: the + /// metric names in the process-ranking template, and real process names + /// pulled from the recorded window — the second one is only possible + /// because the data is already local. + func complete(ref: [String: Any], argumentName: String, value: String) -> [String: Any] { + let refType = ref["type"] as? String ?? "" + var values: [String] = [] + + if refType == "ref/resource", (ref["uri"] as? String)?.contains("{metric}") == true { + values = MetricsStore.ProcessRank.allCases.map(\.rawValue) + } else if refType == "ref/prompt", ref["name"] as? String == "investigate_process", + argumentName == "name" { + values = self.recentProcessNames() + } + + let matches = values + .filter { value.isEmpty || $0.lowercased().hasPrefix(value.lowercased()) } + .sorted() + // 100 is the per-response cap the spec puts on completion values. + let capped = Array(matches.prefix(100)) + return ["completion": ["values": capped, "total": matches.count, + "hasMore": matches.count > capped.count]] + } + + /// Distinct process names seen in the last hour, so completing a process + /// name suggests things that actually ran on this machine. + private func recentProcessNames() -> [String] { + let now = Int(Date().timeIntervalSince1970) + let window = MetricsStore.Window(since: now - 3_600, until: now) + let ranked = self.metrics.processWindow(window).ranked(by: .peakCPU, limit: 100) + var seen = Set() + return ranked.map(\.name).filter { seen.insert($0).inserted } + } +} diff --git a/macos/Sources/MCPServer.swift b/macos/Sources/MCPServer.swift new file mode 100644 index 00000000..9ae4ac93 --- /dev/null +++ b/macos/Sources/MCPServer.swift @@ -0,0 +1,508 @@ +// +// MCPServer.swift +// Burrow +// +// JSON-RPC routing for the MCP server: one method per spec method, and +// the two-era handling that keeps a 2024-11-05 client working next to a +// 2026-07-28 one. +// +// The whole surface is reachable through `response(toLine:)`, which takes +// bytes and returns a dictionary (or nil for a notification). No +// FileHandles are involved, which is what makes the conformance tests +// cheap to write — `serve(input:output:)` is only framing on top of it. +// +// Two rules run through everything here: +// +// * Never hand a client a shape it didn't ask for. Task handles need the +// tasks extension in the request's `_meta`; MRTR elicitations need the +// elicitation capability. Without those, the same call behaves exactly +// as it did before this revision existed. +// +// * MRTR is not a consent mechanism. It fills in arguments the server +// cannot invent. The destructive tools stay gated by `MoActions.decide` +// and the user's Settings opt-ins, which no round trip can satisfy. +// + +import Foundation + +final class MCPServer { + private let db: DB + private let catalog: ToolCatalog + private let resources: MCPResources + private let tasks: MCPTaskStore + private let serverVersion: String + + /// What a pre-2026 client settled on at `initialize`. The stateless era + /// repeats its version on every request and never reads this; it exists + /// for the era that physically can't. + private var legacyVersion = MCPProtocol.legacyFallback + + /// Guards the output stream. Task progress notifications are emitted + /// from the task queue, so writes are no longer single-threaded. + private let writeLock = NSLock() + private var out: FileHandle? + + init(db: DB, serverVersion: String = RuntimeEnvironment.current.appVersion) { + let catalog = ToolCatalog(db: db) + self.db = db + self.catalog = catalog + self.resources = MCPResources(catalog: catalog, db: db) + self.tasks = MCPTaskStore() + self.serverVersion = serverVersion + self.tasks.notify = { [weak self] note in self?.write(note) } + } + + // MARK: - Framing + + /// Drive the loop. Reads line by line from `input`; one JSON-RPC + /// message per line is the de-facto standard for stdio MCP. Exits + /// cleanly on EOF. + func serve(input: FileHandle, output: FileHandle) { + self.writeLock.lock() + self.out = output + self.writeLock.unlock() + + var buffer = Data() + while true { + let chunk = input.availableData + if chunk.isEmpty { break } // EOF — peer closed + buffer.append(chunk) + while let nl = buffer.firstIndex(of: 0x0A) { + let line = buffer.subdata(in: buffer.startIndex.. [String: Any]? { + guard let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return MCPServer.errorResponse(id: nil, code: MCPProtocol.ErrorCode.parse, + message: "parse error") + } + let method = (raw["method"] as? String) ?? "" + let id = raw["id"] + let params = (raw["params"] as? [String: Any]) ?? [:] + + // Notifications have no id. Answering one — even with an error — + // would be malformed JSON-RPC. + guard id != nil else { + self.handleNotification(method: method, params: params) + return nil + } + + let ctx = MCPRequestContext.parse(params: params, fallbackVersion: self.legacyVersion) + // The version gate only bites once a client has actually declared a + // version. `initialize` negotiates in its own params, below. + if ctx.declaredVersion, !MCPProtocol.supported.contains(ctx.protocolVersion) { + return self.unsupportedVersion(id: id, requested: ctx.protocolVersion) + } + + switch method { + case "server/discover": + return self.discover(id: id) + case "initialize": + return self.initializeResponse(id: id, params: params) + // `ping` and `logging/setLevel` were removed in this revision, but a + // pre-2026 client still sends them and an error would look like a + // broken server. Answer empty and move on. + case "ping", "logging/setLevel": + return self.ok(id: id, [:]) + case "tools/list": + return self.toolsList(id: id) + case "tools/call": + return self.toolsCall(id: id, params: params, ctx: ctx) + case "resources/list": + return self.resourcesList(id: id) + case "resources/templates/list": + return self.resourceTemplatesList(id: id) + case "resources/read": + return self.resourcesRead(id: id, params: params) + case "prompts/list": + return self.promptsList(id: id) + case "prompts/get": + return self.promptsGet(id: id, params: params) + case "completion/complete": + return self.completionComplete(id: id, params: params) + case "tasks/get": + return self.tasksGet(id: id, params: params) + case "tasks/update": + return self.tasksUpdate(id: id, params: params) + case "tasks/cancel": + return self.tasksCancel(id: id, params: params) + default: + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.methodNotFound, + message: "method not found: \(method)") + } + } + + /// Notifications are fire-and-forget. `notifications/cancelled` targets a + /// JSON-RPC request id; our long work is addressed by task id instead, so + /// there is nothing to cancel here — a client that wants to stop a task + /// sends `tasks/cancel`. + private func handleNotification(method: String, params: [String: Any]) { + // Deliberately empty. Listed here so the silence is a decision. + _ = method + _ = params + } + + // MARK: - Result helpers + + private func ok(id: Any?, _ body: [String: Any]) -> [String: Any] { + [ + "jsonrpc": "2.0", + "id": id as Any, + "result": MCPResult.complete(body, serverVersion: self.serverVersion), + ] + } + + private func okCacheable(id: Any?, _ body: [String: Any], ttlMs: Int, scope: String) -> [String: Any] { + [ + "jsonrpc": "2.0", + "id": id as Any, + "result": MCPResult.cacheable(body, ttlMs: ttlMs, scope: scope, + serverVersion: self.serverVersion), + ] + } + + private func unsupportedVersion(id: Any?, requested: String) -> [String: Any] { + [ + "jsonrpc": "2.0", + "id": id as Any, + "error": [ + "code": MCPProtocol.ErrorCode.unsupportedProtocolVersion, + "message": "unsupported protocol version: \(requested)", + "data": ["requested": requested, "supported": MCPProtocol.supported], + ] as [String: Any], + ] + } + + static func errorResponse(id: Any?, code: Int, message: String) -> [String: Any] { + return [ + "jsonrpc": "2.0", + "id": id as Any, + "error": ["code": code, "message": message], + ] + } + + // MARK: - Discovery and handshake + + /// What we can do, in the shape both eras read it. + private func capabilities() -> [String: Any] { + return [ + "tools": ["listChanged": false], + "resources": ["listChanged": false, "subscribe": false], + "prompts": ["listChanged": false], + "completions": [String: Any](), + "extensions": [MCPProtocol.Extensions.tasks: [String: Any]()], + ] + } + + /// `server/discover` — mandatory in 2026-07-28, and the probe a stdio + /// client uses to work out which era it's talking to. + private func discover(id: Any?) -> [String: Any] { + let body: [String: Any] = [ + "supportedVersions": MCPProtocol.supported, + "capabilities": self.capabilities(), + "instructions": MCPProtocol.instructions, + ] + return self.okCacheable(id: id, body, + ttlMs: MCPProtocol.Cache.catalogTTL, + scope: MCPProtocol.Cache.publicScope) + } + + /// The legacy handshake. Still answered because the deprecation window + /// runs for a year and clients probe downward. + private func initializeResponse(id: Any?, params: [String: Any]) -> [String: Any] { + let requested = params["protocolVersion"] as? String + let negotiated = requested.flatMap { MCPProtocol.supported.contains($0) ? $0 : nil } + ?? MCPProtocol.latest + self.legacyVersion = negotiated + let body: [String: Any] = [ + "protocolVersion": negotiated, + "capabilities": self.capabilities(), + "serverInfo": MCPProtocol.serverInfo(version: self.serverVersion), + "instructions": MCPProtocol.instructions, + ] + return self.ok(id: id, body) + } + + // MARK: - Tools + + private func toolsList(id: Any?) -> [String: Any] { + let tools = MCPToolMetadata.decorate(self.catalog.descriptors()) + // The catalogue is fixed for the lifetime of the binary and carries + // nothing machine-specific, so it is both long-lived and shareable. + return self.okCacheable(id: id, ["tools": tools], + ttlMs: MCPProtocol.Cache.catalogTTL, + scope: MCPProtocol.Cache.publicScope) + } + + private func toolsCall(id: Any?, params: [String: Any], ctx: MCPRequestContext) -> [String: Any] { + var name = params["name"] as? String ?? "" + var arguments = (params["arguments"] as? [String: Any]) ?? [:] + + if let responses = params["inputResponses"] as? [String: Any] { + // MRTR retry: the client is answering what we asked for. + guard let state = MCPInputRequests.decodeState(params["requestState"] as? String) else { + return MCPServer.errorResponse( + id: id, code: MCPProtocol.ErrorCode.invalidParams, + message: "inputResponses arrived without a requestState this server issued") + } + name = state.tool + switch MCPInputRequests.resolve(state: state, inputResponses: responses) { + case .proceed(let merged): + arguments = merged + case .declined(let why): + // A decline is an outcome, not a fault — report it in-band so + // the model can see it and pick something else. + return self.toolResult(id: id, name: name, isError: false, + text: MCPServer.jsonObject(["declined": true, "reason": why])) + case .unusable(let why): + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.invalidParams, + message: why) + } + } else if let missing = MCPInputRequests.missingArgument(tool: name, arguments: arguments), + ctx.supportsElicitation { + // Ask instead of erroring — but only when the client can ask a + // human. Without the capability this falls through and the tool + // returns its usual argument error. + return [ + "jsonrpc": "2.0", + "id": id as Any, + "result": MCPResult.inputRequired( + inputRequests: MCPInputRequests.elicitation(for: missing), + requestState: MCPInputRequests.encodeState(tool: name, arguments: arguments, + asking: missing.key), + serverVersion: self.serverVersion), + ] + } + + if MCPToolMetadata.longRunning.contains(name), ctx.supportsTasks { + return self.startTask(id: id, name: name, arguments: arguments, ctx: ctx) + } + + return self.runToolSynchronously(id: id, name: name, arguments: arguments) + } + + private func runToolSynchronously(id: Any?, name: String, arguments: [String: Any]) -> [String: Any] { + do { + let text = try self.catalog.call(name: name, arguments: arguments) + return self.toolResult(id: id, name: name, isError: false, text: text) + } catch let MCPToolError.unknown(toolName) { + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.invalidParams, + message: "unknown tool: \(toolName)") + } catch let MCPToolError.badArguments(reason) { + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.invalidParams, + message: "bad arguments: \(reason)") + } catch { + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.internalError, + message: "internal error: \(error.localizedDescription)") + } + } + + private func toolResult(id: Any?, name: String, isError: Bool, text: String) -> [String: Any] { + [ + "jsonrpc": "2.0", + "id": id as Any, + "result": MCPResult.complete(MCPServer.callToolBody(name: name, text: text, isError: isError), + serverVersion: self.serverVersion), + ] + } + + /// The `CallToolResult` body. `structuredContent` is attached only where + /// we declared an `outputSchema` and the payload really is a JSON object + /// — `burrow_report` returns Markdown, and promising structure for it + /// would be a lie the schema can't back up. + static func callToolBody(name: String, text: String, isError: Bool) -> [String: Any] { + var body: [String: Any] = ["content": [["type": "text", "text": text]]] + if isError { body["isError"] = true } + guard MCPToolMetadata.table[name]?.outputSchema != nil, + let data = text.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return body + } + body["structuredContent"] = obj + return body + } + + // MARK: - Tasks + + /// Hand back a task handle and run the work behind it. Only reached when + /// the client declared `io.modelcontextprotocol/tasks` on this request. + private func startTask(id: Any?, name: String, arguments: [String: Any], + ctx: MCPRequestContext) -> [String: Any] { + let catalog = self.catalog + let record = self.tasks.start(label: name, progressToken: ctx.progressToken) { progress in + progress("running \(name)") + do { + let text = try catalog.call(name: name, arguments: arguments) + return .success(MCPServer.callToolBody(name: name, text: text, isError: false)) + } catch let MCPToolError.badArguments(reason) { + return .failure(.invalidParams("bad arguments: \(reason)")) + } catch let MCPToolError.unknown(toolName) { + return .failure(.invalidParams("unknown tool: \(toolName)")) + } catch { + return .failure(.internalError(error.localizedDescription)) + } + } + var body = MCPTaskStore.wire(record) + // A task handle is its own result type — not `complete`, because + // nothing has completed yet. + body["resultType"] = MCPProtocol.ResultType.task + return [ + "jsonrpc": "2.0", + "id": id as Any, + "result": MCPResult.stamped(body, serverVersion: self.serverVersion), + ] + } + + private func tasksGet(id: Any?, params: [String: Any]) -> [String: Any] { + guard let taskId = params["taskId"] as? String, !taskId.isEmpty else { + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.invalidParams, + message: "tasks/get needs a taskId") + } + guard let record = self.tasks.get(taskId) else { + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.invalidParams, + message: "unknown or expired task: \(taskId)") + } + var body = MCPTaskStore.wire(record) + body["resultType"] = MCPProtocol.ResultType.task + return [ + "jsonrpc": "2.0", + "id": id as Any, + "result": MCPResult.stamped(body, serverVersion: self.serverVersion), + ] + } + + private func tasksUpdate(id: Any?, params: [String: Any]) -> [String: Any] { + guard let taskId = params["taskId"] as? String, !taskId.isEmpty else { + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.invalidParams, + message: "tasks/update needs a taskId") + } + let responses = (params["inputResponses"] as? [String: Any]) ?? [:] + guard self.tasks.update(taskId, inputResponses: responses) else { + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.invalidParams, + message: "unknown or expired task: \(taskId)") + } + return self.ok(id: id, [:]) + } + + private func tasksCancel(id: Any?, params: [String: Any]) -> [String: Any] { + guard let taskId = params["taskId"] as? String, !taskId.isEmpty else { + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.invalidParams, + message: "tasks/cancel needs a taskId") + } + guard self.tasks.cancel(taskId) else { + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.invalidParams, + message: "unknown or expired task: \(taskId)") + } + return self.ok(id: id, [:]) + } + + // MARK: - Resources + + private func resourcesList(id: Any?) -> [String: Any] { + // The list of resources is static; their contents are not, which is + // why the TTL here is nothing like the TTL on a read. + self.okCacheable(id: id, ["resources": MCPResources.listing()], + ttlMs: MCPProtocol.Cache.resourceListTTL, + scope: MCPProtocol.Cache.publicScope) + } + + private func resourceTemplatesList(id: Any?) -> [String: Any] { + self.okCacheable(id: id, ["resourceTemplates": MCPResources.templates], + ttlMs: MCPProtocol.Cache.catalogTTL, + scope: MCPProtocol.Cache.publicScope) + } + + private func resourcesRead(id: Any?, params: [String: Any]) -> [String: Any] { + guard let uri = params["uri"] as? String, !uri.isEmpty else { + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.invalidParams, + message: "resources/read needs a uri") + } + do { + let contents = try self.resources.read(uri: uri) + let body: [String: Any] = [ + "contents": [[ + "uri": uri, + "mimeType": contents.mimeType, + "text": contents.text, + ]], + ] + // Everything readable here describes this Mac, so it is private + // to this authorization context no matter how short-lived it is. + return self.okCacheable(id: id, body, ttlMs: contents.ttlMs, + scope: MCPProtocol.Cache.privateScope) + } catch let MCPResources.ReadError.notFound(message) { + // -32602, not -32002: this revision realigned resource-not-found + // with JSON-RPC's Invalid Params. + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.invalidParams, + message: message) + } catch { + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.internalError, + message: error.localizedDescription) + } + } + + // MARK: - Prompts + + private func promptsList(id: Any?) -> [String: Any] { + self.okCacheable(id: id, ["prompts": MCPResources.prompts], + ttlMs: MCPProtocol.Cache.catalogTTL, + scope: MCPProtocol.Cache.publicScope) + } + + private func promptsGet(id: Any?, params: [String: Any]) -> [String: Any] { + guard let name = params["name"] as? String, !name.isEmpty else { + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.invalidParams, + message: "prompts/get needs a name") + } + let arguments = (params["arguments"] as? [String: Any]) ?? [:] + guard let rendered = MCPResources.prompt(name: name, arguments: arguments) else { + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.invalidParams, + message: "unknown prompt, or a required argument is missing: \(name)") + } + return self.ok(id: id, rendered) + } + + private func completionComplete(id: Any?, params: [String: Any]) -> [String: Any] { + guard let ref = params["ref"] as? [String: Any], + let argument = params["argument"] as? [String: Any], + let argName = argument["name"] as? String else { + return MCPServer.errorResponse(id: id, code: MCPProtocol.ErrorCode.invalidParams, + message: "completion/complete needs `ref` and `argument`") + } + let value = argument["value"] as? String ?? "" + return self.ok(id: id, self.resources.complete(ref: ref, argumentName: argName, value: value)) + } + + // MARK: - Small helpers + + static func jsonObject(_ obj: [String: Any]) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: obj, + options: [.withoutEscapingSlashes]), + let s = String(data: data, encoding: .utf8) else { + return "{\"error\":\"encode failed\"}" + } + return s + } +} diff --git a/macos/Sources/MCPTasks.swift b/macos/Sources/MCPTasks.swift new file mode 100644 index 00000000..7e1adc19 --- /dev/null +++ b/macos/Sources/MCPTasks.swift @@ -0,0 +1,264 @@ +// +// MCPTasks.swift +// Burrow +// +// The `io.modelcontextprotocol/tasks` extension. +// +// Why Burrow needs it: `mo clean` on a full disk, or `mo analyze` on a +// home folder, routinely runs past the point where a client is willing to +// wait. Today those calls block the agent and then come back as +// `timed_out: true`, which a model reads as "there was nothing to clean" +// rather than "we gave up". A task handle turns that into a poll: the work +// keeps running, `tasks/get` reports how long it has been going, and the +// real result arrives whenever it arrives. +// +// Opt-in is strictly two-sided. We advertise the extension in +// `server/discover`, but a task handle is only ever returned to a client +// that declared the same extension in that request's `_meta` — to anyone +// else the call stays synchronous and behaves exactly as it did before. +// +// Cancellation is cooperative and we are honest about the limit: the work +// is a blocking engine subprocess with its own timeout, so `tasks/cancel` +// records the intent and stops us reporting the result, but it does not +// reach into `mo` and stop it mid-delete. +// + +import Foundation + +/// One asynchronous unit of work, as the client sees it. +final class MCPTaskStore { + struct Record { + var taskId: String + var status: String + var statusMessage: String? + var createdAt: Date + var lastUpdatedAt: Date + var ttlMs: Int + var pollIntervalMs: Int + /// Set on `completed` — the result the synchronous call would have returned. + var result: [String: Any]? + /// Set on `failed` — a JSON-RPC error object. + var error: [String: Any]? + /// Outstanding MRTR requests when the task is `input_required`. + var inputRequests: [String: Any]? + /// Set once the client asked us to stop. + var cancelRequested: Bool + } + + enum Status { + static let working = "working" + static let inputRequired = "input_required" + static let completed = "completed" + static let failed = "failed" + static let cancelled = "cancelled" + + static let terminal: Set = [completed, failed, cancelled] + } + + /// How long a finished task stays readable. Long enough that a client + /// which dropped its connection can come back for the answer. + static let defaultTTLMs = 600_000 + static let defaultPollIntervalMs = 1_000 + + private var records: [String: Record] = [:] + private let lock = NSLock() + private let queue: DispatchQueue + private var counter = 0 + + /// Where progress notifications go. The stdio writer installs this; in + /// tests it collects into an array. + var notify: (([String: Any]) -> Void)? + + /// Serial on purpose. The work these tasks wrap is disk-bound engine + /// scans; running two at once would thrash the disk and make both + /// slower, and a queued task still reports `working` to the client + /// instead of blocking it. The DB underneath is FULLMUTEX so + /// concurrency would be *safe* — it just wouldn't be faster. + init(queue: DispatchQueue = DispatchQueue(label: "dev.caezium.Burrow.mcp.tasks", + qos: .utility)) { + self.queue = queue + } + + // MARK: - Creating and running + + /// Start `work` in the background and hand back the initial task record. + /// `work` runs off the stdio reader thread, so it must not touch the + /// output stream directly — progress goes through `notify`. + @discardableResult + func start(label: String, progressToken: Any?, + work: @escaping (_ progress: @escaping (String) -> Void) -> Result<[String: Any], MCPTaskFailure>) -> Record { + let now = Date() + self.lock.lock() + self.counter += 1 + let taskId = "burrow-task-\(self.counter)-\(UUID().uuidString.prefix(8))" + let record = Record(taskId: taskId, status: Status.working, + statusMessage: "\(label) started", + createdAt: now, lastUpdatedAt: now, + ttlMs: Self.defaultTTLMs, + pollIntervalMs: Self.defaultPollIntervalMs, + result: nil, error: nil, inputRequests: nil, + cancelRequested: false) + self.records[taskId] = record + self.lock.unlock() + + self.queue.async { [weak self] in + guard let self else { return } + let started = Date() + let progress: (String) -> Void = { [weak self] message in + guard let self else { return } + let elapsed = Int(Date().timeIntervalSince(started)) + self.setStatusMessage(taskId, "\(message) (\(elapsed)s elapsed)") + self.emitProgress(token: progressToken, taskId: taskId, + elapsed: elapsed, message: message) + } + let outcome = work(progress) + self.finish(taskId, outcome: outcome) + } + return record + } + + private func finish(_ taskId: String, outcome: Result<[String: Any], MCPTaskFailure>) { + self.lock.lock() + defer { self.lock.unlock() } + guard var rec = self.records[taskId] else { return } + // A cancelled task keeps its cancelled status: the client already + // stopped caring, and reporting a fresh result would contradict the + // terminal state it was told about. + if rec.status == Status.cancelled { return } + rec.lastUpdatedAt = Date() + switch outcome { + case .success(let value): + rec.status = Status.completed + rec.result = value + rec.statusMessage = "finished" + case .failure(let failure): + rec.status = Status.failed + rec.error = ["code": failure.code, "message": failure.message] + rec.statusMessage = failure.message + } + self.records[taskId] = rec + } + + private func setStatusMessage(_ taskId: String, _ message: String) { + self.lock.lock() + defer { self.lock.unlock() } + guard var rec = self.records[taskId], !Status.terminal.contains(rec.status) else { return } + rec.statusMessage = message + rec.lastUpdatedAt = Date() + self.records[taskId] = rec + } + + /// `notifications/progress`, but only when the client actually asked for + /// it. The spec makes progress opt-in via `progressToken`; without one + /// we stay quiet and let the client poll. + private func emitProgress(token: Any?, taskId: String, elapsed: Int, message: String) { + guard let token, let notify = self.notify else { return } + notify([ + "jsonrpc": "2.0", + "method": "notifications/progress", + "params": [ + "progressToken": token, + "progress": elapsed, + "message": message, + "_meta": [MCPProtocol.Meta.relatedTask: ["taskId": taskId]], + ] as [String: Any], + ]) + } + + // MARK: - Reading + + func get(_ taskId: String) -> Record? { + self.lock.lock() + defer { self.lock.unlock() } + self.sweepExpiredLocked() + return self.records[taskId] + } + + /// Cooperative cancel. Returns false when the id is unknown. + @discardableResult + func cancel(_ taskId: String) -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + guard var rec = self.records[taskId] else { return false } + guard !Status.terminal.contains(rec.status) else { return true } + rec.cancelRequested = true + rec.status = Status.cancelled + rec.statusMessage = "cancellation requested — the engine subprocess may still be finishing" + rec.lastUpdatedAt = Date() + self.records[taskId] = rec + return true + } + + /// `tasks/update`. Responses for unknown or already-satisfied keys are + /// ignored per the extension, so this only ever acknowledges. + @discardableResult + func update(_ taskId: String, inputResponses: [String: Any]) -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + guard var rec = self.records[taskId] else { return false } + guard rec.status == Status.inputRequired else { return true } + var outstanding = rec.inputRequests ?? [:] + for key in inputResponses.keys { outstanding.removeValue(forKey: key) } + rec.inputRequests = outstanding.isEmpty ? nil : outstanding + if outstanding.isEmpty { rec.status = Status.working } + rec.lastUpdatedAt = Date() + self.records[taskId] = rec + return true + } + + /// Drop terminal tasks whose TTL has run out. Called on read, which is + /// enough for a process that only lives as long as a conversation. + private func sweepExpiredLocked() { + let now = Date() + self.records = self.records.filter { _, rec in + guard Status.terminal.contains(rec.status) else { return true } + return now.timeIntervalSince(rec.lastUpdatedAt) * 1000 < Double(rec.ttlMs) + } + } + + var count: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.records.count + } + + // MARK: - Wire shapes + + private static let isoFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + /// The `Task` object the extension defines. `result`/`error` only appear + /// on the terminal states that carry them. + static func wire(_ rec: Record) -> [String: Any] { + var out: [String: Any] = [ + "taskId": rec.taskId, + "status": rec.status, + "createdAt": Self.isoFormatter.string(from: rec.createdAt), + "lastUpdatedAt": Self.isoFormatter.string(from: rec.lastUpdatedAt), + "ttlMs": rec.ttlMs, + "pollIntervalMs": rec.pollIntervalMs, + ] + if let m = rec.statusMessage { out["statusMessage"] = m } + if rec.status == Status.completed, let r = rec.result { out["result"] = r } + if rec.status == Status.failed, let e = rec.error { out["error"] = e } + if rec.status == Status.inputRequired, let r = rec.inputRequests { out["inputRequests"] = r } + return out + } +} + +/// A task that ended badly, in the shape `FailedTask.error` wants. +struct MCPTaskFailure: Error { + let code: Int + let message: String + + static func invalidParams(_ message: String) -> MCPTaskFailure { + MCPTaskFailure(code: MCPProtocol.ErrorCode.invalidParams, message: message) + } + + static func internalError(_ message: String) -> MCPTaskFailure { + MCPTaskFailure(code: MCPProtocol.ErrorCode.internalError, message: message) + } +} diff --git a/macos/Sources/MCPToolMetadata.swift b/macos/Sources/MCPToolMetadata.swift new file mode 100644 index 00000000..2d689e3b --- /dev/null +++ b/macos/Sources/MCPToolMetadata.swift @@ -0,0 +1,414 @@ +// +// MCPToolMetadata.swift +// Burrow +// +// Annotations and output schemas for the tool catalogue. +// +// These live apart from `ToolCatalog.descriptors()` on purpose: the +// descriptors are already dense with prose, and the read-only/destructive +// split is a safety property that deserves to be readable in one place +// rather than scattered across twenty-six inline dictionaries. +// +// On `outputSchema` honesty: declaring one is a promise that +// `structuredContent` conforms to it. Most of these tools can also return +// a bare `{"error": …}` object when the engine is missing or a scan times +// out, and several pass the engine's payload through verbatim so the shape +// tracks the engine's contract rather than ours. So the schemas below +// describe properties without requiring them — an agent gets the field map, +// and the error path stays conformant instead of becoming a spec violation. +// + +import Foundation + +enum MCPToolMetadata { + /// Per-tool display name, behavioural hints, and output shape. + struct Entry { + let title: String + let readOnly: Bool + /// Only meaningful when `readOnly` is false. + let destructive: Bool + /// Only meaningful when `readOnly` is false. + let idempotent: Bool + /// True when the tool's answer depends on live machine state outside + /// Burrow's own recorded data — the filesystem, running processes, + /// the installed-app inventory. + let openWorld: Bool + /// nil for tools whose payload isn't a JSON object (burrow_report is + /// Markdown), which also suppresses `structuredContent`. + let outputSchema: [String: Any]? + + var annotations: [String: Any] { + var a: [String: Any] = [ + "title": self.title, + "readOnlyHint": self.readOnly, + "openWorldHint": self.openWorld, + ] + if !self.readOnly { + a["destructiveHint"] = self.destructive + a["idempotentHint"] = self.idempotent + } + return a + } + } + + // MARK: - Schema helpers + + private static func object(_ properties: [String: Any]) -> [String: Any] { + ["type": "object", "properties": properties, "additionalProperties": true] + } + + private static func int(_ desc: String) -> [String: Any] { + ["type": "integer", "description": desc] + } + + private static func num(_ desc: String) -> [String: Any] { + ["type": "number", "description": desc] + } + + private static func str(_ desc: String) -> [String: Any] { + ["type": "string", "description": desc] + } + + private static func arr(of items: [String: Any], _ desc: String) -> [String: Any] { + ["type": "array", "items": items, "description": desc] + } + + private static let stringArray: [String: Any] = ["type": "array", "items": ["type": "string"]] + + /// The shape every tool that can fail softly shares. Merged into the + /// property map rather than declared as a variant so the schema stays a + /// plain object — agents read these as "may be present". + private static let softError: [String: Any] = [ + "error": ["type": "string", "description": "Present instead of the payload when the tool could not run (missing engine, failed scan). Not a protocol error."], + "hint": ["type": "string", "description": "What to try instead, when the error is actionable."], + ] + + private static func withError(_ properties: [String: Any]) -> [String: Any] { + var p = properties + for (k, v) in Self.softError { p[k] = v } + return Self.object(p) + } + + /// Payloads that come straight from the engine (`mo`) or the bundled + /// conductor (`burrow --json`). We deliberately don't restate the + /// engine's contract here — it would drift the moment the engine + /// changes, and the descriptions already point at the source. + private static func passthrough(_ what: String) -> [String: Any] { + Self.withError([ + "_passthrough": ["type": "string", "description": "This tool returns \(what) verbatim; the payload's shape tracks the engine's contract, not Burrow's."], + ]) + } + + /// The frozen action-tool contract from `ActionWire`. + private static var actionResult: [String: Any] { + Self.withError([ + "command": Self.str("The engine subcommand that ran."), + "dry_run": ["type": "boolean", "description": "True when this was a preview and nothing was changed."], + "ran": ["type": "boolean", "description": "True only when a real (non-preview) run actually executed."], + "blocked": ["type": "boolean", "description": "Present and true when the user's Settings opt-in refused the run."], + "reason": Self.str("Why the run was blocked. The user changes this in Burrow's Settings, not the agent."), + "exit_code": Self.int("The engine's exit status."), + "output": Self.str("The engine's console output, ANSI-stripped."), + "summary": Self.object([ + "space": Self.str("Space freed, as the engine reported it."), + "items": Self.str("Item count."), + "categories": Self.str("Categories touched."), + "free_change": Self.str("Change in free space."), + "free_now": Self.str("Free space after the run."), + ]), + "apps": Self.stringArray, + "permanent": ["type": "boolean", "description": "True when removed files bypassed the Trash."], + "interactive_only": ["type": "boolean", "description": "True when the real flow is GUI-only and this is the preview."], + "note": Self.str("Why the tool returned a preview instead of running."), + "timed_out": ["type": "boolean", "description": "True when the engine was killed before finishing — NOT the same as 'nothing to do'."], + "matched": Self.stringArray, + ]) + } + + // MARK: - The table + + static let table: [String: Entry] = [ + "burrow_snapshot": Entry( + title: "Latest system snapshot", readOnly: true, destructive: false, + idempotent: true, openWorld: false, + outputSchema: Self.withError([ + "ts": Self.int("Unix seconds the snapshot was taken."), + "snapshot": Self.object([:]), + ])), + + "burrow_history": Entry( + title: "Metric history", readOnly: true, destructive: false, + idempotent: true, openWorld: false, + outputSchema: Self.withError([ + "count": Self.int("Number of returned samples."), + "rows": Self.arr(of: Self.object([ + "ts": Self.int("Unix seconds."), + "snapshot": Self.object([:]), + ]), "Sampled snapshots, oldest first."), + ])), + + "burrow_top_processes": Entry( + title: "Top processes by peak CPU", readOnly: true, destructive: false, + idempotent: true, openWorld: false, + outputSchema: Self.withError([ + "window_minutes": Self.int("The window that was aggregated."), + "processes": Self.arr(of: Self.object([ + "name": Self.str("Process name."), + "peak_cpu": Self.num("Highest single-sample CPU percent."), + "peak_mem": Self.num("Highest single-sample memory percent."), + ]), "Ranked by peak CPU, highest first."), + ])), + + "burrow_process_usage": Entry( + title: "Process usage ranking", readOnly: true, destructive: false, + idempotent: true, openWorld: false, + outputSchema: Self.withError([ + "metric": Self.str("The ranking metric that was applied."), + "window_minutes": Self.int("Requested window."), + "start_ts": Self.int("First sample in the window, unix seconds."), + "end_ts": Self.int("Last sample in the window, unix seconds."), + "sample_count": Self.int("How many samples backed the ranking."), + "interval_seconds": Self.int("Mean gap between samples."), + "processes": Self.arr(of: Self.object([ + "name": Self.str("Process name."), + "peak_cpu": Self.num("Highest single-sample CPU percent."), + "avg_cpu": Self.num("Mean CPU percent while present."), + "est_cpu_time_seconds": Self.num("Estimated cumulative CPU-seconds — sample-derived, not kernel accounting."), + "peak_mem": Self.num("Highest memory percent."), + "samples": Self.int("Samples this process appeared in."), + ]), "Ranked by the requested metric."), + ])), + + "burrow_disk_forecast": Entry( + title: "Disk-full forecast", readOnly: true, destructive: false, + idempotent: true, openWorld: false, + outputSchema: Self.withError([ + "mount": Self.str("Volume the forecast covers."), + "basis_days": Self.num("How many days of history the fit used."), + "slope_bytes_per_day": Self.int("Trend in free bytes per day; negative means filling."), + "days_until_full": ["type": ["number", "null"], "description": "Null when the trend is flat, free space is growing, or there is under a week of history."], + "samples": Self.int("Free-space samples in the window."), + ])), + + "burrow_diff": Entry( + title: "What changed since", readOnly: true, destructive: false, + idempotent: true, openWorld: false, + outputSchema: Self.withError([ + "since_ts": Self.int("Unix seconds of the earlier snapshot."), + "until_ts": Self.int("Unix seconds of the later snapshot."), + "processes_entered": Self.stringArray, + "processes_left": Self.stringArray, + "login_items_added": Self.stringArray, + "login_items_removed": Self.stringArray, + "disk_free_delta_bytes": Self.int("Change in free bytes on the largest volume."), + "note": Self.str("Scope caveats for this diff."), + ])), + + // Markdown, not JSON — no schema, and no structuredContent. + "burrow_report": Entry( + title: "Weekly digest", readOnly: true, destructive: false, + idempotent: true, openWorld: false, outputSchema: nil), + + "burrow_doctor": Entry( + title: "Diagnostics", readOnly: true, destructive: false, + idempotent: true, openWorld: true, + outputSchema: Self.withError([ + "checks": Self.arr(of: Self.object([ + "name": Self.str("Check name."), + "level": ["type": "string", "enum": ["ok", "warn", "fail"]], + "detail": Self.str("One-line explanation."), + ]), "One entry per diagnostic check."), + ])), + + "burrow_ports": Entry( + title: "Listening ports", readOnly: true, destructive: false, + idempotent: true, openWorld: true, + outputSchema: Self.withError([ + "count": Self.int("Number of listening sockets."), + "ports": Self.arr(of: Self.object([ + "pid": Self.int("Owning process id."), + "process": Self.str("Owning process name."), + "port": Self.int("Port number."), + "proto": Self.str("tcp or udp."), + "uid": Self.int("Owning user id."), + ]), "Listening sockets with their owners."), + ])), + + "burrow_info": Entry( + title: "Burrow's own state", readOnly: true, destructive: false, + idempotent: true, openWorld: false, + outputSchema: Self.withError([ + "now": Self.int("Server clock, unix seconds."), + "retention_days": Self.int("How long samples are kept."), + "sample_interval_seconds": Self.int("Sampler cadence."), + "decode_skipped_total": Self.int("Rows skipped because they failed to decode."), + "last_drift": Self.object([:]), + "readers": Self.arr(of: Self.object([ + "prefix": Self.str("Data stream name."), + "latest_ts": ["type": ["integer", "null"]], + "age_seconds": ["type": ["integer", "null"]], + ]), "One entry per recorded stream, with staleness."), + ])), + + "burrow_cleanup_history": Entry( + title: "Cleanup history", readOnly: true, destructive: false, + idempotent: true, openWorld: true, + outputSchema: Self.withError([ + "sessions": Self.arr(of: Self.object([:]), "Past cleanup sessions as the engine records them."), + ])), + + "burrow_deleted_files": Entry( + title: "Deleted file paths", readOnly: true, destructive: false, + idempotent: true, openWorld: true, + outputSchema: Self.withError([ + "count": Self.int("Entries returned."), + "log": Self.str("Path of the deletion log that was read."), + "files": Self.arr(of: Self.object([ + "ts": Self.str("When the deletion happened."), + "action": Self.str("trash or remove."), + "category": Self.str("Cleanup category."), + "status": Self.str("ok or failed."), + "path": Self.str("Absolute path that was removed."), + ]), "Newest first."), + ])), + + "burrow_analyze": Entry( + title: "Disk usage breakdown", readOnly: true, destructive: false, + idempotent: true, openWorld: true, + outputSchema: Self.withError([ + "entries": Self.arr(of: Self.object([ + "path": Self.str("Absolute path."), + "size": Self.int("Bytes."), + "is_dir": ["type": "boolean"], + "children": ["type": "array", "items": ["type": "object"], "description": "Present when `depth` descended into this directory."], + ]), "Immediate children, largest first."), + "entries_omitted": Self.int("Entries dropped by `limit`/`min_size`."), + "omitted_bytes": Self.int("Bytes represented by the omitted entries."), + "partial": ["type": "boolean", "description": "True when the depth descent hit its time budget."], + "timed_out": ["type": "boolean", "description": "True when the scan was killed before finishing."], + ])), + + "burrow_list_apps": Entry( + title: "Installed applications", readOnly: true, destructive: false, + idempotent: true, openWorld: true, + outputSchema: Self.withError([ + "apps": ["type": "array", "description": "Installed apps, in the exact form burrow_uninstall accepts."], + ])), + + "burrow_dupes": Entry( + title: "Duplicate files", readOnly: true, destructive: false, + idempotent: true, openWorld: true, + outputSchema: Self.passthrough("the conductor's `dupes` report")), + + "burrow_net": Entry( + title: "Per-app network use", readOnly: true, destructive: false, + idempotent: true, openWorld: true, + outputSchema: Self.passthrough("the conductor's `net` report")), + + "burrow_orphans": Entry( + title: "Orphaned files", readOnly: true, destructive: false, + idempotent: true, openWorld: true, + outputSchema: Self.passthrough("the conductor's `orphans` report")), + + "burrow_photos": Entry( + title: "Near-duplicate photos", readOnly: true, destructive: false, + idempotent: true, openWorld: true, + outputSchema: Self.passthrough("the conductor's `photos` report")), + + "burrow_rules_dryrun": Entry( + title: "Rules preview", readOnly: true, destructive: false, + idempotent: true, openWorld: true, + outputSchema: Self.passthrough("the conductor's `rules dryrun` report")), + + "burrow_sentinel": Entry( + title: "Apps in the Trash", readOnly: true, destructive: false, + idempotent: true, openWorld: true, + outputSchema: Self.passthrough("the conductor's `sentinel` report")), + + "burrow_slim_check": Entry( + title: "Fat-binary reclaim estimate", readOnly: true, destructive: false, + idempotent: true, openWorld: true, + outputSchema: Self.passthrough("the conductor's `slim-check` report")), + + "burrow_agent_audit": Entry( + title: "What agents have done", readOnly: true, destructive: false, + idempotent: true, openWorld: false, + outputSchema: Self.withError([ + "count": Self.int("Rows returned."), + "window_minutes": Self.int("The window that was searched."), + "entries": Self.arr(of: Self.object([ + "ts": Self.int("Unix seconds the call was made."), + "tool": Self.str("Which MCP tool ran."), + "client": Self.str("Which surface called it — \"mcp\" for this server."), + "dry_run": ["type": "boolean", "description": "True when the call was a preview."], + "duration_ms": Self.int("How long the call took."), + "ok": ["type": "boolean", "description": "Whether the call succeeded."], + "summary": Self.str("First 200 characters of what it returned."), + "args": Self.object([:]), + "args_raw": Self.str("Present instead of `args` when the stored arguments did not parse."), + ]), "Newest first."), + ])), + + "burrow_anomalies": Entry( + title: "CPU anomalies", readOnly: true, destructive: false, + idempotent: true, openWorld: false, + outputSchema: Self.withError([ + "count": Self.int("Findings returned."), + "findings": Self.arr(of: Self.object([ + "process": Self.str("Process name."), + "recent_median_cpu": Self.num("Median CPU percent over the last 24 hours."), + "baseline_median_cpu": Self.num("Median CPU percent over the prior 14 days."), + ]), "Worst regression first. Empty when there isn't enough history."), + "basis": Self.str("The comparison that produced these findings."), + ])), + + // The five that can change the disk. destructiveHint stays true for + // purge and installer even though MCP only ever previews them: being + // wrong in the cautious direction costs nothing. + "burrow_clean": Entry( + title: "Clean caches and temp files", readOnly: false, destructive: true, + idempotent: true, openWorld: true, outputSchema: Self.actionResult), + + "burrow_optimize": Entry( + title: "Run system maintenance", readOnly: false, destructive: false, + idempotent: true, openWorld: true, outputSchema: Self.actionResult), + + "burrow_uninstall": Entry( + title: "Uninstall applications", readOnly: false, destructive: true, + idempotent: true, openWorld: true, outputSchema: Self.actionResult), + + "burrow_purge": Entry( + title: "Purge build artifacts", readOnly: false, destructive: true, + idempotent: true, openWorld: true, outputSchema: Self.actionResult), + + "burrow_installer": Entry( + title: "Remove installer leftovers", readOnly: false, destructive: true, + idempotent: true, openWorld: true, outputSchema: Self.actionResult), + ] + + /// Merge annotations, titles, and output schemas into raw descriptors, + /// and return them in a stable order. The spec asks servers to return + /// tools deterministically so clients can cache and prompt-cache them; + /// dictionary iteration order in `descriptors()` is stable today but + /// sorting makes it a property rather than an accident. + static func decorate(_ descriptors: [[String: Any]]) -> [[String: Any]] { + let decorated: [[String: Any]] = descriptors.map { raw in + guard let name = raw["name"] as? String, let entry = Self.table[name] else { return raw } + var out = raw + out["title"] = entry.title + out["annotations"] = entry.annotations + if let schema = entry.outputSchema { out["outputSchema"] = schema } + return out + } + return decorated.sorted { ($0["name"] as? String ?? "") < ($1["name"] as? String ?? "") } + } + + /// Tools whose work can run for minutes. These are the ones offered as + /// tasks when the client supports the extension, and the reason the + /// extension is worth having: today they either block the agent or come + /// back as `timed_out: true`, which reads like "nothing to do". + static let longRunning: Set = [ + "burrow_analyze", "burrow_dupes", "burrow_photos", "burrow_orphans", + "burrow_clean", "burrow_optimize", "burrow_uninstall", "burrow_purge", "burrow_installer", + ] +} diff --git a/macos/Tests/MCPConformanceTests.swift b/macos/Tests/MCPConformanceTests.swift new file mode 100644 index 00000000..47f9d86e --- /dev/null +++ b/macos/Tests/MCPConformanceTests.swift @@ -0,0 +1,524 @@ +// +// MCPConformanceTests.swift +// BurrowTests +// +// The 2026-07-28 protocol surface: the stateless request path, the legacy +// handshake alongside it, cache hints, tool metadata, MRTR, tasks, and the +// resource/prompt surface. +// +// MCPEnvelopeTests pins the pre-2026 envelope rules and stays as-is — +// those are the guarantees a legacy client still depends on. This file +// pins what the new revision added, plus the one safety property that must +// survive all of it: no round trip and no transport can substitute for the +// user's Settings opt-in. +// + +import XCTest +@testable import Burrow + +final class MCPConformanceTests: XCTestCase { + private var tempDir: URL! + private var server: MCPServer! + + private static let version = "2026-07-28" + + override func setUpWithError() throws { + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("burrow-conformance-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + server = MCPServer(db: try DB(at: tempDir.appendingPathComponent("burrow.db")), + serverVersion: "9.8.7") + } + + override func tearDown() { + server = nil + try? FileManager.default.removeItem(at: tempDir) + Store.d.removePersistentDomain(forName: StoreTests.scratchSuite) + Store.d = .standard + } + + // MARK: - Helpers + + /// A stateless-era request: protocol version and capabilities travel in + /// `params._meta`, and there is no handshake before it. + private func call(_ method: String, _ params: [String: Any] = [:], + capabilities: [String: Any] = [:], + version: String = MCPConformanceTests.version, + id: Int = 1) -> [String: Any]? { + var p = params + var meta = (p["_meta"] as? [String: Any]) ?? [:] + meta[MCPProtocol.Meta.protocolVersion] = version + meta[MCPProtocol.Meta.clientCapabilities] = capabilities + meta[MCPProtocol.Meta.clientInfo] = ["name": "xctest", "version": "1"] + p["_meta"] = meta + let body: [String: Any] = ["jsonrpc": "2.0", "id": id, "method": method, "params": p] + let data = try! JSONSerialization.data(withJSONObject: body) + return server.response(toLine: data) + } + + /// A pre-2026 request: no `_meta` at all. + private func legacyCall(_ method: String, _ params: [String: Any] = [:], + id: Int = 1) -> [String: Any]? { + let body: [String: Any] = ["jsonrpc": "2.0", "id": id, "method": method, "params": params] + let data = try! JSONSerialization.data(withJSONObject: body) + return server.response(toLine: data) + } + + private func result(_ response: [String: Any]?) throws -> [String: Any] { + let r = try XCTUnwrap(response) + if let err = r["error"] as? [String: Any] { + XCTFail("expected a result, got error \(err)") + } + return try XCTUnwrap(r["result"] as? [String: Any]) + } + + private func errorCode(_ response: [String: Any]?) -> Int? { + (response?["error"] as? [String: Any])?["code"] as? Int + } + + private static let tasksCapability: [String: Any] = + ["extensions": [MCPProtocol.Extensions.tasks: [String: Any]()]] + private static let elicitCapability: [String: Any] = + ["elicitation": ["form": [String: Any]()]] + + // MARK: - Stateless core + + func testDiscover_advertisesVersionsCapabilitiesAndIdentity() throws { + let r = try result(call("server/discover")) + let versions = try XCTUnwrap(r["supportedVersions"] as? [String]) + XCTAssertEqual(versions.first, MCPProtocol.latest, "newest revision leads the list") + XCTAssertTrue(versions.contains("2024-11-05"), "legacy revisions stay negotiable") + + let caps = try XCTUnwrap(r["capabilities"] as? [String: Any]) + XCTAssertNotNil(caps["tools"]) + XCTAssertNotNil(caps["resources"]) + XCTAssertNotNil(caps["prompts"]) + let ext = try XCTUnwrap(caps["extensions"] as? [String: Any]) + XCTAssertNotNil(ext[MCPProtocol.Extensions.tasks], "tasks must be advertised to be usable") + + let meta = try XCTUnwrap(r["_meta"] as? [String: Any]) + let info = try XCTUnwrap(meta[MCPProtocol.Meta.serverInfo] as? [String: Any]) + XCTAssertEqual(info["name"] as? String, "burrow") + XCTAssertEqual(info["version"] as? String, "9.8.7") + } + + func testStatelessClient_needsNoHandshake() throws { + // No `initialize` first — this is the whole point of the revision. + let r = try result(call("tools/list")) + XCTAssertFalse((r["tools"] as? [[String: Any]] ?? []).isEmpty) + } + + func testEveryResult_carriesResultTypeComplete() throws { + for method in ["server/discover", "tools/list", "resources/list", + "resources/templates/list", "prompts/list"] { + let r = try result(call(method)) + XCTAssertEqual(r["resultType"] as? String, MCPProtocol.ResultType.complete, + "\(method) must declare its result type") + } + } + + func testEveryResult_carriesServerInfo() throws { + for method in ["server/discover", "tools/list", "prompts/list"] { + let r = try result(call(method)) + let meta = try XCTUnwrap(r["_meta"] as? [String: Any], "\(method) has no _meta") + XCTAssertNotNil(meta[MCPProtocol.Meta.serverInfo], "\(method) omitted serverInfo") + } + } + + func testUnsupportedVersion_is32022WithTheSupportedList() throws { + let r = try XCTUnwrap(call("tools/list", version: "1999-01-01")) + XCTAssertEqual(errorCode(r), MCPProtocol.ErrorCode.unsupportedProtocolVersion) + let data = try XCTUnwrap((r["error"] as? [String: Any])?["data"] as? [String: Any]) + XCTAssertEqual(data["requested"] as? String, "1999-01-01") + let supported = try XCTUnwrap(data["supported"] as? [String]) + XCTAssertTrue(supported.contains(MCPProtocol.latest), + "the error has to tell the client what to retry with") + } + + func testLegacyClient_stillWorksWithoutMeta() throws { + let init_ = try result(legacyCall("initialize", ["protocolVersion": "2024-11-05"])) + XCTAssertEqual(init_["protocolVersion"] as? String, "2024-11-05", + "a legacy client must be answered in its own revision") + XCTAssertNotNil(init_["serverInfo"]) + + let tools = try result(legacyCall("tools/list", id: 2)) + XCTAssertFalse((tools["tools"] as? [[String: Any]] ?? []).isEmpty) + } + + func testRemovedMethods_stillAnsweredForOldClients() throws { + // `ping` and `logging/setLevel` are gone in 2026-07-28, but an old + // client sending them should not see a broken server. + XCTAssertNil(errorCode(legacyCall("ping"))) + XCTAssertNil(errorCode(legacyCall("logging/setLevel", ["level": "info"], id: 2))) + } + + // MARK: - Cache hints + + func testCacheableResults_carryTtlAndScope() throws { + for method in ["server/discover", "tools/list", "resources/list", + "resources/templates/list", "prompts/list"] { + let r = try result(call(method)) + XCTAssertNotNil(r["ttlMs"] as? Int, "\(method) is missing ttlMs") + let scope = r["cacheScope"] as? String + XCTAssertTrue(scope == "public" || scope == "private", + "\(method) has cacheScope \(scope ?? "nil")") + } + } + + func testResourceRead_isPrivateAndShortLived() throws { + let r = try result(call("resources/read", ["uri": "burrow://info"])) + XCTAssertEqual(r["cacheScope"] as? String, "private", + "anything describing this Mac must not be shared across contexts") + let ttl = try XCTUnwrap(r["ttlMs"] as? Int) + XCTAssertLessThanOrEqual(ttl, MCPProtocol.Cache.digestTTL, + "live data must not be cacheable for long") + } + + // MARK: - Tool metadata + + func testToolsList_isDeterministicallyOrdered() throws { + let r = try result(call("tools/list")) + let names = (r["tools"] as? [[String: Any]] ?? []).compactMap { $0["name"] as? String } + XCTAssertEqual(names, names.sorted(), "clients cache on this order") + } + + func testEveryTool_hasMetadata() throws { + let r = try result(call("tools/list")) + let tools = try XCTUnwrap(r["tools"] as? [[String: Any]]) + for tool in tools { + let name = try XCTUnwrap(tool["name"] as? String) + XCTAssertNotNil(MCPToolMetadata.table[name], "\(name) has no metadata entry") + XCTAssertNotNil(tool["annotations"], "\(name) has no annotations") + XCTAssertNotNil(tool["title"], "\(name) has no title") + } + } + + /// The read-only/destructive split is a safety claim, so it gets pinned + /// rather than left to whoever edits the table next. + func testAnnotations_matchTheMutatingSet() throws { + let r = try result(call("tools/list")) + let tools = try XCTUnwrap(r["tools"] as? [[String: Any]]) + let mutating = Set(ToolCatalog.auditedTools) + for tool in tools { + let name = try XCTUnwrap(tool["name"] as? String) + let ann = try XCTUnwrap(tool["annotations"] as? [String: Any]) + let readOnly = ann["readOnlyHint"] as? Bool ?? false + XCTAssertEqual(readOnly, !mutating.contains(name), + "\(name): readOnlyHint disagrees with the audited-tools set") + if mutating.contains(name) { + XCTAssertNotNil(ann["destructiveHint"], "\(name) must state destructiveHint") + } + } + } + + func testStructuredContent_mirrorsJSONPayloads() throws { + let r = try result(call("tools/call", ["name": "burrow_info", "arguments": [String: Any]()])) + let structured = try XCTUnwrap(r["structuredContent"] as? [String: Any], + "a tool with an outputSchema must return structuredContent") + let content = try XCTUnwrap(r["content"] as? [[String: Any]]) + let text = try XCTUnwrap(content.first?["text"] as? String) + let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]) + XCTAssertEqual(structured.keys.sorted(), parsed.keys.sorted(), + "structuredContent must be the same payload, not a second answer") + } + + /// burrow_report is Markdown. Declaring a schema for it would be a + /// promise the payload can't keep. + func testMarkdownTool_hasNoSchemaAndNoStructuredContent() throws { + XCTAssertNil(MCPToolMetadata.table["burrow_report"]?.outputSchema) + let body = MCPServer.callToolBody(name: "burrow_report", text: "# not json", isError: false) + XCTAssertNil(body["structuredContent"]) + } + + func testStructuredContent_skippedWhenPayloadIsNotAnObject() { + let body = MCPServer.callToolBody(name: "burrow_info", text: "not json at all", isError: false) + XCTAssertNil(body["structuredContent"], + "a soft-failed payload must not be presented as structured") + } + + // MARK: - MRTR + + func testMissingArgument_asksWhenTheClientCanAsk() throws { + let r = try result(call("tools/call", + ["name": "burrow_uninstall", "arguments": [String: Any]()], + capabilities: Self.elicitCapability)) + XCTAssertEqual(r["resultType"] as? String, MCPProtocol.ResultType.inputRequired) + let requests = try XCTUnwrap(r["inputRequests"] as? [String: Any]) + let apps = try XCTUnwrap(requests["apps"] as? [String: Any]) + XCTAssertEqual(apps["method"] as? String, "elicitation/create") + let params = try XCTUnwrap(apps["params"] as? [String: Any]) + XCTAssertNotNil(params["requestedSchema"]) + XCTAssertNotNil(r["requestState"] as? String) + } + + func testMissingArgument_staysAnErrorWithoutElicitation() { + // No elicitation capability: the client gets exactly what it got + // before this revision existed. + let r = call("tools/call", ["name": "burrow_uninstall", "arguments": [String: Any]()]) + XCTAssertEqual(errorCode(r), MCPProtocol.ErrorCode.invalidParams) + } + + func testInputResponses_withoutState_isRejected() { + let r = call("tools/call", ["name": "burrow_uninstall", + "inputResponses": ["apps": ["action": "accept"]]]) + XCTAssertEqual(errorCode(r), MCPProtocol.ErrorCode.invalidParams) + } + + func testDecline_isReportedInBandNotAsAnError() throws { + let state = MCPInputRequests.encodeState(tool: "burrow_uninstall", + arguments: [:], asking: "apps") + let r = try result(call("tools/call", [ + "name": "burrow_uninstall", "requestState": state, + "inputResponses": ["apps": ["action": "decline"]], + ], capabilities: Self.elicitCapability)) + let content = try XCTUnwrap(r["content"] as? [[String: Any]]) + let text = try XCTUnwrap(content.first?["text"] as? String) + XCTAssertTrue(text.contains("\"declined\":true"), + "a decline is an outcome the model should see, not a protocol fault") + } + + func testStateRoundTrip_expandsACommaSeparatedAnswer() throws { + let state = MCPInputRequests.encodeState(tool: "burrow_dupes", + arguments: ["extra": 1], asking: "paths") + let decoded = try XCTUnwrap(MCPInputRequests.decodeState(state)) + XCTAssertEqual(decoded.tool, "burrow_dupes") + XCTAssertEqual(decoded.asking, "paths") + XCTAssertEqual(decoded.arguments["extra"] as? Int, 1, "original arguments must survive") + + let resolution = MCPInputRequests.resolve( + state: decoded, + inputResponses: ["paths": ["action": "accept", "content": ["paths": "/a, /b"]]]) + guard case .proceed(let merged) = resolution else { + return XCTFail("expected to proceed, got \(resolution)") + } + XCTAssertEqual(merged["paths"] as? [String], ["/a", "/b"]) + XCTAssertEqual(merged["extra"] as? Int, 1) + } + + func testEmptyAnswer_isUnusableRatherThanSilentlyAccepted() throws { + let state = try XCTUnwrap(MCPInputRequests.decodeState( + MCPInputRequests.encodeState(tool: "burrow_photos", arguments: [:], asking: "path"))) + let resolution = MCPInputRequests.resolve( + state: state, + inputResponses: ["path": ["action": "accept", "content": ["path": " "]]]) + guard case .unusable = resolution else { + return XCTFail("a blank answer must not become an argument") + } + } + + // MARK: - Tasks + + func testTaskHandle_onlyForClientsThatDeclaredTheExtension() throws { + // Same tool, two clients. Without the capability it must stay + // synchronous — a task handle would be an unreadable result. + let withoutExt = try result(call("tools/call", + ["name": "burrow_analyze", + "arguments": ["path": NSTemporaryDirectory()]])) + XCTAssertEqual(withoutExt["resultType"] as? String, MCPProtocol.ResultType.complete) + + let withExt = try result(call("tools/call", + ["name": "burrow_analyze", + "arguments": ["path": NSTemporaryDirectory()]], + capabilities: Self.tasksCapability, id: 2)) + XCTAssertEqual(withExt["resultType"] as? String, MCPProtocol.ResultType.task) + XCTAssertNotNil(withExt["taskId"] as? String) + XCTAssertEqual(withExt["status"] as? String, "working") + XCTAssertNotNil(withExt["createdAt"] as? String) + XCTAssertNotNil(withExt["ttlMs"]) + } + + func testShortTool_staysSynchronousEvenForATasksClient() throws { + let r = try result(call("tools/call", ["name": "burrow_info", "arguments": [String: Any]()], + capabilities: Self.tasksCapability)) + XCTAssertEqual(r["resultType"] as? String, MCPProtocol.ResultType.complete, + "a fast tool gains nothing from a poll loop") + } + + func testTaskLifecycle_completesAndCarriesTheResult() throws { + let store = MCPTaskStore() + let record = store.start(label: "unit", progressToken: nil) { progress in + progress("halfway") + return .success(["content": [["type": "text", "text": "{}"]]]) + } + XCTAssertEqual(record.status, "working") + + let done = expectation(description: "task reaches a terminal state") + DispatchQueue.global().async { + while store.get(record.taskId)?.status == "working" { usleep(20_000) } + done.fulfill() + } + wait(for: [done], timeout: 5) + + let final = try XCTUnwrap(store.get(record.taskId)) + XCTAssertEqual(final.status, "completed") + let wire = MCPTaskStore.wire(final) + XCTAssertNotNil(wire["result"]) + XCTAssertNil(wire["error"]) + } + + func testTaskFailure_carriesAJSONRPCError() throws { + let store = MCPTaskStore() + let record = store.start(label: "unit", progressToken: nil) { _ in + .failure(.invalidParams("nope")) + } + let done = expectation(description: "task fails") + DispatchQueue.global().async { + while store.get(record.taskId)?.status == "working" { usleep(20_000) } + done.fulfill() + } + wait(for: [done], timeout: 5) + + let wire = MCPTaskStore.wire(try XCTUnwrap(store.get(record.taskId))) + XCTAssertEqual(wire["status"] as? String, "failed") + let error = try XCTUnwrap(wire["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? Int, MCPProtocol.ErrorCode.invalidParams) + } + + func testCancel_isTerminalAndSurvivesALateResult() throws { + let store = MCPTaskStore() + let gate = DispatchSemaphore(value: 0) + let record = store.start(label: "unit", progressToken: nil) { _ in + gate.wait() + return .success(["content": []]) + } + XCTAssertTrue(store.cancel(record.taskId)) + XCTAssertEqual(store.get(record.taskId)?.status, "cancelled") + + gate.signal() // let the work finish after the cancel + // The late success must not resurrect a task the client was told + // had already stopped. + let stillCancelled = expectation(description: "stays cancelled") + DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + XCTAssertEqual(store.get(record.taskId)?.status, "cancelled") + stillCancelled.fulfill() + } + wait(for: [stillCancelled], timeout: 5) + } + + func testUnknownTask_is32602() { + XCTAssertEqual(errorCode(call("tasks/get", ["taskId": "nope"])), + MCPProtocol.ErrorCode.invalidParams) + XCTAssertEqual(errorCode(call("tasks/cancel", ["taskId": "nope"])), + MCPProtocol.ErrorCode.invalidParams) + } + + func testProgressNotification_onlyWithAProgressToken() throws { + let store = MCPTaskStore() + var notes: [[String: Any]] = [] + let lock = NSLock() + store.notify = { note in + lock.lock(); notes.append(note); lock.unlock() + } + + let silent = store.start(label: "quiet", progressToken: nil) { progress in + progress("working") + return .success([:]) + } + let loud = store.start(label: "loud", progressToken: "tok-1") { progress in + progress("working") + return .success([:]) + } + let done = expectation(description: "both finish") + DispatchQueue.global().async { + while store.get(silent.taskId)?.status == "working" + || store.get(loud.taskId)?.status == "working" { usleep(20_000) } + done.fulfill() + } + wait(for: [done], timeout: 5) + + lock.lock() + let emitted = notes + lock.unlock() + XCTAssertEqual(emitted.count, 1, "progress is opt-in via progressToken") + let params = try XCTUnwrap(emitted.first?["params"] as? [String: Any]) + XCTAssertEqual(params["progressToken"] as? String, "tok-1") + XCTAssertEqual(emitted.first?["method"] as? String, "notifications/progress") + } + + // MARK: - Resources and prompts + + func testResourceRead_returnsTheSameAnswerAsItsTool() throws { + let viaResource = try result(call("resources/read", ["uri": "burrow://info"])) + let contents = try XCTUnwrap(viaResource["contents"] as? [[String: Any]]) + let text = try XCTUnwrap(contents.first?["text"] as? String) + XCTAssertTrue(text.contains("\"readers\""), "the resource must be the tool's payload") + XCTAssertEqual(contents.first?["mimeType"] as? String, "application/json") + } + + func testUnknownResource_is32602NotTheOldResourceCode() { + // 2026-07-28 moved resource-not-found from -32002 to -32602. + XCTAssertEqual(errorCode(call("resources/read", ["uri": "burrow://nope"])), + MCPProtocol.ErrorCode.invalidParams) + } + + func testResourceTemplate_rejectsAJunkParameter() { + XCTAssertEqual(errorCode(call("resources/read", ["uri": "burrow://history/abc"])), + MCPProtocol.ErrorCode.invalidParams) + XCTAssertEqual(errorCode(call("resources/read", ["uri": "burrow://processes/nonsense"])), + MCPProtocol.ErrorCode.invalidParams) + } + + func testPrompt_interpolatesItsArguments() throws { + let r = try result(call("prompts/get", ["name": "investigate_process", + "arguments": ["name": "Weird Helper"]])) + let messages = try XCTUnwrap(r["messages"] as? [[String: Any]]) + let content = try XCTUnwrap(messages.first?["content"] as? [String: Any]) + let text = try XCTUnwrap(content["text"] as? String) + XCTAssertTrue(text.contains("Weird Helper")) + XCTAssertEqual(messages.first?["role"] as? String, "user") + } + + func testPrompt_missingRequiredArgumentIsRejected() { + XCTAssertEqual(errorCode(call("prompts/get", ["name": "investigate_process"])), + MCPProtocol.ErrorCode.invalidParams) + } + + func testCompletion_offersTheRealMetricNames() throws { + let r = try result(call("completion/complete", [ + "ref": ["type": "ref/resource", "uri": "burrow://processes/{metric}"], + "argument": ["name": "metric", "value": "peak"], + ])) + let completion = try XCTUnwrap(r["completion"] as? [String: Any]) + let values = try XCTUnwrap(completion["values"] as? [String]) + XCTAssertEqual(values.sorted(), ["peak_cpu", "peak_mem"]) + } + + // MARK: - The property none of the above may break + // + // Everything new in this revision — MRTR round trips, task handles, + // structured results — is a presentation change. None of it is allowed + // to become a way to run a destructive action the user hasn't opted in + // to. This runs against a scratch defaults suite, where both opt-ins are + // off by construction, so it can assert the refusal without touching the + // machine's real settings. + + func testConfirmTrue_withoutTheOptIn_isStillBlocked() throws { + Store.d = UserDefaults(suiteName: StoreTests.scratchSuite)! + Store.d.removePersistentDomain(forName: StoreTests.scratchSuite) + XCTAssertFalse(Store.mcpActionsEnabled, "precondition: the opt-in is off") + + let r = try result(call("tools/call", + ["name": "burrow_clean", "arguments": ["confirm": true]])) + let structured = try XCTUnwrap(r["structuredContent"] as? [String: Any]) + XCTAssertEqual(structured["blocked"] as? Bool, true) + XCTAssertEqual(structured["ran"] as? Bool, false) + XCTAssertNotNil(structured["reason"] as? String, "the refusal has to say why") + } + + func testMRTRAnswer_cannotUnlockAnUninstall() throws { + Store.d = UserDefaults(suiteName: StoreTests.scratchSuite)! + Store.d.removePersistentDomain(forName: StoreTests.scratchSuite) + + // The agent answers its own elicitation and passes confirm:true. The + // Settings opt-in is the only thing that matters, and it is off. + let state = MCPInputRequests.encodeState(tool: "burrow_uninstall", + arguments: ["confirm": true], asking: "apps") + let r = try result(call("tools/call", [ + "name": "burrow_uninstall", "requestState": state, + "inputResponses": ["apps": ["action": "accept", "content": ["apps": "Anything"]]], + ], capabilities: Self.elicitCapability)) + let structured = try XCTUnwrap(r["structuredContent"] as? [String: Any]) + XCTAssertEqual(structured["ran"] as? Bool, false, + "no round trip may substitute for the user's opt-in") + } +} diff --git a/macos/Tests/MCPTests.swift b/macos/Tests/MCPTests.swift index 0a8b5a08..886d5cdd 100644 --- a/macos/Tests/MCPTests.swift +++ b/macos/Tests/MCPTests.swift @@ -104,7 +104,8 @@ final class MCPTests: XCTestCase { "burrow_analyze", "burrow_list_apps", "burrow_dupes", "burrow_net", "burrow_orphans", "burrow_photos", "burrow_rules_dryrun", "burrow_sentinel", - "burrow_slim_check", "burrow_clean", + "burrow_slim_check", "burrow_agent_audit", "burrow_anomalies", + "burrow_clean", "burrow_optimize", "burrow_uninstall", "burrow_purge", "burrow_installer"]) // Every tool must carry an inputSchema and a description. From fd1bced1a6b29550b4de4219179f8ff1680a9f01 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:07:17 -0700 Subject: [PATCH 2/4] test(macos): keep the MCP stdio conformance harness in the repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit tests cover the envelope; this drives the real signed binary over stdio the way a client does. Carries a hard rule in the safety section: it runs against the user's live defaults, so it must never pass confirm:true — the gate's refusal path is unit-tested against a scratch defaults suite instead. --- macos/scripts/mcp-conformance.py | 332 +++++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 macos/scripts/mcp-conformance.py diff --git a/macos/scripts/mcp-conformance.py b/macos/scripts/mcp-conformance.py new file mode 100644 index 00000000..0c0b75d5 --- /dev/null +++ b/macos/scripts/mcp-conformance.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Drive Burrow --mcp over stdio and check it against the 2026-07-28 spec. + +This is an end-to-end harness, not a unit test: it spawns the real signed +binary, speaks JSON-RPC at it, and asserts on what comes back. +""" +import json +import subprocess +import sys +import time + +BIN = sys.argv[1] if len(sys.argv) > 1 else None +VERSION = "2026-07-28" + +FAILURES = [] +PASSES = [] + + +def check(name, cond, detail=""): + if cond: + PASSES.append(name) + else: + FAILURES.append(f"{name}: {detail}") + + +class Client: + """One server process. Stateless era by default.""" + + def __init__(self, capabilities=None, declare_version=VERSION): + self.proc = subprocess.Popen( + [BIN, "--mcp"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, text=True, bufsize=1) + self.n = 0 + self.caps = capabilities if capabilities is not None else {} + self.declare_version = declare_version + + def meta(self): + if self.declare_version is None: + return None + return { + "io.modelcontextprotocol/protocolVersion": self.declare_version, + "io.modelcontextprotocol/clientCapabilities": self.caps, + "io.modelcontextprotocol/clientInfo": {"name": "conformance-harness", "version": "1.0"}, + } + + def send(self, method, params=None, raw=None, want_reply=True): + if want_reply: + self.n += 1 + if raw is not None: + line = raw + else: + p = dict(params or {}) + m = self.meta() + if m is not None: + p["_meta"] = {**m, **p.get("_meta", {})} + msg = {"jsonrpc": "2.0", "method": method, "params": p} + # A notification carries no id — including one makes it a request. + if want_reply: + msg["id"] = self.n + line = json.dumps(msg) + self.proc.stdin.write(line + "\n") + self.proc.stdin.flush() + if not want_reply: + return None + out = self.proc.stdout.readline() + if not out: + raise RuntimeError(f"server closed the stream after {method}") + return json.loads(out) + + def result(self, method, params=None): + r = self.send(method, params) + if "error" in r: + raise AssertionError(f"{method} errored: {r['error']}") + return r["result"] + + def close(self): + try: + self.proc.stdin.close() + self.proc.wait(timeout=10) + except Exception: + self.proc.kill() + + +# ---------------------------------------------------------------- discovery + +c = Client() +disc = c.result("server/discover") +check("discover.resultType", disc.get("resultType") == "complete", disc.get("resultType")) +check("discover.supportedVersions has latest", VERSION in disc.get("supportedVersions", []), + disc.get("supportedVersions")) +check("discover.supportedVersions keeps legacy", "2024-11-05" in disc.get("supportedVersions", [])) +check("discover.ttlMs", isinstance(disc.get("ttlMs"), int), disc.get("ttlMs")) +check("discover.cacheScope", disc.get("cacheScope") in ("public", "private"), disc.get("cacheScope")) +check("discover.capabilities.tools", "tools" in disc.get("capabilities", {})) +check("discover advertises tasks extension", + "io.modelcontextprotocol/tasks" in disc.get("capabilities", {}).get("extensions", {}), + disc.get("capabilities")) +check("discover.instructions", isinstance(disc.get("instructions"), str) and len(disc["instructions"]) > 50) +si = disc.get("_meta", {}).get("io.modelcontextprotocol/serverInfo", {}) +check("discover _meta.serverInfo", si.get("name") == "burrow" and "version" in si, si) + +# ------------------------------------------------------- no handshake needed +tl = c.result("tools/list") +check("tools/list works with no initialize", isinstance(tl.get("tools"), list), "no tools") +tools = tl["tools"] +check("tools/list.resultType", tl.get("resultType") == "complete") +check("tools/list.ttlMs", isinstance(tl.get("ttlMs"), int)) +check("tools/list.cacheScope", tl.get("cacheScope") in ("public", "private")) +names = [t["name"] for t in tools] +check("tools/list deterministic order", names == sorted(names), names[:4]) +check("tools/list count", len(tools) == 28, len(tools)) + +missing_ann = [t["name"] for t in tools if "annotations" not in t] +check("every tool has annotations", not missing_ann, missing_ann) +missing_title = [t["name"] for t in tools if "title" not in t] +check("every tool has a title", not missing_title, missing_title) + +ro = {t["name"] for t in tools if t.get("annotations", {}).get("readOnlyHint")} +mutating = {"burrow_clean", "burrow_optimize", "burrow_uninstall", "burrow_purge", "burrow_installer"} +check("mutating tools are not readOnly", not (ro & mutating), sorted(ro & mutating)) +check("23 read-only tools", len(ro) == 23, len(ro)) +clean = next(t for t in tools if t["name"] == "burrow_clean") +check("burrow_clean destructiveHint", clean["annotations"].get("destructiveHint") is True) +report = next(t for t in tools if t["name"] == "burrow_report") +check("burrow_report has no outputSchema (markdown)", "outputSchema" not in report) +schema_count = sum(1 for t in tools if "outputSchema" in t) +check("27 tools declare outputSchema", schema_count == 27, schema_count) + +# ------------------------------------------------------------- tools/call +r = c.result("tools/call", {"name": "burrow_info", "arguments": {}}) +check("tools/call.resultType", r.get("resultType") == "complete") +check("tools/call content", r["content"][0]["type"] == "text") +check("tools/call structuredContent", isinstance(r.get("structuredContent"), dict), + type(r.get("structuredContent"))) +check("structuredContent mirrors text", + r["structuredContent"] == json.loads(r["content"][0]["text"])) +check("tools/call _meta.serverInfo", + r.get("_meta", {}).get("io.modelcontextprotocol/serverInfo", {}).get("name") == "burrow") + +rep = c.result("tools/call", {"name": "burrow_report", "arguments": {"days": 7}}) +check("markdown tool omits structuredContent", "structuredContent" not in rep, + list(rep.keys())) + +# --------------------------------------------------- newly exposed surfaces +audit = c.result("tools/call", {"name": "burrow_agent_audit", "arguments": {"limit": 5}}) +ap = json.loads(audit["content"][0]["text"]) +check("agent audit returns entries list", isinstance(ap.get("entries"), list), ap) +check("agent audit echoes its window", isinstance(ap.get("window_minutes"), int), ap) +anom = c.result("tools/call", {"name": "burrow_anomalies", "arguments": {}}) +anp = json.loads(anom["content"][0]["text"]) +check("anomalies returns findings list", isinstance(anp.get("findings"), list), anp) +check("anomalies states its basis", "24h" in anp.get("basis", ""), anp.get("basis")) +check("anomalies structuredContent", isinstance(anom.get("structuredContent"), dict)) + +# --------------------------------------------------------------- resources +rl = c.result("resources/list") +check("resources/list.ttlMs", isinstance(rl.get("ttlMs"), int)) +check("resources/list.cacheScope", rl.get("cacheScope") in ("public", "private")) +uris = [x["uri"] for x in rl["resources"]] +check("resources include doctor", "burrow://doctor" in uris, uris) +check("resources count", len(uris) == 10, len(uris)) + +rt = c.result("resources/templates/list") +check("templates present", len(rt.get("resourceTemplates", [])) == 3, rt.get("resourceTemplates")) +check("templates.ttlMs", isinstance(rt.get("ttlMs"), int)) + +rr = c.result("resources/read", {"uri": "burrow://doctor"}) +check("resources/read.contents", rr["contents"][0]["uri"] == "burrow://doctor") +check("resources/read.mimeType", rr["contents"][0]["mimeType"] == "application/json") +check("resources/read.ttlMs", isinstance(rr.get("ttlMs"), int)) +check("resources/read is private", rr.get("cacheScope") == "private", rr.get("cacheScope")) +check("doctor payload parses", isinstance(json.loads(rr["contents"][0]["text"]).get("checks"), list)) + +rh = c.result("resources/read", {"uri": "burrow://history/30"}) +check("template read works", "count" in json.loads(rh["contents"][0]["text"])) + +bad = c.send("resources/read", {"uri": "burrow://nope"}) +check("unknown resource is -32602", bad.get("error", {}).get("code") == -32602, bad.get("error")) +bad2 = c.send("resources/read", {"uri": "burrow://history/notanumber"}) +check("bad template param is -32602", bad2.get("error", {}).get("code") == -32602, bad2.get("error")) + +# ----------------------------------------------------------------- prompts +pl = c.result("prompts/list") +check("prompts present", len(pl.get("prompts", [])) == 5, len(pl.get("prompts", []))) +check("prompts/list.ttlMs", isinstance(pl.get("ttlMs"), int)) +pg = c.result("prompts/get", {"name": "diagnose_slow_mac", "arguments": {"minutes": "120"}}) +check("prompts/get messages", pg["messages"][0]["role"] == "user") +check("prompt interpolates argument", "120" in pg["messages"][0]["content"]["text"]) +check("prompts/get.resultType", pg.get("resultType") == "complete") +pmissing = c.send("prompts/get", {"name": "investigate_process", "arguments": {}}) +check("prompt missing required arg is -32602", + pmissing.get("error", {}).get("code") == -32602, pmissing.get("error")) + +# -------------------------------------------------------------- completion +comp = c.result("completion/complete", { + "ref": {"type": "ref/resource", "uri": "burrow://processes/{metric}"}, + "argument": {"name": "metric", "value": "p"}}) +vals = comp["completion"]["values"] +check("completion returns metrics", set(vals) == {"peak_cpu", "peak_mem"}, vals) + +# ------------------------------------------------------- version negotiation +bad_ver = Client(declare_version="1999-01-01") +rv = bad_ver.send("tools/list") +err = rv.get("error", {}) +check("unsupported version is -32022", err.get("code") == -32022, err) +check("-32022 carries supported list", VERSION in err.get("data", {}).get("supported", []), err.get("data")) +bad_ver.close() + +# ------------------------------------------------------------- legacy client +legacy = Client(declare_version=None) +init = legacy.result("initialize", {"protocolVersion": "2024-11-05"}) +check("initialize still answered", init.get("protocolVersion") == "2024-11-05", init.get("protocolVersion")) +check("initialize serverInfo", init["serverInfo"]["name"] == "burrow") +legacy.send("notifications/initialized", want_reply=False) +ltl = legacy.result("tools/list") +check("legacy tools/list works", len(ltl["tools"]) == 28, len(ltl.get("tools", []))) +lcall = legacy.result("tools/call", {"name": "burrow_info", "arguments": {}}) +check("legacy tools/call works", "content" in lcall) +check("legacy client is never handed a task", lcall.get("resultType") == "complete", + lcall.get("resultType")) +lping = legacy.result("ping") +check("legacy ping answered", lping.get("resultType") == "complete") +legacy.close() + +# ------------------------------------------------------------------- errors +e1 = c.send("nope/nope") +check("unknown method is -32601", e1.get("error", {}).get("code") == -32601, e1.get("error")) +e2 = c.send(None, raw="this is not json") +check("garbage is -32700", e2.get("error", {}).get("code") == -32700, e2.get("error")) +e3 = c.send("tools/call", {"name": "burrow_nope"}) +check("unknown tool is -32602", e3.get("error", {}).get("code") == -32602, e3.get("error")) +c.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n") +c.proc.stdin.flush() +e4 = c.send("tools/call", {"name": "burrow_history", "arguments": {"minutes": 0}}) +check("notification got no reply (next id lines up)", e4.get("id") == c.n, e4.get("id")) +check("bad arguments is -32602", e4.get("error", {}).get("code") == -32602, e4.get("error")) + +# -------------------------------------------------------------------- MRTR +elicit = Client(capabilities={"elicitation": {"form": {}}}) +mr = elicit.result("tools/call", {"name": "burrow_uninstall", "arguments": {}}) +check("MRTR returns input_required", mr.get("resultType") == "input_required", mr.get("resultType")) +check("MRTR asks for apps", "apps" in mr.get("inputRequests", {}), mr.get("inputRequests")) +req = mr["inputRequests"]["apps"] +check("MRTR uses elicitation/create", req["method"] == "elicitation/create", req.get("method")) +check("MRTR has requestedSchema", "requestedSchema" in req["params"]) +state = mr["requestState"] +check("MRTR requestState is a string", isinstance(state, str)) + +retry = elicit.result("tools/call", { + "name": "burrow_uninstall", "requestState": state, + "inputResponses": {"apps": {"action": "accept", "content": {"apps": "Some App"}}}}) +payload = json.loads(retry["content"][0]["text"]) +check("MRTR retry reaches the tool", payload.get("command") == "uninstall", payload) +check("MRTR retry is still gated", payload.get("ran") is False, payload) + +declined = elicit.result("tools/call", { + "name": "burrow_uninstall", "requestState": state, + "inputResponses": {"apps": {"action": "decline"}}}) +dp = json.loads(declined["content"][0]["text"]) +check("MRTR decline is in-band, not an error", dp.get("declined") is True, dp) + +# A client with no elicitation capability must still get the old behaviour. +plain = c.send("tools/call", {"name": "burrow_uninstall", "arguments": {}}) +check("no elicitation capability => plain argument error", + plain.get("error", {}).get("code") == -32602, plain.get("error")) +elicit.close() + +# ------------------------------------------------------------------- safety +# +# NEVER pass confirm:true from this harness. It runs against the real user +# defaults, so if the "Let agents run cleanups" opt-in happens to be ON, a +# confirm:true here performs a real deletion on this machine. The gate's +# refusal path belongs in a unit test with a scratch defaults suite +# (MCPConformanceTests), not in an end-to-end run. What we check here is the +# only thing that is safe to check live: that omitting confirm previews. +gate = c.result("tools/call", {"name": "burrow_clean", "arguments": {}}) +gp = json.loads(gate["content"][0]["text"]) +check("clean without confirm is a dry run", + gp.get("dry_run") is True and gp.get("ran") is False, {k: gp.get(k) for k in ("dry_run", "ran")}) +check("dry run still reports a command", gp.get("command") == "clean", gp.get("command")) + +# -------------------------------------------------------------------- tasks +tc = Client(capabilities={"extensions": {"io.modelcontextprotocol/tasks": {}}}) +tr = tc.result("tools/call", {"name": "burrow_analyze", + "arguments": {"path": "/usr/share/dict", "depth": 1}}) +check("task handle returned", tr.get("resultType") == "task", tr.get("resultType")) +check("task has taskId", isinstance(tr.get("taskId"), str), tr) +check("task starts working", tr.get("status") == "working", tr.get("status")) +check("task has pollIntervalMs", isinstance(tr.get("pollIntervalMs"), int)) +check("task has ttlMs", tr.get("ttlMs") is None or isinstance(tr.get("ttlMs"), (int, float))) +check("task timestamps", "createdAt" in tr and "lastUpdatedAt" in tr, tr) + +task_id = tr["taskId"] +deadline = time.time() + 120 +final = None +while time.time() < deadline: + got = tc.result("tasks/get", {"taskId": task_id}) + if got["status"] in ("completed", "failed", "cancelled"): + final = got + break + time.sleep(0.4) +check("task reaches a terminal state", final is not None, "timed out polling") +if final: + check("task completed", final["status"] == "completed", final.get("statusMessage")) + check("completed task carries result", isinstance(final.get("result"), dict), final.get("result")) + if isinstance(final.get("result"), dict): + check("task result is a CallToolResult", "content" in final["result"], final["result"].keys()) + +unknown = tc.send("tasks/get", {"taskId": "nope"}) +check("unknown task is -32602", unknown.get("error", {}).get("code") == -32602, unknown.get("error")) + +# cancel a fresh one +tr2 = tc.result("tools/call", {"name": "burrow_analyze", "arguments": {"path": "/usr", "depth": 2}}) +cancelled = tc.result("tasks/cancel", {"taskId": tr2["taskId"]}) +check("tasks/cancel acks", cancelled.get("resultType") == "complete", cancelled) +after = tc.result("tasks/get", {"taskId": tr2["taskId"]}) +check("cancelled task reports cancelled", after["status"] == "cancelled", after.get("status")) +upd = tc.result("tasks/update", {"taskId": tr2["taskId"], "inputResponses": {}}) +check("tasks/update acks", upd.get("resultType") == "complete", upd) + +# A non-long-running tool must stay synchronous even for a tasks client. +sync = tc.result("tools/call", {"name": "burrow_info", "arguments": {}}) +check("short tools stay synchronous", sync.get("resultType") == "complete", sync.get("resultType")) +tc.close() +c.close() + +# ------------------------------------------------------------------ verdict +print(f"passed {len(PASSES)}") +for f in FAILURES: + print("FAIL " + f) +print("FAILURES:", len(FAILURES)) +sys.exit(1 if FAILURES else 0) From 8475819bf3f616b7ec5a3bb68423e3afaf8e653d Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:04:30 -0700 Subject: [PATCH 3/4] docs: correct what burrow_doctor actually reports over MCP The tool reference and the agent skill both claimed burrow_doctor returns SIP/Gatekeeper/FileVault/firewall, battery health and CPU load, and told agents to start there for security questions. It doesn't: callDoctor builds Doctor.Input without those facets, so Doctor.report omits the checks and the agent gets a result with the question silently missing. The GUI fills them in, which is why the docs read true. Says what the tool does return, and points security questions at the shell until the gap is closed (BUR-105). --- docs/agent-tools.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/agent-tools.md b/docs/agent-tools.md index ff81f343..d5103c5c 100644 --- a/docs/agent-tools.md +++ b/docs/agent-tools.md @@ -34,7 +34,7 @@ of either era. See [Protocol surface](#protocol-surface) at the bottom. | Tool | Use it proactively when… | Key params | |---|---|---| | **burrow_snapshot** | The user asks "what's my CPU/memory/disk/network/temperature right now", or you need current vitals before reasoning. Returns the latest full status snapshot incl. top processes + a 0–100 health score. | — | -| **burrow_doctor** | "Is my Mac healthy / is anything wrong?", or as a first pass on any vague performance/security complaint. One call returns ok/warn/fail checks for engine presence, Full Disk Access, memory pressure, disk headroom, decode errors, **security posture (SIP/Gatekeeper/FileVault/firewall)**, **battery health**, sustained high-CPU, and display/external-volume/network context. | — | +| **burrow_doctor** | "Is my Mac healthy / is anything wrong?", or as a first pass on any vague performance complaint. Returns ok/warn/fail checks for engine presence, Full Disk Access, memory pressure, disk headroom, SMART disk health, Time Machine backup age, and decode errors. **Not security posture:** SIP/Gatekeeper/FileVault/firewall, battery health, CPU load and display/volume/network context exist in the Doctor engine but are only filled in by the GUI — over MCP those checks are omitted, so answer security questions from the shell, not from this tool. | — | | **burrow_top_processes** | "What's using my CPU?" / "why is my Mac hot or loud?" Ranks processes by **peak** CPU% over a window. | `minutes`, `limit` | | **burrow_process_usage** | "What's been draining my battery / running hottest *over time*?" Ranks by `cpu_time` (cumulative), `peak_cpu`, `avg_cpu`, or `peak_mem`, and echoes the window it used. Prefer this over `top_processes` for "all day / since this morning" questions. | `minutes`, `metric`, `limit` | | **burrow_history** | The user asks about a trend ("has memory crept up since noon?") or you want a time-series slice rather than a single point. | `minutes`, `samples` | @@ -156,8 +156,10 @@ opening a listener needs its own auth model first. - **"My Mac is slow/hot/loud"** → `burrow_doctor` → `burrow_top_processes` (now) or `burrow_process_usage` (over time) → name the culprit; offer `burrow_clean`/`optimize` preview only if relevant. -- **"Is anything insecure / what's listening?"** → `burrow_doctor` (SIP/Gatekeeper/FileVault/ - firewall) + `burrow_ports`. +- **"What's listening?"** → `burrow_ports`. For "is anything insecure", `burrow_doctor` over MCP + does **not** cover SIP/Gatekeeper/FileVault/firewall — read those from the shell (`csrutil + status`, `spctl --status`, `fdesetup status`, `socketfilterfw --getglobalstate`) until the + tool fills them in. - **"What did Burrow change?"** → `burrow_cleanup_history` + `burrow_deleted_files`. - **"Did an agent already do this?"** → `burrow_agent_audit` before repeating a cleanup, and after a call you're unsure completed. From ebf81f8f4de7129ee8c083970d196d3892379406 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:08:20 -0700 Subject: [PATCH 4/4] docs(skill): bring burrow-system-tools up to the current tool surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped skill was missing seven read-only tools that already existed — dupes, photos, orphans, sentinel, slim_check, net, rules_dryrun — so its low-on-disk pattern went straight from analyze to clean and skipped everything that actually finds the big wins. Adds those, the two new tools (agent_audit, anomalies), and the resource / prompt / task surface from the 2026-07-28 work. Corrects the doctor entry: it claimed SIP/Gatekeeper/FileVault/firewall and battery, none of which the MCP path fills in (BUR-105). --- skills/burrow-system-tools/SKILL.md | 67 ++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/skills/burrow-system-tools/SKILL.md b/skills/burrow-system-tools/SKILL.md index 642db405..455d2395 100644 --- a/skills/burrow-system-tools/SKILL.md +++ b/skills/burrow-system-tools/SKILL.md @@ -1,6 +1,6 @@ --- name: burrow-system-tools -description: Diagnose and fix the user's Mac with Burrow's local MCP tools (burrow_doctor, burrow_snapshot, burrow_top_processes, burrow_process_usage, burrow_ports, burrow_analyze, burrow_disk_forecast, burrow_clean, …). Use whenever the Mac is slow, hot, loud, low on disk, draining battery, or misbehaving; when the user asks what's using CPU/memory, what's listening on a port, what's eating disk space, or whether anything is insecure (SIP/FileVault/firewall); AND proactively — if you notice a system problem mid-task (low disk, a runaway process, a port conflict), reach for these tools to diagnose and offer a fix without being asked. Requires Burrow's MCP server connected (burrow_* tools available). +description: Diagnose and fix the user's Mac with Burrow's local MCP tools (burrow_doctor, burrow_snapshot, burrow_top_processes, burrow_process_usage, burrow_ports, burrow_analyze, burrow_disk_forecast, burrow_dupes, burrow_anomalies, burrow_agent_audit, burrow_clean, …). Use whenever the Mac is slow, hot, loud, low on disk, draining battery, or misbehaving; when the user asks what's using CPU/memory, what's listening on a port, what's eating disk space, where the duplicate or leftover files are, whether anything is behaving unusually, or what an agent already changed; AND proactively — if you notice a system problem mid-task (low disk, a runaway process, a port conflict), reach for these tools to diagnose and offer a fix without being asked. Requires Burrow's MCP server connected (burrow_* tools available). --- # Burrow system tools @@ -13,11 +13,13 @@ Read-only tools never change anything, so there's no reason to hesitate. ## Diagnose first (read-only — always safe) -- **burrow_doctor** — one-call health sweep: Full Disk Access, memory pressure, - disk headroom, SIP / Gatekeeper / FileVault / firewall, battery, sustained - high-CPU, display/volume/network. **Start here for any vague "something's - wrong / is my Mac healthy / is it secure?"** — it tells you which area to - drill into. +- **burrow_doctor** — one-call health sweep: engine present, Full Disk Access, + memory pressure, disk headroom, SMART disk health, Time Machine backup age, + recent decode errors. **Start here for any vague "something's wrong / is my + Mac healthy?"** — it tells you which area to drill into. It does **not** + report SIP / Gatekeeper / FileVault / firewall over MCP (only the GUI fills + those in), so for "is my Mac secure?" read them from the shell rather than + claiming the tool checked them. - **burrow_snapshot** — current vitals (CPU, memory, disk, network, temperature, top processes, a 0–100 health score). For "what's happening right now". - **burrow_top_processes** — top CPU *right now*. For "what's using my CPU / why @@ -40,6 +42,22 @@ Read-only tools never change anything, so there's no reason to hesitate. - **burrow_list_apps** — installed apps + the exact names uninstall accepts (call this before any uninstall). **burrow_info** — whether Burrow is even recording data (use when results look empty or stale). +- **Reclaim candidates** (read-only, report-only — they find things worth + deleting but never delete): **burrow_dupes** `paths` (duplicate files), + **burrow_photos** `path` (visually near-duplicate images), **burrow_orphans** + `path` (files belonging to no installed app), **burrow_sentinel** (apps + sitting in the Trash whose leftovers you could sweep), **burrow_slim_check** + `binary` (how much thinning a fat binary would reclaim), **burrow_net** + (which app is moving bytes right now), **burrow_rules_dryrun** `dir` (what a + community rules directory would target). +- **burrow_anomalies** — processes whose last-24h CPU has regressed against + *their own* 14-day baseline. Reach for it when the user says something feels + off but nothing looks obviously high: this is per-process, so a program that + always sits at 40% isn't flagged and one that went 2% → 15% is. +- **burrow_agent_audit** — what agents (including you, earlier) have already run + through this server: the tool, the exact arguments, dry-run or real, and the + outcome. Check it before repeating a cleanup, and whenever you're not sure a + call went through. ## Then act (gated — preview by default) @@ -58,6 +76,28 @@ run will execute. - **burrow_purge** / **burrow_installer** — preview-only over MCP (dev build artifacts / leftover installers); the real run is interactive in the app. +## Resources, prompts, and long scans + +Burrow also exposes its read-only answers as **resources**, which you can attach +instead of re-calling a tool: `burrow://doctor`, `burrow://snapshot/latest`, +`burrow://ports`, `burrow://info`, `burrow://forecast/disk`, +`burrow://cleanup/history`, `burrow://cleanup/deleted-files`, +`burrow://agent-audit`, `burrow://anomalies`, `burrow://report/weekly`, plus +`burrow://history/{minutes}`, `burrow://processes/{metric}` and +`burrow://report/{days}`. Each read says how long it stays fresh — five seconds +for a live snapshot, a minute for a digest — so re-read rather than trusting a +minutes-old attachment. + +Its **prompts** (`diagnose_slow_mac`, `reclaim_disk_space`, +`explain_last_cleanup`, `investigate_process`, `pre_uninstall_check`) encode the +tool orderings that avoid wrong answers — worth offering when the user's request +matches one. + +Slow scans (`burrow_analyze` on a big folder, `burrow_clean`, `burrow_dupes`) +may come back as a **task handle** instead of a result if your client supports +the tasks extension. Poll `tasks/get` until it reaches a terminal status rather +than assuming the call failed. + ## Be proactive The biggest win is catching problems the user hasn't mentioned. If, mid-task, you @@ -70,14 +110,19 @@ behaviour to lean into; don't wait to be asked. - **Low on disk** (the most common real emergency) → `burrow_analyze` with `depth: 2` on the suspect folder (largest user dirs first; skip the forecast - if the disk is already full) → `burrow_clean` preview → `burrow_purge` / - `burrow_installer` previews. If user folders don't account for the usage, - check APFS local snapshots (`tmutil listlocalsnapshots /`) and purgeable - space — Burrow doesn't report those yet, so shell out for that piece. + if the disk is already full) → `burrow_dupes` / `burrow_photos` / + `burrow_orphans` / `burrow_sentinel` for reclaim candidates → `burrow_clean` + preview → `burrow_purge` / `burrow_installer` previews. If user folders don't + account for the usage, check APFS local snapshots + (`tmutil listlocalsnapshots /`) and purgeable space — Burrow doesn't report + those yet, so shell out for that piece. - **Slow / hot / loud** → `burrow_doctor` → `burrow_top_processes` (now) or `burrow_process_usage` (over time) → name the culprit → offer a clean/optimize *preview* if relevant. -- **Security / what's listening** → `burrow_doctor` + `burrow_ports`. +- **"Feels off" but nothing looks high** → `burrow_anomalies`. +- **"Did that cleanup actually run?"** → `burrow_agent_audit`. +- **What's listening** → `burrow_ports`. For SIP / FileVault / firewall, use the + shell — `burrow_doctor` doesn't cover them over MCP. - **Empty or stale results** → `burrow_info` to confirm Burrow is recording. Full per-tool params + the safety model live in the Burrow repo at