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
152 GIT_COMMITTER_EMAIL: "apps@executor.local",152 GIT_COMMITTER_EMAIL: "apps@executor.local",
153 };153 };
154 const commitHash = (yield* Effect.callback<string, ArtifactStoreError>((resume) => {154 const commitHash = (yield* Effect.callback<string, ArtifactStoreError>((resume) => {
155 const child = execFile(155 execFile(
156 "git",156 "git",
157 commitArgs,157 commitArgs,
158 { cwd: repoDir, encoding: "buffer", env: commitEnvVars },158 { cwd: repoDir, encoding: "buffer", env: commitEnvVars },
packages/plugins/apps/src/http/routes.ts+208−0
@@ -0,0 +1,208 @@
1import { Effect } from "effect";
2
3import type { AppsRuntime } from "../plugin/runtime";
4import 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
22export interface AppsHttpDeps {
23 readonly runtime: AppsRuntime;
24 /** Mount prefix (default "/api/apps"). */
25 readonly prefix?: string;
26}
27
28const json = (body: unknown, status = 200): Response =>
29 new Response(JSON.stringify(body), {
30 status,
31 headers: { "content-type": "application/json" },
32 });
33
34const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect as never);
35
36export 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.
169const 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 @@
1import { z } from "zod";
2import { Effect } from "effect";
3
4import 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
22interface McpToolResult {
23 content: { type: "text"; text: string }[];
24 structuredContent?: Record<string, unknown>;
25 isError?: boolean;
26}
27
28export 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
42export interface AppsMcpDeps {
43 readonly runtime: AppsRuntime;
44 /** The scope this MCP session serves (self-host single-tenant). */
45 readonly scope: string;
46}
47
48const UI_MIME = "application/mcp-resource+json;type=html";
49
50const 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
58const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect as never);
59
60export 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