@nowline/mcp 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/branding.d.ts +20 -0
  2. package/dist/branding.d.ts.map +1 -0
  3. package/dist/branding.js +27 -0
  4. package/dist/branding.js.map +1 -0
  5. package/dist/diagnostics.d.ts +59 -0
  6. package/dist/diagnostics.d.ts.map +1 -0
  7. package/dist/diagnostics.js +117 -0
  8. package/dist/diagnostics.js.map +1 -0
  9. package/dist/generated/ui-bundle.d.ts +2 -0
  10. package/dist/generated/ui-bundle.d.ts.map +1 -1
  11. package/dist/generated/ui-bundle.js +4 -3
  12. package/dist/generated/ui-bundle.js.map +1 -1
  13. package/dist/reference-cheatsheet.d.ts +2 -0
  14. package/dist/reference-cheatsheet.d.ts.map +1 -0
  15. package/dist/reference-cheatsheet.js +47 -0
  16. package/dist/reference-cheatsheet.js.map +1 -0
  17. package/dist/schema-vocab.d.ts +6 -0
  18. package/dist/schema-vocab.d.ts.map +1 -0
  19. package/dist/schema-vocab.js +55 -0
  20. package/dist/schema-vocab.js.map +1 -0
  21. package/dist/schemas.d.ts +52 -0
  22. package/dist/schemas.d.ts.map +1 -1
  23. package/dist/schemas.js +24 -1
  24. package/dist/schemas.js.map +1 -1
  25. package/dist/server.d.ts +2 -0
  26. package/dist/server.d.ts.map +1 -1
  27. package/dist/server.js +312 -165
  28. package/dist/server.js.map +1 -1
  29. package/dist/ui/entry.js +148 -110
  30. package/dist/ui/entry.js.map +1 -1
  31. package/dist/ui/payload.d.ts +30 -0
  32. package/dist/ui/payload.d.ts.map +1 -0
  33. package/dist/ui/payload.js +49 -0
  34. package/dist/ui/payload.js.map +1 -0
  35. package/package.json +11 -7
  36. package/src/branding.ts +26 -0
  37. package/src/diagnostics.ts +185 -0
  38. package/src/generated/ui-bundle.ts +5 -3
  39. package/src/reference-cheatsheet.ts +47 -0
  40. package/src/schema-vocab.ts +55 -0
  41. package/src/schemas.ts +28 -1
  42. package/src/server.ts +419 -200
  43. package/src/ui/entry.ts +167 -122
  44. package/src/ui/payload.ts +63 -0
package/src/ui/entry.ts CHANGED
@@ -1,44 +1,37 @@
1
1
  // Browser entry bundled into the MCP Apps in-chat live preview.
2
2
  //
3
- // Unlike the VS Code webview entry which receives pre-rendered SVG from
4
- // the extension host over postMessage this entry runs standalone inside
5
- // the MCP host's sandboxed iframe. It reads the .nowline source the server
6
- // injected as a JSON <script> (#nl-preview-data), renders it to SVG in the
7
- // browser via `renderSource` (@nowline/browser, the same parse → layout →
8
- // render pipeline the embed CDN ships), and mounts the shared preview
9
- // viewport via `mountPreview` (@nowline/preview-shell). No host transport
10
- // is assumed, so the preview works in any MCP Apps host that renders the
11
- // embedded text/html resource.
3
+ // The MCP host loads a pre-declared ui:// HTML resource and hydrates it via the
4
+ // ext-apps handshake. The widget mounts from the earliest signal available:
5
+ // 1. ontoolinput — the LLM's tool arguments, delivered before the server runs.
6
+ // This is the primary, fast path for the common inline-`source` case and
7
+ // mirrors the official ext-apps examples (e.g. mermaid-mcp-app), which paint
8
+ // from tool input rather than waiting on the result notification.
9
+ // 2. ontoolresult the lean { kind: 'nowline.preview', source, } payload the
10
+ // server returns. Authoritative: it carries the resolved `source` even when
11
+ // the caller passed `path:` instead of `source:`, so it reconciles the input
12
+ // render and covers hosts that never deliver ontoolinput.
13
+ // A #nl-preview-data JSON <script> fallback remains for the /widget-preview dev
14
+ // shim and non-handshake degradation.
12
15
  //
