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.
- package/LICENSE +21 -0
- package/README.md +420 -0
- package/bin/mulmoterminal.js +188 -0
- package/dist/assets/index-Bhcnvx68.css +1 -0
- package/dist/assets/index-r5DjoOQU.js +186 -0
- package/dist/assets/marp-DqK0_Srt.js +3451 -0
- package/dist/index.html +14 -0
- package/package.json +100 -0
- package/plugins/plugins.json +4 -0
- package/server/backends/image-gen.ts +74 -0
- package/server/backends/markdown.ts +164 -0
- package/server/fix-pty-perms.js +15 -0
- package/server/host-tools.ts +45 -0
- package/server/index.ts +911 -0
- package/server/mcp/broker.ts +105 -0
- package/server/plugins-registry.ts +110 -0
- package/server/pubsub.ts +36 -0
|
@@ -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
|
+
}
|
package/server/pubsub.ts
ADDED
|
@@ -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
|
+
}
|