Files

325 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* openwork-ui-mcp
*
* MCP server that exposes OpenWork's semantic app context and UI control
* surface as an MCP resource and tools.
* Speaks MCP stdio and proxies to the OpenWork desktop bridge HTTP API.
*
* Requires OpenWork desktop running with the local UI control bridge active.
*
* Usage:
* npx openwork-ui-mcp
*
* MCP config (OpenCode / Claude Desktop / Cursor / etc.):
* {
* "mcpServers": {
* "openwork-ui": {
* "command": "npx",
* "args": ["-y", "openwork-ui-mcp"]
* }
* }
* }
*/
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { homedir, platform } from "node:os";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// ── Bridge discovery ──
const DISCOVERY_FILE = "openwork-ui-control.json";
const BRIDGE_CACHE_MS = 2_000;
const BRIDGE_TIMEOUT_MS = 5_000;
let cachedBridge = null;
let cachedBridgeAt = 0;
function userAppDataDir() {
if (platform() === "darwin") return join(homedir(), "Library", "Application Support");
if (platform() === "win32") return process.env.APPDATA || join(homedir(), "AppData", "Roaming");
return process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
}
function discoveryPaths() {
return [
process.env.OPENWORK_UI_CONTROL_DISCOVERY?.trim(),
join(userAppDataDir(), "com.differentai.openwork", DISCOVERY_FILE),
join(userAppDataDir(), "com.differentai.openwork.dev", DISCOVERY_FILE),
].filter(Boolean);
}
function clearBridgeCache() {
cachedBridge = null;
cachedBridgeAt = 0;
}
async function discoverBridge() {
if (cachedBridge && Date.now() - cachedBridgeAt < BRIDGE_CACHE_MS) return cachedBridge;
for (const candidate of discoveryPaths()) {
try {
const raw = await readFile(candidate, "utf8");
const parsed = JSON.parse(raw);
if (typeof parsed.baseUrl === "string" && typeof parsed.token === "string") {
cachedBridge = { baseUrl: parsed.baseUrl, token: parsed.token, path: candidate };
cachedBridgeAt = Date.now();
return cachedBridge;
}
} catch {
// Try next
}
}
return null;
}
async function bridgeRequest(path, options = {}) {
const bridge = await discoverBridge();
if (!bridge) {
return {
ok: false,
error: "OpenWork is not running. Launch the OpenWork desktop app first.",
hint: "The MCP server connects to a running OpenWork instance via its local bridge.",
};
}
const url = `${bridge.baseUrl}${path}`;
try {
const response = await fetch(url, {
method: options.method || "GET",
signal: AbortSignal.timeout(options.timeoutMs ?? BRIDGE_TIMEOUT_MS),
headers: {
Authorization: `Bearer ${bridge.token}`,
...(options.body ? { "Content-Type": "application/json" } : {}),
},
...(options.body ? { body: JSON.stringify(options.body) } : {}),
});
const text = await response.text();
try {
const parsed = JSON.parse(text);
if (!response.ok) clearBridgeCache();
return parsed;
} catch {
if (!response.ok) clearBridgeCache();
return { ok: false, error: text || `HTTP ${response.status}` };
}
} catch (error) {
clearBridgeCache();
return { ok: false, error: `Bridge unreachable at ${url}: ${error.message}` };
}
}
function formatArgs(action) {
const lines = [];
if (Array.isArray(action.args) && action.args.length > 0) {
lines.push(" Args:");
for (const arg of action.args) {
const required = arg.required ? "required" : "optional";
const type = arg.type || "unknown";
lines.push(` - ${arg.name} (${type}, ${required})${arg.description ? `: ${arg.description}` : ""}`);
}
} else if (action.requiresArgs) {
lines.push(" Args: required; this action has not published detailed argument metadata yet.");
}
if (action.previewArgs !== undefined) {
lines.push(` Example: ${JSON.stringify(action.previewArgs)}`);
}
return lines.join("\n");
}
function formatActionLine(action) {
const disabled = action.disabled ? " [disabled]" : "";
const busy = action.busy ? " [busy]" : "";
const args = formatArgs(action);
return `${action.id}${disabled}${busy}\n ${action.label || ""}${action.description ? ` — ${action.description}` : ""}${args ? `\n${args}` : ""}`;
}
function formatExecutionResult(actionId, result) {
const payload = result?.result ?? result;
if (payload === true || payload === undefined || payload === null) return `Executed ${actionId}.`;
if (typeof payload === "string") return payload;
if (typeof payload === "number" || typeof payload === "boolean") return `Result: ${payload}`;
if (typeof payload === "object") {
const lines = [`Executed ${actionId}.`];
for (const [key, value] of Object.entries(payload).slice(0, 12)) {
if (key === "ok" || key === "actionId") continue;
const rendered = typeof value === "object" ? JSON.stringify(value) : String(value);
lines.push(`${key}: ${rendered}`);
}
return lines.join("\n");
}
return `Executed ${actionId}.`;
}
// ── MCP Server ──
const server = new McpServer({
name: "openwork-ui",
version: "0.2.0",
});
// ── openwork://context/current ──
server.resource(
"current_openwork_context",
"openwork://context/current",
async (uri) => {
const result = await bridgeRequest("/context");
const context = result.context ?? result;
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(context, null, 2),
}],
};
},
);
// ── ui.context ──
server.tool(
"ui_context",
"Read OpenWork's semantic app context: current screen, all open conversation tabs, split-screen layout, focused pane, sidebar, side panel, settings, and currently available queries and commands. Reads app state without focusing the window.",
{},
async () => {
const result = await bridgeRequest("/context");
if (!result.ok && result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(result.context ?? result, null, 2) }] };
},
);
// ── ui.snapshot ──
server.tool(
"ui_snapshot",
"Get a snapshot of the current OpenWork UI state: active route, narration, visible actions, and status. Use this before taking action to understand what the user sees.",
{},
async () => {
const result = await bridgeRequest("/snapshot");
if (!result.ok && result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}${result.hint ? `\n${result.hint}` : ""}` }], isError: true };
}
const snapshot = result.snapshot ?? result;
const lines = [];
if (snapshot.route) lines.push(`Route: ${snapshot.route}`);
if (snapshot.status) lines.push(`Status: ${snapshot.status}`);
if (snapshot.narration) lines.push(`Narration: ${snapshot.narration}`);
if (snapshot.busyActionId) lines.push(`Busy: ${snapshot.busyActionId}`);
if (Array.isArray(snapshot.actions)) {
lines.push(`\nActions (${snapshot.actions.length}):`);
for (const action of snapshot.actions) {
const args = Array.isArray(action.args) && action.args.length ? ` [${action.args.map((a) => a.name).join(", ")}]` : "";
lines.push(` ${action.id} — ${action.label || action.description || ""}${args}`);
}
}
return { content: [{ type: "text", text: lines.join("\n") || "OpenWork is reachable, but it did not return visible UI state." }] };
}
);
// ── ui.list_actions ──
server.tool(
"ui_list_actions",
"List all UI control actions currently available in OpenWork: session navigation, composer control, transcript access, and more. Each action has an id you can pass to ui_execute_action.",
{},
async () => {
const result = await bridgeRequest("/actions");
if (!result.ok && result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
if (!Array.isArray(result.actions) || result.actions.length === 0) {
return { content: [{ type: "text", text: "No actions available. Is OpenWork on the main screen?" }] };
}
const text = result.actions.map(formatActionLine).join("\n\n");
return { content: [{ type: "text", text: `${result.actions.length} actions:\n\n${text}` }] };
}
);
// ── ui.execute_action ──
server.tool(
"ui_execute_action",
"Execute an OpenWork UI action by its id without activating the desktop window. Use ui_list_actions first to see available actions and their required arguments.",
{
actionId: z.string().describe("The action id from ui_list_actions, e.g. 'session.create_task' or 'composer.set_text'"),
args: z.record(z.unknown()).optional().describe("JSON arguments for the action, if required"),
},
async ({ actionId, args }) => {
const result = await bridgeRequest("/execute", {
method: "POST",
body: { actionId, args: args ?? {} },
});
if (!result.ok && result.error) {
return { content: [{ type: "text", text: `Error executing ${actionId}: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: formatExecutionResult(actionId, result) }] };
}
);
// ── ui.query ──
server.tool(
"ui_query",
"Run a side-effect-free OpenWork query by id. Use ui_context to inspect currently available queries. Queries never focus or navigate the app.",
{
id: z.string().describe("Query id from ui_context, such as 'session.list_sessions' or 'notifications.list'"),
args: z.record(z.unknown()).optional().describe("JSON arguments for the query, if required"),
},
async ({ id, args }) => {
const result = await bridgeRequest("/query", {
method: "POST",
body: { id, args: args ?? {} },
});
if (!result.ok && result.error) {
return { content: [{ type: "text", text: `Error querying ${id}: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
},
);
// ── ui.command ──
server.tool(
"ui_command",
"Execute a semantic OpenWork command by id while OpenWork remains in the background. Use ui_context first and pass its revision to prevent acting on stale UI state.",
{
id: z.string().describe("Command id from ui_context"),
args: z.record(z.unknown()).optional().describe("JSON arguments for the command, if required"),
expectedRevision: z.number().int().nonnegative().optional().describe("Revision returned by ui_context"),
actor: z.string().optional().describe("Optional agent or client id for serialized-command attribution"),
},
async ({ id, args, expectedRevision, actor }) => {
const result = await bridgeRequest("/command", {
method: "POST",
body: { id, args: args ?? {}, expectedRevision, actor },
});
if (!result.ok && result.error) {
return { content: [{ type: "text", text: `Error executing ${id}: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
},
);
// ── ui.status ──
server.tool(
"ui_status",
"Check if OpenWork is running and the bridge is reachable. Returns connection status and app info.",
{},
async () => {
const bridge = await discoverBridge();
if (!bridge) {
return { content: [{ type: "text", text: "OpenWork is not running.\nLaunch the OpenWork desktop app to enable UI control." }], isError: true };
}
try {
const response = await fetch(`${bridge.baseUrl}/health`, { signal: AbortSignal.timeout(3000) });
const data = await response.json();
return { content: [{ type: "text", text: `Connected to ${data.app || "OpenWork"}\nBridge: ${bridge.baseUrl}\nVersion: ${data.version ?? "?"}` }] };
} catch (error) {
clearBridgeCache();
return { content: [{ type: "text", text: `Bridge file found but not reachable: ${error.message}\nOpenWork may have quit. Relaunch it.` }], isError: true };
}
}
);
// ── Start ──
const transport = new StdioServerTransport();
await server.connect(transport);