@eventcatalog/core 5.0.0-beta.4 → 5.0.0-beta.6

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.
Files changed (86) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +2 -2
  4. package/dist/analytics/log-build.js +4 -4
  5. package/dist/{chunk-TOMV5YJH.js → chunk-56CD2NH6.js} +1 -1
  6. package/dist/{chunk-M7OVJHPV.js → chunk-DE3QHVJP.js} +1 -1
  7. package/dist/{chunk-Q7FBYUCZ.js → chunk-L2IRFOE7.js} +1 -1
  8. package/dist/{chunk-TQLRG35T.js → chunk-PIYJM642.js} +1 -1
  9. package/dist/{chunk-VA77ENFQ.js → chunk-QBP3BBSV.js} +1 -1
  10. package/dist/{chunk-PL3T5TWF.js → chunk-XMPFHLK4.js} +2 -2
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +2 -2
  14. package/dist/eventcatalog.js +6 -6
  15. package/dist/generate.cjs +1 -1
  16. package/dist/generate.js +3 -3
  17. package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
  18. package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
  19. package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
  20. package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
  21. package/dist/runtime-dependencies/manifest.json +8 -0
  22. package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
  23. package/dist/runtime-dependencies/y-protocols.mjs +3 -0
  24. package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
  25. package/dist/runtime-dependencies/yjs.mjs +3 -0
  26. package/dist/utils/cli-logger.cjs +1 -1
  27. package/dist/utils/cli-logger.js +2 -2
  28. package/dist/utils/license-status.cjs +1 -1
  29. package/dist/utils/license-status.js +1 -1
  30. package/eventcatalog/astro.config.mjs +2 -0
  31. package/eventcatalog/integrations/studio-server.mjs +17 -0
  32. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +20 -1
  33. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +39 -0
  34. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
  35. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +269 -0
  36. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +209 -0
  37. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +2 -2
  38. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
  39. package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
  40. package/eventcatalog/src/features/mcp/mcp-server.ts +17 -4
  41. package/eventcatalog/src/features/studio/agent-choreography.ts +378 -0
  42. package/eventcatalog/src/features/studio/canvas-actions.ts +287 -0
  43. package/eventcatalog/src/features/studio/canvas-doc.ts +280 -0
  44. package/eventcatalog/src/features/studio/catalog-resources.ts +152 -0
  45. package/eventcatalog/src/features/studio/catalog.ts +171 -0
  46. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +153 -0
  47. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +332 -0
  48. package/eventcatalog/src/features/studio/components/Comments.tsx +455 -0
  49. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +217 -0
  50. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
  51. package/eventcatalog/src/features/studio/components/JoinForm.tsx +58 -0
  52. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +218 -0
  53. package/eventcatalog/src/features/studio/components/Presence.tsx +284 -0
  54. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +75 -0
  55. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +760 -0
  56. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +79 -0
  57. package/eventcatalog/src/features/studio/components/icons.ts +48 -0
  58. package/eventcatalog/src/features/studio/components/status.ts +22 -0
  59. package/eventcatalog/src/features/studio/edges.ts +54 -0
  60. package/eventcatalog/src/features/studio/flow-state.ts +92 -0
  61. package/eventcatalog/src/features/studio/grouping.ts +133 -0
  62. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
  63. package/eventcatalog/src/features/studio/hooks/presence-store.ts +115 -0
  64. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +337 -0
  65. package/eventcatalog/src/features/studio/hooks/use-comments.ts +47 -0
  66. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +99 -0
  67. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +423 -0
  68. package/eventcatalog/src/features/studio/layout.ts +39 -0
  69. package/eventcatalog/src/features/studio/levels.ts +216 -0
  70. package/eventcatalog/src/features/studio/node-types.ts +173 -0
  71. package/eventcatalog/src/features/studio/pages/[id].astro +38 -0
  72. package/eventcatalog/src/features/studio/pages/index.astro +8 -0
  73. package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
  74. package/eventcatalog/src/features/studio/placement.ts +197 -0
  75. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +475 -0
  76. package/eventcatalog/src/features/studio/server/runtime.ts +215 -0
  77. package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
  78. package/eventcatalog/src/features/studio/studio.css +89 -0
  79. package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
  80. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +9 -2
  81. package/eventcatalog/src/utils/feature.ts +4 -0
  82. package/eventcatalog/src/utils/trial.ts +1 -1
  83. package/package.json +9 -2
  84. package/eventcatalog/public/studio-bg.png +0 -0
  85. package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
  86. package/eventcatalog/src/pages/studio.astro +0 -316
