From 69fbf4185fb6caa77a8ec9c71361bda16a31c795 Mon Sep 17 00:00:00 2001 From: Harshith Reddy Peta <157769359+PetaHarshith@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:33:19 -0500 Subject: [PATCH 1/4] feat(server): publish workflows as MCP tools Adds a server-side primitive for exposing OpenCode skills as MCP-callable tools via per-workflow URLs and tokens. - Storage: PublishedWorkflowsService persists workflows under audit-dir/published-workflows.json with SHA256 token hashes. - Transport: stateless JSON-RPC 2.0 over HTTP at POST /published/:token/mcp with initialize, tools/list, tools/call. - Bridge: synchronous OpenCode invocation via POST /session and POST /session/:id/message, surfacing text parts of the assistant response to the MCP caller (60s default timeout, configurable via OPENWORK_PUBLISHED_WORKFLOW_TIMEOUT_MS). - Admin routes: GET/POST/DELETE /workspace/:id/published-workflows scoped to the host token; create/revoke recorded in the workspace audit log as published-workflow.create/.revoke. - Tests: unit coverage for storage and an in-process e2e harness driving the MCP transport against a fake OpenCode at /session and /session/:id/message. - PRD and a real-OpenCode curl transcript are included as evidence under apps/app/pr/. UI is intentionally deferred to a follow-up commit. --- apps/app/pr/published-workflows-mcp.curl.txt | 134 +++++++++ apps/app/pr/published-workflows-mcp.md | 158 +++++++++++ apps/server/src/published-mcp.e2e.test.ts | 280 +++++++++++++++++++ apps/server/src/published-mcp.ts | 157 +++++++++++ apps/server/src/published-workflows.test.ts | 128 +++++++++ apps/server/src/published-workflows.ts | 183 ++++++++++++ apps/server/src/server.ts | 246 ++++++++++++++-- 7 files changed, 1259 insertions(+), 27 deletions(-) create mode 100644 apps/app/pr/published-workflows-mcp.curl.txt create mode 100644 apps/app/pr/published-workflows-mcp.md create mode 100644 apps/server/src/published-mcp.e2e.test.ts create mode 100644 apps/server/src/published-mcp.ts create mode 100644 apps/server/src/published-workflows.test.ts create mode 100644 apps/server/src/published-workflows.ts diff --git a/apps/app/pr/published-workflows-mcp.curl.txt b/apps/app/pr/published-workflows-mcp.curl.txt new file mode 100644 index 000000000..0ad2b913b --- /dev/null +++ b/apps/app/pr/published-workflows-mcp.curl.txt @@ -0,0 +1,134 @@ +# Real-OpenCode curl verification — published-workflows MCP +# +# Captured against a live OpenWork server with managed OpenCode +# (OPENWORK_MANAGE_OPENCODE=1) bound to a workspace at +# ~/openwork-mcp-test containing a single skill: echo. +# +# Server: bun apps/server/src/cli.ts --host 127.0.0.1 --port 4747 +# --token $CLIENT_TOKEN --host-token $HOST_TOKEN +# --workspace ~/openwork-mcp-test --verbose +# +# Outcome: A (success). tools/call returned the agent's text reply +# with no isError, confirming the bridge runs through real OpenCode +# /session and /session/:id/message. + +$ export HOST_TOKEN=owt_test_host +$ export CLIENT_TOKEN=owt_test_client +$ BASE=http://127.0.0.1:4747 + +# --- Step 3: list workspaces, capture id --- +$ curl -s "$BASE/workspaces" -H "Authorization: Bearer $CLIENT_TOKEN" | jq +{ + "items": [ + { + "id": "ws_30eed34021d9", + "name": "openwork-mcp-test", + "path": "/Users/harshithpeta/openwork-mcp-test", + "preset": "starter", + "workspaceType": "local", + "baseUrl": "http://127.0.0.1:56999", + "directory": "/Users/harshithpeta/openwork-mcp-test", + "opencode": { + "baseUrl": "http://127.0.0.1:56999", + "directory": "/Users/harshithpeta/openwork-mcp-test", + "username": "", + "password": "" + } + } + ], + "activeId": "ws_30eed34021d9" +} +$ WS_ID=ws_30eed34021d9 + +# --- Step 4: confirm skill name --- +$ curl -s "$BASE/workspace/$WS_ID/skills" -H "Authorization: Bearer $CLIENT_TOKEN" \ + | jq '.items[].name' +"echo" + +# --- Step 5: publish the skill --- +$ curl -s -X POST "$BASE/workspace/$WS_ID/published-workflows" \ + -H "x-openwork-host-token: $HOST_TOKEN" \ + -H "content-type: application/json" \ + -d '{ + "skillName": "echo", + "description": "Echo the input back verbatim", + "inputSchema": { + "type": "object", + "properties": { "input": { "type": "string", "description": "Text to echo" } }, + "required": ["input"] + } + }' | jq +{ + "id": "c3b84969-a9d1-45c8-ad4f-ae6b33a502a9", + "workspaceId": "ws_30eed34021d9", + "skillName": "echo", + "toolName": "echo", + "description": "Echo the input back verbatim", + "createdAt": 1777580681405, + "inputSchema": { + "type": "object", + "properties": { "input": { "type": "string", "description": "Text to echo" } }, + "required": ["input"] + }, + "token": "pwt_233a57851f024a09a86cadaff77ae2bf536e1a50b937446b9a43c8ff50c184b0" +} +$ TOKEN=pwt_233a57851f024a09a86cadaff77ae2bf536e1a50b937446b9a43c8ff50c184b0 + +# --- Step 7: initialize --- +$ curl -s -X POST "$BASE/published/$TOKEN/mcp" \ + -H "content-type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | jq +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { "tools": {} }, + "serverInfo": { + "name": "openwork-published-workflow", + "version": "0.1.0" + } + } +} + +# --- Step 8: tools/list --- +$ curl -s -X POST "$BASE/published/$TOKEN/mcp" \ + -H "content-type: application/json" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | jq +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "tools": [ + { + "name": "echo", + "description": "Echo the input back verbatim", + "inputSchema": { + "type": "object", + "properties": { "input": { "type": "string", "description": "Text to echo" } }, + "required": ["input"] + } + } + ] + } +} + +# --- Step 9: tools/call (real bridge through OpenCode) --- +$ curl -s -X POST "$BASE/published/$TOKEN/mcp" \ + -H "content-type: application/json" \ + -d '{ + "jsonrpc":"2.0","id":3,"method":"tools/call", + "params":{"name":"echo","arguments":{"input":"hello from MCP"}} + }' | jq +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "content": [ + { + "type": "text", + "text": "```json\n{\n \"input\": \"hello from MCP\"\n}\n```" + } + ] + } +} diff --git a/apps/app/pr/published-workflows-mcp.md b/apps/app/pr/published-workflows-mcp.md new file mode 100644 index 000000000..1cde5f110 --- /dev/null +++ b/apps/app/pr/published-workflows-mcp.md @@ -0,0 +1,158 @@ +# Publish OpenWork workflows as MCP-callable tools + +Closes: N/A — Path B submission / exploratory product PR. + +## Why + +OpenWork already runs OpenCode agents against a workspace, with skills, +plugins, and MCPs wired in. What it cannot do today is invert that +relationship: there is no way for an _external_ MCP client (Claude +Desktop, Cursor, the OpenCode CLI on a teammate's machine, an automation +in n8n) to call back into a workspace and trigger one of those agents. + +`ARCHITECTURE.md` (line 152) literally describes MCP as the right primitive +for "authenticated third-party flows… when 'auth + capability surface' is +the product boundary," and the same doc lists "frictionless publishing +without signup" as a Skill-Registry roadmap goal. This PR delivers the +narrowest useful version of both. + +The product framing is **publishing a workflow**, not "publishing a skill": + +- Skills are markdown context that guides an agent _inside_ a session. +- A workflow is the executable thing: a session running a specific agent, + primed with a specific skill, against a specific workspace. + +So the user-visible verb is _Publish workflow_. The artifact is a tool an +external MCP client can list and call. The transport is a single URL with +a token in the path; the same token IS the authorization. Stateless POST, +single HTTP turn, real result back. + +## Storage + +Same on-disk pattern as `TokenService` and the env file from +`environment-variables.md`. JSON file, sha256-hashed tokens, never store +the plaintext. + +| OS | Path | +| --- | --- | +| Linux / macOS | `~/.config/openwork/published-workflows.json` | +| Windows | `%APPDATA%\openwork\published-workflows.json` | + +Override via `OPENWORK_PUBLISHED_WORKFLOWS_STORE` (mirrors +`OPENWORK_TOKEN_STORE`). File shape: + +```json +{ + "schemaVersion": 1, + "updatedAt": 1714000000000, + "workflows": [ + { + "id": "uuid", + "tokenHash": "sha256-hex", + "workspaceId": "ws_a", + "skillName": "summarize", + "toolName": "summarize", + "description": "Summarize input text", + "agent": "build", + "inputSchema": { "type": "object", "properties": { "input": { "type": "string" } } }, + "createdAt": 1714000000000 + } + ] +} +``` + +`PublishedWorkflowsService` (apps/server/src/published-workflows.ts) +exposes `list / get / create / revoke / findByToken`. The plaintext token +is returned **only** from `create()`; from then on the only way to use it +is via the MCP transport route, which hashes the inbound URL token and +matches by hash. + +## Server + +Three host-token admin routes, scoped per workspace: + +- `GET /workspace/:id/published-workflows` → `{ items: [...] }` +- `POST /workspace/:id/published-workflows` → `{ id, token, ... }` (201). + Body: `{ skillName, description, toolName?, agent?, inputSchema?, label? }`. +- `DELETE /workspace/:id/published-workflows/:workflowId` → `{ ok: true }`. + +Each create/revoke is mirrored into the workspace audit log. + +## MCP transport + +One public route, registered for `POST/GET/DELETE`: + +``` +/published/:token/mcp +``` + +Auth is the URL-embedded token. Unknown token → JSON-RPC `-32001` +(HTTP 401). Workspace removed from config after publish → JSON-RPC +`-32002` (HTTP 410). Anything else falls through to the JSON-RPC +dispatcher in `apps/server/src/published-mcp.ts`, which implements the +minimal MCP Streamable-HTTP subset: + +- `initialize` → returns `protocolVersion: "2024-11-05"`, `serverInfo`, + `capabilities.tools`. +- `notifications/initialized`, `notifications/cancelled` → 202. +- `ping` → `{}`. +- `tools/list` → returns the single tool descriptor for this token. +- `tools/call` → executes the workflow synchronously and returns + `{ content: [{ type: "text", text }] }`. + +GET (SSE upgrade) and DELETE (session terminate) return `405` and `204` +respectively — the transport is stateless and does not need streams. + +We deliberately do not pull in `@modelcontextprotocol/sdk` or `@hono/mcp`. +`apps/server` is a hand-written Bun fetch router; adding the SDK would +mean either bridging Hono into that router or rewriting it. The handler +is ~150 lines and covers everything Claude Desktop / Cursor / Codex +actually call against a tool server. + +## Synchronous bridge + +`tools/call` runs `executePublishedWorkflow`: + +1. `POST /session` against the workspace's OpenCode → new session id. +2. Build a prompt: ``Run the `` skill with the following input: ```json …`````. +3. `POST /session/:id/prompt` (the **synchronous** OpenCode endpoint — + the SDK's `client.session.prompt`, not `prompt_async`) with optional + `agent` from the publication record. +4. `Promise.race` against a 60s timer (`OPENWORK_PUBLISHED_WORKFLOW_TIMEOUT_MS` + override) — a stuck agent cannot pin the worker. +5. Pull text parts out of the assistant response and return them. + +Errors inside `execute` are caught by the JSON-RPC dispatcher and +returned as `{ isError: true, content: [{ type: "text", text: }] }` +so the calling LLM gets a useful tool error instead of an HTTP 500. + +## UI (next half) + +Skills view at `apps/app/src/react-app/domains/settings/pages/skills-view.tsx` +gains a **Publish** action per skill plus a "Published workflows" panel +listing active publications with copy-URL and revoke. i18n keys land +under `settings.publishedWorkflows.*` mirroring the env-vars precedent. +Implementation lives in the follow-up commit so this PR is reviewable in +two passes. + +## Tests + +| Layer | File | What | +| --- | --- | --- | +| Server unit | `apps/server/src/published-workflows.test.ts` | 7 tests — empty start, create returns issued token + hides hash, list filters by workspace, findByToken hashes input, revoke invalidates, persistence across instances | +| Server HTTP e2e | `apps/server/src/published-mcp.e2e.test.ts` | 9 tests — admin create + list, missing skillName 400, unknown token 401, MCP `initialize` / `tools/list` / `tools/call` / unknown-tool `-32602`, DELETE 204, revoke breaks the token. Uses an in-process fake OpenCode `Bun.serve` | + +## Verification + +``` +pnpm --filter openwork-server typecheck # clean +bun test # 132 pass, 0 fail +``` + +## Non-goals (follow-ups) + +- Multi-tool publications (one workflow = one tool today). +- OAuth / DCR — bearer-style URL token is enough for MVP. +- Streaming partial results back over SSE — sync `/prompt` is the MVP. +- Public marketplace surface — out of scope; this is private per token. +- Per-call billing / metering — Den-team territory. diff --git a/apps/server/src/published-mcp.e2e.test.ts b/apps/server/src/published-mcp.e2e.test.ts new file mode 100644 index 000000000..170429e81 --- /dev/null +++ b/apps/server/src/published-mcp.e2e.test.ts @@ -0,0 +1,280 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { startServer } from "./server.js"; +import type { ServerConfig, WorkspaceInfo } from "./types.js"; + +type Served = { port: number; stop: (closeActiveConnections?: boolean) => void | Promise }; + +const HOST_TOKEN = "owt_published_host_token"; +const stops: Array<() => void | Promise> = []; +const dirs: string[] = []; +const fakeOpencodeStops: Array<() => void> = []; +const priorPublishedStore = process.env.OPENWORK_PUBLISHED_WORKFLOWS_STORE; +const priorTokenStore = process.env.OPENWORK_TOKEN_STORE; + +type FakeOpencode = { + url: string; + promptCalls: Array<{ sessionId: string; body: unknown }>; + stop: () => void; +}; + +function startFakeOpencode(): FakeOpencode { + const promptCalls: FakeOpencode["promptCalls"] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: async (request) => { + const url = new URL(request.url); + if (request.method === "POST" && url.pathname === "/session") { + return Response.json({ id: "sess_test_1", title: "MCP test" }); + } + // Real OpenCode exposes the synchronous prompt at `/session/:id/message` + // (POST). Match that path so the e2e covers the same wire shape we ship. + const promptMatch = url.pathname.match(/^\/session\/([^/]+)\/message$/); + if (request.method === "POST" && promptMatch) { + const body = await request.json().catch(() => null); + promptCalls.push({ sessionId: promptMatch[1], body }); + return Response.json({ + info: { id: "msg_test_1", role: "assistant" }, + parts: [{ type: "text", text: "Echo: ok" }], + }); + } + return new Response("not found", { status: 404 }); + }, + }); + const stop = () => (server as unknown as { stop: (force?: boolean) => void }).stop(true); + fakeOpencodeStops.push(stop); + return { url: `http://127.0.0.1:${(server as unknown as { port: number }).port}`, promptCalls, stop }; +} + +function baseConfig(workspace: WorkspaceInfo): ServerConfig { + return { + host: "127.0.0.1", + port: 0, + token: "owt_published_client_token", + hostToken: HOST_TOKEN, + approval: { mode: "auto", timeoutMs: 1000 }, + corsOrigins: ["*"], + workspaces: [workspace], + authorizedRoots: [workspace.path], + readOnly: false, + startedAt: Date.now(), + tokenSource: "cli", + hostTokenSource: "cli", + logFormat: "pretty", + logRequests: false, + } as ServerConfig; +} + +function bootWith(workspace: WorkspaceInfo) { + const server = startServer(baseConfig(workspace)) as Served; + stops.push(() => server.stop(true)); + return { server, base: `http://127.0.0.1:${server.port}` }; +} + +function hostAuth() { + return { "x-openwork-host-token": HOST_TOKEN, "content-type": "application/json" }; +} + +beforeEach(() => { + const dir = mkdtempSync(join(tmpdir(), "openwork-published-mcp-")); + dirs.push(dir); + process.env.OPENWORK_PUBLISHED_WORKFLOWS_STORE = join(dir, "published.json"); + process.env.OPENWORK_TOKEN_STORE = join(dir, "tokens.json"); +}); + +afterEach(async () => { + while (stops.length) await stops.pop()?.(); + while (fakeOpencodeStops.length) fakeOpencodeStops.pop()?.(); + while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true }); + if (priorPublishedStore === undefined) delete process.env.OPENWORK_PUBLISHED_WORKFLOWS_STORE; + else process.env.OPENWORK_PUBLISHED_WORKFLOWS_STORE = priorPublishedStore; + if (priorTokenStore === undefined) delete process.env.OPENWORK_TOKEN_STORE; + else process.env.OPENWORK_TOKEN_STORE = priorTokenStore; +}); + +function makeWorkspace(opencodeUrl: string): WorkspaceInfo { + const dir = mkdtempSync(join(tmpdir(), "openwork-published-ws-")); + dirs.push(dir); + return { + id: "ws_test", + name: "test", + path: dir, + preset: "default", + workspaceType: "local", + baseUrl: opencodeUrl, + directory: dir, + } as WorkspaceInfo; +} + +describe("published workflows + MCP", () => { + test("admin create issues a token and lists the workflow", async () => { + const fake = startFakeOpencode(); + const ws = makeWorkspace(fake.url); + const { base } = bootWith(ws); + + const issued = await fetch(`${base}/workspace/${ws.id}/published-workflows`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify({ skillName: "summarize", description: "Summarize input" }), + }); + expect(issued.status).toBe(201); + const body = (await issued.json()) as { id: string; token: string; toolName: string }; + expect(body.token).toMatch(/^pwt_/); + expect(body.toolName).toBe("summarize"); + + const listed = await fetch(`${base}/workspace/${ws.id}/published-workflows`, { headers: hostAuth() }); + const list = (await listed.json()) as { items: Array<{ id: string }> }; + expect(list.items.length).toBe(1); + expect(list.items[0].id).toBe(body.id); + }); + + test("admin create rejects missing skillName", async () => { + const fake = startFakeOpencode(); + const ws = makeWorkspace(fake.url); + const { base } = bootWith(ws); + const res = await fetch(`${base}/workspace/${ws.id}/published-workflows`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify({ description: "no skill" }), + }); + expect(res.status).toBe(400); + }); + + test("MCP transport rejects unknown tokens", async () => { + const fake = startFakeOpencode(); + const ws = makeWorkspace(fake.url); + const { base } = bootWith(ws); + const res = await fetch(`${base}/published/not-a-real-token/mcp`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + }); + expect(res.status).toBe(401); + }); + + async function publish(base: string, ws: WorkspaceInfo) { + const res = await fetch(`${base}/workspace/${ws.id}/published-workflows`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify({ skillName: "summarize", description: "Summarize input text" }), + }); + const body = (await res.json()) as { token: string; id: string; toolName: string }; + return body; + } + + test("MCP initialize returns protocol version + serverInfo", async () => { + const fake = startFakeOpencode(); + const ws = makeWorkspace(fake.url); + const { base } = bootWith(ws); + const issued = await publish(base, ws); + + const res = await fetch(`${base}/published/${issued.token}/mcp`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { result?: { protocolVersion: string; capabilities: unknown; serverInfo: { name: string } } }; + expect(body.result?.protocolVersion).toBe("2024-11-05"); + expect(body.result?.serverInfo.name).toBe("openwork-published-workflow"); + }); + + test("MCP tools/list exposes the published tool", async () => { + const fake = startFakeOpencode(); + const ws = makeWorkspace(fake.url); + const { base } = bootWith(ws); + const issued = await publish(base, ws); + + const res = await fetch(`${base}/published/${issued.token}/mcp`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }), + }); + const body = (await res.json()) as { result: { tools: Array<{ name: string; description: string }> } }; + expect(body.result.tools.length).toBe(1); + expect(body.result.tools[0].name).toBe("summarize"); + expect(body.result.tools[0].description).toBe("Summarize input text"); + }); + + test("MCP tools/call invokes OpenCode and returns the assistant text", async () => { + const fake = startFakeOpencode(); + const ws = makeWorkspace(fake.url); + const { base } = bootWith(ws); + const issued = await publish(base, ws); + + const res = await fetch(`${base}/published/${issued.token}/mcp`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "summarize", arguments: { input: "hello world" } }, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { content: Array<{ type: string; text: string }>; isError?: boolean }; + }; + expect(body.result.isError).toBeUndefined(); + expect(body.result.content[0].type).toBe("text"); + expect(body.result.content[0].text).toBe("Echo: ok"); + + expect(fake.promptCalls.length).toBe(1); + expect(fake.promptCalls[0].sessionId).toBe("sess_test_1"); + }); + + test("MCP tools/call rejects unknown tool name with INVALID_PARAMS", async () => { + const fake = startFakeOpencode(); + const ws = makeWorkspace(fake.url); + const { base } = bootWith(ws); + const issued = await publish(base, ws); + + const res = await fetch(`${base}/published/${issued.token}/mcp`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { name: "ghost", arguments: {} }, + }), + }); + const body = (await res.json()) as { error?: { code: number; message: string } }; + expect(body.error?.code).toBe(-32602); + }); + + test("MCP DELETE returns 204 (stateless terminate)", async () => { + const fake = startFakeOpencode(); + const ws = makeWorkspace(fake.url); + const { base } = bootWith(ws); + const issued = await publish(base, ws); + + const res = await fetch(`${base}/published/${issued.token}/mcp`, { method: "DELETE" }); + expect(res.status).toBe(204); + }); + + test("revoke breaks the MCP token", async () => { + const fake = startFakeOpencode(); + const ws = makeWorkspace(fake.url); + const { base } = bootWith(ws); + const issued = await publish(base, ws); + + const del = await fetch(`${base}/workspace/${ws.id}/published-workflows/${issued.id}`, { + method: "DELETE", + headers: hostAuth(), + }); + expect(del.status).toBe(200); + + const after = await fetch(`${base}/published/${issued.token}/mcp`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize" }), + }); + expect(after.status).toBe(401); + }); +}); diff --git a/apps/server/src/published-mcp.ts b/apps/server/src/published-mcp.ts new file mode 100644 index 000000000..1e6d0daad --- /dev/null +++ b/apps/server/src/published-mcp.ts @@ -0,0 +1,157 @@ +// Minimal MCP Streamable HTTP transport for the published-workflows feature. +// Implements only the JSON-RPC subset needed for stateless tool servers: +// initialize, notifications/initialized, tools/list, tools/call. +// +// Spec reference: https://modelcontextprotocol.io/specification (2024-11-05) +// We intentionally do NOT support SSE upgrades or session IDs; each POST is +// stateless and returns a JSON-RPC response in a single HTTP turn. + +const PROTOCOL_VERSION = "2024-11-05"; +const SERVER_INFO = { name: "openwork-published-workflow", version: "0.1.0" } as const; + +export type McpToolInputSchema = { + type: "object"; + properties?: Record; + required?: string[]; +}; + +export type McpToolDescriptor = { + name: string; + description: string; + inputSchema: McpToolInputSchema; +}; + +export type McpExecuteResult = { text: string; isError?: boolean }; + +export type McpExecuteFn = (args: Record) => Promise; + +type JsonRpcId = string | number | null; + +type JsonRpcRequest = { + jsonrpc: "2.0"; + id?: JsonRpcId; + method: string; + params?: Record; +}; + +type JsonRpcSuccess = { jsonrpc: "2.0"; id: JsonRpcId; result: unknown }; +type JsonRpcError = { + jsonrpc: "2.0"; + id: JsonRpcId; + error: { code: number; message: string; data?: unknown }; +}; + +const PARSE_ERROR = -32700; +const INVALID_REQUEST = -32600; +const METHOD_NOT_FOUND = -32601; +const INVALID_PARAMS = -32602; +const INTERNAL_ERROR = -32603; + +function jsonRpcResponse(body: JsonRpcSuccess | JsonRpcError, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function success(id: JsonRpcId, result: unknown): Response { + return jsonRpcResponse({ jsonrpc: "2.0", id, result }); +} + +function error(id: JsonRpcId, code: number, message: string, data?: unknown): Response { + return jsonRpcResponse({ jsonrpc: "2.0", id, error: { code, message, ...(data === undefined ? {} : { data }) } }); +} + +function isJsonRpcRequest(value: unknown): value is JsonRpcRequest { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return v.jsonrpc === "2.0" && typeof v.method === "string"; +} + +export async function handleMcpRequest(input: { + request: Request; + tool: McpToolDescriptor; + execute: McpExecuteFn; +}): Promise { + const { request, tool, execute } = input; + + if (request.method === "GET" || request.method === "HEAD") { + // SSE transport not supported in this minimal handler. + return new Response("Method Not Allowed", { status: 405 }); + } + if (request.method === "DELETE") { + // Stateless server: nothing to terminate. + return new Response(null, { status: 204 }); + } + if (request.method !== "POST") { + return new Response("Method Not Allowed", { status: 405 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return error(null, PARSE_ERROR, "Parse error"); + } + if (!isJsonRpcRequest(body)) { + return error(null, INVALID_REQUEST, "Invalid Request"); + } + + const id: JsonRpcId = body.id ?? null; + const isNotification = body.id === undefined; + + switch (body.method) { + case "initialize": { + return success(id, { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: SERVER_INFO, + }); + } + case "notifications/initialized": + case "notifications/cancelled": { + return new Response(null, { status: 202 }); + } + case "ping": { + return success(id, {}); + } + case "tools/list": { + return success(id, { tools: [tool] }); + } + case "tools/call": { + const params = (body.params ?? {}) as { name?: unknown; arguments?: unknown }; + if (typeof params.name !== "string" || params.name !== tool.name) { + return error(id, INVALID_PARAMS, `Unknown tool: ${String(params.name ?? "")}`); + } + const args = + params.arguments && typeof params.arguments === "object" && !Array.isArray(params.arguments) + ? (params.arguments as Record) + : {}; + try { + const result = await execute(args); + return success(id, { + content: [{ type: "text", text: result.text }], + ...(result.isError ? { isError: true } : {}), + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Tool execution failed"; + return success(id, { + content: [{ type: "text", text: message }], + isError: true, + }); + } + } + default: { + if (isNotification) return new Response(null, { status: 202 }); + return error(id, METHOD_NOT_FOUND, `Method not found: ${body.method}`); + } + } +} + +export const McpErrorCodes = { + PARSE_ERROR, + INVALID_REQUEST, + METHOD_NOT_FOUND, + INVALID_PARAMS, + INTERNAL_ERROR, +} as const; diff --git a/apps/server/src/published-workflows.test.ts b/apps/server/src/published-workflows.test.ts new file mode 100644 index 000000000..efae61d94 --- /dev/null +++ b/apps/server/src/published-workflows.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { PublishedWorkflowsService } from "./published-workflows.js"; +import type { ServerConfig } from "./types.js"; + +function createTestConfig(): ServerConfig { + return { + host: "127.0.0.1", + port: 0, + token: "test-client-token", + hostToken: "test-host-token", + approval: { mode: "auto", timeoutMs: 1000 }, + corsOrigins: ["*"], + workspaces: [], + authorizedRoots: [], + readOnly: false, + startedAt: Date.now(), + tokenSource: "generated", + hostTokenSource: "generated", + logFormat: "pretty", + logRequests: false, + } as ServerConfig; +} + +const dirs: string[] = []; +const priorStore = process.env.OPENWORK_PUBLISHED_WORKFLOWS_STORE; + +beforeEach(() => { + const dir = mkdtempSync(join(tmpdir(), "openwork-published-workflows-")); + dirs.push(dir); + process.env.OPENWORK_PUBLISHED_WORKFLOWS_STORE = join(dir, "published-workflows.json"); +}); + +afterEach(() => { + while (dirs.length) { + rmSync(dirs.pop()!, { recursive: true, force: true }); + } + if (priorStore === undefined) { + delete process.env.OPENWORK_PUBLISHED_WORKFLOWS_STORE; + } else { + process.env.OPENWORK_PUBLISHED_WORKFLOWS_STORE = priorStore; + } +}); + +describe("PublishedWorkflowsService", () => { + test("starts empty", async () => { + const service = new PublishedWorkflowsService(createTestConfig()); + expect(await service.list()).toEqual([]); + }); + + test("create returns issued token + public record", async () => { + const service = new PublishedWorkflowsService(createTestConfig()); + const issued = await service.create({ + workspaceId: "ws_a", + skillName: "summarize", + toolName: "summarize", + description: "Summarize the input text", + }); + + expect(issued.id).toBeTruthy(); + expect(issued.token).toMatch(/^pwt_/); + expect(issued.workspaceId).toBe("ws_a"); + expect((issued as Record).tokenHash).toBeUndefined(); + }); + + test("list filters by workspace and hides hash", async () => { + const service = new PublishedWorkflowsService(createTestConfig()); + await service.create({ workspaceId: "ws_a", skillName: "a", toolName: "a", description: "x" }); + await service.create({ workspaceId: "ws_b", skillName: "b", toolName: "b", description: "y" }); + + const onlyA = await service.list("ws_a"); + expect(onlyA.length).toBe(1); + expect(onlyA[0].workspaceId).toBe("ws_a"); + expect((onlyA[0] as Record).tokenHash).toBeUndefined(); + + const all = await service.list(); + expect(all.length).toBe(2); + }); + + test("findByToken resolves the issued token, not arbitrary strings", async () => { + const service = new PublishedWorkflowsService(createTestConfig()); + const issued = await service.create({ + workspaceId: "ws_a", + skillName: "summarize", + toolName: "summarize", + description: "Summarize", + }); + const found = await service.findByToken(issued.token); + expect(found?.id).toBe(issued.id); + expect(await service.findByToken("not-a-real-token")).toBeNull(); + }); + + test("revoke removes the workflow and invalidates the token", async () => { + const service = new PublishedWorkflowsService(createTestConfig()); + const issued = await service.create({ + workspaceId: "ws_a", + skillName: "summarize", + toolName: "summarize", + description: "Summarize", + }); + expect(await service.revoke(issued.id)).toBe(true); + expect(await service.findByToken(issued.token)).toBeNull(); + expect(await service.list()).toEqual([]); + }); + + test("revoke returns false for unknown id", async () => { + const service = new PublishedWorkflowsService(createTestConfig()); + expect(await service.revoke("ghost-id")).toBe(false); + }); + + test("persists across instances", async () => { + const a = new PublishedWorkflowsService(createTestConfig()); + const issued = await a.create({ + workspaceId: "ws_a", + skillName: "summarize", + toolName: "summarize", + description: "Summarize", + }); + + const b = new PublishedWorkflowsService(createTestConfig()); + const found = await b.findByToken(issued.token); + expect(found?.id).toBe(issued.id); + expect(found?.skillName).toBe("summarize"); + }); +}); diff --git a/apps/server/src/published-workflows.ts b/apps/server/src/published-workflows.ts new file mode 100644 index 000000000..0c8a3eed0 --- /dev/null +++ b/apps/server/src/published-workflows.ts @@ -0,0 +1,183 @@ +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { readFile, writeFile } from "node:fs/promises"; + +import type { ServerConfig } from "./types.js"; +import { ensureDir, exists, hashToken, shortId } from "./utils.js"; + +export type PublishedWorkflowInputSchema = { + type: "object"; + properties?: Record; + required?: string[]; +}; + +export type PublishedWorkflowRecord = { + id: string; + tokenHash: string; + workspaceId: string; + skillName: string; + toolName: string; + description: string; + agent?: string; + inputSchema?: PublishedWorkflowInputSchema; + label?: string; + createdAt: number; +}; + +export type PublishedWorkflowPublic = Omit; + +type StoreFile = { + schemaVersion: number; + updatedAt: number; + workflows: PublishedWorkflowRecord[]; +}; + +function resolveStorePath(config: ServerConfig): string { + const override = (process.env.OPENWORK_PUBLISHED_WORKFLOWS_STORE ?? "").trim(); + if (override) return resolve(override); + + const configPath = config.configPath?.trim(); + const configDir = configPath ? dirname(configPath) : join(homedir(), ".config", "openwork"); + return join(configDir, "published-workflows.json"); +} + +function isInputSchema(value: unknown): value is PublishedWorkflowInputSchema { + if (!value || typeof value !== "object") return false; + const candidate = value as { type?: unknown }; + return candidate.type === "object"; +} + +async function readStore(path: string): Promise { + if (!(await exists(path))) { + return { schemaVersion: 1, updatedAt: Date.now(), workflows: [] }; + } + try { + const raw = await readFile(path, "utf8"); + const parsed = JSON.parse(raw) as Partial; + const workflows = Array.isArray(parsed.workflows) + ? parsed.workflows + .map((entry) => { + const record = entry as Partial; + const id = typeof record.id === "string" ? record.id : ""; + const tokenHash = typeof record.tokenHash === "string" ? record.tokenHash : ""; + const workspaceId = typeof record.workspaceId === "string" ? record.workspaceId : ""; + const skillName = typeof record.skillName === "string" ? record.skillName : ""; + const toolName = typeof record.toolName === "string" ? record.toolName : skillName; + const description = typeof record.description === "string" ? record.description : ""; + const createdAt = typeof record.createdAt === "number" ? record.createdAt : Date.now(); + if (!id || !tokenHash || !workspaceId || !skillName || !toolName) return null; + const out: PublishedWorkflowRecord = { + id, tokenHash, workspaceId, skillName, toolName, description, createdAt, + ...(typeof record.agent === "string" && record.agent ? { agent: record.agent } : {}), + ...(typeof record.label === "string" && record.label ? { label: record.label } : {}), + ...(isInputSchema(record.inputSchema) ? { inputSchema: record.inputSchema } : {}), + }; + return out; + }) + .filter((entry): entry is PublishedWorkflowRecord => Boolean(entry)) + : []; + return { + schemaVersion: typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 1, + updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(), + workflows, + }; + } catch { + return { schemaVersion: 1, updatedAt: Date.now(), workflows: [] }; + } +} + +async function writeStore(path: string, workflows: PublishedWorkflowRecord[]): Promise { + await ensureDir(dirname(path)); + const payload: StoreFile = { schemaVersion: 1, updatedAt: Date.now(), workflows }; + await writeFile(path, JSON.stringify(payload, null, 2) + "\n", "utf8"); +} + +function publicView(record: PublishedWorkflowRecord): PublishedWorkflowPublic { + const { tokenHash: _hash, ...rest } = record; + return rest; +} + +export type CreatePublishedWorkflowInput = { + workspaceId: string; + skillName: string; + toolName: string; + description: string; + agent?: string; + inputSchema?: PublishedWorkflowInputSchema; + label?: string; +}; + +export type CreatedPublishedWorkflow = PublishedWorkflowPublic & { token: string }; + +export class PublishedWorkflowsService { + private path: string; + private loaded = false; + private workflows: PublishedWorkflowRecord[] = []; + private byHash = new Map(); + + constructor(config: ServerConfig) { + this.path = resolveStorePath(config); + } + + private async ensureLoaded(): Promise { + if (this.loaded) return; + const store = await readStore(this.path); + this.workflows = store.workflows; + this.byHash = new Map(store.workflows.map((entry) => [entry.tokenHash, entry])); + this.loaded = true; + } + + async list(workspaceId?: string): Promise { + await this.ensureLoaded(); + const filtered = workspaceId + ? this.workflows.filter((entry) => entry.workspaceId === workspaceId) + : this.workflows; + return filtered.map(publicView); + } + + async get(id: string): Promise { + await this.ensureLoaded(); + const found = this.workflows.find((entry) => entry.id === id); + return found ? publicView(found) : null; + } + + async create(input: CreatePublishedWorkflowInput): Promise { + await this.ensureLoaded(); + const id = shortId(); + const token = `pwt_${shortId().replace(/-/g, "")}${shortId().replace(/-/g, "")}`; + const createdAt = Date.now(); + const record: PublishedWorkflowRecord = { + id, + tokenHash: hashToken(token), + workspaceId: input.workspaceId, + skillName: input.skillName, + toolName: input.toolName, + description: input.description, + createdAt, + ...(input.agent ? { agent: input.agent } : {}), + ...(input.inputSchema ? { inputSchema: input.inputSchema } : {}), + ...(input.label ? { label: input.label } : {}), + }; + this.workflows = [record, ...this.workflows]; + this.byHash.set(record.tokenHash, record); + await writeStore(this.path, this.workflows); + return { ...publicView(record), token }; + } + + async revoke(id: string): Promise { + await this.ensureLoaded(); + const index = this.workflows.findIndex((entry) => entry.id === id); + if (index === -1) return false; + const [removed] = this.workflows.splice(index, 1); + if (removed) this.byHash.delete(removed.tokenHash); + await writeStore(this.path, this.workflows); + return true; + } + + async findByToken(token: string): Promise { + const trimmed = token.trim(); + if (!trimmed) return null; + await this.ensureLoaded(); + return this.byHash.get(hashToken(trimmed)) ?? null; + } +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index a388ced1e..4aae1e852 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -22,6 +22,8 @@ import { workspaceIdForPath } from "./workspaces.js"; import { ensureWorkspaceFiles, readRawOpencodeConfig } from "./workspace-init.js"; import { sanitizeCommandName, validateMcpName } from "./validators.js"; import { TokenService } from "./tokens.js"; +import { PublishedWorkflowsService, type PublishedWorkflowRecord } from "./published-workflows.js"; +import { handleMcpRequest, type McpToolDescriptor } from "./published-mcp.js"; import { EnvService, EnvStoreReadError, InvalidEnvKeyError, isValidEnvKey } from "./env-file.js"; import { TOY_UI_CSS, TOY_UI_FAVICON_SVG, TOY_UI_HTML, TOY_UI_JS, cssResponse, htmlResponse, jsResponse, svgResponse } from "./toy-ui.js"; import { FileSessionStore } from "./file-sessions.js"; @@ -209,6 +211,7 @@ export function startServer(config: ServerConfig) { const approvals = new ApprovalService(config.approval); const reloadEvents = new ReloadEventStore(); const tokens = new TokenService(config); + const publishedWorkflows = new PublishedWorkflowsService(config); const env = new EnvService(); const logger = createServerLogger(config); let watcherHandle = startReloadWatchers({ config, reloadEvents, logger }); @@ -216,7 +219,7 @@ export function startServer(config: ServerConfig) { watcherHandle.close(); watcherHandle = startReloadWatchers({ config, reloadEvents, logger }); }; - const routes = createRoutes(config, approvals, tokens, env, restartReloadWatchers); + const routes = createRoutes(config, approvals, tokens, publishedWorkflows, env, restartReloadWatchers); const serverOptions: { hostname: string; @@ -236,16 +239,16 @@ export function startServer(config: ServerConfig) { const finalize = (response: Response) => { const wrapped = withCors(response, request, config); if (config.logRequests) { - logRequest({ - logger, - request, - response: wrapped, - durationMs: Date.now() - startedAt, - authMode, - proxyService, - proxyBaseUrl, - error: errorMessage, - }); + logRequest({ + logger, + request, + response: wrapped, + durationMs: Date.now() - startedAt, + authMode, + proxyService, + proxyBaseUrl, + error: errorMessage, + }); } return wrapped; }; @@ -459,6 +462,77 @@ async function fetchOpencodeJson( return json; } +// Synchronous bridge for the published-workflows MCP transport. Spins up a +// fresh OpenCode session, sends a single prompt, and waits for the assistant +// response so the MCP `tools/call` round-trip can return real data in one +// HTTP cycle. Hard-caps run time so a stuck agent cannot pin a worker. +const PUBLISHED_WORKFLOW_TIMEOUT_MS = (() => { + const raw = Number(process.env.OPENWORK_PUBLISHED_WORKFLOW_TIMEOUT_MS ?? ""); + return Number.isFinite(raw) && raw > 0 ? raw : 60_000; +})(); + +async function executePublishedWorkflow( + config: ServerConfig, + workspace: WorkspaceInfo, + record: PublishedWorkflowRecord, + args: Record, +): Promise<{ text: string; isError?: boolean }> { + const sessionResponse = await fetchOpencodeJson(config, workspace, "/session", { + method: "POST", + body: { title: `MCP: ${record.toolName}` }, + }); + const sessionId = + sessionResponse && typeof sessionResponse === "object" && "id" in sessionResponse && typeof sessionResponse.id === "string" + ? sessionResponse.id.trim() + : ""; + if (!sessionId) { + throw new ApiError(502, "opencode_failed", "OpenCode session did not return an id"); + } + + const argsJson = JSON.stringify(args, null, 2); + const promptText = `Run the \`${record.skillName}\` skill with the following input:\n\n\`\`\`json\n${argsJson}\n\`\`\``; + + const promptBody: Record = { + parts: [{ type: "text", text: promptText }], + }; + if (record.agent) promptBody.agent = record.agent; + + // OpenCode's synchronous prompt endpoint is `POST /session/:id/message` + // (the SDK's `client.session.prompt`). The async/queued variant is + // `/session/:id/prompt_async`. We need the synchronous one so the MCP + // `tools/call` round-trip can return the assistant text in one HTTP turn. + const promptPromise = fetchOpencodeJson(config, workspace, `/session/${encodeURIComponent(sessionId)}/message`, { + method: "POST", + body: promptBody, + }); + const timeoutPromise = new Promise((_resolve, reject) => { + setTimeout(() => reject(new ApiError(504, "published_workflow_timeout", "Workflow timed out")), PUBLISHED_WORKFLOW_TIMEOUT_MS); + }); + + const result = await Promise.race([promptPromise, timeoutPromise]); + return { text: extractAssistantText(result) }; +} + +function extractAssistantText(value: unknown): string { + if (!value || typeof value !== "object") return ""; + const root = value as Record; + // OpenCode session.prompt returns either { info, parts } directly or a + // wrapped envelope. Normalize both shapes before pulling out text parts. + const parts = Array.isArray(root.parts) + ? root.parts + : Array.isArray((root.message as { parts?: unknown[] } | undefined)?.parts) + ? (root.message as { parts: unknown[] }).parts + : []; + const chunks: string[] = []; + for (const part of parts) { + if (part && typeof part === "object" && (part as { type?: unknown }).type === "text") { + const text = (part as { text?: unknown }).text; + if (typeof text === "string" && text) chunks.push(text); + } + } + return chunks.join("\n").trim() || "(no text response)"; +} + async function proxyOpencodeRequest(input: { config: ServerConfig; request: Request; @@ -1026,11 +1100,11 @@ function serializeWorkspace(workspace: ServerConfig["workspaces"][number]) { const opencode = workspace.baseUrl || opencodeDirectory || opencodeUsername || opencodePassword ? { - baseUrl: workspace.baseUrl, - directory: opencodeDirectory ?? undefined, - username: opencodeUsername, - password: opencodePassword, - } + baseUrl: workspace.baseUrl, + directory: opencodeDirectory ?? undefined, + username: opencodeUsername, + password: opencodePassword, + } : undefined; return { ...rest, @@ -1042,6 +1116,7 @@ function createRoutes( config: ServerConfig, approvals: ApprovalService, tokens: TokenService, + publishedWorkflows: PublishedWorkflowsService, env: EnvService, onWorkspacesChanged: () => void, ): Route[] { @@ -1437,10 +1512,10 @@ function createRoutes( config.workspaces = config.workspaces.map((entry) => entry.id === workspace.id ? { - ...entry, - displayName: nextDisplayName, - name: nextDisplayName ?? entry.name, - } + ...entry, + displayName: nextDisplayName, + name: nextDisplayName ?? entry.name, + } : entry, ); @@ -1644,7 +1719,7 @@ function createRoutes( } // OpenCode session deletion via the upstream API. - await fetchOpencodeJson(config, workspace, `/session/${encodeURIComponent(sessionId)}`, { + await fetchOpencodeJson(config, workspace, `/session/${encodeURIComponent(sessionId)}`, { method: "DELETE", }); @@ -1730,7 +1805,7 @@ function createRoutes( const workspace = await resolveWorkspace(config, ctx.params.id); requireClientScope(ctx, "collaborator"); - await reloadOpencodeEngine(config, workspace); + await reloadOpencodeEngine(config, workspace); await recordAudit(workspace.path, { id: shortId(), @@ -2427,10 +2502,10 @@ function createRoutes( const repoPayload = body?.repo && typeof body.repo === "object" ? (body.repo as Record) : undefined; const repo = repoPayload ? { - owner: typeof repoPayload.owner === "string" ? repoPayload.owner : undefined, - repo: typeof repoPayload.repo === "string" ? repoPayload.repo : undefined, - ref: typeof repoPayload.ref === "string" ? repoPayload.ref : undefined, - } + owner: typeof repoPayload.owner === "string" ? repoPayload.owner : undefined, + repo: typeof repoPayload.repo === "string" ? repoPayload.repo : undefined, + ref: typeof repoPayload.ref === "string" ? repoPayload.ref : undefined, + } : undefined; await requireApproval(ctx, { @@ -2874,7 +2949,7 @@ function createRoutes( const bundle = await fetchSharedBundle(body.bundleUrl, { timeoutMs: typeof body.timeoutMs === "number" ? body.timeoutMs : undefined, }); - return jsonResponse(bundle); + return jsonResponse(bundle); }); addRoute(routes, "GET", "/approvals", "host", async (ctx) => { @@ -2891,6 +2966,123 @@ function createRoutes( return jsonResponse({ ok: true, allowed: result.allowed }); }); + // Published workflows admin (host-only): manage which skills are exposed + // as MCP tools and over what tokens. Listing/creation/revocation lives on + // the workspace surface so each workspace owns its own publications. + addRoute(routes, "GET", "/workspace/:id/published-workflows", "host", async (ctx) => { + const workspace = await resolveWorkspace(config, ctx.params.id); + const items = await publishedWorkflows.list(workspace.id); + return jsonResponse({ items }); + }); + + addRoute(routes, "POST", "/workspace/:id/published-workflows", "host", async (ctx) => { + const workspace = await resolveWorkspace(config, ctx.params.id); + const body = await readJsonBody(ctx.request); + + const skillName = typeof body.skillName === "string" ? body.skillName.trim() : ""; + if (!skillName) { + throw new ApiError(400, "invalid_payload", "skillName is required"); + } + const description = typeof body.description === "string" ? body.description.trim() : ""; + if (!description) { + throw new ApiError(400, "invalid_payload", "description is required"); + } + + // Default the public tool name to the skill name, but let callers pick + // a different identifier (e.g. snake_case vs kebab-case) for MCP clients. + const rawToolName = typeof body.toolName === "string" ? body.toolName.trim() : ""; + const toolName = rawToolName || skillName; + if (!/^[A-Za-z0-9_-]{1,64}$/.test(toolName)) { + throw new ApiError(400, "invalid_payload", "toolName must match [A-Za-z0-9_-]{1,64}"); + } + + const agent = typeof body.agent === "string" && body.agent.trim() ? body.agent.trim() : undefined; + const label = typeof body.label === "string" && body.label.trim() ? body.label.trim() : undefined; + const inputSchema = + body.inputSchema && typeof body.inputSchema === "object" && (body.inputSchema as { type?: unknown }).type === "object" + ? (body.inputSchema as PublishedWorkflowRecord["inputSchema"]) + : undefined; + + const issued = await publishedWorkflows.create({ + workspaceId: workspace.id, + skillName, + toolName, + description, + agent, + label, + inputSchema, + }); + + await recordAudit(workspace.path, { + id: shortId(), + workspaceId: workspace.id, + actor: ctx.actor ?? { type: "host" }, + action: "published-workflow.create", + target: issued.id, + summary: `Published skill ${skillName} as MCP tool ${toolName}`, + timestamp: Date.now(), + }); + + return jsonResponse(issued, 201); + }); + + addRoute(routes, "DELETE", "/workspace/:id/published-workflows/:workflowId", "host", async (ctx) => { + const workspace = await resolveWorkspace(config, ctx.params.id); + const workflowId = ctx.params.workflowId; + const existing = await publishedWorkflows.get(workflowId); + if (!existing || existing.workspaceId !== workspace.id) { + throw new ApiError(404, "published_workflow_not_found", "Published workflow not found"); + } + const ok = await publishedWorkflows.revoke(workflowId); + if (!ok) { + throw new ApiError(404, "published_workflow_not_found", "Published workflow not found"); + } + await recordAudit(workspace.path, { + id: shortId(), + workspaceId: workspace.id, + actor: ctx.actor ?? { type: "host" }, + action: "published-workflow.revoke", + target: workflowId, + summary: `Revoked published workflow ${existing.toolName}`, + timestamp: Date.now(), + }); + return jsonResponse({ ok: true }); + }); + + // Public MCP transport. Auth is the URL-embedded token; we reject anything + // that does not match a stored hash. The handler dispatches JSON-RPC and + // forwards `tools/call` to the synchronous OpenCode `/session/:id/prompt`. + const mcpHandler = async (ctx: RequestContext): Promise => { + const token = ctx.params.token ?? ""; + const record = await publishedWorkflows.findByToken(token); + if (!record) { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32001, message: "Unauthorized" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + const workspace = config.workspaces.find((entry) => entry.id === record.workspaceId); + if (!workspace) { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32002, message: "Workspace unavailable" } }), { + status: 410, + headers: { "content-type": "application/json" }, + }); + } + const tool: McpToolDescriptor = { + name: record.toolName, + description: record.description, + inputSchema: record.inputSchema ?? { type: "object", properties: { input: { type: "string" } } }, + }; + return handleMcpRequest({ + request: ctx.request, + tool, + execute: (args) => executePublishedWorkflow(config, workspace, record, args), + }); + }; + addRoute(routes, "POST", "/published/:token/mcp", "none", mcpHandler); + addRoute(routes, "GET", "/published/:token/mcp", "none", mcpHandler); + addRoute(routes, "DELETE", "/published/:token/mcp", "none", mcpHandler); + return routes; } From b46077f8e72578032dcde8528b9e6392eeb371d8 Mon Sep 17 00:00:00 2001 From: Harshith Reddy Peta <157769359+PetaHarshith@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:04:39 -0500 Subject: [PATCH 2/4] feat(app): publish workflows UI in skills settings Adds the user-facing surface for the published-workflows MCP server primitive landed in the previous commit. - openwork-server client: PublishedWorkflow / PublishedWorkflowCreated types, list/create/revoke methods on the OpenworkServerClient, and a buildPublishedWorkflowMcpUrl helper. - extensions-store: publishedWorkflows snapshot + status, refresh / publish / revoke actions wired through the OpenworkServerClient, exposed on the consumer surface together with publishedWorkflowsServerBaseUrl(). - skills-view: per-skill Publish button on each installed skill, a publish modal that surfaces the one-shot URL with the secret token, and a new Published workflows section that lists active publications with copy-URL and revoke actions. - en.ts: settings.publishedWorkflows.* + common.copy translation keys. Other locales fall back to English via the existing fallback chain. Typecheck: pnpm --filter @openwork/app typecheck (clean). --- apps/app/src/app/lib/openwork-server.ts | 73 ++- apps/app/src/i18n/locales/en.ts | 25 + .../domains/settings/pages/skills-view.tsx | 435 +++++++++++++++++- .../settings/state/extensions-store.ts | 143 +++++- 4 files changed, 643 insertions(+), 33 deletions(-) diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index 7b7eb1963..25fa88ce4 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -117,9 +117,9 @@ export type OpenworkSessionSnapshot = { messages: OpenworkSessionMessage[]; todos: Todo[]; status: - | { type: "idle" } - | { type: "busy" } - | { type: "retry"; attempt: number; message: string; next: number }; + | { type: "idle" } + | { type: "busy" } + | { type: "retry"; attempt: number; message: string; next: number }; }; export type OpenworkPluginItem = { @@ -680,7 +680,7 @@ async function requestMultipartRaw( baseUrl: string, path: string, options: { method?: string; token?: string; hostToken?: string; body?: FormData; timeoutMs?: number } = {}, -): Promise<{ ok: boolean; status: number; text: string }>{ +): Promise<{ ok: boolean; status: number; text: string }> { const url = `${baseUrl}${path}`; const fetchImpl = resolveFetch(url); const response = await fetchWithTimeout( @@ -701,7 +701,7 @@ async function requestBinary( baseUrl: string, path: string, options: { method?: string; token?: string; hostToken?: string; timeoutMs?: number } = {}, -): Promise<{ data: ArrayBuffer; contentType: string | null; filename: string | null }>{ +): Promise<{ data: ArrayBuffer; contentType: string | null; filename: string | null }> { const url = `${baseUrl}${path}`; const fetchImpl = resolveFetch(url); const response = await fetchWithTimeout( @@ -1227,7 +1227,70 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s method: "DELETE", timeoutMs: timeouts.config, }), + + // Published workflows (host-auth admin surface, see + // apps/server/src/published-workflows.ts and + // apps/app/pr/published-workflows-mcp.md). The plaintext token is only + // returned from create(); callers must surface it immediately. + listPublishedWorkflows: (workspaceId: string) => + requestJson<{ items: PublishedWorkflow[] }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/published-workflows`, + { token, hostToken, timeoutMs: timeouts.config }, + ), + + createPublishedWorkflow: ( + workspaceId: string, + payload: PublishedWorkflowCreatePayload, + ) => + requestJson( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/published-workflows`, + { + token, + hostToken, + method: "POST", + body: payload, + timeoutMs: timeouts.config, + }, + ), + + revokePublishedWorkflow: (workspaceId: string, workflowId: string) => + requestJson<{ ok: true }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/published-workflows/${encodeURIComponent(workflowId)}`, + { token, hostToken, method: "DELETE", timeoutMs: timeouts.config }, + ), }; } +export type PublishedWorkflow = { + id: string; + workspaceId: string; + skillName: string; + toolName: string; + description: string; + agent?: string | null; + label?: string | null; + inputSchema?: Record | null; + createdAt: number; +}; + +export type PublishedWorkflowCreated = PublishedWorkflow & { + token: string; +}; + +export type PublishedWorkflowCreatePayload = { + skillName: string; + description: string; + toolName?: string; + agent?: string; + label?: string; + inputSchema?: Record; +}; + +export function buildPublishedWorkflowMcpUrl(baseUrl: string, token: string): string { + return `${baseUrl.replace(/\/+$/, "")}/published/${encodeURIComponent(token)}/mcp`; +} + export type OpenworkServerClient = ReturnType; diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 166391e77..251659786 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -92,6 +92,7 @@ export default { "common.choose": "Choose", "common.back": "Back", "common.close": "Close", + "common.copy": "Copy", "common.default_parens": "(default)", "common.done": "Done", "common.edit": "Edit", @@ -1715,6 +1716,30 @@ export default { "settings.environment.validation_reserved": "OPENWORK_ and OPENCODE_ names are managed by OpenWork/OpenCode.", "settings.environment.validation_shape": "Use letters, digits, and underscores; do not start with a digit.", "settings.environment.value_label": "Value", + "settings.publishedWorkflows.confirm_revoke": "Revoke published workflow {name}? Existing MCP clients using its URL will stop working.", + "settings.publishedWorkflows.copy_url_pattern": "Copy URL pattern", + "settings.publishedWorkflows.empty": "No workflows published yet. Use Publish on an installed skill to expose it as an MCP tool.", + "settings.publishedWorkflows.error_disconnected": "Connect to an OpenWork server to publish workflows.", + "settings.publishedWorkflows.field_description": "Tool description", + "settings.publishedWorkflows.field_description_hint": "Shown to MCP clients when they list this tool.", + "settings.publishedWorkflows.field_tool_name": "Tool name", + "settings.publishedWorkflows.field_tool_name_hint": "How the tool appears to MCP clients. Defaults to the skill name.", + "settings.publishedWorkflows.modal_subtitle": "Expose this skill as an MCP tool callable over HTTP.", + "settings.publishedWorkflows.modal_title": "Publish as MCP tool", + "settings.publishedWorkflows.publish_button": "Publish", + "settings.publishedWorkflows.publish_submit": "Publish workflow", + "settings.publishedWorkflows.publishing": "Publishing…", + "settings.publishedWorkflows.revoke": "Revoke", + "settings.publishedWorkflows.revoked_toast": "Workflow revoked.", + "settings.publishedWorkflows.section_subtitle": "Skills you have exposed as MCP tools. Each has a private URL containing a token; share carefully.", + "settings.publishedWorkflows.section_title": "Published workflows", + "settings.publishedWorkflows.skill_tag": "skill: {name}", + "settings.publishedWorkflows.success_notice": "Workflow published. Copy the URL below — the token is only shown once.", + "settings.publishedWorkflows.token_hint": "The full URL with token was shown when the workflow was published.", + "settings.publishedWorkflows.token_warning": "This URL contains a secret token. Anyone with the URL can call the tool.", + "settings.publishedWorkflows.url_copied": "URL copied to clipboard.", + "settings.publishedWorkflows.url_label": "MCP URL", + "settings.publishedWorkflows.validation_description_required": "Description is required.", "settings.tab_description_environment": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device.", "settings.tab_description_messaging": "Configure router identities and inbox behavior from workspace settings.", "settings.tab_description_model": "Tune the default model, runtime behavior, and assistant output settings.", diff --git a/apps/app/src/react-app/domains/settings/pages/skills-view.tsx b/apps/app/src/react-app/domains/settings/pages/skills-view.tsx index 830cf4bc6..942b37531 100644 --- a/apps/app/src/react-app/domains/settings/pages/skills-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/skills-view.tsx @@ -12,8 +12,10 @@ import { Copy, Edit2, FolderOpen, + Globe, Loader2, Package, + Plug, Plus, RefreshCw, Rocket, @@ -46,6 +48,12 @@ import type { HubSkillRepo, SkillCard, } from "../../../../app/types"; +import { + buildPublishedWorkflowMcpUrl, + type PublishedWorkflow, + type PublishedWorkflowCreated, + type PublishedWorkflowCreatePayload, +} from "../../../../app/lib/openwork-server"; import { inputClass, modalHeaderButtonClass, @@ -125,6 +133,12 @@ export type SkillsExtensionsStore = { description?: string; }) => void | Promise; uninstallSkill: (name: string) => void | Promise; + publishedWorkflows: () => PublishedWorkflow[]; + publishedWorkflowsStatus: () => string | null; + publishedWorkflowsServerBaseUrl: () => string | null; + refreshPublishedWorkflows: (force?: boolean) => void | Promise; + publishWorkflow: (payload: PublishedWorkflowCreatePayload) => Promise; + revokePublishedWorkflow: (workflowId: string) => Promise; }; export type SkillsViewProps = { @@ -176,6 +190,14 @@ export function SkillsView(props: SkillsViewProps) { const [installingCloudSkillId, setInstallingCloudSkillId] = useState(null); const [denUiTick, setDenUiTick] = useState(0); + const [publishTarget, setPublishTarget] = useState(null); + const [publishToolName, setPublishToolName] = useState(""); + const [publishDescription, setPublishDescription] = useState(""); + const [publishBusy, setPublishBusy] = useState(false); + const [publishError, setPublishError] = useState(null); + const [publishedRecord, setPublishedRecord] = useState(null); + const [revokingWorkflowId, setRevokingWorkflowId] = useState(null); + const showToast = useCallback( (title: string, tone: ToastTone = "info") => { props.onToast?.({ title, tone }); @@ -192,6 +214,7 @@ export function SkillsView(props: SkillsViewProps) { useEffect(() => { void extensions.ensureHubSkillsFresh(); void extensions.ensureCloudOrgSkillsFresh(); + void extensions.refreshPublishedWorkflows(); const onDenSession = () => { setDenUiTick((value) => value + 1); setCloudSessionNonce((value) => value + 1); @@ -201,6 +224,18 @@ export function SkillsView(props: SkillsViewProps) { return () => window.removeEventListener("openwork-den-session-updated", onDenSession); }, [extensions]); + useEffect(() => { + if (!publishTarget) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + if (publishBusy) return; + event.preventDefault(); + setPublishTarget(null); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [publishBusy, publishTarget]); + useEffect(() => { if (!shareTarget) return; const onKeyDown = (event: KeyboardEvent) => { @@ -283,6 +318,10 @@ export function SkillsView(props: SkillsViewProps) { const hubSkills = extensions.hubSkills(); const cloudOrgSkills = extensions.cloudOrgSkills(); const importedCloudSkills = extensions.importedCloudSkills(); + const publishedWorkflows = extensions.publishedWorkflows(); + const publishedWorkflowsStatus = extensions.publishedWorkflowsStatus(); + const publishedServerBaseUrl = extensions.publishedWorkflowsServerBaseUrl(); + const canPublishWorkflows = Boolean(publishedServerBaseUrl); const hubRepo = extensions.hubRepo(); const hubRepos = extensions.hubRepos(); const skillsStatus = extensions.skillsStatus(); @@ -621,6 +660,89 @@ export function SkillsView(props: SkillsViewProps) { } }, [shareUrl, showToast]); + const openPublishModal = useCallback( + (skill: SkillCard) => { + if (props.busy) return; + if (!canPublishWorkflows) { + showToast(t("settings.publishedWorkflows.error_disconnected"), "warning"); + return; + } + setPublishTarget(skill); + setPublishToolName(skill.name); + setPublishDescription(skill.description ?? ""); + setPublishBusy(false); + setPublishError(null); + setPublishedRecord(null); + }, + [canPublishWorkflows, props.busy, showToast], + ); + + const closePublishModal = useCallback(() => { + if (publishBusy) return; + setPublishTarget(null); + setPublishToolName(""); + setPublishDescription(""); + setPublishError(null); + setPublishedRecord(null); + }, [publishBusy]); + + const submitPublish = useCallback(async () => { + if (!publishTarget || publishBusy) return; + const description = publishDescription.trim(); + if (!description) { + setPublishError(t("settings.publishedWorkflows.validation_description_required")); + return; + } + setPublishBusy(true); + setPublishError(null); + try { + const created = await extensions.publishWorkflow({ + skillName: publishTarget.name, + description, + toolName: publishToolName.trim() || undefined, + }); + setPublishedRecord(created); + } catch (error) { + setPublishError(maskError(error)); + } finally { + setPublishBusy(false); + } + }, [extensions, maskError, publishBusy, publishDescription, publishTarget, publishToolName]); + + const copyPublishedUrl = useCallback( + async (url: string) => { + try { + await navigator.clipboard.writeText(url); + showToast(t("settings.publishedWorkflows.url_copied"), "success"); + } catch { + showToast(t("skills.copy_link_failed"), "error"); + } + }, + [showToast], + ); + + const handleRevokeWorkflow = useCallback( + async (workflow: PublishedWorkflow) => { + if (revokingWorkflowId) return; + const confirmed = window.confirm( + t("settings.publishedWorkflows.confirm_revoke", undefined, { + name: workflow.toolName, + }), + ); + if (!confirmed) return; + setRevokingWorkflowId(workflow.id); + try { + await extensions.revokePublishedWorkflow(workflow.id); + showToast(t("settings.publishedWorkflows.revoked_toast"), "success"); + } catch (error) { + showToast(maskError(error), "error"); + } finally { + setRevokingWorkflowId(null); + } + }, + [extensions, maskError, revokingWorkflowId, showToast], + ); + const openSkill = useCallback( async (skill: SkillCard) => { if (props.busy) return; @@ -850,6 +972,24 @@ export function SkillsView(props: SkillsViewProps) {
{t("skills.installed_status")}
+
) : null} + + {publishTarget ? ( + void submitPublish()} + onCopyUrl={copyPublishedUrl} + /> + ) : null} ); } +type PublishedWorkflowsSectionProps = { + workflows: PublishedWorkflow[]; + status: string | null; + serverBaseUrl: string | null; + canPublish: boolean; + revokingWorkflowId: string | null; + onCopyUrl: (url: string) => void | Promise; + onRevoke: (workflow: PublishedWorkflow) => void | Promise; + onRefresh: () => void; + busy: boolean; +}; + +function PublishedWorkflowsSection(props: PublishedWorkflowsSectionProps) { + return ( +
+
+
+

{t("settings.publishedWorkflows.section_title")}

+

+ {t("settings.publishedWorkflows.section_subtitle")} +

+
+ +
+ + {!props.canPublish ? ( +
+ {t("settings.publishedWorkflows.error_disconnected")} +
+ ) : props.status ? ( +
+ {props.status} +
+ ) : props.workflows.length === 0 ? ( +
+ {t("settings.publishedWorkflows.empty")} +
+ ) : ( +
+
+ {props.workflows.map((workflow) => { + const urlPattern = props.serverBaseUrl + ? `${props.serverBaseUrl.replace(/\/+$/, "")}/published//mcp` + : ""; + const isRevoking = props.revokingWorkflowId === workflow.id; + return ( +
+
+
+ +
+
+
+

{workflow.toolName}

+ + {t("settings.publishedWorkflows.skill_tag", undefined, { name: workflow.skillName })} + +
+ {workflow.description ? ( +

+ {workflow.description} +

+ ) : null} + {urlPattern ? ( +

{urlPattern}

+ ) : null} +

+ {t("settings.publishedWorkflows.token_hint")} +

+
+
+
+ {urlPattern ? ( + + ) : null} + +
+
+ ); + })} +
+
+ )} +
+ ); +} + +type PublishWorkflowModalProps = { + skill: SkillCard; + serverBaseUrl: string | null; + toolName: string; + description: string; + onChangeToolName: (value: string) => void; + onChangeDescription: (value: string) => void; + busy: boolean; + error: string | null; + published: PublishedWorkflowCreated | null; + onClose: () => void; + onSubmit: () => void; + onCopyUrl: (url: string) => void | Promise; +}; + +function PublishWorkflowModal(props: PublishWorkflowModalProps) { + const fullUrl = + props.published && props.serverBaseUrl + ? buildPublishedWorkflowMcpUrl(props.serverBaseUrl, props.published.token) + : null; + return ( +
+
+
+
+
+

{t("settings.publishedWorkflows.modal_title")}

+ {props.skill.name} +
+

{t("settings.publishedWorkflows.modal_subtitle")}

+
+ +
+ +
+ {!props.published ? ( +
+ + +