diff --git a/docs/agent-tools.md b/docs/agent-tools.md index ca9b4db8..d5103c5c 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 @@ -30,7 +34,7 @@ samples continuously, and every actuating call is **dry-run by default**. | 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` | @@ -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. --- @@ -102,15 +156,21 @@ Every actuating call is recorded to Burrow's audit log, so the user can see what - **"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. +- **"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. 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) 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