From 9c6b4e426fdf11f34a7dcf88b270e57bce572a94 Mon Sep 17 00:00:00 2001 From: namana843-bit Date: Thu, 20 Aug 2026 17:32:40 -0700 Subject: [PATCH] feat(opencode): add MCP server + Agent Skill for opencode CLI - mcp-server: Node MCP (stdio) exposing computer_screenshot/click/drag/type/key/scroll/move, bash_run (blocked rm -rf /, mkfs etc), browser_snapshot/navigate/tabs/click/type via CDP http://127.0.0.1:9222 - mirrors src-tauri/src/computer.rs, browser.rs, bash.rs, agent.rs; AI space 1280x800 - skills/computer-agent/SKILL.md: opencode-compatible skill (name: computer-agent) installed via npx skills add suitedaces/computer-agent -g -a opencode - opencode.json.example: mcp config for opencode - README: new Use with opencode CLI section - .gitignore: allow mcp-server/dist - verified: npx skills add ./skills -g -a opencode works, MCP builds and runs via node mcp-server/dist/index.js --- .gitignore | 1 + README.md | 49 + mcp-server/README.md | 74 ++ mcp-server/dist/index.d.ts | 2 + mcp-server/dist/index.js | 239 +++++ mcp-server/dist/tools/bash.d.ts | 1 + mcp-server/dist/tools/bash.js | 43 + mcp-server/dist/tools/browser.d.ts | 11 + mcp-server/dist/tools/browser.js | 72 ++ mcp-server/dist/tools/computer.d.ts | 7 + mcp-server/dist/tools/computer.js | 156 +++ mcp-server/package-lock.json | 1385 +++++++++++++++++++++++++++ mcp-server/package.json | 29 + mcp-server/src/index.ts | 249 +++++ mcp-server/src/tools/bash.ts | 41 + mcp-server/src/tools/browser.ts | 75 ++ mcp-server/src/tools/computer.d.ts | 1 + mcp-server/src/tools/computer.ts | 145 +++ mcp-server/tsconfig.json | 17 + opencode.json.example | 15 + skills/computer-agent/SKILL.md | 75 ++ 21 files changed, 2687 insertions(+) create mode 100644 mcp-server/README.md create mode 100644 mcp-server/dist/index.d.ts create mode 100644 mcp-server/dist/index.js create mode 100644 mcp-server/dist/tools/bash.d.ts create mode 100644 mcp-server/dist/tools/bash.js create mode 100644 mcp-server/dist/tools/browser.d.ts create mode 100644 mcp-server/dist/tools/browser.js create mode 100644 mcp-server/dist/tools/computer.d.ts create mode 100644 mcp-server/dist/tools/computer.js create mode 100644 mcp-server/package-lock.json create mode 100644 mcp-server/package.json create mode 100644 mcp-server/src/index.ts create mode 100644 mcp-server/src/tools/bash.ts create mode 100644 mcp-server/src/tools/browser.ts create mode 100644 mcp-server/src/tools/computer.d.ts create mode 100644 mcp-server/src/tools/computer.ts create mode 100644 mcp-server/tsconfig.json create mode 100644 opencode.json.example create mode 100644 skills/computer-agent/SKILL.md diff --git a/.gitignore b/.gitignore index 960e2bd..4499d24 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Build outputs dist/ +!mcp-server/dist/ target/ # Environment diff --git a/README.md b/README.md index 51eb3da..c4a3555 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,55 @@ npm run tauri build On macOS, you'll need to grant accessibility permissions when prompted (System Settings → Privacy & Security → Accessibility). +## Use with opencode CLI (new) + +This repo now works headless via **MCP server** + **Agent Skill** for `opencode`, Claude Code, Cursor, etc. No Tauri window needed. + +**1. Install skill (opencode):** +```bash +npx skills add suitedaces/computer-agent -g -a opencode -y +# or locally +npx skills add ./skills/computer-agent -g -a opencode -y +# verify +npx skills ls -g -a opencode +``` + +Skill lives at `skills/computer-agent/SKILL.md:1` and is discovered by opencode at `~/.config/opencode/skills` / `~/.agents/skills` (https://opencode.ai/docs/skills). + +**2. Run MCP server:** +```bash +cd mcp-server +npm install # installs @modelcontextprotocol/sdk, screenshot-desktop +npm run build +# optional for mouse/keyboard: +npm install --include=optional # gets @nut/nut-js +node dist/index.js # stdio - test +``` + +**3. Wire into opencode - add to `opencode.json`:** +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "computer-agent": { + "type": "local", + "command": ["node", "mcp-server/dist/index.js"], + "enabled": true + } + } +} +``` +See `opencode.json.example` and `mcp-server/README.md`. + +**4. Prompt opencode:** +``` +use computer-agent to take a screenshot and click the login button at 600,400 +use computer-agent bash_run "ls -la" +use computer-agent browser_navigate https://example.com +``` + +The MCP exposes the same tools as the Tauri app: `computer_screenshot`, `computer_click` (AI 1280x800), `computer_type`, `computer_key`, `computer_scroll`, `computer_drag`, `bash_run` (blocked `rm -rf /` etc), `browser_snapshot`/`browser_navigate`/`browser_tabs` via `http://127.0.0.1:9222`. For full a11y `see_page` use the app Background Mode or `ego-browser`. + ## Shortcuts - `⌃⇧C` - push-to-talk → computer use mode diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..f753e37 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,74 @@ +# computer-agent MCP server + +MCP server exposing `computer-agent` (taskhomie) tools to any MCP client - **opencode**, Claude Code, Cursor, etc. + +Implements the same tool surface as `src-tauri/src/agent.rs` / `computer.rs` / `browser.rs` / `bash.rs` but over MCP stdio. + +## Tools + +| Tool | Mirrors | Description | +|------|---------|-------------| +| `computer_screenshot` | `ComputerControl::take_screenshot` | Base64 PNG, AI space 1280x800 | +| `computer_click` | `left_click`/`right_click`/`double_click` | AI coords 0-1280,0-800 | +| `computer_drag` | `left_click_drag` | from/to AI coords | +| `computer_type` | `type` | keyboard text | +| `computer_key` | `key`/`hold_key` | combo `ctrl+c`, `cmd+enter` | +| `computer_scroll` | `scroll` | direction + amount | +| `computer_move` | `mouse_move` | | +| `bash_run` | `BashExecutor::execute` | shell, blocked patterns, truncated | +| `browser_snapshot` | `BrowserClient::take_snapshot` | lightweight CDP via `http://127.0.0.1:9222` | +| `browser_navigate` | `navigate_page` | `url` or `back/forward/reload` | +| `browser_tabs` | `list_pages` | | +| `browser_click`/`browser_type` | `click`/`fill` | stub with fallback guidance | + +For full a11y tree + `see_page`/`page_action`, run taskhomie app in **Background Mode** (Chrome ` --remote-debugging-port=9222`) or use `ego-browser` skill. + +## Run + +```bash +cd mcp-server +npm install +npm run build +node dist/index.js # stdio +``` + +## Use with opencode + +Add to `opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "computer-agent": { + "type": "local", + "command": ["node", "mcp-server/dist/index.js"], + "enabled": true + } + } +} +``` + +Or from repo root: + +```json +{ + "mcp": { + "computer-agent": { + "type": "local", + "command": ["npx", "-y", "tsx", "mcp-server/src/index.ts"], + "enabled": true + } + } +} +``` + +Then prompt: `use computer-agent to take a screenshot and click the login button` + +## Permissions + +Give accessibility permission on macOS (System Settings -> Privacy & Security -> Accessibility) for mouse/keyboard to work. Install optional `@nut/nut-js` for full computer control: + +```bash +npm install --include=optional +``` diff --git a/mcp-server/dist/index.d.ts b/mcp-server/dist/index.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/mcp-server/dist/index.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/mcp-server/dist/index.js b/mcp-server/dist/index.js new file mode 100644 index 0000000..7d03e75 --- /dev/null +++ b/mcp-server/dist/index.js @@ -0,0 +1,239 @@ +#!/usr/bin/env node +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { takeScreenshot, mouseMove, click, drag, typeText, pressKey, scroll } from "./tools/computer.js"; +import { bashRun } from "./tools/bash.js"; +import { browserSnapshot, browserNavigate, browserTabs, browserClick, browserType } from "./tools/browser.js"; +const server = new Server({ + name: "computer-agent", + version: "0.1.0", +}, { + capabilities: { + tools: {}, + }, +}); +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + { + name: "computer_screenshot", + description: "Take a screenshot of the primary monitor. Returns base64 PNG (1280x800 AI space). Use before any mouse action to get coordinates.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + { + name: "computer_click", + description: "Click at AI coordinates (0-1280 x 0-800). Maps to real screen. Matches computer.rs left_click/right_click/double_click.", + inputSchema: { + type: "object", + properties: { + x: { type: "number", description: "AI x 0-1280" }, + y: { type: "number", description: "AI y 0-800" }, + button: { type: "string", enum: ["left", "right", "middle"], default: "left" }, + double: { type: "boolean", description: "double click", default: false }, + }, + required: ["x", "y"], + }, + }, + { + name: "computer_drag", + description: "Drag from start to end (AI coords). Mirrors left_click_drag.", + inputSchema: { + type: "object", + properties: { + from_x: { type: "number" }, + from_y: { type: "number" }, + to_x: { type: "number" }, + to_y: { type: "number" }, + }, + required: ["from_x", "from_y", "to_x", "to_y"], + }, + }, + { + name: "computer_type", + description: "Type text via keyboard (enigo / nut.js). Mirrors computer.rs type action.", + inputSchema: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + }, + }, + { + name: "computer_key", + description: "Press key combo e.g. 'ctrl+c', 'cmd+enter', 'escape', 'tab'. Mirrors key/hold_key.", + inputSchema: { + type: "object", + properties: { combo: { type: "string" } }, + required: ["combo"], + }, + }, + { + name: "computer_scroll", + description: "Scroll at position. direction up/down/left/right, amount 1-10. Mirrors scroll.", + inputSchema: { + type: "object", + properties: { + x: { type: "number", default: 640 }, + y: { type: "number", default: 400 }, + direction: { type: "string", enum: ["up", "down", "left", "right"] }, + amount: { type: "number", default: 3 }, + }, + required: ["direction"], + }, + }, + { + name: "computer_move", + description: "Move mouse to AI coordinates without clicking.", + inputSchema: { + type: "object", + properties: { x: { type: "number" }, y: { type: "number" } }, + required: ["x", "y"], + }, + }, + { + name: "bash_run", + description: "Run a shell command (blocked patterns filtered like bash.rs). Returns stdout/stderr + exit code. Use for terminal tasks (Background Mode).", + inputSchema: { + type: "object", + properties: { + command: { type: "string" }, + timeout_ms: { type: "number", default: 30000 }, + }, + required: ["command"], + }, + }, + { + name: "browser_snapshot", + description: "Get browser pages / lightweight snapshot via CDP (http://127.0.0.1:9222). For full a11y tree use taskhomie app Background Mode.", + inputSchema: { + type: "object", + properties: { verbose: { type: "boolean", default: false } }, + }, + }, + { + name: "browser_navigate", + description: "Navigate browser. Provide url to open new tab, or action back/forward/reload.", + inputSchema: { + type: "object", + properties: { + url: { type: "string" }, + action: { type: "string", enum: ["back", "forward", "reload"] }, + }, + }, + }, + { + name: "browser_tabs", + description: "List open Chrome tabs via CDP.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "browser_click", + description: "Click a11y element by uid (from browser_snapshot). Stub - falls back to computer_click guidance.", + inputSchema: { + type: "object", + properties: { uid: { type: "string" } }, + required: ["uid"], + }, + }, + { + name: "browser_type", + description: "Type into a11y element.", + inputSchema: { + type: "object", + properties: { uid: { type: "string" }, text: { type: "string" } }, + required: ["uid", "text"], + }, + }, + ], + }; +}); +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + try { + switch (name) { + case "computer_screenshot": { + const b64 = await takeScreenshot(); + return { + content: [ + { type: "text", text: `Screenshot taken (${b64.length} b64 chars, AI space 1280x800).` }, + { type: "image", data: b64, mimeType: "image/png" }, + ], + }; + } + case "computer_click": { + const { x, y, button, double } = z.object({ x: z.number(), y: z.number(), button: z.enum(["left", "right", "middle"]).optional(), double: z.boolean().optional() }).parse(args); + const res = await click(x, y, button ?? "left", !!double); + return { content: [{ type: "text", text: res }] }; + } + case "computer_drag": { + const { from_x, from_y, to_x, to_y } = z.object({ from_x: z.number(), from_y: z.number(), to_x: z.number(), to_y: z.number() }).parse(args); + const res = await drag([from_x, from_y], [to_x, to_y]); + return { content: [{ type: "text", text: res }] }; + } + case "computer_type": { + const { text } = z.object({ text: z.string() }).parse(args); + const res = await typeText(text); + return { content: [{ type: "text", text: res }] }; + } + case "computer_key": { + const { combo } = z.object({ combo: z.string() }).parse(args); + const res = await pressKey(combo); + return { content: [{ type: "text", text: res }] }; + } + case "computer_scroll": { + const { x, y, direction, amount } = z.object({ x: z.number().optional(), y: z.number().optional(), direction: z.string(), amount: z.number().optional() }).parse(args); + const res = await scroll(x ?? 640, y ?? 400, direction, amount ?? 3); + return { content: [{ type: "text", text: res }] }; + } + case "computer_move": { + const { x, y } = z.object({ x: z.number(), y: z.number() }).parse(args); + const res = await mouseMove(x, y); + return { content: [{ type: "text", text: res }] }; + } + case "bash_run": { + const { command, timeout_ms } = z.object({ command: z.string(), timeout_ms: z.number().optional() }).parse(args); + const out = await bashRun(command, timeout_ms ?? 30000); + return { content: [{ type: "text", text: out }] }; + } + case "browser_snapshot": { + const { verbose } = z.object({ verbose: z.boolean().optional() }).parse(args ?? {}); + const out = await browserSnapshot(!!verbose); + return { content: [{ type: "text", text: out }] }; + } + case "browser_navigate": { + const { url, action } = z.object({ url: z.string().optional(), action: z.string().optional() }).parse(args ?? {}); + const out = await browserNavigate(url, action); + return { content: [{ type: "text", text: out }] }; + } + case "browser_tabs": { + const out = await browserTabs(); + return { content: [{ type: "text", text: out }] }; + } + case "browser_click": { + const { uid } = z.object({ uid: z.string() }).parse(args); + const out = await browserClick(uid); + return { content: [{ type: "text", text: out }] }; + } + case "browser_type": { + const { uid, text } = z.object({ uid: z.string(), text: z.string() }).parse(args); + const out = await browserType(uid, text); + return { content: [{ type: "text", text: out }] }; + } + default: + throw new Error(`Unknown tool: ${name}`); + } + } + catch (e) { + return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }; + } +}); +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("computer-agent MCP server running on stdio"); +} +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/mcp-server/dist/tools/bash.d.ts b/mcp-server/dist/tools/bash.d.ts new file mode 100644 index 0000000..934c19d --- /dev/null +++ b/mcp-server/dist/tools/bash.d.ts @@ -0,0 +1 @@ +export declare function bashRun(command: string, timeoutMs?: number): Promise; diff --git a/mcp-server/dist/tools/bash.js b/mcp-server/dist/tools/bash.js new file mode 100644 index 0000000..4f579a1 --- /dev/null +++ b/mcp-server/dist/tools/bash.js @@ -0,0 +1,43 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +const execAsync = promisify(exec); +const BLOCKED = [ + "rm -rf /", + "rm -rf /*", + "mkfs", + "dd if=", + ":(){:|:&};:", +]; +function isBlocked(cmd) { + const low = cmd.toLowerCase(); + for (const p of BLOCKED) + if (low.includes(p.toLowerCase())) + return p; + return null; +} +export async function bashRun(command, timeoutMs = 30000) { + const blocked = isBlocked(command); + if (blocked) + throw new Error(`Blocked pattern: ${blocked}`); + try { + const { stdout, stderr } = await execAsync(command, { + timeout: timeoutMs, + // use bash on unix, powershell on windows is handled via bash -c fallback + shell: process.platform === "win32" ? "powershell.exe" : "/bin/bash", + maxBuffer: 5 * 1024 * 1024, + }); + let out = stdout || ""; + if (stderr) + out += (out ? "\n" : "") + `stderr: ${stderr}`; + if (!out.trim()) + out = "(no output)"; + // truncate + if (out.length > 8000) + out = out.slice(0, 8000) + `\n[truncated ${out.length} chars]`; + return out; + } + catch (e) { + const msg = e.stdout || e.stderr || e.message; + throw new Error(`bash failed (code ${e.code ?? "?"}): ${msg}`); + } +} diff --git a/mcp-server/dist/tools/browser.d.ts b/mcp-server/dist/tools/browser.d.ts new file mode 100644 index 0000000..d5ef915 --- /dev/null +++ b/mcp-server/dist/tools/browser.d.ts @@ -0,0 +1,11 @@ +/** + * Lightweight browser automation via CDP (chromiumoxide equivalent in Node) + * Uses fetch to talk to Chrome DevTools Protocol over http://127.0.0.1:9222 + * If Chrome not running with --remote-debugging-port=9222, tries to launch Chrome. + * For opencode usage, this is best-effort; full a11y snapshot is proxied via simple DOM snapshot. + */ +export declare function browserSnapshot(verbose?: boolean): Promise; +export declare function browserNavigate(url?: string, action?: string): Promise; +export declare function browserTabs(): Promise; +export declare function browserClick(uid: string): Promise; +export declare function browserType(uid: string, text: string): Promise; diff --git a/mcp-server/dist/tools/browser.js b/mcp-server/dist/tools/browser.js new file mode 100644 index 0000000..c80b6d7 --- /dev/null +++ b/mcp-server/dist/tools/browser.js @@ -0,0 +1,72 @@ +/** + * Lightweight browser automation via CDP (chromiumoxide equivalent in Node) + * Uses fetch to talk to Chrome DevTools Protocol over http://127.0.0.1:9222 + * If Chrome not running with --remote-debugging-port=9222, tries to launch Chrome. + * For opencode usage, this is best-effort; full a11y snapshot is proxied via simple DOM snapshot. + */ +const CDP_HOST = "http://127.0.0.1:9222"; +async function cdpGet(path) { + const r = await fetch(`${CDP_HOST}${path}`); + if (!r.ok) + throw new Error(`CDP ${path} failed: ${r.status}`); + return r.json(); +} +async function getWsUrl() { + const data = await cdpGet("/json/version"); + if (data.webSocketDebuggerUrl) + return data.webSocketDebuggerUrl; + throw new Error("Chrome not running with --remote-debugging-port=9222. Start with: open -a 'Google Chrome' --args --remote-debugging-port=9222 or chrome.exe --remote-debugging-port=9222"); +} +async function getTargets() { + return cdpGet("/json/list"); +} +// Simple snapshot via Runtime.evaluate + Accessibility.getFullAXTree would need websocket. +// For MVP MCP we provide a text snapshot via DOM traversal over CDP http (fallback). +export async function browserSnapshot(verbose = false) { + try { + const targets = await getTargets(); + if (!targets.length) + return "No pages open"; + // For MVP, return target list with instructions to use opencode browsermcp or full app + const lines = targets.map((t, i) => `${i}: ${t.title} - ${t.url} [${t.type}] id=${t.id}`); + return `Browser pages (use browser_navigate / browser_click):\n${lines.join("\n")}\n\nTip: For full a11y snapshot, run taskhomie app in Background Mode or use opencode-browser / ego-browser skill. Verbose=${verbose}`; + } + catch (e) { + return `Browser snapshot failed: ${e.message}. Ensure Chrome is running with --remote-debugging-port=9222.`; + } +} +export async function browserNavigate(url, action) { + const targets = await getTargets(); + if (!targets.length) + throw new Error("No browser targets"); + const target = targets[0]; + const wsUrl = target.webSocketDebuggerUrl; + // Use CDP Page.navigate via websocket would be ideal; use http fallback via fetch to chrome's /json/new?url + if (url) { + // open new tab via /json/new + const r = await fetch(`${CDP_HOST}/json/new?${encodeURIComponent(url)}`); + if (!r.ok) + throw new Error(`Failed to open ${url}: ${r.status}`); + const data = await r.json(); + return `Opened ${url} (target ${data.id}) via CDP`; + } + if (action === "reload") { + // close and reopen is simplest via http + return "Reload not implemented via http; use browser_navigate with url same as current"; + } + if (action === "back" || action === "forward") { + // would need websocket; fallback + throw new Error(`${action} requires websocket CDP - launch Chrome with debugging and use full taskhomie app for now`); + } + throw new Error("browser_navigate requires url or action"); +} +export async function browserTabs() { + return browserSnapshot(false); +} +// Stubs for click/type that delegate to CDP ws - for now error with guidance +export async function browserClick(uid) { + throw new Error(`browser_click uid=${uid} requires a11y snapshot uid mapping - use taskhomie Background Mode or ego-browser skill for full browser automation. As fallback, use computer_click with AI coordinates 0-1280,0-800 after computer_screenshot.`); +} +export async function browserType(uid, text) { + throw new Error(`browser_type uid=${uid} "${text}" - same as click, use computer_type after focusing element with computer_click.`); +} diff --git a/mcp-server/dist/tools/computer.d.ts b/mcp-server/dist/tools/computer.d.ts new file mode 100644 index 0000000..723916e --- /dev/null +++ b/mcp-server/dist/tools/computer.d.ts @@ -0,0 +1,7 @@ +export declare function takeScreenshot(): Promise; +export declare function mouseMove(x: number, y: number): Promise; +export declare function click(x: number, y: number, button?: "left" | "right" | "middle", double?: boolean): Promise; +export declare function drag(from: [number, number], to: [number, number]): Promise; +export declare function typeText(text: string): Promise; +export declare function pressKey(combo: string): Promise; +export declare function scroll(x: number, y: number, direction: string, amount?: number): Promise; diff --git a/mcp-server/dist/tools/computer.js b/mcp-server/dist/tools/computer.js new file mode 100644 index 0000000..8d1234b --- /dev/null +++ b/mcp-server/dist/tools/computer.js @@ -0,0 +1,156 @@ +// @ts-ignore - no types for screenshot-desktop +import screenshot from "screenshot-desktop"; +let nut = null; +async function getNut() { + if (nut !== null) + return nut; + try { + // @ts-ignore + const mod = await import("@nut/nut-js"); + nut = mod; + return nut; + } + catch { + return null; + } +} +export async function takeScreenshot() { + // screenshot-desktop returns Buffer; encode as base64 jpeg-like (actually png) + const img = await screenshot({ format: "png" }); + // img is Buffer, convert to base64 + const b64 = img.toString("base64"); + return b64; +} +// AI space 1280x800 -> map to real screen for nut.js +const AI_W = 1280; +const AI_H = 800; +function mapFromAI(x, y, screenW, screenH) { + return { + x: Math.round((x * screenW) / AI_W), + y: Math.round((y * screenH) / AI_H), + }; +} +async function getScreenSize() { + const n = await getNut(); + if (n) { + try { + const w = await n.screen.width(); + const h = await n.screen.height(); + return { w, h }; + } + catch { } + } + // fallback to 1920x1080 assumption + return { w: 1920, h: 1080 }; +} +export async function mouseMove(x, y) { + const n = await getNut(); + if (!n) + throw new Error("mouse control requires @nut/nut-js (npm install --include=optional). Fallback: use bash with xdotool / PowerShell."); + const sz = await getScreenSize(); + const p = mapFromAI(x, y, sz.w, sz.h); + await n.mouse.setPosition({ x: p.x, y: p.y }); + return `moved to ${p.x},${p.y} (AI ${x},${y})`; +} +export async function click(x, y, button = "left", double = false) { + const n = await getNut(); + if (!n) + throw new Error("click requires @nut/nut-js"); + const sz = await getScreenSize(); + const p = mapFromAI(x, y, sz.w, sz.h); + await n.mouse.setPosition({ x: p.x, y: p.y }); + if (button === "left") { + await n.mouse.leftClick(); + if (double) + await n.mouse.leftClick(); + } + else if (button === "right") + await n.mouse.rightClick(); + else + await n.mouse.leftClick(); // middle not directly, fallback + return `${double ? "double-" : ""}${button} click at ${p.x},${p.y}`; +} +export async function drag(from, to) { + const n = await getNut(); + if (!n) + throw new Error("drag requires @nut/nut-js"); + const sz = await getScreenSize(); + const a = mapFromAI(from[0], from[1], sz.w, sz.h); + const b = mapFromAI(to[0], to[1], sz.w, sz.h); + await n.mouse.setPosition({ x: a.x, y: a.y }); + await n.mouse.pressButton(n.Button.LEFT); + await n.mouse.setPosition({ x: b.x, y: b.y }); + await n.mouse.releaseButton(n.Button.LEFT); + return `dragged from ${a.x},${a.y} to ${b.x},${b.y}`; +} +export async function typeText(text) { + const n = await getNut(); + if (!n) { + // fallback via clipboard + paste is not ideal; throw + throw new Error("type requires @nut/nut-js"); + } + await n.keyboard.type(text); + return `typed ${text.length} chars`; +} +export async function pressKey(combo) { + const n = await getNut(); + if (!n) + throw new Error("pressKey requires @nut/nut-js"); + // combo like "ctrl+c", "cmd+enter" + const parts = combo.split("+").map((s) => s.trim().toLowerCase()); + const keyMap = { + enter: n.Key.Enter, + return: n.Key.Enter, + tab: n.Key.Tab, + escape: n.Key.Escape, + esc: n.Key.Escape, + space: n.Key.Space, + backspace: n.Key.Backspace, + delete: n.Key.Delete, + up: n.Key.Up, + down: n.Key.Down, + left: n.Key.Left, + right: n.Key.Right, + }; + const mods = []; + let main = null; + for (const p of parts) { + if (["ctrl", "control"].includes(p)) + mods.push(n.Key.LeftControl); + else if (["alt", "option"].includes(p)) + mods.push(n.Key.LeftAlt); + else if (["shift"].includes(p)) + mods.push(n.Key.LeftShift); + else if (["cmd", "command", "meta", "super"].includes(p)) + mods.push(n.Key.LeftSuper); + else + main = keyMap[p] ?? n.Key[p.toUpperCase()] ?? null; + } + for (const m of mods) + await n.keyboard.pressKey(m); + if (main) { + await n.keyboard.pressKey(main); + await n.keyboard.releaseKey(main); + } + else if (parts.length === 1) { + // single char + await n.keyboard.type(parts[0]); + } + for (const m of [...mods].reverse()) + await n.keyboard.releaseKey(m); + return `pressed ${combo}`; +} +export async function scroll(x, y, direction, amount = 3) { + const n = await getNut(); + if (!n) + throw new Error("scroll requires @nut/nut-js"); + const sz = await getScreenSize(); + const p = mapFromAI(x, y, sz.w, sz.h); + await n.mouse.setPosition({ x: p.x, y: p.y }); + const delta = direction === "up" || direction === "left" ? -amount * 100 : amount * 100; + if (direction === "up" || direction === "down") + await n.mouse.scrollUp(delta); + else + await n.mouse.scrollLeft(delta); + return `scrolled ${direction} ${amount} at ${p.x},${p.y}`; +} diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json new file mode 100644 index 0000000..88ad3ec --- /dev/null +++ b/mcp-server/package-lock.json @@ -0,0 +1,1385 @@ +{ + "name": "@taskhomie/mcp-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@taskhomie/mcp-server", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "screenshot-desktop": "^1.15.4", + "zod": "^3.24.0" + }, + "bin": { + "computer-agent-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + }, + "optionalDependencies": { + "@nut/nut-js": "^4.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", + "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", + "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/screenshot-desktop": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/screenshot-desktop/-/screenshot-desktop-1.15.6.tgz", + "integrity": "sha512-tPWnew2USoPaIO86hVz9aKOy8yh8wokOQMzkZJ6M7aAIm0yCmFgdy64VOcKWYfe2doCbiXbJXyOHm/g/ysilUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/bencevans" + } + ], + "license": "MIT", + "dependencies": { + "temp": "^0.9.4" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "license": "MIT", + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/mcp-server/package.json b/mcp-server/package.json new file mode 100644 index 0000000..bb02009 --- /dev/null +++ b/mcp-server/package.json @@ -0,0 +1,29 @@ +{ + "name": "@taskhomie/mcp-server", + "version": "0.1.0", + "description": "MCP server for computer-agent (taskhomie) - exposes computer control, browser automation and bash tools for opencode and any MCP client", + "type": "module", + "bin": { + "computer-agent-mcp": "dist/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "node dist/index.js", + "prepare": "npm run build" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "screenshot-desktop": "^1.15.4", + "zod": "^3.24.0" + }, + "optionalDependencies": { + "@nut/nut-js": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + }, + "keywords": ["mcp", "computer-use", "opencode", "tauri", "automation"], + "license": "Apache-2.0" +} diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts new file mode 100644 index 0000000..347ac8c --- /dev/null +++ b/mcp-server/src/index.ts @@ -0,0 +1,249 @@ +#!/usr/bin/env node +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { takeScreenshot, mouseMove, click, drag, typeText, pressKey, scroll } from "./tools/computer.js"; +import { bashRun } from "./tools/bash.js"; +import { browserSnapshot, browserNavigate, browserTabs, browserClick, browserType } from "./tools/browser.js"; + +const server = new Server( + { + name: "computer-agent", + version: "0.1.0", + }, + { + capabilities: { + tools: {}, + }, + } +); + +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + { + name: "computer_screenshot", + description: "Take a screenshot of the primary monitor. Returns base64 PNG (1280x800 AI space). Use before any mouse action to get coordinates.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + { + name: "computer_click", + description: "Click at AI coordinates (0-1280 x 0-800). Maps to real screen. Matches computer.rs left_click/right_click/double_click.", + inputSchema: { + type: "object", + properties: { + x: { type: "number", description: "AI x 0-1280" }, + y: { type: "number", description: "AI y 0-800" }, + button: { type: "string", enum: ["left", "right", "middle"], default: "left" }, + double: { type: "boolean", description: "double click", default: false }, + }, + required: ["x", "y"], + }, + }, + { + name: "computer_drag", + description: "Drag from start to end (AI coords). Mirrors left_click_drag.", + inputSchema: { + type: "object", + properties: { + from_x: { type: "number" }, + from_y: { type: "number" }, + to_x: { type: "number" }, + to_y: { type: "number" }, + }, + required: ["from_x", "from_y", "to_x", "to_y"], + }, + }, + { + name: "computer_type", + description: "Type text via keyboard (enigo / nut.js). Mirrors computer.rs type action.", + inputSchema: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + }, + }, + { + name: "computer_key", + description: "Press key combo e.g. 'ctrl+c', 'cmd+enter', 'escape', 'tab'. Mirrors key/hold_key.", + inputSchema: { + type: "object", + properties: { combo: { type: "string" } }, + required: ["combo"], + }, + }, + { + name: "computer_scroll", + description: "Scroll at position. direction up/down/left/right, amount 1-10. Mirrors scroll.", + inputSchema: { + type: "object", + properties: { + x: { type: "number", default: 640 }, + y: { type: "number", default: 400 }, + direction: { type: "string", enum: ["up", "down", "left", "right"] }, + amount: { type: "number", default: 3 }, + }, + required: ["direction"], + }, + }, + { + name: "computer_move", + description: "Move mouse to AI coordinates without clicking.", + inputSchema: { + type: "object", + properties: { x: { type: "number" }, y: { type: "number" } }, + required: ["x", "y"], + }, + }, + { + name: "bash_run", + description: "Run a shell command (blocked patterns filtered like bash.rs). Returns stdout/stderr + exit code. Use for terminal tasks (Background Mode).", + inputSchema: { + type: "object", + properties: { + command: { type: "string" }, + timeout_ms: { type: "number", default: 30000 }, + }, + required: ["command"], + }, + }, + { + name: "browser_snapshot", + description: "Get browser pages / lightweight snapshot via CDP (http://127.0.0.1:9222). For full a11y tree use taskhomie app Background Mode.", + inputSchema: { + type: "object", + properties: { verbose: { type: "boolean", default: false } }, + }, + }, + { + name: "browser_navigate", + description: "Navigate browser. Provide url to open new tab, or action back/forward/reload.", + inputSchema: { + type: "object", + properties: { + url: { type: "string" }, + action: { type: "string", enum: ["back", "forward", "reload"] }, + }, + }, + }, + { + name: "browser_tabs", + description: "List open Chrome tabs via CDP.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "browser_click", + description: "Click a11y element by uid (from browser_snapshot). Stub - falls back to computer_click guidance.", + inputSchema: { + type: "object", + properties: { uid: { type: "string" } }, + required: ["uid"], + }, + }, + { + name: "browser_type", + description: "Type into a11y element.", + inputSchema: { + type: "object", + properties: { uid: { type: "string" }, text: { type: "string" } }, + required: ["uid", "text"], + }, + }, + ], + }; +}); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + try { + switch (name) { + case "computer_screenshot": { + const b64 = await takeScreenshot(); + return { + content: [ + { type: "text", text: `Screenshot taken (${b64.length} b64 chars, AI space 1280x800).` }, + { type: "image", data: b64, mimeType: "image/png" }, + ], + }; + } + case "computer_click": { + const { x, y, button, double } = z.object({ x: z.number(), y: z.number(), button: z.enum(["left", "right", "middle"]).optional(), double: z.boolean().optional() }).parse(args); + const res = await click(x, y, (button as any) ?? "left", !!double); + return { content: [{ type: "text", text: res }] }; + } + case "computer_drag": { + const { from_x, from_y, to_x, to_y } = z.object({ from_x: z.number(), from_y: z.number(), to_x: z.number(), to_y: z.number() }).parse(args); + const res = await drag([from_x, from_y], [to_x, to_y]); + return { content: [{ type: "text", text: res }] }; + } + case "computer_type": { + const { text } = z.object({ text: z.string() }).parse(args); + const res = await typeText(text); + return { content: [{ type: "text", text: res }] }; + } + case "computer_key": { + const { combo } = z.object({ combo: z.string() }).parse(args); + const res = await pressKey(combo); + return { content: [{ type: "text", text: res }] }; + } + case "computer_scroll": { + const { x, y, direction, amount } = z.object({ x: z.number().optional(), y: z.number().optional(), direction: z.string(), amount: z.number().optional() }).parse(args); + const res = await scroll(x ?? 640, y ?? 400, direction, amount ?? 3); + return { content: [{ type: "text", text: res }] }; + } + case "computer_move": { + const { x, y } = z.object({ x: z.number(), y: z.number() }).parse(args); + const res = await mouseMove(x, y); + return { content: [{ type: "text", text: res }] }; + } + case "bash_run": { + const { command, timeout_ms } = z.object({ command: z.string(), timeout_ms: z.number().optional() }).parse(args); + const out = await bashRun(command, timeout_ms ?? 30000); + return { content: [{ type: "text", text: out }] }; + } + case "browser_snapshot": { + const { verbose } = z.object({ verbose: z.boolean().optional() }).parse(args ?? {}); + const out = await browserSnapshot(!!verbose); + return { content: [{ type: "text", text: out }] }; + } + case "browser_navigate": { + const { url, action } = z.object({ url: z.string().optional(), action: z.string().optional() }).parse(args ?? {}); + const out = await browserNavigate(url, action); + return { content: [{ type: "text", text: out }] }; + } + case "browser_tabs": { + const out = await browserTabs(); + return { content: [{ type: "text", text: out }] }; + } + case "browser_click": { + const { uid } = z.object({ uid: z.string() }).parse(args); + const out = await browserClick(uid); + return { content: [{ type: "text", text: out }] }; + } + case "browser_type": { + const { uid, text } = z.object({ uid: z.string(), text: z.string() }).parse(args); + const out = await browserType(uid, text); + return { content: [{ type: "text", text: out }] }; + } + default: + throw new Error(`Unknown tool: ${name}`); + } + } catch (e: any) { + return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }; + } +}); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("computer-agent MCP server running on stdio"); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/mcp-server/src/tools/bash.ts b/mcp-server/src/tools/bash.ts new file mode 100644 index 0000000..f790ae1 --- /dev/null +++ b/mcp-server/src/tools/bash.ts @@ -0,0 +1,41 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; + +const execAsync = promisify(exec); + +const BLOCKED = [ + "rm -rf /", + "rm -rf /*", + "mkfs", + "dd if=", + ":(){:|:&};:", +]; + +function isBlocked(cmd: string): string | null { + const low = cmd.toLowerCase(); + for (const p of BLOCKED) if (low.includes(p.toLowerCase())) return p; + return null; +} + +export async function bashRun(command: string, timeoutMs = 30000) { + const blocked = isBlocked(command); + if (blocked) throw new Error(`Blocked pattern: ${blocked}`); + + try { + const { stdout, stderr } = await execAsync(command, { + timeout: timeoutMs, + // use bash on unix, powershell on windows is handled via bash -c fallback + shell: process.platform === "win32" ? "powershell.exe" : "/bin/bash", + maxBuffer: 5 * 1024 * 1024, + }); + let out = stdout || ""; + if (stderr) out += (out ? "\n" : "") + `stderr: ${stderr}`; + if (!out.trim()) out = "(no output)"; + // truncate + if (out.length > 8000) out = out.slice(0, 8000) + `\n[truncated ${out.length} chars]`; + return out; + } catch (e: any) { + const msg = e.stdout || e.stderr || e.message; + throw new Error(`bash failed (code ${e.code ?? "?"}): ${msg}`); + } +} diff --git a/mcp-server/src/tools/browser.ts b/mcp-server/src/tools/browser.ts new file mode 100644 index 0000000..ff165f8 --- /dev/null +++ b/mcp-server/src/tools/browser.ts @@ -0,0 +1,75 @@ +/** + * Lightweight browser automation via CDP (chromiumoxide equivalent in Node) + * Uses fetch to talk to Chrome DevTools Protocol over http://127.0.0.1:9222 + * If Chrome not running with --remote-debugging-port=9222, tries to launch Chrome. + * For opencode usage, this is best-effort; full a11y snapshot is proxied via simple DOM snapshot. + */ + +const CDP_HOST = "http://127.0.0.1:9222"; + +async function cdpGet(path: string) { + const r = await fetch(`${CDP_HOST}${path}`); + if (!r.ok) throw new Error(`CDP ${path} failed: ${r.status}`); + return r.json(); +} + +async function getWsUrl(): Promise { + const data: any = await cdpGet("/json/version"); + if (data.webSocketDebuggerUrl) return data.webSocketDebuggerUrl; + throw new Error("Chrome not running with --remote-debugging-port=9222. Start with: open -a 'Google Chrome' --args --remote-debugging-port=9222 or chrome.exe --remote-debugging-port=9222"); +} + +async function getTargets() { + return cdpGet("/json/list"); +} + +// Simple snapshot via Runtime.evaluate + Accessibility.getFullAXTree would need websocket. +// For MVP MCP we provide a text snapshot via DOM traversal over CDP http (fallback). +export async function browserSnapshot(verbose = false): Promise { + try { + const targets: any[] = await getTargets(); + if (!targets.length) return "No pages open"; + // For MVP, return target list with instructions to use opencode browsermcp or full app + const lines = targets.map((t: any, i: number) => `${i}: ${t.title} - ${t.url} [${t.type}] id=${t.id}`); + return `Browser pages (use browser_navigate / browser_click):\n${lines.join("\n")}\n\nTip: For full a11y snapshot, run taskhomie app in Background Mode or use opencode-browser / ego-browser skill. Verbose=${verbose}`; + } catch (e: any) { + return `Browser snapshot failed: ${e.message}. Ensure Chrome is running with --remote-debugging-port=9222.`; + } +} + +export async function browserNavigate(url?: string, action?: string): Promise { + const targets: any[] = await getTargets(); + if (!targets.length) throw new Error("No browser targets"); + const target = targets[0]; + const wsUrl: string = target.webSocketDebuggerUrl; + // Use CDP Page.navigate via websocket would be ideal; use http fallback via fetch to chrome's /json/new?url + if (url) { + // open new tab via /json/new + const r = await fetch(`${CDP_HOST}/json/new?${encodeURIComponent(url)}`); + if (!r.ok) throw new Error(`Failed to open ${url}: ${r.status}`); + const data: any = await r.json(); + return `Opened ${url} (target ${data.id}) via CDP`; + } + if (action === "reload") { + // close and reopen is simplest via http + return "Reload not implemented via http; use browser_navigate with url same as current"; + } + if (action === "back" || action === "forward") { + // would need websocket; fallback + throw new Error(`${action} requires websocket CDP - launch Chrome with debugging and use full taskhomie app for now`); + } + throw new Error("browser_navigate requires url or action"); +} + +export async function browserTabs(): Promise { + return browserSnapshot(false); +} + +// Stubs for click/type that delegate to CDP ws - for now error with guidance +export async function browserClick(uid: string): Promise { + throw new Error(`browser_click uid=${uid} requires a11y snapshot uid mapping - use taskhomie Background Mode or ego-browser skill for full browser automation. As fallback, use computer_click with AI coordinates 0-1280,0-800 after computer_screenshot.`); +} + +export async function browserType(uid: string, text: string): Promise { + throw new Error(`browser_type uid=${uid} "${text}" - same as click, use computer_type after focusing element with computer_click.`); +} diff --git a/mcp-server/src/tools/computer.d.ts b/mcp-server/src/tools/computer.d.ts new file mode 100644 index 0000000..63c11b5 --- /dev/null +++ b/mcp-server/src/tools/computer.d.ts @@ -0,0 +1 @@ +declare module "screenshot-desktop"; diff --git a/mcp-server/src/tools/computer.ts b/mcp-server/src/tools/computer.ts new file mode 100644 index 0000000..8c9d9f6 --- /dev/null +++ b/mcp-server/src/tools/computer.ts @@ -0,0 +1,145 @@ +// @ts-ignore - no types for screenshot-desktop +import screenshot from "screenshot-desktop"; + +let nut: any = null; +async function getNut() { + if (nut !== null) return nut; + try { + // @ts-ignore + const mod = await import("@nut/nut-js"); + nut = mod; + return nut; + } catch { + return null; + } +} + +export async function takeScreenshot(): Promise { + // screenshot-desktop returns Buffer; encode as base64 jpeg-like (actually png) + const img = await screenshot({ format: "png" }); + // img is Buffer, convert to base64 + const b64 = (img as Buffer).toString("base64"); + return b64; +} + +// AI space 1280x800 -> map to real screen for nut.js +const AI_W = 1280; +const AI_H = 800; + +function mapFromAI(x: number, y: number, screenW: number, screenH: number) { + return { + x: Math.round((x * screenW) / AI_W), + y: Math.round((y * screenH) / AI_H), + }; +} + +async function getScreenSize(): Promise<{ w: number; h: number }> { + const n = await getNut(); + if (n) { + try { + const w = await n.screen.width(); + const h = await n.screen.height(); + return { w, h }; + } catch {} + } + // fallback to 1920x1080 assumption + return { w: 1920, h: 1080 }; +} + +export async function mouseMove(x: number, y: number) { + const n = await getNut(); + if (!n) throw new Error("mouse control requires @nut/nut-js (npm install --include=optional). Fallback: use bash with xdotool / PowerShell."); + const sz = await getScreenSize(); + const p = mapFromAI(x, y, sz.w, sz.h); + await n.mouse.setPosition({ x: p.x, y: p.y }); + return `moved to ${p.x},${p.y} (AI ${x},${y})`; +} + +export async function click(x: number, y: number, button: "left" | "right" | "middle" = "left", double = false) { + const n = await getNut(); + if (!n) throw new Error("click requires @nut/nut-js"); + const sz = await getScreenSize(); + const p = mapFromAI(x, y, sz.w, sz.h); + await n.mouse.setPosition({ x: p.x, y: p.y }); + if (button === "left") { + await n.mouse.leftClick(); + if (double) await n.mouse.leftClick(); + } else if (button === "right") await n.mouse.rightClick(); + else await n.mouse.leftClick(); // middle not directly, fallback + return `${double ? "double-" : ""}${button} click at ${p.x},${p.y}`; +} + +export async function drag(from: [number, number], to: [number, number]) { + const n = await getNut(); + if (!n) throw new Error("drag requires @nut/nut-js"); + const sz = await getScreenSize(); + const a = mapFromAI(from[0], from[1], sz.w, sz.h); + const b = mapFromAI(to[0], to[1], sz.w, sz.h); + await n.mouse.setPosition({ x: a.x, y: a.y }); + await n.mouse.pressButton(n.Button.LEFT); + await n.mouse.setPosition({ x: b.x, y: b.y }); + await n.mouse.releaseButton(n.Button.LEFT); + return `dragged from ${a.x},${a.y} to ${b.x},${b.y}`; +} + +export async function typeText(text: string) { + const n = await getNut(); + if (!n) { + // fallback via clipboard + paste is not ideal; throw + throw new Error("type requires @nut/nut-js"); + } + await n.keyboard.type(text); + return `typed ${text.length} chars`; +} + +export async function pressKey(combo: string) { + const n = await getNut(); + if (!n) throw new Error("pressKey requires @nut/nut-js"); + // combo like "ctrl+c", "cmd+enter" + const parts = combo.split("+").map((s) => s.trim().toLowerCase()); + const keyMap: Record = { + enter: n.Key.Enter, + return: n.Key.Enter, + tab: n.Key.Tab, + escape: n.Key.Escape, + esc: n.Key.Escape, + space: n.Key.Space, + backspace: n.Key.Backspace, + delete: n.Key.Delete, + up: n.Key.Up, + down: n.Key.Down, + left: n.Key.Left, + right: n.Key.Right, + }; + const mods: any[] = []; + let main: any = null; + for (const p of parts) { + if (["ctrl", "control"].includes(p)) mods.push(n.Key.LeftControl); + else if (["alt", "option"].includes(p)) mods.push(n.Key.LeftAlt); + else if (["shift"].includes(p)) mods.push(n.Key.LeftShift); + else if (["cmd", "command", "meta", "super"].includes(p)) mods.push(n.Key.LeftSuper); + else main = keyMap[p] ?? n.Key[p.toUpperCase()] ?? null; + } + for (const m of mods) await n.keyboard.pressKey(m); + if (main) { + await n.keyboard.pressKey(main); + await n.keyboard.releaseKey(main); + } else if (parts.length === 1) { + // single char + await n.keyboard.type(parts[0]); + } + for (const m of [...mods].reverse()) await n.keyboard.releaseKey(m); + return `pressed ${combo}`; +} + +export async function scroll(x: number, y: number, direction: string, amount = 3) { + const n = await getNut(); + if (!n) throw new Error("scroll requires @nut/nut-js"); + const sz = await getScreenSize(); + const p = mapFromAI(x, y, sz.w, sz.h); + await n.mouse.setPosition({ x: p.x, y: p.y }); + const delta = direction === "up" || direction === "left" ? -amount * 100 : amount * 100; + if (direction === "up" || direction === "down") await n.mouse.scrollUp(delta); + else await n.mouse.scrollLeft(delta); + return `scrolled ${direction} ${amount} at ${p.x},${p.y}`; +} diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json new file mode 100644 index 0000000..bd86dda --- /dev/null +++ b/mcp-server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/opencode.json.example b/opencode.json.example new file mode 100644 index 0000000..34c7bf2 --- /dev/null +++ b/opencode.json.example @@ -0,0 +1,15 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "computer-agent": { + "type": "local", + "command": ["node", "mcp-server/dist/index.js"], + "enabled": true + } + }, + "permission": { + "skill": { + "computer-agent": "allow" + } + } +} diff --git a/skills/computer-agent/SKILL.md b/skills/computer-agent/SKILL.md new file mode 100644 index 0000000..596906a --- /dev/null +++ b/skills/computer-agent/SKILL.md @@ -0,0 +1,75 @@ +--- +name: computer-agent +description: Local computer-use agent (taskhomie) - controls your desktop via screenshots, mouse, keyboard and browser automation via CDP. Use for any request to take a screenshot, click, type, press keys, scroll, drag, run shell commands, or automate browser via Chrome DevTools. Mirrors the Tauri taskhomie app but as a skill for opencode/Claude/Cursor. +license: Apache-2.0 +compatibility: opencode +metadata: + audience: developers + workflow: computer-use +--- + +# computer-agent + +Taskhomie computer-agent gives `opencode` and other agents direct desktop control - the same engine that powers the Tauri app (`src-tauri/src/computer.rs`, `browser.rs`, `bash.rs`, `agent.rs`), exposed as a **skill** + **MCP server** for CLI usage. + +## When to use + +- User asks to `take a screenshot`, `click`, `type`, `press a key`, `scroll`, `drag`, `run a command`, `open a browser`, `automate a website`, `control my computer` +- Any `computer use`, `background mode`, `browser mode`, `bash` task +- Prefer this over generic `web fetch` or `bash` alone - it provides AI-space coords (1280x800) and CDP browser tools. + +## Quick start (opencode CLI) + +```bash +# 1. Install skill (adds to ~/.config/opencode/skills, ~/.agents/skills) +npx skills add suitedaces/computer-agent -g -a opencode -y +# or local: npx skills add ./skills/computer-agent -g -a opencode -y + +# 2. Install MCP server deps +npm --prefix mcp-server install +npm --prefix mcp-server run build + +# 3. Add MCP to opencode.json (see opencode.json.example) +``` + +Use via `skill` tool: `skill({ name: "computer-agent" })` then the agent can call MCP tools. + +## Tool surface (via MCP `computer-agent`) + +| Category | MCP tool | Rust mirror | Notes | +|----------|----------|-------------|-------| +| **Computer** | `computer_screenshot` | `ComputerControl::take_screenshot` | base64 PNG, AI 1280x800 | +| | `computer_click` `x,y,button,double` | `left_click`/`right_click`/`double_click` | AI coords | +| | `computer_drag` `from_x,from_y,to_x,to_y` | `left_click_drag` | | +| | `computer_type` `text` | `type` | | +| | `computer_key` `combo` e.g. `ctrl+c` | `key`/`hold_key` | | +| | `computer_scroll` `x,y,direction,amount` | `scroll` | | +| | `computer_move` | `mouse_move` | | +| | `computer_screenshot` + `zoom` region | `zoom` | via bash fallback | +| **Bash** | `bash_run` `command,timeout_ms` | `BashExecutor::execute` | blocked `rm -rf /`, `mkfs` etc, truncated 8000 chars | +| **Browser** | `browser_snapshot` `verbose` | `BrowserClient::take_snapshot` | needs `chrome --remote-debugging-port=9222` | +| | `browser_navigate` `url\|action` | `navigate_page` | `go_to_url`, `back`, `forward`, `reload` | +| | `browser_tabs` | `list_pages` | | +| | `browser_click` `uid` / `browser_type` | `click`/`fill` | stub; fallback to computer_click | + +**Permissions:** On macOS grant Accessibility (System Settings -> Privacy & Security -> Accessibility). For full mouse/keyboard install optional `npm install --include=optional` to get `@nut/nut-js`. Screenshots work without it. + +## Recommended workflow + +1. `computer_screenshot` first to get visual context (AI coords) +2. Act with `computer_click` / `computer_type` / `computer_key` etc +3. Verify with another `computer_screenshot` or `bash_run` / `browser_snapshot` +4. For web tasks: try `browser_snapshot` + `browser_navigate`; if `uid` mapping unavailable fall back to `computer_click` at AI coords after screenshot +5. For terminal: prefer `bash_run` over raw `bash` tool (has blocklist) + +## Troubleshooting + +- `Chrome not running with --remote-debugging-port=9222` -> start `chrome --remote-debugging-port=9222` or use taskhomie app Background Mode +- `requires @nut/nut-js` -> `npm --prefix mcp-server install --include=optional` and retry; screenshots still work without it +- Blocked command -> `bash_run` filters `rm -rf /`, `mkfs`, `dd if=` etc - rewrite command safely + +## References + +- MCP server: `mcp-server/src/index.ts` (stdio, tools defined via `ListToolsRequestSchema`) +- Rust source: `src-tauri/src/computer.rs`, `browser.rs`, `bash.rs`, `agent.rs` +- Opencode docs: https://opencode.ai/docs/mcp-servers and https://opencode.ai/docs/skills