| 1 | // Skill pack over MCP (resources + skills methods), tool annotations, and the |
| 2 | // pure helpers behind list_apps filtering, menu targeting and native error |
| 3 | // codes. The protocol part spawns the real server; state is isolated. |
| 4 | import { test, before, after } from "node:test"; |
| 5 | import assert from "node:assert/strict"; |
| 6 | import { spawn } from "node:child_process"; |
| 7 | import crypto from "node:crypto"; |
| 8 | import fs from "node:fs"; |
| 9 | import os from "node:os"; |
| 10 | import path from "node:path"; |
| 11 | import url from "node:url"; |
| 12 | import { TOOLS } from "../src/tools.mjs"; |
| 13 | import { pickMenuElement, selectApps, nativeErrorCode } from "../src/backends/darwin.mjs"; |
| 14 | |
| 15 | const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); |
| 16 | const ROOT = path.resolve(__dirname, ".."); |
| 17 | |
| 18 | // ---------- pure helpers ---------- |
| 19 | |
| 20 | test("every tool carries MCP annotations, and the observation/action split holds", () => { |
| 21 | assert.equal(TOOLS.length, TOOLS.filter((t) => t.annotations).length, "a tool without annotations would let a host guess"); |
| 22 | for (const t of TOOLS) { |
| 23 | for (const key of ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"]) { |
| 24 | assert.equal(typeof t.annotations[key], "boolean", `${t.name}.annotations.${key} must be a boolean`); |
| 25 | } |
| 26 | } |
| 27 | const byName = Object.fromEntries(TOOLS.map((t) => [t.name, t.annotations])); |
| 28 | assert.equal(byName.get_app_state.readOnlyHint, true); |
| 29 | assert.equal(byName.screenshot.readOnlyHint, true); |
| 30 | assert.equal(byName.request_access.readOnlyHint, true); |
| 31 | assert.equal(byName.left_click.readOnlyHint, false); |
| 32 | assert.equal(byName.left_click.destructiveHint, true); |
| 33 | assert.equal(byName.stop_computer_control.readOnlyHint, false); |
| 34 | assert.equal(byName.invoke_menu.readOnlyHint, false); |
| 35 | assert.equal(byName.list_apps.readOnlyHint, true); |
| 36 | }); |
| 37 | |
| 38 | test("invoke_menu and list_apps advertise their new surfaces", () => { |
| 39 | const menu = TOOLS.find((t) => t.name === "invoke_menu"); |
| 40 | assert.ok(menu, "invoke_menu is part of the surface"); |
| 41 | assert.deepEqual(menu.inputSchema.required, ["path"]); |
| 42 | assert.equal(menu.inputSchema.properties.path.maxItems, 3); |
| 43 | const apps = TOOLS.find((t) => t.name === "list_apps"); |
| 44 | assert.equal(apps.inputSchema.properties.all.type, "boolean"); |
| 45 | }); |
| 46 | |
| 47 | test("selectApps keeps regular apps by default, passes everything with all:true, and tolerates an old helper", () => { |
| 48 | const apps = [ |
| 49 | { name: "Finder", activation_policy: "regular" }, |
| 50 | { name: "Terminal", activation_policy: "regular", frontmost: true }, |
| 51 | { name: "chmod", activation_policy: "prohibited" }, |
| 52 | { name: "SwiftBar", activation_policy: "accessory" }, |
| 53 | ]; |
| 54 | assert.deepEqual(selectApps(apps, false).map((a) => a.name), ["Finder", "Terminal"]); |
| 55 | assert.equal(selectApps(apps, true).length, 4); |
| 56 | // A pre-0.6.2 helper does not report the field: return the list whole |
| 57 | // rather than hiding every app. |
| 58 | const legacy = [{ name: "Finder" }, { name: "chmod" }]; |
| 59 | assert.equal(selectApps(legacy, false).length, 2); |
| 60 | }); |
| 61 | |
| 62 | test("pickMenuElement matches exact titles and roles only", () => { |
| 63 | const els = [ |
| 64 | { role: "AXMenuBarItem", label: "File", path: [3] }, |
| 65 | { role: "AXMenuItem", label: "New Window", path: [3, 0, 1] }, |
| 66 | { role: "AXMenuItem", label: "New", path: [3, 0, 0] }, |
| 67 | ]; |
| 68 | assert.equal(pickMenuElement(els, "File", true)?.label, "File"); |
| 69 | assert.equal(pickMenuElement(els, "New", false)?.path[2], 0, "exact match must not take \u201cNew Window\u201d"); |
| 70 | assert.equal(pickMenuElement(els, "Open\u2026", true), null, "no fuzzy matches"); |
| 71 | assert.equal(pickMenuElement(els, "File", false), null, "role must match the level"); |
| 72 | }); |
| 73 | |
| 74 | test("nativeErrorCode maps refusal reasons to stable codes", () => { |
| 75 | assert.equal(nativeErrorCode("the selected window is ambiguous; observe the app windows again"), "window_ambiguous"); |
| 76 | assert.equal(nativeErrorCode("the selected app window is not capturable; observe the app windows again"), "window_not_capturable"); |
| 77 | assert.equal(nativeErrorCode("application not found"), "app_not_found"); |
| 78 | assert.equal(nativeErrorCode("no running application with pid 42"), "app_not_found"); |
| 79 | assert.equal(nativeErrorCode("accessibility action failed: -25205"), null); |
| 80 | }); |
| 81 | |
| 82 | // ---------- protocol: the bundled skill pack ---------- |
| 83 | |
| 84 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-skill-state-")); |
| 85 | let server; |
| 86 | let buf = ""; |
| 87 | const pending = new Map(); |
| 88 | let nextId = 1; |
| 89 | |
| 90 | function rpc(method, params) { |
| 91 | const id = nextId++; |
| 92 | return new Promise((resolve, reject) => { |
| 93 | const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, 15_000); |
| 94 | pending.set(id, (msg) => { clearTimeout(t); resolve(msg); }); |
| 95 | server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); |
| 96 | }); |
| 97 | } |
| 98 | |
| 99 | before(() => { |
| 100 | server = spawn("node", [path.join(ROOT, "mcp", "server.mjs")], { |
| 101 | env: { ...process.env, CODEWHALE_CU_STATE_DIR: stateDir, CODEWHALE_CU_RECORDINGS_DIR: path.join(stateDir, "rec") }, |
| 102 | stdio: ["pipe", "pipe", "pipe"], |
| 103 | }); |
| 104 | server.stdout.on("data", (c) => { |
| 105 | buf += c.toString(); |
| 106 | let i; |
| 107 | while ((i = buf.indexOf("\n")) !== -1) { |
| 108 | const line = buf.slice(0, i).trim(); |
| 109 | buf = buf.slice(i + 1); |
| 110 | if (!line) continue; |
| 111 | const msg = JSON.parse(line); |
| 112 | if (msg.id != null && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); } |
| 113 | } |
| 114 | }); |
| 115 | }); |
| 116 | |
| 117 | after(() => { try { server.stdin.end(); } catch {} server?.kill("SIGTERM"); }); |
| 118 | |
| 119 | test("initialize advertises resources and the skills extension", async () => { |
| 120 | const init = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {} }); |
| 121 | assert.equal(init.result.capabilities.resources.listChanged, false); |
| 122 | assert.ok(init.result.capabilities.experimental["io.modelcontextprotocol/skills"], "the skills extension is advertised"); |
| 123 | }); |
| 124 | |
| 125 | test("resources/list names the pack; resources/read returns exact bytes with hashes", async () => { |
| 126 | const list = await rpc("resources/list", {}); |
| 127 | const uris = list.result.resources.map((r) => r.uri); |
| 128 | assert.ok(uris.includes("skill://codewhale-cu/SKILL.md")); |
| 129 | assert.ok(uris.includes("skill://codewhale-cu/references/quick-reference.md")); |
| 130 | assert.ok(uris.includes("skill://codewhale-cu/references/refusal-codes.md")); |
| 131 | |
| 132 | for (const uri of uris) { |
| 133 | const read = await rpc("resources/read", { uri }); |
| 134 | const text = read.result.contents[0].text; |
| 135 | const rel = uri.replace("skill://codewhale-cu/", ""); |
| 136 | const onDisk = fs.readFileSync(path.join(ROOT, "skills", "computer-use", rel), "utf8"); |
| 137 | assert.equal(text, onDisk, `${uri} must serve exactly the file on disk`); |
| 138 | } |
| 139 | }); |
| 140 | |
| 141 | test("resources/read refuses unknown URIs with invalid-params, never a traversal", async () => { |
| 142 | const bad = await rpc("resources/read", { uri: "skill://codewhale-cu/../../../etc/passwd" }); |
| 143 | assert.equal(bad.error.code, -32602); |
| 144 | const alsoBad = await rpc("resources/read", { uri: "file:///etc/passwd" }); |
| 145 | assert.equal(alsoBad.error.code, -32602); |
| 146 | }); |
| 147 | |
| 148 | test("skills/list and skills/get carry the manifest with matching sha256 digests", async () => { |
| 149 | const skills = await rpc("skills/list", {}); |
| 150 | const entry = skills.result.skills[0]; |
| 151 | assert.equal(entry.name, "computer-use"); |
| 152 | assert.ok(entry.description.length > 40, "the description comes from SKILL.md frontmatter"); |
| 153 | assert.equal(entry.files.length, 3); |
| 154 | |
| 155 | const got = await rpc("skills/get", { uri: "skill://codewhale-cu/SKILL.md" }); |
| 156 | assert.equal(got.result.skill.frontmatter.name, "computer-use"); |
| 157 | for (const file of got.result.manifest) { |
| 158 | const rel = file.uri.replace("skill://codewhale-cu/", ""); |
| 159 | const bytes = fs.readFileSync(path.join(ROOT, "skills", "computer-use", rel)); |
| 160 | const digest = crypto.createHash("sha256").update(bytes).digest("hex"); |
| 161 | assert.equal(file.sha256, digest, `${rel} sha256 must match the bytes`); |
| 162 | assert.equal(file.bytes, bytes.length); |
| 163 | } |
| 164 | }); |
| 165 | |
| 166 | test("tools/list still answers after the resource methods (no dispatch regressions)", async () => { |
| 167 | const tools = await rpc("tools/list", {}); |
| 168 | const names = tools.result.tools.map((t) => t.name); |
| 169 | assert.ok(names.includes("invoke_menu")); |
| 170 | assert.equal(new Set(names).size, names.length, "tool names stay unique"); |
| 171 | }); |
| 172 |