13
- // Mirrors packages/vscode-extension/src/preview/webview/entry.ts; the seam
14
- // is that this entry owns the render (no host editor feeding it SVG), so
15
- // theme / now / show-links changes re-render in-place via renderSource.
16
+ // Mirrors packages/vscode-extension/src/preview/webview/entry.ts; this entry
17
+ // owns the render (no host editor feeding it SVG), so theme / now / show-links
18
+ // changes re-render in-place.
16
19
 
17
20
  // Runs in a browser-like iframe, not Node. Pull in the DOM lib only here
18
21
  // (the rest of @nowline/mcp is Node) — mirrors the VS Code webview entry.
19
22
  /// <reference lib="dom" />
20
23
 
21
- import { renderSource } from '@nowline/browser';
22
- import {
23
- mountPreview,
24
- type NowOverride,
25
- type PreviewHandle,
26
- type ThemeOverride,
27
- } from '@nowline/preview-shell';
28
-
29
- /** Server-injected render inputs (see buildPreviewHtml in server.ts). */
30
- interface PreviewPayload {
31
- source: string;
32
- theme?: string;
33
- now?: string;
34
- width?: number;
35
- locale?: string;
36
- showLinks?: boolean;
37
- showMinimap?: boolean;
38
- initialFit?: 'fitPage' | 'fitWidth' | 'actual';
39
- }
24
+ // Injected by bundle-ui.mjs at build time from package.json; not a runtime import.
25
+ declare const __MCP_VERSION__: string;
40
26
 
41
- type DiagramTheme = 'light' | 'dark' | 'grayscale';
27
+ import { App } from '@modelcontextprotocol/ext-apps';
28
+ import { mountLivePreview } from '@nowline/preview';
29
+ import type { NowOverride, ThemeOverride } from '@nowline/preview-shell';
30
+ import {
31
+ type PreviewPayload,
32
+ parsePreviewFromArguments,
33
+ parsePreviewFromContent,
34
+ } from './payload.js';
42
35
 
43
36
  function readPayload(): PreviewPayload | undefined {
44
37
  const el = document.getElementById('nl-preview-data');
@@ -50,7 +43,7 @@ function readPayload(): PreviewPayload | undefined {
50
43
  }
51
44
  }
52
45
 
