@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.
- package/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/catalog-runtime.cjs +1 -1
- package/dist/{chunk-RDF2OMEF.js → chunk-5HSYALWK.js} +1 -1
- package/dist/{chunk-754U4T7K.js → chunk-7ZEKF2LA.js} +1 -1
- package/dist/{chunk-3TREOJOK.js → chunk-CNN747RO.js} +1 -1
- package/dist/{chunk-UBDTKNFS.js → chunk-MC4F3VVM.js} +1 -1
- package/dist/{chunk-WPFA7AGD.js → chunk-WG6ZK6L3.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +2 -2
- package/dist/eventcatalog.js +5 -5
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/src/components/SchemaExplorer/AvroSchemaViewer.tsx +5 -3
- package/eventcatalog/src/components/SchemaExplorer/JSONSchemaViewer.tsx +6 -5
- package/eventcatalog/src/components/SchemaExplorer/ProtobufSchemaViewer.tsx +6 -5
- package/eventcatalog/src/features/mcp/apps/architecture-diagram/view.tsx +8 -2
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +2 -2
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +71 -71
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +72 -72
- package/eventcatalog/src/features/mcp/apps/schema-viewer/view.tsx +2 -1
- package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +24 -5
- package/eventcatalog/src/features/mcp/mcp-apps.ts +7 -2
- package/eventcatalog/src/features/mcp/mcp-server.ts +7 -4
- package/eventcatalog/src/features/mcp/schema-viewer.ts +3 -3
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +3 -1
- package/eventcatalog/src/features/studio/tool-sync.ts +67 -3
- package/eventcatalog/src/features/tools/catalog-tools.ts +135 -74
- 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=
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 }
|
|
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;
|
|
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.
|
|
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.
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
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 (
|
|
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 =
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|