mulmoterminal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ // GUI chat-protocol MCP server, built per session and served over HTTP from the
2
+ // main mulmoterminal process (see the `/mcp/:sessionId` route in server/index.ts).
3
+ // Registers one MCP tool per enabled plugin (from server/plugins-registry.js,
4
+ // driven by plugins/plugins.json) and acts as a thin bridge to the host routes:
5
+ //
6
+ // tool call -> POST /api/plugin/<toolName> (the dispatch route)
7
+ // -> envelope { data, message, instructions, title? }
8
+ // -> POST /api/agent/toolResult (store + publish; data gates it)
9
+ // -> return message+instructions text to claude
10
+ //
11
+ // Tools are registered straight from gui-chat-protocol ToolDefinitions: the
12
+ // JSON-schema `parameters` is passed through as the MCP inputSchema (no zod). This
13
+ // is the same shape MulmoClaude's broker uses (server/agent/mcp-server.ts), and it
14
+ // is why we drive the low-level Server API directly instead of McpServer.registerTool
15
+ // (which expects a zod raw shape) — so a shared plugin package's JSON-schema
16
+ // definition becomes an MCP tool without translation.
17
+ //
18
+ // The form round-trip is NOT a blocking call: the user's answer comes back by being
19
+ // typed into the PTY (see the GUI panel / form view), so every tool returns at once.
20
+ //
21
+ // Previously this ran as a per-session stdio subprocess that claude spawned via
22
+ // --mcp-config. It now lives in-process and is exposed over Streamable HTTP so the
23
+ // agent can reach it from anywhere (host or, later, a Docker sandbox) without us
24
+ // shipping the server code + tsx into the agent's environment. The session id and
25
+ // the host base URL are passed in by the caller instead of via env.
26
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
27
+ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
28
+ import { randomUUID } from "node:crypto";
29
+ import { toolDefinitions } from "../plugins-registry.js";
30
+
31
+ // Shape of the dispatch route's response (POST /api/plugin/<tool>). `data` gates
32
+ // whether a toolResult is published to the GUI; the rest is narration/metadata.
33
+ interface ToolEnvelope {
34
+ data?: unknown;
35
+ title?: unknown;
36
+ jsonData?: unknown;
37
+ message?: unknown;
38
+ instructions?: unknown;
39
+ }
40
+
41
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
42
+
43
+ async function postJson(url: string, body: unknown) {
44
+ const res = await fetch(url, {
45
+ method: "POST",
46
+ headers: { "content-type": "application/json" },
47
+ body: JSON.stringify(body),
48
+ });
49
+ if (!res.ok) throw new Error(`${url} responded ${res.status}`);
50
+ return res;
51
+ }
52
+
53
+ // A ToolDefinition's optional `prompt` is host-injected usage guidance that isn't
54
+ // part of the JSON-schema sent to the model; fold it into the description so claude
55
+ // still sees it (MulmoClaude does the same).
56
+ function describe(def: { description?: string; prompt?: string }) {
57
+ return [def.description, def.prompt].filter(Boolean).join("\n\n");
58
+ }
59
+
60
+ /**
61
+ * Build a fresh GUI MCP server bound to one chat session. Stateless: create one
62
+ * per request (Streamable HTTP in stateless mode forbids reusing a transport, and
63
+ * the server has no per-connection state beyond the captured `sessionId`).
64
+ *
65
+ * @param sessionId the chat session whose GUI panel should render tool results
66
+ * @param baseUrl the mulmoterminal host origin to POST plugin dispatch + results to
67
+ */
68
+ export function buildGuiMcpServer(sessionId: string, baseUrl: string): Server {
69
+ const server = new Server({ name: "mulmoterminal-gui", version: "0.0.0" }, { capabilities: { tools: {} } });
70
+
71
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
72
+ tools: toolDefinitions.map((def) => ({
73
+ name: def.name,
74
+ description: describe(def),
75
+ inputSchema: def.parameters ?? { type: "object", properties: {} },
76
+ })),
77
+ }));
78
+
79
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
80
+ const { name, arguments: args } = request.params;
81
+
82
+ // 1. Dispatch to the plugin's server-side handler.
83
+ const parsed = await (await postJson(`${baseUrl}/api/plugin/${name}`, args ?? {})).json();
84
+ const envelope: ToolEnvelope = isRecord(parsed) ? parsed : {};
85
+
86
+ // 2. Publish a toolResult to the GUI — only when there is data to render.
87
+ if (envelope.data !== undefined) {
88
+ await postJson(`${baseUrl}/api/agent/toolResult`, {
89
+ sessionId,
90
+ toolName: name,
91
+ uuid: randomUUID(),
92
+ title: envelope.title,
93
+ data: envelope.data,
94
+ jsonData: envelope.jsonData ?? envelope.data,
95
+ message: envelope.message,
96
+ });
97
+ }
98
+
99
+ // 3. Return the narration to claude.
100
+ const parts = [envelope.message, envelope.instructions].filter(Boolean);
101
+ return { content: [{ type: "text", text: parts.length ? parts.join("\n") : "Done" }] };
102
+ });
103
+
104
+ return server;
105
+ }
@@ -0,0 +1,110 @@
1
+ // Server-side plugin registry. Loads two kinds of GUI-protocol plugins and
2
+ // normalizes them into one shape the MCP broker and the dispatch route consume:
3
+ //
4
+ // - packages: gui-chat-protocol plugin packages (e.g. @gui-chat-plugin/markdown).
5
+ // Their core entry exports a ToolPluginCore { toolDefinition, execute } plus
6
+ // TOOL_DEFINITION. These are shared VERBATIM with MulmoClaude — one source of
7
+ // truth, loaded as an npm dependency.
8
+ // - local: in-tree plugins under plugins/<name>/ whose definition.js exports a
9
+ // gui-chat-protocol ToolDefinition and whose server.js exports execute(args).
10
+ // These are pre-extraction holdovers that migrate to packages over time.
11
+ //
12
+ // Both the main server (which mounts the dispatch route) and the MCP broker (which
13
+ // registers the tools) import this, so the GUI tool set is driven entirely by
14
+ // plugins.json.
15
+ import fs from "fs";
16
+ import path from "path";
17
+ import { fileURLToPath, pathToFileURL } from "url";
18
+ import type { Express } from "express";
19
+ import { generateImage } from "./backends/image-gen.js";
20
+ import { markdownHostApp } from "./backends/markdown.js";
21
+ import { HOST_TOOL_DEFINITIONS } from "./host-tools.js";
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const PLUGINS_DIR = path.join(__dirname, "..", "plugins");
25
+
26
+ const MCP_SERVER_NAME = "mulmoterminal-gui";
27
+
28
+ // The gui-chat-protocol ToolContext.app — host-provided backends a plugin's
29
+ // execute() may call (e.g. @mulmochat-plugin/generate-image calls
30
+ // `context.app.generateImage(prompt)`). Plugins that don't need a backend simply
31
+ // ignore it. Passed to every package's execute below.
32
+ // Spread the markdown host app (loadDoc/saveDoc/saveNewDoc/marpThemes/exportPdf/
33
+ // fillImages) alongside generateImage — context.app is a shared capability bag;
34
+ // each plugin's execute uses only what it needs. The markdown backend is
35
+ // initialised with the workspace + pubsub at boot (server/index.ts).
36
+ const APP_CONTEXT = { generateImage, ...markdownHostApp };
37
+
38
+ function loadConfig() {
39
+ const raw = fs.readFileSync(path.join(PLUGINS_DIR, "plugins.json"), "utf8");
40
+ const parsed = JSON.parse(raw);
41
+ return {
42
+ packages: Array.isArray(parsed.packages) ? parsed.packages : [],
43
+ local: Array.isArray(parsed.local) ? parsed.local : [],
44
+ };
45
+ }
46
+
47
+ // A gui-chat-protocol package. The core entry exposes TOOL_DEFINITION (a JSON-schema
48
+ // ToolDefinition) and a ToolPluginCore whose execute(context, args) returns the
49
+ // result envelope. We invoke it in-process when the broker dispatches, passing the
50
+ // host backends as context.app (image generation, etc.).
51
+ async function loadPackage(name: string) {
52
+ const mod = await import(name);
53
+ const definition = mod.TOOL_DEFINITION ?? mod.pluginCore?.toolDefinition;
54
+ const execute = mod.pluginCore?.execute ?? mod.execute;
55
+ if (!definition || typeof execute !== "function") {
56
+ throw new Error(`Package "${name}" is not a gui-chat-protocol plugin (missing TOOL_DEFINITION/execute).`);
57
+ }
58
+ return { toolName: definition.name, definition, execute: (args?: unknown) => execute({ app: APP_CONTEXT }, args ?? {}) };
59
+ }
60
+
61
+ // A local plugin: definition.js exports TOOL_DEFINITION (a gui-chat-protocol
62
+ // ToolDefinition), server.js exports execute(args).
63
+ async function loadLocal(name: string) {
64
+ const dir = path.join(PLUGINS_DIR, name);
65
+ const importJs = (file: string) => import(pathToFileURL(path.join(dir, file)).href);
66
+ const [{ TOOL_DEFINITION }, { execute }] = await Promise.all([importJs("definition.js"), importJs("server.js")]);
67
+ if (!TOOL_DEFINITION || typeof execute !== "function") {
68
+ throw new Error(`Local plugin "${name}" must export TOOL_DEFINITION and execute().`);
69
+ }
70
+ return { toolName: TOOL_DEFINITION.name, definition: TOOL_DEFINITION, execute: (args?: unknown) => execute(args ?? {}) };
71
+ }
72
+
73
+ const config = loadConfig();
74
+ // Top-level await: the loaded set is ready by the time importers use it.
75
+ export const plugins = [...(await Promise.all(config.packages.map(loadPackage))), ...(await Promise.all(config.local.map(loadLocal)))];
76
+
77
+ const byName = Object.fromEntries(plugins.map((p) => [p.toolName, p]));
78
+
79
+ // MCP tool definitions the broker registers — gui-chat-protocol ToolDefinitions
80
+ // ({ name, description, prompt?, parameters }), one per enabled plugin plus the
81
+ // built-in host tools (which the server dispatches itself; see host-tools.ts).
82
+ export const toolDefinitions = [...plugins.map((p) => p.definition), ...HOST_TOOL_DEFINITIONS];
83
+
84
+ // JSON-serializable summaries (no schema) for the GUI's tools pane.
85
+ export const toolSummaries = toolDefinitions.map((d) => ({
86
+ toolName: d.name,
87
+ title: d.name,
88
+ description: d.description,
89
+ }));
90
+
91
+ // Mount the uniform dispatch route. The MCP broker POSTs a tool's args to
92
+ // /api/plugin/<toolName>; the plugin's execute returns the result envelope
93
+ // { data?, jsonData?, message?, instructions?, title? } the broker forwards.
94
+ export function mountAllRoutes(app: Express) {
95
+ app.post("/api/plugin/:toolName", async (req, res) => {
96
+ const plugin = byName[req.params.toolName];
97
+ if (!plugin) return res.status(404).json({ error: `Unknown tool: ${req.params.toolName}` });
98
+ try {
99
+ res.json(await plugin.execute(req.body));
100
+ } catch (e) {
101
+ res.status(400).json({ error: e instanceof Error ? e.message : String(e) });
102
+ }
103
+ });
104
+ }
105
+
106
+ // Fully-qualified MCP tool names for claude's --allowedTools (auto-run, no prompt).
107
+ // Includes host tools so they run without a permission prompt too.
108
+ export function allowedToolNames() {
109
+ return toolDefinitions.map((d) => `mcp__${MCP_SERVER_NAME}__${d.name}`);
110
+ }
@@ -0,0 +1,36 @@
1
+ import { Server as IOServer } from "socket.io";
2
+ import type { Server as HttpServer } from "node:http";
3
+
4
+ // Minimal socket.io pub/sub, modeled on mulmoclaude's server/events/pub-sub.
5
+ // Channel names are socket.io rooms — subscribe/unsubscribe map to
6
+ // socket.join / socket.leave, and publish broadcasts to the room.
7
+ // socket.io handles reconnect / heartbeat / transport for us.
8
+ export function createPubSub(server: HttpServer, isAllowedOrigin: (origin?: string) => boolean = () => true) {
9
+ const io = new IOServer(server, {
10
+ path: "/ws/pubsub",
11
+ transports: ["websocket"],
12
+ // Reject cross-origin connections so an untrusted website can't subscribe to
13
+ // session activity. allowRequest covers the websocket handshake; cors covers
14
+ // any polling/preflight.
15
+ allowRequest: (req, cb) => cb(null, isAllowedOrigin(req.headers.origin)),
16
+ cors: {
17
+ origin: (origin, cb) => cb(null, isAllowedOrigin(origin)),
18
+ credentials: true,
19
+ },
20
+ });
21
+
22
+ io.on("connection", (socket) => {
23
+ socket.on("subscribe", (channel) => {
24
+ if (typeof channel === "string") socket.join(channel);
25
+ });
26
+ socket.on("unsubscribe", (channel) => {
27
+ if (typeof channel === "string") socket.leave(channel);
28
+ });
29
+ });
30
+
31
+ return {
32
+ publish(channel: string, data: unknown) {
33
+ io.to(channel).emit("data", { channel, data });
34
+ },
35
+ };
36
+ }