@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.
- package/dist/branding.d.ts +20 -0
- package/dist/branding.d.ts.map +1 -0
- package/dist/branding.js +27 -0
- package/dist/branding.js.map +1 -0
- package/dist/diagnostics.d.ts +59 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +117 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/generated/ui-bundle.d.ts +2 -0
- package/dist/generated/ui-bundle.d.ts.map +1 -1
- package/dist/generated/ui-bundle.js +4 -3
- package/dist/generated/ui-bundle.js.map +1 -1
- package/dist/reference-cheatsheet.d.ts +2 -0
- package/dist/reference-cheatsheet.d.ts.map +1 -0
- package/dist/reference-cheatsheet.js +47 -0
- package/dist/reference-cheatsheet.js.map +1 -0
- package/dist/schema-vocab.d.ts +6 -0
- package/dist/schema-vocab.d.ts.map +1 -0
- package/dist/schema-vocab.js +55 -0
- package/dist/schema-vocab.js.map +1 -0
- package/dist/schemas.d.ts +52 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +24 -1
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +312 -165
- package/dist/server.js.map +1 -1
- package/dist/ui/entry.js +148 -110
- package/dist/ui/entry.js.map +1 -1
- package/dist/ui/payload.d.ts +30 -0
- package/dist/ui/payload.d.ts.map +1 -0
- package/dist/ui/payload.js +49 -0
- package/dist/ui/payload.js.map +1 -0
- package/package.json +11 -7
- package/src/branding.ts +26 -0
- package/src/diagnostics.ts +185 -0
- package/src/generated/ui-bundle.ts +5 -3
- package/src/reference-cheatsheet.ts +47 -0
- package/src/schema-vocab.ts +55 -0
- package/src/schemas.ts +28 -1
- package/src/server.ts +419 -200
- package/src/ui/entry.ts +167 -122
- 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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// the
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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;
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
/**
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
172
|
+
// …and when the host changes our width.
|
|
173
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
174
|
+
new ResizeObserver(trigger).observe(root);
|
|
98
175
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
+
}
|