History

apps: HTTP routes (publish/invoke/ui/SSE/workflows) and MCP surface (publish door, skills, ui resources)

Rhys Sullivan committed 4 months ago

Commit 76be9be1 parent e0a832d3 files · +374 −1Browse files

Unified · Split

3 files
  1. packages/plugins/apps/src/backing/git-artifact-store.ts +1 −1
  2. packages/plugins/apps/src/http/routes.ts +208 −0
  3. packages/plugins/apps/src/mcp/register.ts +165 −0
packages/plugins/apps/src/backing/git-artifact-store.ts+1−1
@@ -152,7 +152,7 @@ const makeScopeStore = (repoDir: string): ScopeArtifactStore => { · 151 unmodified lines
152152 GIT_COMMITTER_EMAIL: "apps@executor.local",
153153 };
154154 const commitHash = (yield* Effect.callback<string, ArtifactStoreError>((resume) => {
155− const child = execFile(
155+ execFile(
156156 "git",
157157 commitArgs,
158158 { cwd: repoDir, encoding: "buffer", env: commitEnvVars },
packages/plugins/apps/src/http/routes.ts+208−0
@@ -0,0 +1,208 @@
1+import { Effect } from "effect";
2+
3+import type { AppsRuntime } from "../plugin/runtime";
4+import type { Bindings } from "../plugin/bindings";
5+
6+// ---------------------------------------------------------------------------
7+// Apps HTTP surface — a plain web handler (Request -> Response) the self-host
8+// app mounts as an extension route under `/api/apps/*`. Covers:
9+// POST /api/apps/:scope/publish { files } -> descriptor
10+// GET /api/apps/:scope/descriptor -> descriptor
11+// POST /api/apps/:scope/tools/:tool { args, bindings } -> result
12+// POST /api/apps/:scope/workflows/:wf/start { input, bindings } -> run
13+// POST /api/apps/:scope/workflows/runs/:runId/signal { event, payload } -> run
14+// GET /api/apps/:scope/workflows/runs/:runId -> run + steps
15+// GET /api/apps/:scope/workflows/runs -> runs
16+// GET /api/apps/:scope/ui/:name -> compiled bundle (JS)
17+// GET /api/apps/:scope/live (SSE) -> invalidations
18+//
19+// It's deliberately transport-thin: all logic is in AppsRuntime.
20+// ---------------------------------------------------------------------------
21+
22+export interface AppsHttpDeps {
23+ readonly runtime: AppsRuntime;
24+ /** Mount prefix (default "/api/apps"). */
25+ readonly prefix?: string;
26+}
27+
28+const json = (body: unknown, status = 200): Response =>
29+ new Response(JSON.stringify(body), {
30+ status,
31+ headers: { "content-type": "application/json" },
32+ });
33+
34+const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect as never);
35+
36+export const makeAppsHttpRoutes = (
37+ deps: AppsHttpDeps,
38+): { readonly path: string; readonly handler: (request: Request) => Promise<Response> } => {
39+ const prefix = deps.prefix ?? "/api/apps";
40+ const runtime = deps.runtime;
41+
42+ const handler = async (request: Request): Promise<Response> => {
43+ const url = new URL(request.url);
44+ if (!url.pathname.startsWith(prefix)) return new Response("not found", { status: 404 });
45+ const rest = url.pathname.slice(prefix.length).replace(/^\//, "");
46+ const parts = rest.split("/").filter(Boolean);
47+ // parts[0] = scope
48+ const scope = parts[0];
49+ if (!scope) return json({ error: "scope required" }, 400);
50+
51+ try {
52+ // POST :scope/publish
53+ if (parts[1] === "publish" && request.method === "POST") {
54+ const body = (await request.json()) as { files: Record<string, string>; message?: string };
55+ const files = new Map(Object.entries(body.files ?? {}));
56+ const out = await run(runtime.publish({ scope, files, message: body.message }));
57+ return json({ snapshotId: out.snapshotId, descriptor: out.descriptor });
58+ }
59+
60+ // GET :scope/descriptor
61+ if (parts[1] === "descriptor" && request.method === "GET") {
62+ const descriptor = await run(runtime.getDescriptor(scope));
63+ return json({ descriptor });
64+ }
65+
66+ // POST :scope/tools/:tool
67+ if (parts[1] === "tools" && parts[2] && request.method === "POST") {
68+ const body = (await request.json()) as { args?: unknown; bindings?: Bindings };
69+ const result = await run(
70+ runtime.invokeTool({
71+ scope,
72+ tool: parts[2],
73+ args: body.args ?? {},
74+ bindings: body.bindings ?? {},
75+ }),
76+ );
77+ return json({ result });
78+ }
79+
80+ // POST :scope/workflows/:wf/start
81+ if (parts[1] === "workflows" && parts[3] === "start" && request.method === "POST") {
82+ const body = (await request.json()) as {
83+ input?: unknown;
84+ bindings?: Bindings;
85+ runId?: string;
86+ };
87+ const runView = await run(
88+ runtime.startWorkflow({
89+ scope,
90+ workflow: parts[2],
91+ input: body.input,
92+ bindings: body.bindings,
93+ runId: body.runId,
94+ }),
95+ );
96+ return json({ run: runView });
97+ }
98+
99+ // POST :scope/workflows/runs/:runId/signal
100+ if (
101+ parts[1] === "workflows" &&
102+ parts[2] === "runs" &&
103+ parts[3] &&
104+ parts[4] === "signal" &&
105+ request.method === "POST"
106+ ) {
107+ const body = (await request.json()) as { event: string; payload?: unknown };
108+ const runView = await run(
109+ runtime.signalWorkflow({
110+ scope,
111+ runId: parts[3],
112+ event: body.event,
113+ payload: body.payload,
114+ }),
115+ );
116+ return json({ run: runView });
117+ }
118+
119+ // GET :scope/workflows/runs/:runId
120+ if (parts[1] === "workflows" && parts[2] === "runs" && parts[3] && request.method === "GET") {
121+ const runView = await run(runtime.getRun(parts[3]));
122+ const steps = await run(runtime.listSteps(parts[3]));
123+ return json({ run: runView, steps });
124+ }
125+
126+ // GET :scope/workflows/runs
127+ if (
128+ parts[1] === "workflows" &&
129+ parts[2] === "runs" &&
130+ !parts[3] &&
131+ request.method === "GET"
132+ ) {
133+ const runs = await run(runtime.listRuns(scope));
134+ return json({ runs });
135+ }
136+
137+ // GET :scope/ui/:name -> compiled JS bundle
138+ if (parts[1] === "ui" && parts[2] && request.method === "GET") {
139+ const bundle = await run(runtime.getUiBundle(scope, parts[2]));
140+ if (!bundle) return new Response("ui not found", { status: 404 });
141+ return new Response(bundle.code, {
142+ status: 200,
143+ headers: {
144+ "content-type": "application/javascript",
145+ "x-ui-title": bundle.title ?? "",
146+ "x-ui-max-height": String(bundle.maxHeight ?? ""),
147+ },
148+ });
149+ }
150+
151+ // GET :scope/live -> SSE invalidations
152+ if (parts[1] === "live" && request.method === "GET") {
153+ return sseResponse(scope, runtime);
154+ }
155+
156+ return new Response("not found", { status: 404 });
157+ } catch (cause) {
158+ const message = cause instanceof Error ? cause.message : String(cause);
159+ return json({ error: message }, 400);
160+ }
161+ };
162+
163+ return { path: `${prefix}/*`, handler };
164+};
165+
166+// An SSE stream of `{table, version}` invalidations for a scope. Each scope-db
167+// write bumps a counter and publishes here; a `: keepalive` comment holds the
168+// connection open.
169+const sseResponse = (scope: string, runtime: AppsRuntime): Response => {
170+ let unsubscribe: (() => void) | undefined;
171+ let keepalive: ReturnType<typeof setInterval> | undefined;
172+ const stream = new ReadableStream<Uint8Array>({
173+ start(controller) {
174+ const enc = new TextEncoder();
175+ controller.enqueue(enc.encode(`event: ready\ndata: {"scope":"${scope}"}\n\n`));
176+ unsubscribe = runtime.subscribeLive(scope, (event) => {
177+ try {
178+ controller.enqueue(
179+ enc.encode(
180+ `event: invalidate\ndata: ${JSON.stringify({ scope, table: event.table, version: event.version })}\n\n`,
181+ ),
182+ );
183+ } catch {
184+ /* controller closed */
185+ }
186+ });
187+ keepalive = setInterval(() => {
188+ try {
189+ controller.enqueue(enc.encode(`: keepalive\n\n`));
190+ } catch {
191+ /* closed */
192+ }
193+ }, 15_000);
194+ },
195+ cancel() {
196+ unsubscribe?.();
197+ if (keepalive) clearInterval(keepalive);
198+ },
199+ });
200+ return new Response(stream, {
201+ status: 200,
202+ headers: {
203+ "content-type": "text/event-stream",
204+ "cache-control": "no-cache",
205+ connection: "keep-alive",
206+ },
207+ });
208+};
packages/plugins/apps/src/mcp/register.ts+165−0
@@ -0,0 +1,165 @@
1+import { z } from "zod";
2+import { Effect } from "effect";
3+
4+import type { AppsRuntime } from "../plugin/runtime";
5+
6+// ---------------------------------------------------------------------------
7+// MCP surface for the apps subsystem. Registers, on a McpServer:
8+// - `apps_publish` tool: the chat-authoring door (agent publishes a file set)
9+// - `apps_list_skills` / `apps_read_skill` tools: published skills over MCP
10+// - one `ui://<scope>/<name>` resource per published ui view (MCP Apps),
11+// carrying `_meta.ui` so a client renders it, + the raw bundle in the body
12+//
13+// Published TOOLS are already catalog citizens through the source plugin, so
14+// they surface over the host's normal `execute`/tools surface with no extra
15+// wiring here. This module adds the publish door, skills, and ui resources.
16+//
17+// `server` is a minimal structural view of the MCP SDK's McpServer (registerTool
18+// / registerResource), so this module has no hard dependency on a specific SDK
19+// version's class.
20+// ---------------------------------------------------------------------------
21+
22+interface McpToolResult {
23+ content: { type: "text"; text: string }[];
24+ structuredContent?: Record<string, unknown>;
25+ isError?: boolean;
26+}
27+
28+export interface McpServerLike {
29+ registerTool: (
30+ name: string,
31+ config: { description?: string; inputSchema?: Record<string, unknown> },
32+ handler: (args: Record<string, unknown>) => Promise<McpToolResult> | McpToolResult,
33+ ) => unknown;
34+ registerResource: (
35+ name: string,
36+ uri: string,
37+ metadata: Record<string, unknown>,
38+ reader: (uri: URL) => Promise<{ contents: unknown[] }> | { contents: unknown[] },
39+ ) => unknown;
40+}
41+
42+export interface AppsMcpDeps {
43+ readonly runtime: AppsRuntime;
44+ /** The scope this MCP session serves (self-host single-tenant). */
45+ readonly scope: string;
46+}
47+
48+const UI_MIME = "application/mcp-resource+json;type=html";
49+
50+const text = (value: unknown): McpToolResult => ({
51+ content: [
52+ { type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) },
53+ ],
54+ structuredContent:
55+ typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined,
56+});
57+
58+const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect as never);
59+
60+export const registerAppsMcp = (server: McpServerLike, deps: AppsMcpDeps): void => {
61+ const { runtime, scope } = deps;
62+
63+ // --- the publish door -----------------------------------------------------
64+ server.registerTool(
65+ "apps_publish",
66+ {
67+ description:
68+ "Publish a set of app files (tools/, workflows/, ui/, skills/) into this scope. " +
69+ "Returns the compiled descriptor: which tools, workflows, ui views and skills were published.",
70+ inputSchema: {
71+ files: z
72+ .record(z.string(), z.string())
73+ .describe("Map of POSIX path -> file contents, e.g. { 'tools/x.ts': '...' }"),
74+ message: z.string().optional().describe("Publish message"),
75+ },
76+ },
77+ async ({ files, message }) => {
78+ try {
79+ const out = await run(
80+ runtime.publish({
81+ scope,
82+ files: new Map(Object.entries((files as Record<string, string>) ?? {})),
83+ message: message as string | undefined,
84+ }),
85+ );
86+ return text({
87+ snapshotId: out.snapshotId,
88+ tools: out.descriptor.tools.map((t) => t.name),
89+ workflows: out.descriptor.workflows.map((w) => w.name),
90+ ui: out.descriptor.ui.map((u) => u.name),
91+ skills: out.descriptor.skills.map((s) => s.name),
92+ });
93+ } catch (cause) {
94+ return { ...text(cause instanceof Error ? cause.message : String(cause)), isError: true };
95+ }
96+ },
97+ );
98+
99+ // --- skills over MCP ------------------------------------------------------
100+ server.registerTool(
101+ "apps_list_skills",
102+ {
103+ description: "List the skills published in this scope (name + description).",
104+ inputSchema: {},
105+ },
106+ async () => {
107+ const descriptor = await run(runtime.getDescriptor(scope));
108+ const skills = (descriptor?.skills ?? []).map((s) => ({
109+ name: s.name,
110+ description: s.description,
111+ }));
112+ return text({ skills });
113+ },
114+ );
115+
116+ server.registerTool(
117+ "apps_read_skill",
118+ {
119+ description: "Read a published skill's full SKILL.md body by name.",
120+ inputSchema: { name: z.string().describe("The skill name (== its directory)") },
121+ },
122+ async ({ name }) => {
123+ const descriptor = await run(runtime.getDescriptor(scope));
124+ const skill = descriptor?.skills.find((s) => s.name === name);
125+ if (!skill) return { ...text(`no skill named "${name}"`), isError: true };
126+ const body = await run(runtime.deps.store.getBlob(`skill/${skill.bodyHash}`));
127+ return text(body ?? "");
128+ },
129+ );
130+
131+ // --- ui views as MCP Apps resources --------------------------------------
132+ // We register a single dynamic resource whose URI carries the ui name; the
133+ // reader resolves the compiled bundle + config for that view. `_meta.ui`
134+ // marks it renderable in an MCP Apps client.
135+ server.registerResource(
136+ "apps-ui",
137+ `ui://${scope}/`,
138+ { description: "Published app UI views", mimeType: UI_MIME },
139+ async (uri: URL) => {
140+ // uri like ui://<scope>/<name>
141+ const name = uri.pathname.replace(/^\//, "") || uri.hostname;
142+ const viewName = name.includes("/") ? name.split("/").pop()! : name;
143+ const bundle = await run(runtime.getUiBundle(scope, viewName));
144+ if (!bundle) {
145+ return { contents: [{ uri: uri.toString(), mimeType: "text/plain", text: "not found" }] };
146+ }
147+ return {
148+ contents: [
149+ {
150+ uri: uri.toString(),
151+ mimeType: UI_MIME,
152+ text: bundle.code,
153+ _meta: {
154+ ui: {
155+ title: bundle.title,
156+ maxHeight: bundle.maxHeight,
157+ csp: { connectDomains: [], resourceDomains: [] },
158+ },
159+ },
160+ },
161+ ],
162+ };
163+ },
164+ );
165+};

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t