@eventcatalog/core 5.5.0 → 5.5.2

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 (34) 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 +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/catalog-runtime.cjs +1 -1
  6. package/dist/{chunk-RDF2OMEF.js → chunk-5HSYALWK.js} +1 -1
  7. package/dist/{chunk-754U4T7K.js → chunk-7ZEKF2LA.js} +1 -1
  8. package/dist/{chunk-3TREOJOK.js → chunk-CNN747RO.js} +1 -1
  9. package/dist/{chunk-UBDTKNFS.js → chunk-MC4F3VVM.js} +1 -1
  10. package/dist/{chunk-WPFA7AGD.js → chunk-WG6ZK6L3.js} +1 -1
  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 +5 -5
  15. package/dist/generate.cjs +1 -1
  16. package/dist/generate.js +3 -3
  17. package/dist/utils/cli-logger.cjs +1 -1
  18. package/dist/utils/cli-logger.js +2 -2
  19. package/eventcatalog/src/components/SchemaExplorer/AvroSchemaViewer.tsx +5 -3
  20. package/eventcatalog/src/components/SchemaExplorer/JSONSchemaViewer.tsx +6 -5
  21. package/eventcatalog/src/components/SchemaExplorer/ProtobufSchemaViewer.tsx +6 -5
  22. package/eventcatalog/src/features/mcp/apps/architecture-diagram/view.tsx +8 -2
  23. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +2 -2
  24. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +71 -71
  25. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +72 -72
  26. package/eventcatalog/src/features/mcp/apps/schema-viewer/view.tsx +2 -1
  27. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +24 -5
  28. package/eventcatalog/src/features/mcp/mcp-apps.ts +7 -2
  29. package/eventcatalog/src/features/mcp/mcp-server.ts +7 -4
  30. package/eventcatalog/src/features/mcp/schema-viewer.ts +3 -3
  31. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +3 -1
  32. package/eventcatalog/src/features/studio/tool-sync.ts +67 -3
  33. package/eventcatalog/src/features/tools/catalog-tools.ts +135 -74
  34. package/package.json +5 -5
@@ -12,6 +12,7 @@ import AvroSchemaViewer from '../../../../components/SchemaExplorer/AvroSchemaVi
12
12
  import ProtobufSchemaViewer from '../../../../components/SchemaExplorer/ProtobufSchemaViewer';
13
13
  import { SchemaCode } from './schema-code';
14
14
  import { RESOURCE_TYPE_LABELS } from '../shared/resource-types';
15
+ import { fullscreenStyle } from '../shared/app-view';
15
16
  import type { SchemaViewerPayload, SchemaViewerSchema } from './shared';
16
17
 
17
18
  const INLINE_MAX_HEIGHT = '560px';