53
- /** Coerce a raw theme token to the shell's ThemeOverride (defaults to Auto). */
46
+ /** Coerce a raw theme token from the payload to the shell's ThemeOverride. */
54
47
  function toThemeOverride(theme: string | undefined): ThemeOverride {
55
48
  switch (theme) {
56
49
  case 'light':
@@ -64,106 +57,158 @@ function toThemeOverride(theme: string | undefined): ThemeOverride {
64
57
  }
65
58
  }
66
59
 
67
- /** Map a shell ThemeOverride to a renderer ThemeName (Auto → renderer default). */
68
- function toDiagramTheme(theme: ThemeOverride): DiagramTheme | undefined {
69
- switch (theme) {
70
- case 'light':
71
- return 'light';
72
- case 'dark':
73
- return 'dark';
74
- case 'grayscale':
75
- case 'greyscale':
76
- return 'grayscale';
77
- default:
78
- return undefined;
60
+ let mounted = false;
61
+ let mountedSource: string | undefined;
62
+
63
+ function mountFromPayload(payload: PreviewPayload): void {
64
+ const root = document.getElementById('nl-preview-root');
65
+ if (!root || typeof payload.source !== 'string') {
66
+ return;
79
67
  }
68
+
69
+ // Idempotent across the ontoolinput → ontoolresult sequence: the result
70
+ // notification normally repeats the same `source` the input already mounted,
71
+ // so skip it. A genuinely different `source` (e.g. path resolution, or a
72
+ // re-invocation on the same iframe) tears down the prior mount first.
73
+ if (mounted) {
74
+ if (payload.source === mountedSource) return;
75
+ (root as HTMLElement).replaceChildren();
76
+ }
77
+
78
+ mountLivePreview(root as HTMLElement, {
79
+ source: payload.source,
80
+ initialView: {
81
+ theme: toThemeOverride(payload.theme),
82
+ now: (payload.now ?? 'today') as NowOverride,
83
+ showLinks: payload.showLinks !== false,
84
+ },
85
+ renderOptions: {
86
+ width: payload.width,
87
+ locale: payload.locale,
88
+ },
89
+ themeControl: 'show',
90
+ exportControls: 'hide',
91
+ locale: payload.locale,
92
+ // Inline chat widget: fill the width and take the diagram's natural
93
+ // height (reportHeight sizes the iframe to match), rather than fitPage
94
+ // which centers a width-constrained diagram and leaves empty space.
95
+ initialFit: payload.initialFit ?? 'fitWidth',
96
+ showMinimap: payload.showMinimap,
97
+ });
98
+ mounted = true;
99
+ mountedSource = payload.source;
100
+ }
101
+
102
+ // Sizing for a size-to-content host (Claude Desktop), which sizes the iframe
103
+ // from our ui/notifications/size-changed. The shell is a fill-the-container
104
+ // layout (html / body / #nl-preview-root are height:100%) with no intrinsic
105
+ // height, so the ext-apps SDK's default autoResize — which measures
106
+ // documentElement at `max-content` — collapses to 0 and the host shrinks the
107
+ // iframe to nothing (blank, no console error). We disable autoResize and report
108
+ // the *diagram's* height instead: the rendered SVG shown at fit-width, clamped
109
+ // to the host's available height. A short roadmap stays compact (no empty band
110
+ // pushing it below the fold); a tall one is capped and scrolls internally. User
111
+ // zoom does not resize the iframe (the report is viewBox-based, scale-free).
112
+ // VS Code / embed own their panel height and never mount this entry.
113
+ const MIN_HEIGHT_PX = 160;
114
+ const DEFAULT_MAX_HEIGHT_PX = 640;
115
+ const PREPAINT_HEIGHT_PX = 360;
116
+
117
+ function hostMaxHeight(app: App): number {
118
+ const dims = app.getHostContext()?.containerDimensions as
119
+ | { height?: number; maxHeight?: number }
120
+ | undefined;
121
+ return Math.round(dims?.maxHeight ?? dims?.height ?? DEFAULT_MAX_HEIGHT_PX);
80
122
  }
81
123
 
82
- /** Map a shell NowOverride to the renderSource `today` input. */
83
- function toToday(now: NowOverride): Date | string | null | undefined {
84
- if (now === 'today') return undefined;
85
- if (now === 'hide' || now === 'none') return null;
86
- return now;
124
+ /** viewBox aspect of the rendered diagram, or null before the first paint. */
125
+ function diagramNaturalSize(): { w: number; h: number } | null {
126
+ const svg = document.querySelector('#nl-preview-root svg') as SVGSVGElement | null;
127
+ const vb = svg?.viewBox?.baseVal;
128
+ return vb && vb.width > 0 && vb.height > 0 ? { w: vb.width, h: vb.height } : null;
87
129
  }
88
130
 
89
- function bootstrap(): void {
131
+ let lastReportedHeight = -1;
132
+
133
+ function desiredHeight(app: App): number {
134
+ const cap = Math.max(hostMaxHeight(app), MIN_HEIGHT_PX);
135
+ const nat = diagramNaturalSize();
136
+ const width = document.documentElement.clientWidth || 0;
137
+ if (!nat || width <= 0) {
138
+ // Pre-paint, or a diagnostics-only state: a modest non-zero height.
139
+ return Math.min(Math.max(PREPAINT_HEIGHT_PX, MIN_HEIGHT_PX), cap);
140
+ }
141
+ // Fit-width display height = naturalHeight × (width / naturalWidth).
142
+ const fitHeight = Math.ceil(nat.h * (width / nat.w));
143
+ return Math.min(Math.max(fitHeight, MIN_HEIGHT_PX), cap);
144
+ }
145
+
146
+ function reportHeight(app: App): void {
147
+ const height = desiredHeight(app);
148
+ if (height === lastReportedHeight) return;
149
+ lastReportedHeight = height;
150
+ // Concrete base for the height:100% chain (so the fill layout paints) and the
151
+ // height the host should give the iframe.
152
+ document.documentElement.style.height = `${height}px`;
153
+ void app.sendSizeChanged({ height });
154
+ }
155
+
156
+ function watchSize(app: App): void {
90
157
  const root = document.getElementById('nl-preview-root');
91
- if (!root) {
92
- console.error('nowline mcp preview: #nl-preview-root missing');
93
- return;
158
+ if (!root) return;
159
+ let scheduled = false;
160
+ const trigger = (): void => {
161
+ if (scheduled) return;
162
+ scheduled = true;
163
+ requestAnimationFrame(() => {
164
+ scheduled = false;
165
+ reportHeight(app);
166
+ });
167
+ };
168
+ // Re-measure when the diagram (re)renders into the canvas…
169
+ if (typeof MutationObserver !== 'undefined') {
170
+ new MutationObserver(trigger).observe(root, { childList: true, subtree: true });
94
171
  }
95
- const payload = readPayload();
96
- if (!payload || typeof payload.source !== 'string') {
97
- return;
172
+ // …and when the host changes our width.
173
+ if (typeof ResizeObserver !== 'undefined') {
174
+ new ResizeObserver(trigger).observe(root);
98
175
  }
99
- const { source, width, locale } = payload;
100
-
101
- // Live view state — seeded from the server payload, mutated by the
102
- // shell's view-options menu, and read on every re-render.
103
- const current = {
104
- theme: toThemeOverride(payload.theme),
105
- now: (payload.now ?? 'today') as NowOverride,
106
- showLinks: payload.showLinks !== false,
176
+ }
177
+
178
+ async function bootstrap(): Promise<void> {
179
+ // autoResize:false see the sizing block above.
180
+ const app = new App(
181
+ { name: 'NowlinePreview', version: __MCP_VERSION__ },
182
+ {},
183
+ { autoResize: false },
184
+ );
185
+
186
+ // Primary path: paint from the LLM's tool arguments the moment they arrive,
187
+ // before the server finishes rendering. Mirrors the official ext-apps examples.
188
+ app.ontoolinput = ({ arguments: args }) => {
189
+ const payload = parsePreviewFromArguments(args as Record<string, unknown> | undefined);
190
+ if (payload) mountFromPayload(payload);
107
191
  };
108
192
 
109
- let handle: PreviewHandle | undefined;
110
-
111
- async function render(): Promise<void> {
112
- if (!handle) return;
113
- try {
114
- const result = await renderSource(source, {
115
- theme: toDiagramTheme(current.theme),
116
- today: toToday(current.now),
117
- width,
118
- locale,
119
- showLinks: current.showLinks,
120
- });
121
- if (result.kind === 'svg') {
122
- handle.setSvg(result.svg);
123
- handle.setDiagnostics(result.warnings);
124
- } else {
125
- handle.setDiagnostics(result.diagnostics);
126
- }
127
- } catch (err) {
128
- handle.setFatal(err instanceof Error ? err.message : String(err));
129
- }
130
- }
193
+ // Authoritative path: the server-resolved lean preview payload. Reconciles the
194
+ // input render and covers the `path:`-only case and hosts without ontoolinput.
195
+ app.ontoolresult = ({ content, isError }) => {
196
+ if (isError) return;
197
+ const payload = parsePreviewFromContent(content);
198
+ if (payload) mountFromPayload(payload);
199
+ };
131
200
 
132
- handle = mountPreview(root, {
133
- themeControl: 'show',
134
- locale,
135
- initialFit: payload.initialFit,
136
- showMinimap: payload.showMinimap,
137
- viewBaseline: {
138
- theme: current.theme,
139
- now: current.now,
140
- showLinks: current.showLinks,
141
- },
142
- onViewOptions: (overrides) => {
143
- if (overrides.theme !== undefined) current.theme = overrides.theme;
144
- if (overrides.now !== undefined) current.now = overrides.now;
145
- if (overrides.showLinks !== undefined) current.showLinks = overrides.showLinks;
146
- void render();
147
- },
148
- onSave: (req) => {
149
- // Best-effort in-iframe download; the host's iframe sandbox may
150
- // block it, in which case the copy actions remain available.
151
- try {
152
- const type = req.format === 'png' ? 'image/png' : 'image/svg+xml';
153
- const blob = new Blob([req.body as BlobPart], { type });
154
- const url = URL.createObjectURL(blob);
155
- const a = document.createElement('a');
156
- a.href = url;
157
- a.download = `roadmap.${req.format}`;
158
- a.click();
159
- setTimeout(() => URL.revokeObjectURL(url), 1000);
160
- } catch {
161
- // Download blocked by the sandbox — nothing to do.
162
- }
163
- },
164
- });
201
+ // Re-report when the host changes our width / available height.
202
+ app.onhostcontextchanged = () => reportHeight(app);
165
203
 
166
- void render();
204
+ await app.connect();
205
+ watchSize(app);
206
+ reportHeight(app);
207
+
208
+ if (!mounted) {
209
+ const fallback = readPayload();
210
+ if (fallback) mountFromPayload(fallback);
211
+ }
167
212
  }
168
213
 
169
- bootstrap();
214
+ void bootstrap();
@@ -0,0 +1,63 @@
1
+ // Pure helpers shared by the MCP Apps preview entry (src/ui/entry.ts).
2
+ //
3
+ // Kept DOM-free and side-effect-free so they are unit-testable in Node: entry.ts
4
+ // runs bootstrap() on import and touches the iframe DOM, so its logic can't be
5
+ // imported directly under Vitest. The widget derives its render inputs from two
6
+ // ext-apps signals — tool arguments (ontoolinput) and the tool result
7
+ // (ontoolresult) — and both mappings live here.
8
+
9
+ /** Server-injected or per-call render inputs for the live preview. */
10
+ export interface PreviewPayload {
11
+ kind?: string;
12
+ source: string;
13
+ theme?: string;
14
+ now?: string;
15
+ width?: number;
16
+ locale?: string;
17
+ showLinks?: boolean;
18
+ showMinimap?: boolean;
19
+ initialFit?: 'fitPage' | 'fitWidth' | 'actual';
20
+ }
21
+
22
+ /**
23
+ * Extract the lean `{ kind: 'nowline.preview', source, … }` payload from a tool
24
+ * result's content blocks (the `ontoolresult` notification). This is the
25
+ * authoritative path: the server resolves `path:` → `source` before emitting it.
26
+ */
27
+ export function parsePreviewFromContent(
28
+ content: Array<{ type: string; text?: string }> | undefined,
29
+ ): PreviewPayload | undefined {
30
+ if (!content) return undefined;
31
+ for (const block of content) {
32
+ if (block.type !== 'text' || !block.text) continue;
33
+ try {
34
+ const parsed = JSON.parse(block.text) as PreviewPayload;
35
+ if (parsed.kind === 'nowline.preview' && typeof parsed.source === 'string') {
36
+ return parsed;
37
+ }
38
+ } catch {
39
+ /* not JSON — skip */
40
+ }
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ /**
46
+ * Build a preview payload from the LLM's raw `render` tool arguments
47
+ * (the `ontoolinput` notification). Only viable for the inline-`source` case —
48
+ * when the caller passed `path:` instead, the file can't be read in the iframe,
49
+ * so we return undefined and wait for `ontoolresult` (which carries the
50
+ * server-resolved `source`). Locale mirrors the server default (`en-US`).
51
+ */
52
+ export function parsePreviewFromArguments(
53
+ args: Record<string, unknown> | undefined,
54
+ ): PreviewPayload | undefined {
55
+ if (!args || typeof args.source !== 'string') return undefined;
56
+ return {
57
+ source: args.source,
58
+ theme: typeof args.theme === 'string' ? args.theme : undefined,
59
+ now: typeof args.now === 'string' ? args.now : undefined,
60
+ width: typeof args.width === 'number' ? args.width : undefined,
61
+ locale: 'en-US',
62
+ };
63
+ }