@@ -0,0 +1,475 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type * as Y from 'yjs';
3
+ import { z } from 'zod';
4
+ import { buildUrl } from '@utils/url-builder';
5
+ import { appViewMeta, getMcpAppViewLoader, registerMcpApp } from '@features/mcp/mcp-apps';
6
+ import {
7
+ CANVAS_CATALOG_TOOL,
8
+ CANVAS_SYNC_TOOL,
9
+ CANVAS_META_KEY,
10
+ CANVAS_RESOURCE_URI,
11
+ CANVAS_VIEW_TOOL,
12
+ type CanvasPayload,
13
+ } from '@features/mcp/apps/canvas/shared';
14
+ import { getCanvasMaps, readCanvas, readMeta, replyToThread, setMeta, setThreadResolved, type Author } from '../canvas-doc';
15
+ import { ADD_TO_CANVAS_DESCRIPTION, describeCanvas, edgeSpecsSchema, indexCatalog, nodeSpecsSchema } from '../canvas-actions';
16
+ import { getCatalogResources } from '../catalog-resources';
17
+ import { getLayoutPositions } from '../layout';
18
+ import {
19
+ playAddToCanvas,
20
+ playComment,
21
+ playConnectAll,
22
+ playLayout,
23
+ playOnNode,
24
+ playRemove,
25
+ playUpdateNode,
26
+ type AgentStage,
27
+ } from '../agent-choreography';
28
+ import { STUDIO_SOCKET_PATH, findStudioRuntime, type AgentIdentity } from './runtime';
29
+ import { addToolIcons } from './tool-icons';
30
+
31
+ /**
32
+ * MCP tools for the collaborative canvas: agents create canvases, join the ones people are on, and
33
+ * add, connect, move and comment on things there, live, like anyone else on the canvas. openCanvas
34
+ * also shows the canvas in the chat (MCP App), where the user works on it with everyone else.
35
+ */
36
+
37
+ const loadCanvasView = getMcpAppViewLoader('canvas');
38
+
39
+ const AGENT_COLORS: Record<string, string> = { ChatGPT: '#10a37f', Claude: '#d97757' };
40
+
41
+ // The canvases entrypoint's icon (OpenAI MCP extensions: a 20x20, monochrome SVG in currentColor, 1.33px strokes)
42
+ const CANVAS_ICON_SVG =
43
+ '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="3.5" width="5.5" height="4.5" rx="1"/><rect x="12" y="3.5" width="5.5" height="4.5" rx="1"/><rect x="12" y="12" width="5.5" height="4.5" rx="1"/><path d="M8 5.75h4M14.75 8v4"/></svg>';
44
+ const CANVAS_ICON = { src: `data:image/svg+xml;base64,${btoa(CANVAS_ICON_SVG)}`, mimeType: 'image/svg+xml', sizes: ['20x20'] };
45
+
46
+ /** Who the agent is, from what it tells us or its client's user agent */
47
+ const getAgent = (agentName: string | undefined, userAgent = ''): AgentIdentity => {
48
+ const name =
49
+ agentName?.trim() ||
50
+ (/openai|chatgpt/i.test(userAgent) ? 'ChatGPT' : /claude|anthropic/i.test(userAgent) ? 'Claude' : 'AI agent');
51
+ return { name, color: AGENT_COLORS[name] ?? '#8b5cf6' };
52
+ };
53
+
54
+ const text = (value: unknown, isError = false) => ({
55
+ content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }],
56
+ ...(isError && { isError }),
57
+ });
58
+
59
+ const canvasId = z.string().describe('The id of the canvas (from openCanvas, createCanvas, listCanvases or a /studio/<id> link)');
60
+ const agentName = z
61
+ .string()
62
+ .optional()
63
+ .describe('Your name, as people on the canvas see it (e.g. "Claude"). Defaults to the name of your client.');
64
+
65
+ const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
66
+ const writes = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false } as const;
67
+
68
+ /** Names of the canvas tools, for the MCP server's tool list */
69
+ export const CANVAS_TOOL_NAMES = [
70
+ 'openCanvas',
71
+ 'listCanvases',
72
+ 'createCanvas',
73
+ 'getCanvas',
74
+ 'addToCanvas',
75
+ 'updateCanvasNode',
76
+ 'connectCanvasNodes',
77
+ 'removeFromCanvas',
78
+ 'layoutCanvas',
79
+ 'commentOnCanvas',
80
+ 'replyToCanvasComment',
81
+ 'resolveCanvasComment',
82
+ ];
83
+
84
+ type ToolContext = { catalogUrl: string; userAgent?: string };
85
+
86
+ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }: ToolContext) {
87
+ const runtime = findStudioRuntime();
88
+ if (!runtime) return;
89
+
90
+ const canvasUrl = (id: string) => `${catalogUrl}${buildUrl(`/studio/${id}`)}`;
91
+ const socketUrl = `${catalogUrl.replace(/^http/, 'ws')}${buildUrl(STUDIO_SOCKET_PATH, true)}`;
92
+ const missing = (id: string) =>
93
+ text({ error: `No canvas "${id}". Use listCanvases to find one, or openCanvas to start one.` }, true);
94
+
95
+ const createCanvas = async (title: string, name?: string) => {
96
+ const id = crypto.randomUUID();
97
+ const agent = getAgent(name, userAgent);
98
+ await runtime.withCanvas(id, (doc) => setMeta(doc, { title, createdAt: Date.now(), createdBy: agent.name }));
99
+ return id;
100
+ };
101
+
102
+ /**
103
+ * Work on a canvas as an agent: changes are played out like a person would make them (see agent-choreography),
104
+ * with the agent's pointer, selection and what it's doing shown to everyone on the canvas.
105
+ */
106
+ const asAgent = (id: string, name: string | undefined) => {
107
+ const agent = getAgent(name, userAgent);
108
+ const stage: AgentStage = {
109
+ change: (fn) => runtime.withCanvas(id, fn),
110
+ present: (presence) => runtime.setAgentPresence(id, agent, presence),
111
+ lastPointer: runtime.getAgentPointer(id, agent),
112
+ sharesMoves: true,
113
+ };
114
+ return { stage, author: { ...agent, agent: true } as Author };
115
+ };
116
+
117
+ // ---- The canvas in the chat (MCP App) ----
118
+
119
+ registerMcpApp(server, {
120
+ loadView: loadCanvasView,
121
+ resource: {
122
+ name: 'EventCatalog canvas',
123
+ uri: CANVAS_RESOURCE_URI,
124
+ description: 'A live, collaborative EventCatalog canvas, shown by MCP hosts that support MCP Apps',
125
+ // The view joins the canvas over the collaboration WebSocket, and shows icons served by this EventCatalog
126
+ csp: { connectDomains: [new URL(socketUrl).origin, catalogUrl], resourceDomains: [catalogUrl] },
127
+ displayModes: { preferred: 'fullscreen', available: ['inline', 'fullscreen'] },
128
+ },
129
+ metaKey: CANVAS_META_KEY,
130
+ tool: {
131
+ name: 'openCanvas',
132
+ // Also the name of the entrypoints in ChatGPT's sidebar and thread tabs (it differs from the plugin's name)
133
+ title: 'Canvases',
134
+ description: [
135
+ 'Shows the user a collaborative canvas right here in the chat, live: they can drag in catalog resources, connect them and comment, together with anyone else on it (in EventCatalog or another chat) and with you through the canvas tools.',
136
+ 'Pass canvasId to open an existing canvas (e.g. one the user shared a /studio/<id> link to), or a title to start a new one. With neither, it shows the user the canvases to pick one or start one.',
137
+ 'Use it when the user wants to design, sketch, map out or brainstorm architecture with you, or to see a canvas.',
138
+ ].join(' '),
139
+ // ChatGPT entrypoints (OpenAI MCP extensions): people can open canvases from the sidebar or as a tab in a
140
+ // thread, which calls the tool with {} (the list of canvases)
141
+ meta: { 'openai/ui': { entrypoints: [{ type: 'global' }, { type: 'thread' }] } },
142
+ input: {
143
+ canvasId: canvasId.optional(),
144
+ title: z.string().optional().describe('For a new canvas: what it is for'),
145
+ userName: z
146
+ .string()
147
+ .optional()
148
+ .describe(
149
+ 'The name of the person you are working with, if you know it: others on the canvas see them by this name. Leave it out if you do not know it.'
150
+ ),
151
+ agentName,
152
+ },
153
+ },
154
+ viewTool: {
155
+ name: CANVAS_VIEW_TOOL,
156
+ title: 'Load a canvas for the canvas view',
157
+ description:
158
+ 'Loads what the canvas view needs to join a canvas (or, without a canvasId, the list of canvases). To show the user a canvas, use openCanvas instead.',
159
+ input: { canvasId: canvasId.optional() },
160
+ },
161
+ annotations: writes,
162
+ errorMessage: 'Failed to open the canvas',
163
+ load: async (params) => {
164
+ const { title: newTitle, agentName: name, userName } = params as { title?: string; agentName?: string; userName?: string };
165
+
166
+ // Opened without a canvas (e.g. from an entrypoint): the canvases to pick from, or start one
167
+ if (!params.canvasId && !newTitle) {
168
+ const canvases = runtime
169
+ .listCanvases()
170
+ .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
171
+ .map(({ canvasId: id, title, people, nodeCount, openComments }) => ({
172
+ canvasId: id,
173
+ title,
174
+ people,
175
+ nodeCount,
176
+ openComments,
177
+ }));
178
+ const home: CanvasPayload = { view: 'home', canvases };
179
+ return {
180
+ forModel: {
181
+ canvases: canvases.map((canvas) => ({ ...canvas, url: canvasUrl(canvas.canvasId) })),
182
+ note: 'Clients that support MCP Apps are showing the user these canvases to pick one or start one. Otherwise, list them for the user.',
183
+ },
184
+ label: 'the canvases',
185
+ getPayload: async (): Promise<CanvasPayload> => home,
186
+ };
187
+ }
188
+
189
+ const id = params.canvasId ?? (await createCanvas(newTitle ?? 'Untitled canvas', name));
190
+ if (!runtime.exists(id))
191
+ return { error: `No canvas "${id}". Use listCanvases to find one, or open a new one without a canvasId.` };
192
+ const { title } = await runtime.withCanvas(id, readMeta);
193
+ const payload: CanvasPayload = {
194
+ view: 'canvas',
195
+ canvasId: id,
196
+ title,
197
+ socketUrl,
198
+ canvasUrl: canvasUrl(id),
199
+ mcpUrl: `${catalogUrl}${buildUrl('/docs/mcp', true)}`,
200
+ userName,
201
+ };
202
+ return {
203
+ forModel: {
204
+ canvasId: id,
205
+ title,
206
+ url: canvasUrl(id),
207
+ people: runtime.peopleOn(id),
208
+ note: 'Clients that support MCP Apps are showing the user this canvas, live. Work on it with them using the canvas tools (getCanvas, addToCanvas, commentOnCanvas...): they see your changes as you make them. Otherwise, share the url so they can open it.',
209
+ },
210
+ label: `the canvas ${title ?? id}`,
211
+ getPayload: async (): Promise<CanvasPayload> => payload,
212
+ };
213
+ },
214
+ });
215
+
216
+ // The view loads the catalog separately, so the canvas opens fast and tool results stay small
217
+ server.registerTool(
218
+ CANVAS_CATALOG_TOOL,
219
+ {
220
+ title: 'Load the catalog for the canvas view',
221
+ description: 'Loads the catalog resources the canvas view lets people drag onto a canvas.',
222
+ inputSchema: z.object({}),
223
+ annotations: readOnly,
224
+ _meta: appViewMeta(CANVAS_RESOURCE_URI, ['app']),
225
+ },
226
+ async () => {
227
+ const catalog = await getCatalogResources();
228
+ return {
229
+ content: [{ type: 'text' as const, text: `Loaded ${catalog.resources.length} catalog resources` }],
230
+ structuredContent: catalog,
231
+ };
232
+ }
233
+ );
234
+
235
+ // The view syncs through this when the chat's sandbox won't let it open the collaboration WebSocket
236
+ server.registerTool(
237
+ CANVAS_SYNC_TOOL,
238
+ {
239
+ title: 'Sync the canvas view',
240
+ description:
241
+ 'Exchanges changes and presence between the canvas view and the canvas, when the view cannot use the WebSocket.',
242
+ inputSchema: z.object({
243
+ canvasId,
244
+ clientId: z.number(),
245
+ stateVector: z.string(),
246
+ update: z.string(),
247
+ awareness: z.string(),
248
+ }),
249
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
250
+ _meta: appViewMeta(CANVAS_RESOURCE_URI, ['app']),
251
+ },
252
+ async (request) => {
253
+ if (!runtime.exists(request.canvasId)) return missing(request.canvasId);
254
+ const response = await runtime.syncCanvas(request);
255
+ return { content: [{ type: 'text' as const, text: 'Synced' }], structuredContent: response };
256
+ }
257
+ );
258
+
259
+ // ---- Working on canvases ----
260
+
261
+ server.registerTool(
262
+ 'listCanvases',
263
+ {
264
+ title: 'List canvases',
265
+ description:
266
+ 'Lists the collaborative canvases on this EventCatalog: shared, live design boards where people and agents map out architecture and ideas together. Shows who is on each one right now.',
267
+ inputSchema: z.object({}),
268
+ annotations: readOnly,
269
+ },
270
+ async () => text({ canvases: runtime.listCanvases().map((canvas) => ({ ...canvas, url: canvasUrl(canvas.canvasId) })) })
271
+ );
272
+
273
+ server.registerTool(
274
+ 'createCanvas',
275
+ {
276
+ title: 'Create a canvas',
277
+ description:
278
+ 'Creates a new collaborative canvas and returns its link, without showing it. Prefer openCanvas, which also shows it to the user in the chat. Share the link so people can join you on it.',
279
+ inputSchema: z.object({ title: z.string().describe('What the canvas is for, e.g. "Payments redesign"'), agentName }),
280
+ annotations: writes,
281
+ },
282
+ async ({ title, agentName: name }) => {
283
+ const id = await createCanvas(title, name);
284
+ return text({ canvasId: id, url: canvasUrl(id), title });
285
+ }
286
+ );
287
+
288
+ server.registerTool(
289
+ 'getCanvas',
290
+ {
291
+ title: 'Read a canvas',
292
+ description:
293
+ 'Reads everything on a canvas: its nodes (with ids, names, positions (their centres), sizes and the catalog resource they stand for), the connections between them, the comment threads, and who is on it right now. Read it before changing a canvas, and again to see what people have changed.',
294
+ inputSchema: z.object({ canvasId }),
295
+ annotations: readOnly,
296
+ },
297
+ async ({ canvasId: id }) => {
298
+ if (!runtime.exists(id)) return missing(id);
299
+ const canvas = await runtime.withCanvas(id, describeCanvas);
300
+ return text({ canvasId: id, url: canvasUrl(id), people: runtime.peopleOn(id), ...canvas });
301
+ }
302
+ );
303
+
304
+ server.registerTool(
305
+ 'addToCanvas',
306
+ {
307
+ title: 'Add to a canvas',
308
+ description: `${ADD_TO_CANVAS_DESCRIPTION} People on the canvas watch you add them one by one, like a person would. Use getResources to find catalog resources first.`,
309
+ inputSchema: z.object({ canvasId, nodes: nodeSpecsSchema, edges: edgeSpecsSchema.optional(), agentName }),
310
+ annotations: writes,
311
+ },
312
+ async ({ canvasId: id, nodes, edges, agentName: name }) => {
313
+ if (!runtime.exists(id)) return missing(id);
314
+ const { resources, relations } = await getCatalogResources();
315
+ const result = await playAddToCanvas(asAgent(id, name).stage, { nodes, edges }, indexCatalog(resources, relations));
316
+ return text(result, result.created.length === 0 && result.errors.length > 0);
317
+ }
318
+ );
319
+
320
+ server.registerTool(
321
+ 'updateCanvasNode',
322
+ {
323
+ title: 'Update a node on a canvas',
324
+ description:
325
+ 'Renames, re-describes or moves a node on a canvas (x/y: its new centre). Catalog resources keep their catalog name and summary; change those in the catalog. For notes, summary is the note text.',
326
+ inputSchema: z.object({
327
+ canvasId,
328
+ nodeId: z.string(),
329
+ name: z.string().optional(),
330
+ summary: z.string().optional(),
331
+ x: z.number().optional(),
332
+ y: z.number().optional(),
333
+ agentName,
334
+ }),
335
+ annotations: writes,
336
+ },
337
+ async ({ canvasId: id, agentName: name, ...update }) => {
338
+ if (!runtime.exists(id)) return missing(id);
339
+ const result = await playUpdateNode(asAgent(id, name).stage, update);
340
+ return text(result, 'error' in result);
341
+ }
342
+ );
343
+
344
+ server.registerTool(
345
+ 'connectCanvasNodes',
346
+ {
347
+ title: 'Connect nodes on a canvas',
348
+ description:
349
+ 'Draws connections between nodes on a canvas, one at a time like a person dragging them. Labels default to EventCatalog wording (e.g. "publishes event", "sent to").',
350
+ inputSchema: z.object({ canvasId, edges: edgeSpecsSchema.min(1), agentName }),
351
+ annotations: writes,
352
+ },
353
+ async ({ canvasId: id, edges, agentName: name }) => {
354
+ if (!runtime.exists(id)) return missing(id);
355
+ const result = await playConnectAll(asAgent(id, name).stage, edges);
356
+ return text(result, result.connections.length === 0);
357
+ }
358
+ );
359
+
360
+ server.registerTool(
361
+ 'removeFromCanvas',
362
+ {
363
+ title: 'Remove from a canvas',
364
+ description:
365
+ 'Removes nodes (and their connections) or connections from a canvas. Comments on removed nodes stay on the canvas. Only remove what the user asked for or what you added.',
366
+ inputSchema: z.object({
367
+ canvasId,
368
+ nodeIds: z.array(z.string()).optional(),
369
+ connectionIds: z.array(z.string()).optional(),
370
+ agentName,
371
+ }),
372
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
373
+ },
374
+ async ({ canvasId: id, nodeIds = [], connectionIds = [], agentName: name }) => {
375
+ if (!runtime.exists(id)) return missing(id);
376
+ await playRemove(asAgent(id, name).stage, nodeIds, connectionIds);
377
+ return text({ removedNodes: nodeIds, removedConnections: connectionIds });
378
+ }
379
+ );
380
+
381
+ server.registerTool(
382
+ 'layoutCanvas',
383
+ {
384
+ title: 'Tidy a canvas',
385
+ description:
386
+ "Lays the whole canvas out again with EventCatalog's visualiser layout (left to right, connections routed around nodes); everyone on it sees the nodes glide into place. It moves everything, including what people placed themselves, so only use it when the user asks to tidy up: addToCanvas already places what you add next to what it connects to.",
387
+ inputSchema: z.object({ canvasId, agentName }),
388
+ annotations: writes,
389
+ },
390
+ async ({ canvasId: id, agentName: name }) => {
391
+ if (!runtime.exists(id)) return missing(id);
392
+ const { nodes, edges } = await runtime.withCanvas(id, readCanvas);
393
+ const positions = await getLayoutPositions(nodes, edges);
394
+ await playLayout(asAgent(id, name).stage, positions);
395
+ return text({ laidOut: positions.size });
396
+ }
397
+ );
398
+
399
+ server.registerTool(
400
+ 'commentOnCanvas',
401
+ {
402
+ title: 'Comment on a canvas',
403
+ description:
404
+ 'Starts a comment thread on a canvas, pinned to a node (nodeId) or a spot (x, y). Use it to ask questions, flag concerns or explain your suggestions where people can see them.',
405
+ inputSchema: z.object({
406
+ canvasId,
407
+ text: z.string(),
408
+ nodeId: z.string().optional(),
409
+ x: z.number().optional(),
410
+ y: z.number().optional(),
411
+ agentName,
412
+ }),
413
+ annotations: writes,
414
+ },
415
+ async ({ canvasId: id, agentName: name, ...comment }) => {
416
+ if (!runtime.exists(id)) return missing(id);
417
+ const { stage, author } = asAgent(id, name);
418
+ const result = await playComment(stage, comment, author);
419
+ return 'error' in result ? text(result, true) : text(result);
420
+ }
421
+ );
422
+
423
+ /** For replies and resolving: point at the thread's node (if it's on one) while doing it */
424
+ const onThread = async <T>(
425
+ id: string,
426
+ name: string | undefined,
427
+ threadId: string,
428
+ activity: string,
429
+ change: (doc: Y.Doc, author: Author) => T
430
+ ) => {
431
+ const { stage, author } = asAgent(id, name);
432
+ const nodeId = await runtime.withCanvas(
433
+ id,
434
+ (doc) => getCanvasMaps(doc).threads.get(threadId)?.get('nodeId') as string | undefined
435
+ );
436
+ return nodeId
437
+ ? playOnNode(stage, nodeId, activity, (doc) => change(doc, author))
438
+ : stage.change((doc) => change(doc, author));
439
+ };
440
+
441
+ server.registerTool(
442
+ 'replyToCanvasComment',
443
+ {
444
+ title: 'Reply to a comment',
445
+ description: 'Replies to a comment thread on a canvas (threadId from getCanvas).',
446
+ inputSchema: z.object({ canvasId, threadId: z.string(), text: z.string(), agentName }),
447
+ annotations: writes,
448
+ },
449
+ async ({ canvasId: id, threadId, text: message, agentName: name }) => {
450
+ if (!runtime.exists(id)) return missing(id);
451
+ const replied = await onThread(id, name, threadId, 'Replying to a comment', (doc, author) =>
452
+ replyToThread(doc, threadId, message, author)
453
+ );
454
+ return replied ? text({ threadId }) : text({ error: `No comment thread "${threadId}"` }, true);
455
+ }
456
+ );
457
+
458
+ server.registerTool(
459
+ 'resolveCanvasComment',
460
+ {
461
+ title: 'Resolve a comment',
462
+ description: 'Resolves (or reopens) a comment thread on a canvas once it has been dealt with.',
463
+ inputSchema: z.object({ canvasId, threadId: z.string(), resolved: z.boolean().default(true), agentName }),
464
+ annotations: writes,
465
+ },
466
+ async ({ canvasId: id, threadId, resolved, agentName: name }) => {
467
+ if (!runtime.exists(id)) return missing(id);
468
+ const activity = resolved ? 'Resolving a comment' : 'Reopening a comment';
469
+ const found = await onThread(id, name, threadId, activity, (doc) => setThreadResolved(doc, threadId, resolved));
470
+ return found ? text({ threadId, resolved }) : text({ error: `No comment thread "${threadId}"` }, true);
471
+ }
472
+ );
473
+
474
+ addToolIcons(server, { openCanvas: [CANVAS_ICON] });
475
+ }
@@ -0,0 +1,215 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import { Server } from '@hocuspocus/server';
3
+ import * as Y from 'yjs';
4
+ import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness';
5
+ import type { XYPosition } from '@xyflow/react';
6
+ import type { AgentPresence } from '../agent-choreography';
7
+ import { canvasDocumentName, canvasIdFromDocumentName, getCanvasMaps, readMeta } from '../canvas-doc';
8
+ import { fromBase64, toBase64, type SyncRequest, type SyncResponse } from '../tool-sync';
9
+
10
+ export const STUDIO_SOCKET_PATH = '/_eventcatalog/studio';
11
+ const SOCKET_PATH_PATTERN = /\/_eventcatalog\/studio\/?$/;
12
+ // Agents show as present while they work, and leave a while after their last change (before awareness's own
13
+ // 30 second timeout would drop them anyway)
14
+ const AGENT_PRESENCE_TTL_MS = 25_000;
15
+
16
+ export type AgentIdentity = { name: string; color: string };
17
+ export type CanvasSummary = {
18
+ canvasId: string;
19
+ title?: string;
20
+ createdAt?: number;
21
+ nodeCount: number;
22
+ edgeCount: number;
23
+ openComments: number;
24
+ people: string[];
25
+ };
26
+
27
+ /**
28
+ * What the collaboration server keeps for the life of the process: one Hocuspocus instance, and the canvases
29
+ * in memory (a snapshot each time one is stored), so they last until the process restarts.
30
+ */
31
+ type RuntimeState = {
32
+ server: Server;
33
+ snapshots: Map<string, Uint8Array>;
34
+ agents: Map<string, { awareness: Awareness; expires: ReturnType<typeof setTimeout> }>;
35
+ attached: WeakSet<HttpServer>;
36
+ };
37
+
38
+ const createState = (): RuntimeState => {
39
+ const snapshots = new Map<string, Uint8Array>();
40
+ return {
41
+ snapshots,
42
+ agents: new Map(),
43
+ attached: new WeakSet(),
44
+ server: new Server({
45
+ quiet: true,
46
+ onLoadDocument: async ({ documentName, document }) => {
47
+ const snapshot = snapshots.get(documentName);
48
+ if (!snapshot) return;
49
+ // A hook that throws leaves the document half loaded in Hocuspocus (ueberdosis/hocuspocus#1156)
50
+ try {
51
+ Y.applyUpdate(document, snapshot);
52
+ } catch (error) {
53
+ console.error(`[studio] Could not load canvas ${documentName}, starting it empty`, error);
54
+ }
55
+ },
56
+ onStoreDocument: async ({ documentName, document }) => {
57
+ snapshots.set(documentName, Y.encodeStateAsUpdate(document));
58
+ },
59
+ }),
60
+ };
61
+ };
62
+
63
+ /**
64
+ * The collaboration server, shared (through globalThis) by the WebSocket upgrade handler and server code such
65
+ * as MCP tools, which edit canvases as agents. Only the state is shared, so code changes apply without a restart.
66
+ */
67
+ class StudioRuntime {
68
+ constructor(private readonly state: RuntimeState) {}
69
+
70
+ get server() {
71
+ return this.state.server;
72
+ }
73
+ private get snapshots() {
74
+ return this.state.snapshots;
75
+ }
76
+ private get agents() {
77
+ return this.state.agents;
78
+ }
79
+ private get attached() {
80
+ return this.state.attached;
81
+ }
82
+
83
+ /** Handle collaboration WebSocket upgrades on an HTTP server (the dev server, or the production server) */
84
+ attach(httpServer: HttpServer) {
85
+ if (this.attached.has(httpServer)) return;
86
+ this.attached.add(httpServer);
87
+ httpServer.on('upgrade', (request, socket, head) => {
88
+ const { pathname } = new URL(request.url ?? '/', 'http://localhost');
89
+ if (SOCKET_PATH_PATTERN.test(pathname)) this.server.httpServer.emit('upgrade', request, socket, head);
90
+ });
91
+ }
92
+
93
+ exists(canvasId: string) {
94
+ const name = canvasDocumentName(canvasId);
95
+ return this.server.hocuspocus.documents.has(name) || this.snapshots.has(name);
96
+ }
97
+
98
+ /** Read or change a canvas as the server. Changes sync to everyone on it, like anyone else's. */
99
+ async withCanvas<T>(canvasId: string, fn: (doc: Y.Doc) => T): Promise<T> {
100
+ const connection = await this.server.hocuspocus.openDirectConnection(canvasDocumentName(canvasId));
101
+ let result!: T;
102
+ try {
103
+ await connection.transact((doc) => {
104
+ result = fn(doc);
105
+ });
106
+ } finally {
107
+ await connection.disconnect({ unloadImmediately: false });
108
+ }
109
+ return result;
110
+ }
111
+
112
+ /**
113
+ * Sync a view that can't use the WebSocket (see tool-sync): apply its changes and presence (everyone on the
114
+ * WebSocket gets them live), and send back what it's missing and everyone else's presence.
115
+ */
116
+ async syncCanvas({ canvasId, clientId, stateVector, update, awareness }: SyncRequest): Promise<SyncResponse> {
117
+ const name = canvasDocumentName(canvasId);
118
+ const connection = await this.server.hocuspocus.openDirectConnection(name);
119
+ let response!: SyncResponse;
120
+ try {
121
+ await connection.transact((doc) => {
122
+ Y.applyUpdate(doc, fromBase64(update));
123
+ response = {
124
+ update: toBase64(Y.encodeStateAsUpdate(doc, fromBase64(stateVector))),
125
+ stateVector: toBase64(Y.encodeStateVector(doc)),
126
+ };
127
+ });
128
+ const document = this.server.hocuspocus.documents.get(name);
129
+ if (document) {
130
+ applyAwarenessUpdate(document.awareness, fromBase64(awareness), 'tool-sync');
131
+ const others = [...document.awareness.getStates().keys()].filter((id) => id !== clientId);
132
+ if (others.length) response.awareness = toBase64(encodeAwarenessUpdate(document.awareness, others));
133
+ }
134
+ } finally {
135
+ await connection.disconnect({ unloadImmediately: false });
136
+ }
137
+ return response;
138
+ }
139
+
140
+ listCanvases(): CanvasSummary[] {
141
+ const names = new Set([...this.server.hocuspocus.documents.keys(), ...this.snapshots.keys()]);
142
+ return [...names].flatMap((name) => {
143
+ const canvasId = canvasIdFromDocumentName(name);
144
+ if (!canvasId) return [];
145
+ const live = this.server.hocuspocus.documents.get(name);
146
+ const doc = live ?? new Y.Doc();
147
+ if (!live) Y.applyUpdate(doc, this.snapshots.get(name)!);
148
+ const { nodes, edges, threads } = getCanvasMaps(doc);
149
+ const meta = readMeta(doc);
150
+ return [
151
+ {
152
+ canvasId,
153
+ title: meta.title,
154
+ createdAt: meta.createdAt,
155
+ nodeCount: nodes.size,
156
+ edgeCount: edges.size,
157
+ openComments: Array.from(threads.values()).filter((thread) => !thread.get('resolved')).length,
158
+ people: live ? this.peopleOn(canvasId) : [],
159
+ },
160
+ ];
161
+ });
162
+ }
163
+
164
+ /** Names of the people and agents on a canvas right now */
165
+ peopleOn(canvasId: string): string[] {
166
+ const document = this.server.hocuspocus.documents.get(canvasDocumentName(canvasId));
167
+ if (!document) return [];
168
+ return Array.from(document.awareness.getStates().values())
169
+ .map((state) => state.user?.name)
170
+ .filter((name): name is string => !!name);
171
+ }
172
+
173
+ /** Where an agent's pointer was left on a canvas, so its next action carries on from there */
174
+ getAgentPointer(canvasId: string, agent: AgentIdentity): XYPosition | null {
175
+ const state = this.agents.get(`${canvasDocumentName(canvasId)}::${agent.name}`)?.awareness.getLocalState();
176
+ return (state?.pointer as XYPosition | undefined) ?? null;
177
+ }
178
+
179
+ /**
180
+ * Show an agent on a canvas like a person: in the list of who's here, with a pointer and selection.
181
+ * Each agent gets its own awareness client, kept while it works and removed for everyone after.
182
+ */
183
+ setAgentPresence(canvasId: string, agent: AgentIdentity, presence: AgentPresence) {
184
+ const name = canvasDocumentName(canvasId);
185
+ const document = this.server.hocuspocus.documents.get(name);
186
+ if (!document) return;
187
+
188
+ const key = `${name}::${agent.name}`;
189
+ const existing = this.agents.get(key);
190
+ if (existing) clearTimeout(existing.expires);
191
+ const awareness = existing?.awareness ?? new Awareness(new Y.Doc());
192
+ awareness.setLocalState({ user: { ...agent, agent: true }, ...presence });
193
+ applyAwarenessUpdate(document.awareness, encodeAwarenessUpdate(awareness, [awareness.clientID]), 'agent');
194
+
195
+ const expires = setTimeout(() => {
196
+ const current = this.server.hocuspocus.documents.get(name);
197
+ if (current) removeAwarenessStates(current.awareness, [awareness.clientID], 'agent');
198
+ awareness.destroy();
199
+ this.agents.delete(key);
200
+ }, AGENT_PRESENCE_TTL_MS);
201
+ this.agents.set(key, { awareness, expires });
202
+ }
203
+ }
204
+
205
+ const RUNTIME_KEY = Symbol.for('eventcatalog.studio.runtime');
206
+ const store = globalThis as typeof globalThis & { [RUNTIME_KEY]?: RuntimeState };
207
+
208
+ /** The process wide collaboration runtime (created on first use) */
209
+ export const getStudioRuntime = (): StudioRuntime => new StudioRuntime((store[RUNTIME_KEY] ??= createState()));
210
+
211
+ /** The runtime if the collaboration server is running in this process */
212
+ export const findStudioRuntime = (): StudioRuntime | undefined => {
213
+ const state = store[RUNTIME_KEY];
214
+ return state ? new StudioRuntime(state) : undefined;
215
+ };