源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
#!/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);
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "openwork-ui-mcp",
|
||||
"version": "0.2.0",
|
||||
"description": "MCP server for OpenWork app context and semantic UI control",
|
||||
"author": "OpenWork <support@openworklabs.com>",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"openwork-ui-mcp": "index.mjs"
|
||||
},
|
||||
"files": [
|
||||
"index.mjs"
|
||||
],
|
||||
"scripts": {
|
||||
"check": "node --check index.mjs",
|
||||
"test": "node --check index.mjs"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/different-ai/openwork.git",
|
||||
"directory": "packages/openwork-ui-mcp"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user