@@ -75,7 +76,7 @@ export function SchemaViewerView({ app, payload, hostContext }: SchemaViewerView
75
76
  'inline-flex h-7 items-center gap-1.5 rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] px-2.5 text-xs font-medium text-[rgb(var(--ec-page-text))] transition-colors hover:border-[rgb(var(--ec-accent))] hover:text-[rgb(var(--ec-accent))]';
76
77
 
77
78
  return (
78
- <div className={`flex flex-col ${isFullscreen ? 'h-screen' : ''}`}>
79
+ <div className="flex flex-col" style={isFullscreen ? fullscreenStyle(hostContext) : undefined}>
79
80
  <div className="flex items-center justify-between gap-3 border-b border-[rgb(var(--ec-page-border))] px-3 py-2">
80
81
  <div className="flex min-w-0 items-center gap-2 text-[13px]">
81
82
  <span className="shrink-0 rounded bg-[rgb(var(--ec-accent-subtle))] px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-accent))]">
@@ -2,7 +2,7 @@
2
2
  * Connecting the EventCatalog viewer MCP App to the host, loading the tool result's payload, following the
3
3
  * host's theme and display mode, and mounting the view.
4
4
  */
5
- import { useEffect, useState, type ComponentType } from 'react';
5
+ import { useEffect, useState, type ComponentType, type CSSProperties } from 'react';
6
6
  import { config as configureZod } from 'zod';
7
7
  import { createRoot } from 'react-dom/client';
8
8
  import type { App, McpUiHostContext } from '@modelcontextprotocol/ext-apps';
@@ -20,6 +20,24 @@ const applyTheme = (context?: McpUiHostContext) => {
20
20
  if (context?.theme) document.documentElement.setAttribute('data-theme', context.theme);
21
21
  };
22
22
 
23
+ /**
24
+ * The view full screen: the height of the frame, kept clear of what the host draws over it on phones (the notch,
25
+ * the home indicator, the message box), which it reports as safe area insets
26
+ */
27
+ export const fullscreenStyle = (context?: McpUiHostContext): CSSProperties => {
28
+ const insets = context?.safeAreaInsets;
29
+ return {
30
+ height: '100vh',
31
+ boxSizing: 'border-box',
32
+ ...(insets && {
33
+ paddingTop: insets.top,
34
+ paddingRight: insets.right,
35
+ paddingBottom: insets.bottom,
36
+ paddingLeft: insets.left,
37
+ }),
38
+ };
39
+ };
40
+
23
41
  type McpAppViewOptions<Payload> = {
24
42
  name: string;
25
43
  /** Key of the payload in the model-facing tool result's `_meta` */
@@ -61,8 +79,9 @@ export function useMcpAppView<Payload>(options: McpAppViewOptions<Payload>) {
61
79
  appInfo: { name: options.name, version: '1.0.0' },
62
80
  // The views fit inline and have a full screen button (the server asks hosts to open them full screen)
63
81
  capabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
82
+ // Listeners go on before the view connects: hosts send the tool's result as soon as it has
64
83
  onAppCreated: (createdApp) => {
65
- createdApp.ontoolresult = (result) => {
84
+ createdApp.addEventListener('toolresult', (result) => {
66
85
  if (result.isError) {
67
86
  setError(result.content?.find((item) => item.type === 'text')?.text ?? options.loadErrorMessage);
68
87
  return;
@@ -70,11 +89,11 @@ export function useMcpAppView<Payload>(options: McpAppViewOptions<Payload>) {
70
89
  loadPayload(createdApp, result, options)
71
90
  .then(setPayload)
72
91
  .catch((loadError) => setError(String(loadError?.message ?? loadError)));
73
- };
74
- createdApp.onhostcontextchanged = (context) => {
92
+ });
93
+ createdApp.addEventListener('hostcontextchanged', (context) => {
75
94
  applyTheme(context);
76
95
  setHostContext((previous) => ({ ...previous, ...context }));
77
- };
96
+ });
78
97
  },
79
98
  });
80
99
 
@@ -54,6 +54,8 @@ export type McpAppDefinition<Shape extends z.ZodRawShape, ViewShape extends z.Zo
54
54
  csp?: { resourceDomains?: string[]; connectDomains?: string[] };
55
55
  /** How hosts open the view: ChatGPT reads these before loading it, so it can open straight in that mode */
56
56
  displayModes?: { preferred: McpUiDisplayMode; available: McpUiDisplayMode[] };
57
+ /** Whether the host frames the view with a border and background (hosts' defaults differ, e.g. Claude's web and mobile apps) */
58
+ prefersBorder?: boolean;
57
59
  };
58
60
  /**
59
61
  * Ties every result to one view (ChatGPT's `openai/widgetSessionId`): the host updates the view it already
@@ -93,7 +95,7 @@ export function registerMcpApp<Shape extends z.ZodRawShape, ViewShape extends z.
93
95
  if ('error' in result) return errorResult(result);
94
96
  return {
95
97
  // The model reads the text; hosts that support MCP Apps show the view from `_meta`
96
- content: [{ type: 'text' as const, text: JSON.stringify(result.forModel, null, 2) }],
98
+ content: [{ type: 'text' as const, text: JSON.stringify(result.forModel) }],
97
99
  ...(loadView && {
98
100
  _meta: {
99
101
  [app.metaKey]: await result.getPayload(),
@@ -151,7 +153,10 @@ export function registerMcpApp<Shape extends z.ZodRawShape, ViewShape extends z.
151
153
  mimeType: RESOURCE_MIME_TYPE,
152
154
  text: await loadView(),
153
155
  _meta: {
154
- ...(app.resource.csp && { ui: { csp: app.resource.csp } }),
156
+ ui: {
157
+ ...(app.resource.csp && { csp: app.resource.csp }),
158
+ ...(app.resource.prefersBorder !== undefined && { prefersBorder: app.resource.prefersBorder }),
159
+ },
155
160
  ...(app.resource.displayModes && {
156
161
  'openai/ui': {
157
162
  preferredDisplayMode: app.resource.displayModes.preferred,
@@ -48,7 +48,8 @@ type McpServerOptions = {
48
48
 
49
49
  const catalogDirectory = process.env.PROJECT_DIR || process.cwd();
50
50
 
51
- // Helper to create consistent MCP tool handlers with error handling
51
+ // Helper to create consistent MCP tool handlers with error handling. Results are compact JSON: indenting them costs
52
+ // the model about a fifth more tokens for nothing
52
53
  function createToolHandler<T>(fn: (params: T) => Promise<any>, errorMessage: string) {
53
54
  return async (params: T) => {
54
55
  try {
@@ -62,7 +63,7 @@ function createToolHandler<T>(fn: (params: T) => Promise<any>, errorMessage: str
62
63
  }
63
64
 
64
65
  return {
65
- content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
66
+ content: [{ type: 'text' as const, text: JSON.stringify(result) }],
66
67
  };
67
68
  } catch (error) {
68
69
  return {
@@ -511,6 +512,8 @@ function createMcpServer(
511
512
  csp: { resourceDomains: [catalogUrl] },
512
513
  // Diagrams and schemas need the room, so hosts that can open the viewer full screen straight away
513
514
  displayModes: { preferred: 'fullscreen', available: ['inline', 'fullscreen'] },
515
+ // The viewer has its own background: no frame around it, on the web and on phones alike
516
+ prefersBorder: false,
514
517
  },
515
518
  // One viewer per server: each result updates the viewer the host already shows
516
519
  sessionId: scope ? `eventcatalog:${scope.ref.kind}:${scope.ref.id}` : 'eventcatalog',
@@ -665,7 +668,7 @@ function createMcpServer(
665
668
  try {
666
669
  const result = await execute(params);
667
670
  return {
668
- content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
671
+ content: [{ type: 'text' as const, text: JSON.stringify(result) }],
669
672
  };
670
673
  } catch (error) {
671
674
  return {
@@ -840,7 +843,7 @@ function createMcpServer(
840
843
  contents: [
841
844
  {
842
845
  uri: uri.href,
843
- text: JSON.stringify({ ...(scope && { scope: scope.ref }), resources: allResources }, null, 2),
846
+ text: JSON.stringify({ ...(scope && { scope: scope.ref }), resources: allResources }),
844
847
  mimeType: 'application/json',
845
848
  },
846
849
  ],
@@ -3,7 +3,7 @@ import { getLanguageForHighlight } from '@components/SchemaExplorer/utils';
3
3
  import { getSchemaExtensionForFormat } from '@utils/collections/schemas';
4
4
  import type { SchemaViewerSchema } from './apps/schema-viewer/shared';
5
5
 
6
- type SchemaToolResult = { name?: string; format?: string; url?: string; code: string };
6
+ type SchemaToolResult = { name?: string; format?: string; path?: string; code: string };
7
7
 
8
8
  /**
9
9
  * The schemas returned by getSchemaForResource, ready for the schema viewer: parsed for its Properties tab
@@ -12,11 +12,11 @@ type SchemaToolResult = { name?: string; format?: string; url?: string; code: st
12
12
  export const toSchemaViewerSchemas = (schemas: SchemaToolResult[]): SchemaViewerSchema[] =>
13
13
  schemas.map((schema) => {
14
14
  // Schemas in the schemas collection have a format (jsonschema, avro, protobuf), others a file extension
15
- const format = schema.format ?? schema.url?.split('.').pop() ?? '';
15
+ const format = schema.format ?? schema.path?.split('.').pop() ?? '';
16
16
  const extension = getSchemaExtensionForFormat(format);
17
17
  return {
18
18
  ...parseSchemaForViewer(extension, schema.code),
19
- ...(schemas.length > 1 && { name: schema.name ?? schema.url?.split('/').pop() ?? format }),
19
+ ...(schemas.length > 1 && { name: schema.name ?? schema.path?.split('/').pop() ?? format }),
20
20
  format,
21
21
  language: getLanguageForHighlight(extension),
22
22
  code: schema.code,
@@ -52,7 +52,7 @@ const CANVAS_ICON_SVG =
52
52
  const CANVAS_ICON = { src: `data:image/svg+xml;base64,${btoa(CANVAS_ICON_SVG)}`, mimeType: 'image/svg+xml', sizes: ['20x20'] };
53
53
 
54
54
  const text = (value: unknown, isError = false) => ({
55
- content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }],
55
+ content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value) }],
56
56
  ...(isError && { isError }),
57
57
  });
58
58
 
@@ -124,6 +124,8 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
124
124
  // The view joins the canvas over the collaboration WebSocket, and shows icons served by this EventCatalog
125
125
  csp: { connectDomains: [new URL(socketUrl).origin, catalogUrl], resourceDomains: [catalogUrl] },
126
126
  displayModes: { preferred: 'fullscreen', available: ['inline', 'fullscreen'] },
127
+ // The canvas has its own background: no frame around it, on the web and on phones alike
128
+ prefersBorder: false,
127
129
  },
128
130
  metaKey: CANVAS_META_KEY,
129
131
  tool: {
@@ -6,7 +6,8 @@ import type { Status } from './hooks/use-studio-flow';
6
6
  * Syncing a canvas through MCP tool calls, for when a view can't open the collaboration WebSocket
7
7
  * (e.g. an MCP App inside a chat whose sandbox blocks it). Each call sends the server what it doesn't have
8
8
  * yet (by its state vector) and our presence, and gets back what we don't have and everyone's presence.
9
- * Slower than the WebSocket (it polls), but works wherever the chat can call the MCP server's tools.
9
+ * Slower than the WebSocket (it polls), but works wherever the chat can call the MCP server's tools. It polls less
10
+ * while nothing changes, and rarely while the view is out of sight (chats keep every view they showed running).
10
11
  */
11
12
 
12
13
  export type SyncRequest = {
@@ -33,6 +34,11 @@ export const fromBase64 = (text: string) => Uint8Array.from(atob(text), (char) =
33
34
  /** Applied with this origin, so undo (which tracks local changes) leaves other people's changes alone */
34
35
  const REMOTE = Symbol('tool-sync');
35
36
 
37
+ /** Waiting longest between syncs while nothing changes on either side */
38
+ const IDLE_INTERVAL_MS = 5000;
39
+ /** Between syncs while the view is out of sight (slowed, not stopped: a host that wrongly reports it hidden still syncs) */
40
+ const OUT_OF_SIGHT_INTERVAL_MS = 15_000;
41
+
36
42
  export function startToolSync(
37
43
  doc: Y.Doc,
38
44
  awareness: Awareness,
@@ -44,32 +50,90 @@ export function startToolSync(
44
50
  }: { canvasId: string; sync: ToolSync; onStatus: (status: Status) => void; intervalMs?: number }
45
51
  ) {
46
52
  let serverVector: Uint8Array | undefined;
53
+ let lastServerVector: string | undefined;
54
+ let lastAwareness: string | undefined;
47
55
  let stopped = false;
56
+ let syncing = false;
48
57
  let timer: ReturnType<typeof setTimeout> | undefined;
58
+ let dueAt = 0;
59
+ // Slower while nothing changes (a chat keeps every view it showed running, each polling), back to quick on a change
60
+ let delay = intervalMs;
61
+ // Hosts keep a chat's earlier views running: one scrolled out of sight, or in a hidden tab, syncs rarely
62
+ let inView = true;
63
+ const isShown = () => inView && document.visibilityState !== 'hidden';
64
+
65
+ const schedule = (ms: number) => {
66
+ clearTimeout(timer);
67
+ dueAt = Date.now() + ms;
68
+ timer = setTimeout(tick, ms);
69
+ };
49
70
 
50
71
  const tick = async () => {
72
+ timer = undefined;
73
+ if (stopped || syncing) return;
74
+ syncing = true;
51
75
  try {
76
+ const update = Y.encodeStateAsUpdate(doc, serverVector);
52
77
  const response = await sync({
53
78
  canvasId,
54
79
  clientId: doc.clientID,
55
80
  stateVector: toBase64(Y.encodeStateVector(doc)),
56
- update: toBase64(Y.encodeStateAsUpdate(doc, serverVector)),
81
+ update: toBase64(update),
57
82
  awareness: toBase64(encodeAwarenessUpdate(awareness, [doc.clientID])),
58
83
  });
59
84
  if (stopped) return;
60
85
  Y.applyUpdate(doc, fromBase64(response.update), REMOTE);
61
86
  serverVector = fromBase64(response.stateVector);
62
87
  if (response.awareness) applyAwarenessUpdate(awareness, fromBase64(response.awareness), REMOTE);
88
+ // Anything new either way (the canvas, or anyone's presence) keeps it quick
89
+ const changed = response.stateVector !== lastServerVector || response.awareness !== lastAwareness;
90
+ lastServerVector = response.stateVector;
91
+ lastAwareness = response.awareness;
92
+ delay = changed ? intervalMs : Math.min(delay * 1.5, IDLE_INTERVAL_MS);
63
93
  onStatus('connected');
64
94
  } catch {
65
95
  if (!stopped) onStatus('disconnected');
96
+ } finally {
97
+ syncing = false;
66
98
  }
67
- if (!stopped) timer = setTimeout(tick, intervalMs);
99
+ // Out of sight: rarely, and straight away again once it's shown (see show)
100
+ if (!stopped) schedule(isShown() ? delay : Math.max(delay, OUT_OF_SIGHT_INTERVAL_MS));
101
+ };
102
+
103
+ // Our own changes and presence go out within the quick interval, however long it's been waiting
104
+ const wake = (_update: unknown, origin: unknown) => {
105
+ if (origin === REMOTE || stopped) return;
106
+ delay = intervalMs;
107
+ if (timer && dueAt > Date.now() + intervalMs) schedule(intervalMs);
68
108
  };
109
+ doc.on('update', wake);
110
+ awareness.on('update', wake);
111
+
112
+ const show = () => {
113
+ if (stopped || !isShown() || syncing || dueAt <= Date.now() + intervalMs) return;
114
+ clearTimeout(timer);
115
+ delay = intervalMs;
116
+ void tick();
117
+ };
118
+ document.addEventListener('visibilitychange', show);
119
+ // Seen from inside the chat's frame: whether the view is on the screen at all
120
+ const observer =
121
+ typeof IntersectionObserver === 'undefined'
122
+ ? undefined
123
+ : new IntersectionObserver(([entry]) => {
124
+ inView = entry?.isIntersecting ?? true;
125
+ show();
126
+ });
127
+ observer?.observe(document.documentElement);
128
+
69
129
  void tick();
70
130
 
71
131
  return () => {
72
132
  stopped = true;
73
133
  clearTimeout(timer);
134
+ doc.off('update', wake);
135
+ awareness.off('update', wake);
136
+ document.removeEventListener('visibilitychange', show);
137
+ observer?.disconnect();
74
138
  };
75
139
  }
@@ -4,8 +4,8 @@
4
4
  */
5
5
  import { getCollection } from 'astro:content';
6
6
  import { z } from 'zod';
7
- import { getSchemasFromResource, getSchemaFormatFromURL } from '@utils/collections/schemas';
8
- import { getItemsFromCollectionByIdAndSemverOrLatest } from '@utils/collections/util';
7
+ import { getSchemaFormatFromURL } from '@utils/collections/schemas';
8
+ import { getItemsFromCollectionByIdAndSemverOrLatest, processSpecifications, versionMatches } from '@utils/collections/util';
9
9
  import { getUbiquitousLanguageWithSubdomains } from '@utils/collections/domains';
10
10
  import { getAbsoluteFilePathForAstroFile } from '@utils/files';
11
11
  import fs from 'node:fs';
@@ -280,6 +280,74 @@ export async function getResource(params: { collection: string; id: string; vers
280
280
 
281
281
  type MessagePointer = { id: string; version?: string };
282
282
 
283
+ type MessageEntry = { collection: string; data: { id: string; version: string } };
284
+
285
+ /** Resources that send and receive messages, and the collections that hold them */
286
+ const MESSAGING_COLLECTIONS = ['services', 'agents', 'domains'] as const;
287
+
288
+ /** Old versions live in a versioned folder: what they send and receive is history, not the catalog today */
289
+ const isLatestVersion = (entry: { filePath?: string }) => !entry.filePath?.includes('versioned');
290
+
291
+ /**
292
+ * Whether a sends/receives pointer points at this version of a message, as on the message's pages: one without a
293
+ * version (or "latest") points at the latest version only; others match exactly, by semver range or by x-pattern (1.x)
294
+ */
295
+ const pointsAtMessage = (pointer: MessagePointer, message: MessageEntry, latestVersion: string) => {
296
+ if (pointer.id !== message.data.id) return false;
297
+ if (!pointer.version || pointer.version === 'latest') return message.data.version === latestVersion;
298
+ return versionMatches(message.data.version, pointer.version);
299
+ };
300
+
301
+ /** The services, agents and domains (latest versions) that send (or receive) this version of a message */
302
+ const getResourcesThat = async (direction: 'sends' | 'receives', message: MessageEntry) => {
303
+ const [latestMessage, ...collections] = await Promise.all([
304
+ findEntry(message.collection, message.data.id),
305
+ ...MESSAGING_COLLECTIONS.map((collection) => getCollection(collection)),
306
+ ]);
307
+ const latestVersion = latestMessage?.data.version ?? message.data.version;
308
+
309
+ return collections
310
+ .flat()
311
+ .filter(isLatestVersion)
312
+ .filter((resource) =>
313
+ ((resource.data as any)[direction] || []).some((pointer: MessagePointer) =>
314
+ pointsAtMessage(pointer, message, latestVersion)
315
+ )
316
+ );
317
+ };
318
+
319
+ /**
320
+ * The resources a domain or system is made of, with it: its services and agents, and for a domain its systems and
321
+ * subdomains (and everything inside them)
322
+ */
323
+ const getResourcesInside = async (container: any) => {
324
+ const [domains, systems, services, agents] = await Promise.all(
325
+ (['domains', 'systems', 'services', 'agents'] as const).map((collection) => getCollection(collection))
326
+ );
327
+ const resolve = (entries: any[], pointers: MessagePointer[] = []) =>
328
+ pointers
329
+ .map((pointer) => getItemsFromCollectionByIdAndSemverOrLatest(entries, pointer.id, pointer.version)[0])
330
+ .filter(Boolean);
331
+
332
+ const found = new Map<string, any>();
333
+ const visit = (entry: any) => {
334
+ const key = `${entry.collection}/${entry.data.id}`;
335
+ if (found.has(key)) return;
336
+ found.set(key, entry);
337
+ if (entry.collection !== 'domains' && entry.collection !== 'systems') return;
338
+ [
339
+ ...resolve(services, entry.data.services),
340
+ ...resolve(agents, entry.data.agents),
341
+ ...(entry.collection === 'domains'
342
+ ? [...resolve(systems, entry.data.systems), ...resolve(domains, entry.data.domains)]
343
+ : []),
344
+ ].forEach(visit);
345
+ };
346
+ visit(container);
347
+
348
+ return [...found.values()];
349
+ };
350
+
283
351
  /**
284
352
  * Resolve a sends/receives pointer to the documented message it points at
285
353
  */
@@ -321,17 +389,39 @@ export async function getMessagesProducedOrConsumedByResource(params: {
321
389
  getCollection('queries'),
322
390
  ]);
323
391
  const messages = [...events, ...commands, ...queries];
392
+ const summary = {
393
+ id: resource.data.id,
394
+ version: resource.data.version,
395
+ name: resource.data.name || resource.data.id,
396
+ collection: params.resourceCollection,
397
+ };
324
398
 
325
- return {
326
- resource: {
327
- id: resource.data.id,
328
- version: resource.data.version,
329
- name: resource.data.name || resource.data.id,
330
- collection: params.resourceCollection,
331
- },
332
- sends: (resource.data.sends || []).map((pointer: MessagePointer) => resolveMessagePointer(messages, pointer)),
333
- receives: (resource.data.receives || []).map((pointer: MessagePointer) => resolveMessagePointer(messages, pointer)),
399
+ if (params.resourceCollection !== 'domains' && params.resourceCollection !== 'systems') {
400
+ return {
401
+ resource: summary,
402
+ sends: (resource.data.sends || []).map((pointer: MessagePointer) => resolveMessagePointer(messages, pointer)),
403
+ receives: (resource.data.receives || []).map((pointer: MessagePointer) => resolveMessagePointer(messages, pointer)),
404
+ };
405
+ }
406
+
407
+ // A domain or system sends and receives what it and everything inside it does: `by` names who, e.g. services/orders
408
+ const members = await getResourcesInside(resource);
409
+ const collect = (direction: 'sends' | 'receives') => {
410
+ const found = new Map<string, ReturnType<typeof resolveMessagePointer> & { by: string[] }>();
411
+ for (const member of members) {
412
+ for (const pointer of (member.data[direction] || []) as MessagePointer[]) {
413
+ const message = resolveMessagePointer(messages, pointer);
414
+ const key = `${message.collection ?? ''}/${message.id}@${message.version}`;
415
+ const entry = found.get(key) ?? { ...message, by: [] };
416
+ const by = `${member.collection}/${member.data.id}`;
417
+ if (!entry.by.includes(by)) entry.by.push(by);
418
+ found.set(key, entry);
419
+ }
420
+ }
421
+ return [...found.values()];
334
422
  };
423
+
424
+ return { resource: summary, sends: collect('sends'), receives: collect('receives') };
335
425
  }
336
426
 
337
427
  /**
@@ -367,15 +457,27 @@ export async function getSchemaForResource(params: { resourceId: string; resourc
367
457
  }
368
458
  }
369
459
 
370
- const schema = await getSchemasFromResource(resource);
460
+ const files = [
461
+ ...(resource.data.schemaPath
462
+ ? [{ path: resource.data.schemaPath, format: getSchemaFormatFromURL(resource.data.schemaPath) }]
463
+ : []),
464
+ ...processSpecifications(resource.data.specifications).map((spec) => ({
465
+ name: spec.name,
466
+ path: spec.path,
467
+ format: spec.type,
468
+ })),
469
+ ];
470
+
471
+ // Read from beside the resource's own file (paths are relative to it); files that can't be read are left out
472
+ const schemas = files.flatMap((file) => {
473
+ try {
474
+ return [{ ...file, code: fs.readFileSync(getAbsoluteFilePathForAstroFile(resource.filePath ?? '', file.path), 'utf-8') }];
475
+ } catch {
476
+ return [];
477
+ }
478
+ });
371
479
 
372
- if (schema.length > 0) {
373
- return schema.map((schemaItem) => ({
374
- url: schemaItem.url,
375
- format: schemaItem.format,
376
- code: fs.readFileSync(schemaItem.url, 'utf-8'),
377
- }));
378
- }
480
+ if (schemas.length > 0) return schemas;
379
481
 
380
482
  return { message: 'No schemas found for this resource' };
381
483
  }
@@ -431,8 +533,6 @@ export async function findResourcesByOwner(params: { ownerId: string }) {
431
533
  * Get resources that produce (send) a specific message
432
534
  */
433
535
  export async function getProducersOfMessage(params: { messageId: string; messageVersion?: string; messageCollection: string }) {
434
- const [services, agents] = await Promise.all([getCollection('services'), getCollection('agents')]);
435
- const routableResources = [...services, ...agents];
436
536
  const message = await findEntry(params.messageCollection, params.messageId, params.messageVersion);
437
537
 
438
538
  if (!message) {
@@ -440,21 +540,13 @@ export async function getProducersOfMessage(params: { messageId: string; message
440
540
  error: `Message not found: ${params.messageId} (${describeVersion(params.messageVersion)}) in ${params.messageCollection}`,
441
541
  };
442
542
  }
443
- const messageVersion = message.data.version;
444
543
 
445
- const producers = routableResources.filter((resource) => {
446
- const sends = (resource.data as any).sends || [];
447
- return sends.some((send: any) => {
448
- const idMatch = send.id === params.messageId;
449
- if (!send.version || send.version === 'latest') return idMatch;
450
- return idMatch && send.version === messageVersion;
451
- });
452
- });
544
+ const producers = await getResourcesThat('sends', message);
453
545
 
454
546
  return {
455
547
  message: {
456
548
  id: params.messageId,
457
- version: messageVersion,
549
+ version: message.data.version,
458
550
  collection: params.messageCollection,
459
551
  },
460
552
  producers: producers.map((s) => ({
@@ -511,8 +603,6 @@ export async function getC4Diagram(params: { viewId?: string }) {
511
603
  * Get resources that consume (receive) a specific message
512
604
  */
513
605
  export async function getConsumersOfMessage(params: { messageId: string; messageVersion?: string; messageCollection: string }) {
514
- const [services, agents] = await Promise.all([getCollection('services'), getCollection('agents')]);
515
- const routableResources = [...services, ...agents];
516
606
  const message = await findEntry(params.messageCollection, params.messageId, params.messageVersion);
517
607
 
518
608
  if (!message) {
@@ -520,21 +610,13 @@ export async function getConsumersOfMessage(params: { messageId: string; message
520
610
  error: `Message not found: ${params.messageId} (${describeVersion(params.messageVersion)}) in ${params.messageCollection}`,
521
611
  };
522
612
  }
523
- const messageVersion = message.data.version;
524
613
 
525
- const consumers = routableResources.filter((resource) => {
526
- const receives = (resource.data as any).receives || [];
527
- return receives.some((receive: any) => {
528
- const idMatch = receive.id === params.messageId;
529
- if (!receive.version || receive.version === 'latest') return idMatch;
530
- return idMatch && receive.version === messageVersion;
531
- });
532
- });
614
+ const consumers = await getResourcesThat('receives', message);
533
615
 
534
616
  return {
535
617
  message: {
536
618
  id: params.messageId,
537
- version: messageVersion,
619
+ version: message.data.version,
538
620
  collection: params.messageCollection,
539
621
  },
540
622
  consumers: consumers.map((s) => ({
@@ -552,8 +634,6 @@ export async function getConsumersOfMessage(params: { messageId: string; message
552
634
  * Returns all affected services (producers and consumers) and their owners
553
635
  */
554
636
  export async function analyzeChangeImpact(params: { messageId: string; messageVersion?: string; messageCollection: string }) {
555
- const [services, agents] = await Promise.all([getCollection('services'), getCollection('agents')]);
556
- const routableResources = [...services, ...agents];
557
637
  const message = await findEntry(params.messageCollection, params.messageId, params.messageVersion);
558
638
 
559
639
  if (!message) {
@@ -563,17 +643,7 @@ export async function analyzeChangeImpact(params: { messageId: string; messageVe
563
643
  }
564
644
  const messageVersion = message.data.version;
565
645
 
566
- // Find producers
567
- const producers = routableResources.filter((resource) => {
568
- const sends = (resource.data as any).sends || [];
569
- return sends.some((send: any) => send.id === params.messageId);
570
- });
571
-
572
- // Find consumers
573
- const consumers = routableResources.filter((resource) => {
574
- const receives = (resource.data as any).receives || [];
575
- return receives.some((receive: any) => receive.id === params.messageId);
576
- });
646
+ const [producers, consumers] = await Promise.all([getResourcesThat('sends', message), getResourcesThat('receives', message)]);
577
647
 
578
648
  // Collect all affected teams/owners
579
649
  const affectedOwners = new Set<string>();
@@ -796,19 +866,10 @@ export async function findMessageBySchemaId(params: {
796
866
  const matches = getItemsFromCollectionByIdAndSemverOrLatest(collection, params.messageId, params.messageVersion);
797
867
  const resource = matches[0];
798
868
  if (resource) {
799
- // Get producers and consumers
800
- const [services, agents] = await Promise.all([getCollection('services'), getCollection('agents')]);
801
- const routableResources = [...services, ...agents];
802
-
803
- const producers = routableResources.filter((resource) => {
804
- const sends = (resource.data as any).sends || [];
805
- return sends.some((send: any) => send.id === params.messageId);
806
- });
807
-
808
- const consumers = routableResources.filter((resource) => {
809
- const receives = (resource.data as any).receives || [];
810
- return receives.some((receive: any) => receive.id === params.messageId);
811
- });
869
+ const [producers, consumers] = await Promise.all([
870
+ getResourcesThat('sends', resource),
871
+ getResourcesThat('receives', resource),
872
+ ]);
812
873
 
813
874
  return {
814
875
  resource: {
@@ -1265,23 +1326,23 @@ export async function getCustomDoc(params: { id: string; section?: string }) {
1265
1326
 
1266
1327
  export const toolDescriptions = {
1267
1328
  getResources:
1268
- 'Use this tool to get events, agents, services, commands, queries, flows, domains, channels, entities from EventCatalog. Supports pagination via cursor and filtering by search term (searches name, id, and summary).',
1329
+ 'Use this tool to list the resources in a collection of EventCatalog (events, commands, queries, services, agents, domains, systems, flows, channels, entities, containers, data products, diagrams or ADRs). Supports pagination via cursor and filtering by search term (searches name, id, and summary).',
1269
1330
  getResource:
1270
1331
  'Use this tool to get a specific resource from EventCatalog by its id. Returns the frontmatter and markdown documentation. Omit the version to get the latest version.',
1271
1332
  getMessagesProducedOrConsumedByResource:
1272
- 'Use this tool to get the messages (events, commands, queries) a service, agent or domain sends and receives. Each message is resolved to its catalog id, version, name and summary; messages that are referenced but not documented in the catalog are marked notFound. Omit the version to use the latest version of the resource.',
1333
+ 'Use this tool to get the messages (events, commands, queries) a service, agent, domain or system sends and receives. Each message is resolved to its catalog id, version, name and summary; messages that are referenced but not documented in the catalog are marked notFound. For a domain or system, this includes what the services, agents, systems and subdomains inside it send and receive, and each message lists `by`: the resources that send or receive it (e.g. services/order-service). Omit the version to use the latest version of the resource.',
1273
1334
  getSchemaForResource:
1274
1335
  'Use this tool to get the schema or specifications (openapi or asyncapi or graphql) for a resource by its id and version',
1275
1336
  findResourcesByOwner:
1276
1337
  'Use this tool to find all resources (agents, services, events, commands, etc.) owned by a specific team or user',
1277
1338
  getProducersOfMessage:
1278
- 'Use this tool to find which agents or services produce (send) a specific message (event, command, or query)',
1339
+ 'Use this tool to find which services, agents or domains produce (send) a specific message (event, command, or query). Only the latest versions of those resources are included.',
1279
1340
  getConsumersOfMessage:
1280
- 'Use this tool to find which agents or services consume (receive) a specific message (event, command, or query)',
1341
+ 'Use this tool to find which services, agents or domains consume (receive) a specific message (event, command, or query). Only the latest versions of those resources are included.',
1281
1342
  getC4Diagram:
1282
1343
  'Use this tool to get the C4 diagram (or likeC4) in EventCatalog. These files usually have a .c4 extension on them',
1283
1344
  analyzeChangeImpact:
1284
- 'Use this tool to analyze the impact of changing a message. Returns all affected agents and services (producers and consumers), the teams that own them, and the blast radius of the change',
1345
+ 'Use this tool to analyze the impact of changing a message. Returns all affected services, agents and domains (producers and consumers), the teams that own them, and the blast radius of the change',
1285
1346
  explainBusinessFlow:
1286
1347
  'Use this tool to get detailed information about a business flow (state machine). Returns the flow definition, steps, mermaid diagram if available, and related agents and services',
1287
1348
  getTeams: