@plitzi/sdk-server 0.32.19 → 0.32.20
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/CHANGELOG.md +10 -0
- package/dist/core/services/mcp.js +2 -1
- package/dist/modules/mcp/apps/index.js +7 -3
- package/dist/modules/mcp/apps/render/index.js +3 -3
- package/dist/modules/mcp/apps/render/styles.js +54 -0
- package/dist/modules/mcp/apps/render/view/index.tsx +106 -4
- package/dist/modules/mcp/apps/render/view/streamProgress.ts +137 -0
- package/dist/modules/mcp/apps/shared/page.js +17 -6
- package/dist/modules/mcp/apps/shared/registerApp.js +2 -2
- package/dist/modules/mcp/apps/shared/shell.ejs +5 -0
- package/dist/modules/mcp/resources/renderGuide.js +51 -6
- package/dist/modules/mcp/server.js +3 -3
- package/dist/modules/mcp/tools/render.js +7 -3
- package/dist/modules/mcp/tools/shared/validator/elements.js +11 -1
- package/dist/modules/mcp/tools/shared/validator/index.js +9 -5
- package/dist/src/modules/mcp/apps/index.d.ts +6 -3
- package/dist/src/modules/mcp/apps/render/index.d.ts +1 -0
- package/dist/src/modules/mcp/apps/render/styles.d.ts +10 -0
- package/dist/src/modules/mcp/apps/render/styles.test.d.ts +1 -0
- package/dist/src/modules/mcp/apps/shared/page.d.ts +2 -2
- package/dist/src/modules/mcp/apps/shared/registerApp.d.ts +2 -2
- package/dist/src/modules/mcp/e2e/mcpEndpoint.d.ts +3 -1
- package/dist/src/modules/mcp/e2e/renderingHost.d.ts +5 -0
- package/dist/src/modules/mcp/handler.d.ts +2 -0
- package/dist/src/modules/mcp/server.d.ts +4 -1
- package/dist/src/modules/mcp/tools/shared/validator/context.d.ts +4 -0
- package/dist/src/modules/mcp/tools/shared/validator/elements.d.ts +1 -0
- package/dist/src/modules/mcp/tools/shared/validator/index.d.ts +3 -3
- package/dist/src/modules/mcp/types/appTypes.d.ts +9 -1
- package/package.json +4 -4
- package/skills/plitzi-render/SKILL.md +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,8 @@ var serveMcp = async (ctx) => {
|
|
|
8
8
|
ctx.operation = await handleMcp(ctx.raw, ctx.rawRes, ctx.req, adapters, {
|
|
9
9
|
preview: previewClient ? createHttpPreviewClient(previewClient) : void 0,
|
|
10
10
|
screenshot: screenshot ? createHttpScreenshotClient(screenshot) : void 0,
|
|
11
|
-
logger
|
|
11
|
+
logger,
|
|
12
|
+
renderStreaming: ctx.config.mcpAi?.renderStreaming
|
|
12
13
|
});
|
|
13
14
|
};
|
|
14
15
|
var mcpStage = async (ctx) => {
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { registerApp } from "./shared/registerApp.js";
|
|
2
|
+
import { iconFontCss } from "./render/styles.js";
|
|
2
3
|
import { RENDER_APP_URI, renderApp } from "./render/index.js";
|
|
3
4
|
//#region src/modules/mcp/apps/index.ts
|
|
5
|
+
/** What a view gets when the deployment says nothing: streaming on, because a host that streams no arguments
|
|
6
|
+
* paints exactly what it painted before. */
|
|
7
|
+
var DEFAULT_VIEW_SETTINGS = { streaming: true };
|
|
4
8
|
/** Every MCP App this server serves. A new one is a folder beside `render/` (its definition + its view) and one
|
|
5
9
|
* line here — the tool that opens it points at its `uri` through `ui: { resourceUri }`. Start from `example/`,
|
|
6
10
|
* which is that same shape stripped to the minimum. */
|
|
7
11
|
var apps = [renderApp];
|
|
8
|
-
var registerApps = (server) => {
|
|
9
|
-
for (const app of apps) registerApp(server, app);
|
|
12
|
+
var registerApps = (server, settings = DEFAULT_VIEW_SETTINGS) => {
|
|
13
|
+
for (const app of apps) registerApp(server, app, settings);
|
|
10
14
|
};
|
|
11
15
|
//#endregion
|
|
12
|
-
export { RENDER_APP_URI, apps, registerApps };
|
|
16
|
+
export { DEFAULT_VIEW_SETTINGS, RENDER_APP_URI, apps, iconFontCss, registerApps };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { VIEW_DIR } from "../shared/assets.js";
|
|
2
|
-
import {
|
|
2
|
+
import { iconFontCss, widgetCss } from "./styles.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
//#region src/modules/mcp/apps/render/index.ts
|
|
@@ -11,7 +11,7 @@ var renderApp = {
|
|
|
11
11
|
title: "Plitzi widget",
|
|
12
12
|
description: "Interactive view that renders a plitzi_render widget with the Plitzi SDK.",
|
|
13
13
|
entry: path.join(HERE, VIEW_DIR, "index.tsx"),
|
|
14
|
-
styles: () => [
|
|
14
|
+
styles: () => [widgetCss()]
|
|
15
15
|
};
|
|
16
16
|
//#endregion
|
|
17
|
-
export { RENDER_APP_URI, renderApp };
|
|
17
|
+
export { RENDER_APP_URI, iconFontCss, renderApp };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { require } from "../shared/resolve.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
//#region src/modules/mcp/apps/render/styles.ts
|
|
5
|
+
/** The SDK stylesheet, split in two.
|
|
6
|
+
*
|
|
7
|
+
* Everything the page shell inlines is paid for by EVERY widget: the host loads the ui:// page into a fresh
|
|
8
|
+
* sandboxed iframe and parses it whole before anything paints, which is the "Rendering…" the user waits through.
|
|
9
|
+
* Three of those @font-face blocks are Font Awesome shipped as base64 woff2 — ~330 KB, close to a fifth of the
|
|
10
|
+
* page — and only a widget that authored a `fontAwesome` element has any use for them.
|
|
11
|
+
*
|
|
12
|
+
* So the shell takes the base, and the fonts travel in the tool result of the renders that actually draw an icon
|
|
13
|
+
* (see tools/render.ts). A widget without icons never downloads a glyph; one with icons looks exactly the same as
|
|
14
|
+
* before. */
|
|
15
|
+
/** Where the closing brace of the block opened at `open` is, so a nested one cannot end it early. Returns the
|
|
16
|
+
* string's length when the CSS is truncated mid-block, which drops that tail rather than mixing it into the base. */
|
|
17
|
+
var blockEnd = (css, open) => {
|
|
18
|
+
let depth = 0;
|
|
19
|
+
for (let at = open; at < css.length; at++) if (css[at] === "{") depth++;
|
|
20
|
+
else if (css[at] === "}") {
|
|
21
|
+
depth--;
|
|
22
|
+
if (depth === 0) return at + 1;
|
|
23
|
+
}
|
|
24
|
+
return css.length;
|
|
25
|
+
};
|
|
26
|
+
/** Lifts every @font-face block out of a stylesheet. Brace counting rather than a CSS parser: a @font-face body is
|
|
27
|
+
* plain declarations, and base64 payloads and url() values carry no braces of their own. */
|
|
28
|
+
var splitFontFaces = (css) => {
|
|
29
|
+
const base = [];
|
|
30
|
+
const fonts = [];
|
|
31
|
+
let cursor = 0;
|
|
32
|
+
for (let at = css.indexOf("@font-face", cursor); at !== -1; at = css.indexOf("@font-face", cursor)) {
|
|
33
|
+
const open = css.indexOf("{", at);
|
|
34
|
+
if (open === -1) break;
|
|
35
|
+
const end = blockEnd(css, open);
|
|
36
|
+
base.push(css.slice(cursor, at));
|
|
37
|
+
fonts.push(css.slice(at, end));
|
|
38
|
+
cursor = end;
|
|
39
|
+
}
|
|
40
|
+
base.push(css.slice(cursor));
|
|
41
|
+
return {
|
|
42
|
+
base: base.join(""),
|
|
43
|
+
fonts: fonts.join("")
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
var stylesheet = () => readFileSync(path.join(path.dirname(require.resolve("@plitzi/plitzi-sdk")), "plitzi-sdk.css"), "utf-8");
|
|
47
|
+
var split;
|
|
48
|
+
var sdkStyles = () => split ??= splitFontFaces(stylesheet());
|
|
49
|
+
/** What the page shell inlines: the SDK stylesheet without the icon fonts. */
|
|
50
|
+
var widgetCss = () => sdkStyles().base;
|
|
51
|
+
/** The icon fonts, for the renders that draw one. */
|
|
52
|
+
var iconFontCss = () => sdkStyles().fonts;
|
|
53
|
+
//#endregion
|
|
54
|
+
export { iconFontCss, splitFontFaces, widgetCss };
|
|
@@ -5,7 +5,9 @@ import { Component, useEffect, useRef, useState } from 'react';
|
|
|
5
5
|
import { createRoot } from 'react-dom/client';
|
|
6
6
|
|
|
7
7
|
import { readHeldBatch, writeHeldBatch } from './heldBatch';
|
|
8
|
+
import { mergeProgress, streamProgress } from './streamProgress';
|
|
8
9
|
|
|
10
|
+
import type { StreamProgress } from './streamProgress';
|
|
9
11
|
import type { App, McpUiHostContext } from '@modelcontextprotocol/ext-apps';
|
|
10
12
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
11
13
|
import type { OfflineDataRaw } from '@plitzi/sdk-shared';
|
|
@@ -25,6 +27,62 @@ const ErrorPanel = ({ title, details }: { title: string; details: string }) => (
|
|
|
25
27
|
</div>
|
|
26
28
|
);
|
|
27
29
|
|
|
30
|
+
/** The wait a user reads as "this is slow" is the model TYPING the batch: the tool only runs once the last brace
|
|
31
|
+
* is written, and until then the page shows its static "Rendering…". The host streams the arguments as they
|
|
32
|
+
* arrive, so this stands in for the widget meanwhile — bars that grow with the elements already authored, and the
|
|
33
|
+
* widget's own title as soon as the batch names it. It never shows a half-built widget: the real one replaces it
|
|
34
|
+
* wholesale when the result lands. A host that streams nothing paints none of this and keeps the static text. */
|
|
35
|
+
const SKELETON_BAR_WIDTHS = ['92%', '78%', '85%', '64%', '88%', '72%'];
|
|
36
|
+
|
|
37
|
+
// The deployment's own switch (mcpAi.renderStreaming), handed over by the page. Absent means on: a page built
|
|
38
|
+
// before the setting existed carries none, and a host that streams no arguments paints the same either way.
|
|
39
|
+
const streamingEnabled =
|
|
40
|
+
(globalThis as { __PLITZI_VIEW__?: { streaming?: boolean } }).__PLITZI_VIEW__?.streaming !== false;
|
|
41
|
+
|
|
42
|
+
const skeletonBarStyle = {
|
|
43
|
+
height: 10,
|
|
44
|
+
borderRadius: 5,
|
|
45
|
+
backgroundColor: 'var(--color-background-tertiary, light-dark(#e2e8f0, #333a48))',
|
|
46
|
+
animation: 'plitzi-skeleton-pulse 1.4s ease-in-out infinite'
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
// One bar per few elements, so the block visibly grows while the model writes, and a ceiling so a 200-element
|
|
50
|
+
// widget does not fill the panel with grey. Total by construction: this paints a wait, and a placeholder that
|
|
51
|
+
// renders nothing at all (an empty slice off a NaN) would read as the view having died.
|
|
52
|
+
const barCount = (elements: number): number => {
|
|
53
|
+
const bars = Math.ceil(elements / 3);
|
|
54
|
+
|
|
55
|
+
return Number.isFinite(bars) ? Math.min(Math.max(bars, 2), 6) : 2;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const StreamSkeleton = ({ progress }: { progress: StreamProgress }) => {
|
|
59
|
+
const caption = progress.patch ? 'Updating the widget…' : 'Building the widget…';
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
role="status"
|
|
64
|
+
aria-live="polite"
|
|
65
|
+
style={{
|
|
66
|
+
display: 'flex',
|
|
67
|
+
flexDirection: 'column',
|
|
68
|
+
gap: 10,
|
|
69
|
+
padding: 16,
|
|
70
|
+
font: '13px/1.5 var(--font-sans, system-ui, sans-serif)'
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<style>{'@keyframes plitzi-skeleton-pulse { 0%, 100% { opacity: 0.5 } 50% { opacity: 1 } }'}</style>
|
|
74
|
+
{progress.title && <strong style={{ fontSize: 15 }}>{progress.title}</strong>}
|
|
75
|
+
{SKELETON_BAR_WIDTHS.slice(0, barCount(progress.elements)).map(width => (
|
|
76
|
+
<div key={width} style={{ ...skeletonBarStyle, width }} />
|
|
77
|
+
))}
|
|
78
|
+
<span style={{ color: 'var(--color-text-tertiary, light-dark(#64748b, #9aa4b2))' }}>
|
|
79
|
+
{caption}
|
|
80
|
+
{progress.elements > 0 && ` ${progress.elements} elements so far`}
|
|
81
|
+
</span>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
28
86
|
class RenderBoundary extends Component<{ children: ReactNode }, { error?: Error }> {
|
|
29
87
|
state: { error?: Error } = {};
|
|
30
88
|
|
|
@@ -56,10 +114,36 @@ const summarise = (result: CallToolResult): string => {
|
|
|
56
114
|
|
|
57
115
|
const RenderApp = () => {
|
|
58
116
|
const [result, setResult] = useState<CallToolResult | null>(null);
|
|
117
|
+
const [progress, setProgress] = useState<StreamProgress | undefined>(undefined);
|
|
59
118
|
const [cancelled, setCancelled] = useState<string | undefined>(undefined);
|
|
60
119
|
const [context, setContext] = useState<McpUiHostContext | undefined>(undefined);
|
|
61
120
|
const held = useRef<Held>({ operations: [] });
|
|
62
121
|
const appRef = useRef<App | null>(null);
|
|
122
|
+
// The handlers are registered once, so they close over the FIRST render's state: a ref is the only way they can
|
|
123
|
+
// tell whether a widget is already on screen.
|
|
124
|
+
const painted = useRef(false);
|
|
125
|
+
|
|
126
|
+
const showWidget = (toolResult: CallToolResult): void => {
|
|
127
|
+
painted.current = true;
|
|
128
|
+
setResult(toolResult);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Streamed arguments are the one input here nobody validated: healed JSON, from a host, about a call that does
|
|
132
|
+
* not exist yet. So it is fenced off — it feeds the placeholder and NOTHING else (never the held batch, never
|
|
133
|
+
* storage, never the server), it cannot run once a widget is up, and it cannot throw: a notification handler
|
|
134
|
+
* that raises would break the App's dispatch, and this one exists only to decorate a wait. */
|
|
135
|
+
const trackInput = (args: unknown): void => {
|
|
136
|
+
if (painted.current) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const next = streamProgress(args);
|
|
142
|
+
setProgress(previous => mergeProgress(previous, next));
|
|
143
|
+
} catch {
|
|
144
|
+
// A placeholder that stops growing is a non-event; the widget is still on its way.
|
|
145
|
+
}
|
|
146
|
+
};
|
|
63
147
|
|
|
64
148
|
// A patch carries only what changed. Merging it onto the held batch and re-calling the tool is what keeps the
|
|
65
149
|
// server stateless: it re-renders the WHOLE widget (so refs, integrity and the audit are all checked as usual)
|
|
@@ -98,7 +182,7 @@ const RenderApp = () => {
|
|
|
98
182
|
const applied = (rendered.structuredContent?.operations as unknown[] | undefined) ?? merged;
|
|
99
183
|
held.current = { renderId, operations: applied };
|
|
100
184
|
writeHeldBatch(renderId, applied);
|
|
101
|
-
|
|
185
|
+
showWidget(rendered);
|
|
102
186
|
}
|
|
103
187
|
|
|
104
188
|
await app.updateModelContext({
|
|
@@ -131,6 +215,15 @@ const RenderApp = () => {
|
|
|
131
215
|
capabilities: {},
|
|
132
216
|
onAppCreated: instance => {
|
|
133
217
|
appRef.current = instance;
|
|
218
|
+
// Streaming arguments (sent zero or more times while the model writes) and then the complete ones, which the
|
|
219
|
+
// host MUST send before the result: the last frame the placeholder gets covers the server render itself.
|
|
220
|
+
// The `on*` setters are the deprecated half of the API, kept because every other handler here uses them —
|
|
221
|
+
// mixing addEventListener for one of them would hide the lifecycle this component is built around.
|
|
222
|
+
if (streamingEnabled) {
|
|
223
|
+
instance.ontoolinputpartial = params => trackInput(params.arguments);
|
|
224
|
+
instance.ontoolinput = params => trackInput(params.arguments);
|
|
225
|
+
}
|
|
226
|
+
|
|
134
227
|
instance.ontoolresult = toolResult => {
|
|
135
228
|
const renderId = toolResult.structuredContent?.renderId as string | undefined;
|
|
136
229
|
if (toolResult.structuredContent?.patch === true && renderId) {
|
|
@@ -152,7 +245,7 @@ const RenderApp = () => {
|
|
|
152
245
|
writeHeldBatch(renderId, operations);
|
|
153
246
|
}
|
|
154
247
|
|
|
155
|
-
|
|
248
|
+
showWidget(toolResult);
|
|
156
249
|
};
|
|
157
250
|
instance.ontoolcancelled = params => setCancelled(params.reason ?? 'The host cancelled the render.');
|
|
158
251
|
instance.onhostcontextchanged = params => setContext(previous => ({ ...previous, ...params }));
|
|
@@ -174,8 +267,13 @@ const RenderApp = () => {
|
|
|
174
267
|
return <ErrorPanel title="Could not render the widget" details={failure} />;
|
|
175
268
|
}
|
|
176
269
|
|
|
177
|
-
//
|
|
270
|
+
// Waiting for the result. Once the host has streamed any of the arguments the skeleton stands in for the widget
|
|
271
|
+
// being written; before that (and on a host that streams nothing) the page's own CSS placeholder covers it.
|
|
178
272
|
if (!result) {
|
|
273
|
+
if (progress) {
|
|
274
|
+
return <StreamSkeleton progress={progress} />;
|
|
275
|
+
}
|
|
276
|
+
|
|
179
277
|
return null;
|
|
180
278
|
}
|
|
181
279
|
|
|
@@ -190,6 +288,9 @@ const RenderApp = () => {
|
|
|
190
288
|
}
|
|
191
289
|
|
|
192
290
|
const insets = context?.safeAreaInsets;
|
|
291
|
+
// The icon fonts, ~330 KB of base64 woff2, are not in the page: they arrive with the widgets that draw an icon,
|
|
292
|
+
// so every other widget's iframe has that much less to load before it paints (see ../styles.ts).
|
|
293
|
+
const iconCss = result.structuredContent?.iconCss as string | undefined;
|
|
193
294
|
|
|
194
295
|
return (
|
|
195
296
|
<div
|
|
@@ -197,8 +298,9 @@ const RenderApp = () => {
|
|
|
197
298
|
padding: `${insets?.top ?? 0}px ${insets?.right ?? 0}px ${insets?.bottom ?? 0}px ${insets?.left ?? 0}px`
|
|
198
299
|
}}
|
|
199
300
|
>
|
|
301
|
+
{iconCss && <style>{iconCss}</style>}
|
|
200
302
|
<RenderBoundary>
|
|
201
|
-
<PlitziSdk offlineData={offlineData} offlineMode environment="main" renderMode="raw" />
|
|
303
|
+
<PlitziSdk offlineData={offlineData} offlineMode environment="main" renderMode="raw" branding={false} />
|
|
202
304
|
</RenderBoundary>
|
|
203
305
|
</div>
|
|
204
306
|
);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/** What the host has streamed of the tool call so far, reduced to the little a placeholder can honestly show.
|
|
2
|
+
*
|
|
3
|
+
* The wait a user sees is almost entirely the model TYPING this batch — the server render takes milliseconds —
|
|
4
|
+
* so the only way to look alive is to read the arguments while they are still arriving. The host sends them as
|
|
5
|
+
* "healed" JSON (it closes the braces the model has not written yet), which means every field may be missing and
|
|
6
|
+
* the last operation is usually half-written: nothing here may assume a shape, and a fragment it cannot read
|
|
7
|
+
* simply contributes nothing.
|
|
8
|
+
*
|
|
9
|
+
* Two properties this module owes the view, because a placeholder that misbehaves is worse than none:
|
|
10
|
+
* - it is TOTAL — no input throws, and every number it returns is a finite count;
|
|
11
|
+
* - it only ever moves FORWARD (see mergeProgress) — healing is best-effort and the spec warns that fields may
|
|
12
|
+
* change between notifications, so a frame that recovers less than the one before it must not shrink the
|
|
13
|
+
* placeholder, which the eye reads as flicker. */
|
|
14
|
+
export type StreamProgress = { elements: number; title?: string; patch: boolean };
|
|
15
|
+
|
|
16
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
17
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
|
|
19
|
+
const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);
|
|
20
|
+
|
|
21
|
+
// The walk is over a structure this code did not build, so it is bounded rather than trusted: a pathological
|
|
22
|
+
// nesting would otherwise turn a placeholder into a stack overflow, which kills the view for good — including
|
|
23
|
+
// the widget that was on its way. Far beyond anything a real widget nests.
|
|
24
|
+
const MAX_DEPTH = 64;
|
|
25
|
+
|
|
26
|
+
// A title is a label in a small panel: whitespace collapsed so a multi-line heading cannot push the bars off
|
|
27
|
+
// screen, and cut where it stops being a label.
|
|
28
|
+
const MAX_TITLE = 80;
|
|
29
|
+
|
|
30
|
+
// A repeat renders its template once per row, so the rows are what the user will actually count on screen. An
|
|
31
|
+
// items list that has not started streaming yet still stands for at least the one template being written.
|
|
32
|
+
const rowsOf = (items: unknown): number => Math.max(asArray(items).length, 1);
|
|
33
|
+
|
|
34
|
+
const countElements = (node: unknown, depth = 0): number => {
|
|
35
|
+
if (!isRecord(node) || depth > MAX_DEPTH) {
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const repeat = isRecord(node.repeat) ? node.repeat : undefined;
|
|
40
|
+
const nested = repeat ? countElements(repeat.template, depth + 1) * rowsOf(repeat.items) : 0;
|
|
41
|
+
|
|
42
|
+
return asArray(node.children).reduce<number>((total, child) => total + countElements(child, depth + 1), 1 + nested);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// A heading's content arrives one fragment at a time, so this runs against half-written text: `{{` rules out a
|
|
46
|
+
// repeat template's placeholder (a literal "{{item.name}}" on screen reads as a bug, not as progress), and an
|
|
47
|
+
// empty string is what a heading looks like before its content exists at all.
|
|
48
|
+
const titleOf = (content: unknown): string | undefined => {
|
|
49
|
+
if (typeof content !== 'string' || content.includes('{{')) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const text = content.replace(/\s+/gu, ' ').trim();
|
|
54
|
+
if (text === '') {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return text.length > MAX_TITLE ? `${text.slice(0, MAX_TITLE)}…` : text;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// The first heading the batch declares is the widget's own title far more often than not, so showing it turns the
|
|
62
|
+
// placeholder from "something is happening" into "your pricing table is coming".
|
|
63
|
+
const findTitle = (node: unknown, depth = 0): string | undefined => {
|
|
64
|
+
if (!isRecord(node) || depth > MAX_DEPTH) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const props = isRecord(node.props) ? node.props : undefined;
|
|
69
|
+
if (node.type === 'heading') {
|
|
70
|
+
const title = titleOf(props?.content);
|
|
71
|
+
if (title !== undefined) {
|
|
72
|
+
return title;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const child of asArray(node.children)) {
|
|
77
|
+
const found = findTitle(child, depth + 1);
|
|
78
|
+
if (found !== undefined) {
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return undefined;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// An op either builds a tree (upsertElement) or repeats one (repeatElement); anything else — a definition, a
|
|
87
|
+
// binding, the truncated tail — adds no elements to count.
|
|
88
|
+
const treeOf = (op: Record<string, unknown>): unknown => {
|
|
89
|
+
if (isRecord(op.element)) {
|
|
90
|
+
return op.element;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return isRecord(op.template) ? op.template : undefined;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const streamProgress = (args: unknown): StreamProgress => {
|
|
97
|
+
const record = isRecord(args) ? args : {};
|
|
98
|
+
let elements = 0;
|
|
99
|
+
let title: string | undefined;
|
|
100
|
+
|
|
101
|
+
for (const op of asArray(record.operations)) {
|
|
102
|
+
if (!isRecord(op)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const tree = treeOf(op);
|
|
107
|
+
if (tree === undefined) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
elements += countElements(tree) * (isRecord(op.template) ? rowsOf(op.items) : 1);
|
|
112
|
+
title ??= findTitle(tree);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { elements: Number.isFinite(elements) ? elements : 0, title, patch: record.patch === true };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Fold a freshly read frame into what the placeholder already shows. Monotonic on purpose — the count never goes
|
|
119
|
+
* down and a title, once read, is never taken away — because the healed JSON of frame N+1 can legitimately
|
|
120
|
+
* recover LESS than frame N, and a placeholder that shrinks and grows is the flicker this whole thing exists to
|
|
121
|
+
* avoid. Returns the previous object unchanged when nothing moved, so an unchanged frame costs no re-render. */
|
|
122
|
+
export const mergeProgress = (previous: StreamProgress | undefined, next: StreamProgress): StreamProgress => {
|
|
123
|
+
if (!previous) {
|
|
124
|
+
return next;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const merged: StreamProgress = {
|
|
128
|
+
elements: Math.max(previous.elements, next.elements),
|
|
129
|
+
title: next.title ?? previous.title,
|
|
130
|
+
patch: previous.patch || next.patch
|
|
131
|
+
};
|
|
132
|
+
if (merged.elements === previous.elements && merged.title === previous.title && merged.patch === previous.patch) {
|
|
133
|
+
return previous;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return merged;
|
|
137
|
+
};
|
|
@@ -6,16 +6,27 @@ import ejs from "ejs";
|
|
|
6
6
|
//#region src/modules/mcp/apps/shared/page.ts
|
|
7
7
|
var SHELL = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "shell.ejs");
|
|
8
8
|
var template = () => ejs.compile(readFileSync(SHELL, "utf-8"), { filename: SHELL });
|
|
9
|
+
var scripts = /* @__PURE__ */ new Map();
|
|
9
10
|
var pages = /* @__PURE__ */ new Map();
|
|
10
|
-
var
|
|
11
|
-
let
|
|
11
|
+
var script = (entry) => {
|
|
12
|
+
let built = scripts.get(entry);
|
|
13
|
+
if (!built) {
|
|
14
|
+
built = bundle(entry);
|
|
15
|
+
scripts.set(entry, built);
|
|
16
|
+
}
|
|
17
|
+
return built;
|
|
18
|
+
};
|
|
19
|
+
var page = (app, settings) => {
|
|
20
|
+
const key = `${app.uri}|${JSON.stringify(settings)}`;
|
|
21
|
+
let html = pages.get(key);
|
|
12
22
|
if (!html) {
|
|
13
|
-
html =
|
|
23
|
+
html = script(app.entry).then((code) => template()({
|
|
14
24
|
title: app.title,
|
|
15
|
-
app:
|
|
16
|
-
css: (app.styles?.() ?? []).
|
|
25
|
+
app: code,
|
|
26
|
+
css: (app.styles?.() ?? []).join("\n"),
|
|
27
|
+
settings: JSON.stringify(settings)
|
|
17
28
|
}));
|
|
18
|
-
pages.set(
|
|
29
|
+
pages.set(key, html);
|
|
19
30
|
}
|
|
20
31
|
return html;
|
|
21
32
|
};
|
|
@@ -11,7 +11,7 @@ var DEFAULT_CSP = {
|
|
|
11
11
|
};
|
|
12
12
|
/** Serves the app as a self-contained page: no import map, no asset mounts, no cross-origin fetches, so the
|
|
13
13
|
* strictest host sandbox runs it and no deployment has to serve anything extra. */
|
|
14
|
-
var registerApp = (server, app) => {
|
|
14
|
+
var registerApp = (server, app, settings) => {
|
|
15
15
|
const meta = { ui: { csp: app.csp ?? DEFAULT_CSP } };
|
|
16
16
|
registerAppResource(server, app.name, app.uri, {
|
|
17
17
|
description: app.description,
|
|
@@ -19,7 +19,7 @@ var registerApp = (server, app) => {
|
|
|
19
19
|
}, async () => ({ contents: [{
|
|
20
20
|
uri: app.uri,
|
|
21
21
|
mimeType: RESOURCE_MIME_TYPE,
|
|
22
|
-
text: await page(app),
|
|
22
|
+
text: await page(app, settings),
|
|
23
23
|
_meta: meta
|
|
24
24
|
}] }));
|
|
25
25
|
};
|
|
@@ -47,6 +47,11 @@
|
|
|
47
47
|
<body>
|
|
48
48
|
<div id="app"></div>
|
|
49
49
|
|
|
50
|
+
<%# Deployment switches, before the app so the view reads them as it mounts. They ride in the page, not the
|
|
51
|
+
bundle, so a server serving both settings still builds the browser bundle once. -%>
|
|
52
|
+
<script id="plitzi-view-settings">
|
|
53
|
+
window.__PLITZI_VIEW__ = <%- settings %>;
|
|
54
|
+
</script>
|
|
50
55
|
<script>
|
|
51
56
|
<%- app %>
|
|
52
57
|
</script>
|
|
@@ -10,6 +10,8 @@ var RENDER_TYPE_CATEGORIES = /* @__PURE__ */ new Set([
|
|
|
10
10
|
"form",
|
|
11
11
|
"provider"
|
|
12
12
|
]);
|
|
13
|
+
var RENDER_RAW_HTML_TYPE = "blockHtml";
|
|
14
|
+
var rawHtmlDescription = "Renders a raw HTML string (props.content) — in a widget, the way to draw an inline <svg>: a logo, an icon, a sparkline, a decorative shape. Use fill/stroke \"currentColor\" so it takes the host theme. Markup only: <script>, javascript: URLs and inline on* handlers are rejected.";
|
|
13
15
|
var renderTypesNote = "The built-in element types you can put in a plitzi_render widget, grouped by category. Pick by `description`. For the props of each type and the full authoring model, read plitzi://render/guide. Types that need a backend (providers) or a plugin are omitted because the widget renders offline.";
|
|
14
16
|
var renderTypes = () => {
|
|
15
17
|
const types = {};
|
|
@@ -18,6 +20,12 @@ var renderTypes = () => {
|
|
|
18
20
|
category: info.category,
|
|
19
21
|
description: info.description
|
|
20
22
|
};
|
|
23
|
+
const rawHtml = BUILTIN_COMPONENTS[RENDER_RAW_HTML_TYPE];
|
|
24
|
+
if (rawHtml) types[RENDER_RAW_HTML_TYPE] = {
|
|
25
|
+
label: rawHtml.label,
|
|
26
|
+
category: rawHtml.category,
|
|
27
|
+
description: rawHtmlDescription
|
|
28
|
+
};
|
|
21
29
|
return {
|
|
22
30
|
note: renderTypesNote,
|
|
23
31
|
types
|
|
@@ -256,24 +264,61 @@ The host publishes its palette as CSS variables on the page, so use them for eve
|
|
|
256
264
|
| \`video\` | embedded video | \`props.src\` |
|
|
257
265
|
| \`list\` / \`listItem\` | \`<ul>\` / \`<li>\` | nesting only |
|
|
258
266
|
| \`markdown\` | rendered markdown | \`props.content\` |
|
|
267
|
+
| \`blockHtml\` | a raw HTML string, as written | \`props.content\` — how you draw an inline \`<svg>\`, see below |
|
|
259
268
|
|
|
260
269
|
\`subType\` is an element-level field (not a prop). Guessing is safe: an unknown prop for a type comes back as a
|
|
261
270
|
**warning naming the right one**, not an error. This table covers the everyday types; the resource
|
|
262
271
|
**plitzi://render/types** lists every built-in type you can use (with descriptions) — read it when you need one
|
|
263
272
|
that is not here (lists, tabs, dialogs, forms, icons…).
|
|
264
273
|
|
|
265
|
-
\`image\`/\`video\` \`src\` accepts any \`https\` URL, or a \`data:\`/\`blob:\` URI for a fully self-contained graphic
|
|
266
|
-
|
|
274
|
+
\`image\`/\`video\` \`src\` accepts any \`https\` URL, or a \`data:\`/\`blob:\` URI for a fully self-contained graphic (a
|
|
275
|
+
base64 raster) — both render with no extra setup. For a **vector** graphic do not encode a \`data:\` URI: draw it
|
|
276
|
+
inline, as below.
|
|
277
|
+
|
|
278
|
+
## Draw with inline SVG
|
|
279
|
+
|
|
280
|
+
A widget can draw its own graphics, and it is often the difference between a plain block of text and something
|
|
281
|
+
worth showing: a brand mark, a sparkline or donut, a badge, an empty-state glyph, a wave or blob behind a header,
|
|
282
|
+
an arrow between two steps. Put the SVG **inline** in a \`blockHtml\` element — its \`props.content\` is rendered as
|
|
283
|
+
written, so the markup needs no escaping, and the element is styled and laid out like any other box.
|
|
284
|
+
|
|
285
|
+
\`\`\`json
|
|
286
|
+
{
|
|
287
|
+
"operations": [
|
|
288
|
+
{ "type": "upsertDefinitions", "definitions": {
|
|
289
|
+
"mark": { "desktop": { "display": "flex", "width": "32px", "height": "32px", "color": "var(--color-text-primary, light-dark(#0f172a, #e8eaed))" } }
|
|
290
|
+
} },
|
|
291
|
+
{ "type": "upsertElement", "pageRef": "render", "element": {
|
|
292
|
+
"ref": "trend", "type": "blockHtml", "style": { "base": ["mark"] },
|
|
293
|
+
"props": { "content": "<svg viewBox='0 0 24 24' width='100%' height='100%' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round'><path d='M3 17l6-6 4 4 8-8'/><path d='M21 7v6h-6'/></svg>" }
|
|
294
|
+
} }
|
|
295
|
+
]
|
|
296
|
+
}
|
|
297
|
+
\`\`\`
|
|
298
|
+
- **Size from the class, not the drawing**: keep a \`viewBox\` and set \`width\`/\`height\` to \`100%\`, then the
|
|
299
|
+
\`blockHtml\`'s own \`width\`/\`height\` decide how big it renders — one drawing, any size.
|
|
300
|
+
- **Colour with \`currentColor\`** (\`fill\` and/or \`stroke\`) and set \`color\` on the class from a host variable. A hex
|
|
301
|
+
buried inside the path is the same mistake as a hardcoded light palette: it cannot follow the theme.
|
|
302
|
+
- **Single quotes inside the markup** keep the JSON free of \`\\"\` escapes, as above.
|
|
303
|
+
- **Markup only.** \`<script>\`, \`javascript:\` URLs and inline \`on*\` handlers are rejected with an error — a widget
|
|
304
|
+
runs inside the host's UI. Animate with CSS (a \`states\` block, a \`transition\`) and wire behaviour with
|
|
305
|
+
interaction flows.
|
|
306
|
+
|
|
307
|
+
**The budget.** Every path is text you write, so an SVG earns its place only while it stays small: a handful of
|
|
308
|
+
paths, a few hundred characters, drawn ONCE and reused (same class, same content, or a \`repeatElement\` row) rather
|
|
309
|
+
than redrawn per card. Skip it entirely when something cheaper does the job — a \`fontAwesome\` icon, an emoji in a
|
|
310
|
+
\`text\`, an \`https\` image the user gave you, a CSS gradient or a \`border-radius\` shape. And a full illustration or
|
|
311
|
+
a photo-real scene (a castle, a skyline, a detailed mascot) is still the wrong trade: it costs more than the entire
|
|
312
|
+
rest of the widget and renders worse than the image it is imitating.
|
|
267
313
|
|
|
268
314
|
## Keep the call small
|
|
269
315
|
|
|
270
316
|
Everything in \`operations\` is text you write, and a widget that takes two calls because the first ran long is a
|
|
271
317
|
widget the user waits twice for. Two habits pay for themselves:
|
|
272
318
|
|
|
273
|
-
- **
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
the decoration. A small self-contained \`data:\` SVG is fine for a **simple** shape (a check, an arrow, a dot).
|
|
319
|
+
- **Keep a drawing to a drawing.** Inline SVG is allowed and often worth it (see above), but a graphic that runs
|
|
320
|
+
longer than the widget around it has stopped paying for itself: a handful of paths, drawn once and reused rather
|
|
321
|
+
than redrawn per card, and an \`https\` image, a flat colour or a two-stop \`linear-gradient\` for anything bigger.
|
|
277
322
|
- **One class per look, not per property.** Classes like \`tone-blue\` + \`tone-blue-text\` + \`tone-blue-bg\` for the
|
|
278
323
|
same card triple the declarations and the attachments. Put everything the look needs in one class, add a second
|
|
279
324
|
only for the part that genuinely varies between siblings (a colour), and reuse it — the whole point of a class.
|
|
@@ -13,7 +13,7 @@ var asText = (data) => ({ content: [{
|
|
|
13
13
|
type: "text",
|
|
14
14
|
text: JSON.stringify(data)
|
|
15
15
|
}] });
|
|
16
|
-
var createMcpServer = async ({ adapters, getSpaceId, preview, screenshot, logger }) => {
|
|
16
|
+
var createMcpServer = async ({ adapters, getSpaceId, preview, screenshot, logger, renderStreaming = true }) => {
|
|
17
17
|
const log = createMcpLog(logger);
|
|
18
18
|
const spaceId = await getSpaceId().catch(() => void 0);
|
|
19
19
|
const hasSpace = spaceId !== void 0;
|
|
@@ -44,10 +44,10 @@ var createMcpServer = async ({ adapters, getSpaceId, preview, screenshot, logger
|
|
|
44
44
|
const getSpace = () => spacePromise ??= loadSpace();
|
|
45
45
|
const server = new McpServer({
|
|
46
46
|
name: "plitzi-mcp",
|
|
47
|
-
version: "0.32.
|
|
47
|
+
version: "0.32.20"
|
|
48
48
|
}, { instructions: hasSpace ? serverInstructions : widgetsOnlyInstructions });
|
|
49
49
|
registerResources(server, getSpace, MCP_ENV, log, hasSpace);
|
|
50
|
-
registerApps(server);
|
|
50
|
+
registerApps(server, { streaming: renderStreaming });
|
|
51
51
|
const toolContext = async () => ({
|
|
52
52
|
space: await getSpace(),
|
|
53
53
|
env: MCP_ENV,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { iconFontCss } from "../apps/render/styles.js";
|
|
1
2
|
import { RENDER_APP_URI } from "../apps/render/index.js";
|
|
2
3
|
import { emptySpace } from "../helpers/space.js";
|
|
3
4
|
import { operations } from "./operations/index.js";
|
|
@@ -48,7 +49,7 @@ var render = (input) => {
|
|
|
48
49
|
errors: expansion.errors
|
|
49
50
|
};
|
|
50
51
|
const ops = expansion.operations;
|
|
51
|
-
const validation = validateOperations(space, ops);
|
|
52
|
+
const validation = validateOperations(space, ops, "widget");
|
|
52
53
|
if (!validation.valid) return {
|
|
53
54
|
rendered: false,
|
|
54
55
|
errors: validation.errors,
|
|
@@ -136,6 +137,8 @@ var toPatchResult = (ops, renderId) => {
|
|
|
136
137
|
};
|
|
137
138
|
};
|
|
138
139
|
var newRenderId = () => `r${randomUUID().slice(0, 8)}`;
|
|
140
|
+
var ICON_TYPE = "fontAwesome";
|
|
141
|
+
var drawsIcons = (offlineData) => Object.values(offlineData.schema.flat).some((element) => element.definition.type === ICON_TYPE);
|
|
139
142
|
var toRenderResult = (res, renderId) => {
|
|
140
143
|
if (!res.rendered) return { content: [{
|
|
141
144
|
type: "text",
|
|
@@ -160,14 +163,15 @@ var toRenderResult = (res, renderId) => {
|
|
|
160
163
|
structuredContent: {
|
|
161
164
|
...summary,
|
|
162
165
|
offlineData: res.offlineData,
|
|
163
|
-
operations: res.operations
|
|
166
|
+
operations: res.operations,
|
|
167
|
+
...drawsIcons(res.offlineData) ? { iconCss: iconFontCss() } : {}
|
|
164
168
|
}
|
|
165
169
|
};
|
|
166
170
|
};
|
|
167
171
|
var renderTool = defineTool({
|
|
168
172
|
name: "plitzi_render",
|
|
169
173
|
title: "Render widget",
|
|
170
|
-
description: "Show the user a real, rendered UI widget instead of describing one — cards, hero sections, pricing tables, forms, menus, checklists, profiles, galleries. It runs the Plitzi SDK fully offline: no backend, account, or setup. Reach for it whenever a visual layout beats prose: the user asks you to design/build/show something, OR your answer is naturally visual (a recipe → a card, a comparison → a table, steps → a checklist). Prefer showing over telling.\n\nAuthor the widget as an ordered list of `operations` that build an element tree under the pre-seeded root page \"render\". Three rules:\n1. STRUCTURE — one upsertElement builds the whole tree: set pageRef:\"render\" and give element a nested `children` array. Each element is { ref (unique), type, subType?, props?, style?, children? }; children render in order. (To attach to something you already made, use a top-level parentRef:\"<existing ref>\" instead.)\n1b. REPEATS — the moment two siblings share a shape and differ only in data (list, steps, cards, table, timeline), do NOT copy-paste them: use repeatElement { pageRef, ref (wrapper), style, template, items }. The template is written once with {{item.field}} placeholders and rendered per row; refs come out numbered (\"step-1\", \"step-2\"…). A list INSIDE each row (days with their own steps) is the same op: give the wrapping node repeat:{ items:\"{{item.<list>}}\", template:… } and put the sub-rows in the row data.\n2. STYLE — declare ALL the classes in ONE upsertDefinitions { definitions: { \"<class>\": { desktop:{ …CSS in kebab-case… } }, … } }, then attach via the element style:{ base:[\"<class ref>\"] }. Lay containers out with flex/grid. Keep the call small: one class per look
|
|
174
|
+
description: "Show the user a real, rendered UI widget instead of describing one — cards, hero sections, pricing tables, forms, menus, checklists, profiles, galleries. It runs the Plitzi SDK fully offline: no backend, account, or setup. Reach for it whenever a visual layout beats prose: the user asks you to design/build/show something, OR your answer is naturally visual (a recipe → a card, a comparison → a table, steps → a checklist). Prefer showing over telling.\n\nAuthor the widget as an ordered list of `operations` that build an element tree under the pre-seeded root page \"render\". Three rules:\n1. STRUCTURE — one upsertElement builds the whole tree: set pageRef:\"render\" and give element a nested `children` array. Each element is { ref (unique), type, subType?, props?, style?, children? }; children render in order. (To attach to something you already made, use a top-level parentRef:\"<existing ref>\" instead.)\n1b. REPEATS — the moment two siblings share a shape and differ only in data (list, steps, cards, table, timeline), do NOT copy-paste them: use repeatElement { pageRef, ref (wrapper), style, template, items }. The template is written once with {{item.field}} placeholders and rendered per row; refs come out numbered (\"step-1\", \"step-2\"…). A list INSIDE each row (days with their own steps) is the same op: give the wrapping node repeat:{ items:\"{{item.<list>}}\", template:… } and put the sub-rows in the row data.\n2. STYLE — declare ALL the classes in ONE upsertDefinitions { definitions: { \"<class>\": { desktop:{ …CSS in kebab-case… } }, … } }, then attach via the element style:{ base:[\"<class ref>\"] }. Lay containers out with flex/grid. Keep the call small: one class per look, not per property.\n2a. GRAPHICS — a logo, a sparkline, a badge, a decorative shape: draw it as an INLINE <svg> in a blockHtml element (props.content), keeping a viewBox with width/height \"100%\" so its class sizes it, and fill/stroke \"currentColor\" so it follows the host theme. Budget it: a handful of paths, drawn once and reused, never a data: URI and never a full illustration — a photo-real scene costs more than the whole widget, so use an https image, a flat colour or a gradient for that. Markup only: scripts and on* handlers are rejected.\n2b. LAYOUT — it renders in a side panel, so width is free and HEIGHT is scarce. Plain containers stack children vertically, which is the tall half-empty default to avoid: put peers (metrics, plans, options, image + text) in a wrapping row — display:flex, flex-direction:row, flex-wrap:wrap, children flex-grow:\"1\" + flex-basis:\"0%\" + min-width — or a grid with grid-template-columns:\"repeat(auto-fit, minmax(160px, 1fr))\". Keep padding 12-16px and gap 8-12px, and let the outer container fill the panel. Stack only what reads in order (heading over paragraph, forms, steps, prose). Nothing carries a minimum size, so an element with no content and no size takes none — give a spacer or a rail its own height/width — while heading/paragraph do keep the margins the browser gives them (zero them, space with the parent gap).\n2c. THEME — it is embedded in the host UI, which MAY BE DARK, so never hardcode a light palette. Take colours from the host variables with a light-dark() fallback — background-color:\"var(--color-background-secondary, light-dark(#ffffff, #1f2430))\", color:\"var(--color-text-primary, light-dark(#0f172a, #e8eaed))\", border-color:\"var(--color-border-primary, light-dark(#e2e8f0, #333a48))\" — and always set `color` wherever you set `background-color` (a brand accent states its own text colour too).\n3. CONTENT — visible copy goes in props.content (text, heading, paragraph, button); heading level is the element subType (\"h1\"..\"h6\"); image/video take props.src. An unknown prop comes back as a warning naming the right one.\n\nCommon types: container, heading, paragraph, text, button, link, image, video, list, listItem, markdown (plitzi://render/types lists every built-in type with descriptions). Widgets can also be data-driven and interactive — an apiContainer fetches at runtime, upsertBinding wires data into elements, and upsertInteractionFlow makes them react to clicks (see the guide).\nREAD the resource plitzi://render/guide first — it has the element/prop table, the style model and a full worked example, and following it is the difference between a widget that renders and repeated failed calls.\nITERATING — to change a widget you already rendered, do NOT rebuild it: call again with patch:true, the `renderId` that render answered with, and ONLY the operations that differ (patchDefinition, patchElement, deleteElement…). The widget merges them and reports back what it applied; address rows by the refs you already know. Patch ONLY to modify that widget: a different subject or a different kind of widget is a fresh render, without patch — a patch is merged into the previous batch, so patching a new idea leaves you with both.\nReturns a compact summary including the renderId (the widget itself is shown to the user); on failure it returns teachable errors (path + hint) — read them and retry.",
|
|
171
175
|
inputShape: renderShape,
|
|
172
176
|
access: "read",
|
|
173
177
|
spaceless: true,
|
|
@@ -32,7 +32,17 @@ var checkVariantApplication = (initialState, path, ctx) => {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
};
|
|
35
|
+
var EXECUTABLE_MARKUP = /<script\b|\son[a-z]+\s*=|javascript:/i;
|
|
36
|
+
var checkRawMarkup = (type, props, path, ctx) => {
|
|
37
|
+
if (ctx.mode !== "widget" || !props || !RAW_CODE_TYPES.has(type)) return;
|
|
38
|
+
for (const [key, value] of Object.entries(props)) if (typeof value === "string" && EXECUTABLE_MARKUP.test(value)) ctx.errors.push({
|
|
39
|
+
path: `${path}.props.${key}`,
|
|
40
|
+
message: `Executable markup in a "${type}" prop is not allowed in a widget`,
|
|
41
|
+
hint: "A widget renders inside the host UI, so <script>, javascript: URLs and inline on* handlers are rejected. Keep the markup inert (an inline <svg> is what this type is for) and wire behaviour with upsertInteractionFlow."
|
|
42
|
+
});
|
|
43
|
+
};
|
|
35
44
|
var checkElementProps = (element, path, ctx) => {
|
|
45
|
+
checkRawMarkup(element.type, element.props, path, ctx);
|
|
36
46
|
if (!element.props || RAW_CODE_TYPES.has(element.type)) return;
|
|
37
47
|
for (const [key, value] of Object.entries(element.props)) if (typeof value === "string") checkVarRefs(value, `${path}.props.${key}`, ctx);
|
|
38
48
|
checkTypeProps(element.type, element.props, path, ctx);
|
|
@@ -55,4 +65,4 @@ var checkElementInput = (element, path, ctx, seen) => {
|
|
|
55
65
|
element.children?.forEach((child, i) => checkElementInput(child, `${path}.children[${i}]`, ctx, seen));
|
|
56
66
|
};
|
|
57
67
|
//#endregion
|
|
58
|
-
export { checkElementInput, checkTypeProps, checkVariantApplication };
|
|
68
|
+
export { checkElementInput, checkRawMarkup, checkTypeProps, checkVariantApplication };
|
|
@@ -8,7 +8,7 @@ import { checkObservedName, checkVarRefs, warnOnce } from "./context.js";
|
|
|
8
8
|
import { checkBindingSourceScope, checkBindingTarget, checkBindingTransformers } from "./bindings.js";
|
|
9
9
|
import { checkSlotCss } from "./css.js";
|
|
10
10
|
import { checkRef } from "./refs.js";
|
|
11
|
-
import { checkElementInput, checkTypeProps, checkVariantApplication } from "./elements.js";
|
|
11
|
+
import { checkElementInput, checkRawMarkup, checkTypeProps, checkVariantApplication } from "./elements.js";
|
|
12
12
|
import { checkInteractionNode } from "./interactions.js";
|
|
13
13
|
//#region src/modules/mcp/tools/shared/validator/index.ts
|
|
14
14
|
var STYLE_CATEGORIES = [
|
|
@@ -35,9 +35,10 @@ var buildTypeMeta = (catalog) => {
|
|
|
35
35
|
};
|
|
36
36
|
/** The shared validation context, derived from a space (+ the batch's ops, for batch-declared names). Extracted so
|
|
37
37
|
* the post-apply resource audit (auditResources) can run the same checks against the resulting draft. */
|
|
38
|
-
var buildValidationCtx = (space, ops) => {
|
|
38
|
+
var buildValidationCtx = (space, ops, mode = "space") => {
|
|
39
39
|
const registry = buildTypeRegistry(space.schema, space.catalog);
|
|
40
40
|
return {
|
|
41
|
+
mode,
|
|
41
42
|
errors: [],
|
|
42
43
|
warnings: [],
|
|
43
44
|
warned: /* @__PURE__ */ new Set(),
|
|
@@ -57,11 +58,11 @@ var buildValidationCtx = (space, ops) => {
|
|
|
57
58
|
observedSources: observedDataSources(space.schema)
|
|
58
59
|
};
|
|
59
60
|
};
|
|
60
|
-
var validateOperations = (space, ops) => {
|
|
61
|
+
var validateOperations = (space, ops, mode = "space") => {
|
|
61
62
|
const batchPages = batchDeclaredPages(ops);
|
|
62
63
|
const batchFolders = batchDeclaredFolders(ops);
|
|
63
64
|
const folderRefs = () => pageFoldersOf(space.schema).map((f) => f.id);
|
|
64
|
-
const ctx = buildValidationCtx(space, ops);
|
|
65
|
+
const ctx = buildValidationCtx(space, ops, mode);
|
|
65
66
|
if (ops.length > 1e3) ctx.errors.push({
|
|
66
67
|
path: "operations",
|
|
67
68
|
message: `Batch has ${ops.length} operations (max ${MAX_OPS})`,
|
|
@@ -91,7 +92,10 @@ var validateOperations = (space, ops) => {
|
|
|
91
92
|
const target = page ? resolveRef(space.schema, page, op.ref) : void 0;
|
|
92
93
|
if (op.props) {
|
|
93
94
|
for (const [key, value] of Object.entries(op.props)) if (typeof value === "string") checkVarRefs(value, `${base}.props.${key}`, ctx);
|
|
94
|
-
if (target && target.id !== page?.id)
|
|
95
|
+
if (target && target.id !== page?.id) {
|
|
96
|
+
checkRawMarkup(target.definition.type, op.props, base, ctx);
|
|
97
|
+
checkTypeProps(target.definition.type, op.props, base, ctx);
|
|
98
|
+
}
|
|
95
99
|
}
|
|
96
100
|
checkVariantApplication(op.initialState, `${base}.initialState`, ctx);
|
|
97
101
|
break;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { McpApp } from '../types';
|
|
1
|
+
import { McpApp, McpViewSettings } from '../types';
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
/** What a view gets when the deployment says nothing: streaming on, because a host that streams no arguments
|
|
4
|
+
* paints exactly what it painted before. */
|
|
5
|
+
export declare const DEFAULT_VIEW_SETTINGS: McpViewSettings;
|
|
3
6
|
/** Every MCP App this server serves. A new one is a folder beside `render/` (its definition + its view) and one
|
|
4
7
|
* line here — the tool that opens it points at its `uri` through `ui: { resourceUri }`. Start from `example/`,
|
|
5
8
|
* which is that same shape stripped to the minimum. */
|
|
6
9
|
export declare const apps: McpApp[];
|
|
7
|
-
export declare const registerApps: (server: McpServer) => void;
|
|
8
|
-
export { RENDER_APP_URI } from './render';
|
|
10
|
+
export declare const registerApps: (server: McpServer, settings?: McpViewSettings) => void;
|
|
11
|
+
export { iconFontCss, RENDER_APP_URI } from './render';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Lifts every @font-face block out of a stylesheet. Brace counting rather than a CSS parser: a @font-face body is
|
|
2
|
+
* plain declarations, and base64 payloads and url() values carry no braces of their own. */
|
|
3
|
+
export declare const splitFontFaces: (css: string) => {
|
|
4
|
+
base: string;
|
|
5
|
+
fonts: string;
|
|
6
|
+
};
|
|
7
|
+
/** What the page shell inlines: the SDK stylesheet without the icon fonts. */
|
|
8
|
+
export declare const widgetCss: () => string;
|
|
9
|
+
/** The icon fonts, for the renders that draw one. */
|
|
10
|
+
export declare const iconFontCss: () => string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { McpApp } from '../../types';
|
|
2
|
-
export declare const page: (app: McpApp) => Promise<string>;
|
|
1
|
+
import { McpApp, McpViewSettings } from '../../types';
|
|
2
|
+
export declare const page: (app: McpApp, settings: McpViewSettings) => Promise<string>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { McpApp } from '../../types';
|
|
1
|
+
import { McpApp, McpViewSettings } from '../../types';
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
/** Serves the app as a self-contained page: no import map, no asset mounts, no cross-origin fetches, so the
|
|
4
4
|
* strictest host sandbox runs it and no deployment has to serve anything extra. */
|
|
5
|
-
export declare const registerApp: (server: McpServer, app: McpApp) => void;
|
|
5
|
+
export declare const registerApp: (server: McpServer, app: McpApp, settings: McpViewSettings) => void;
|
|
@@ -18,8 +18,10 @@ export interface McpEndpointOptions {
|
|
|
18
18
|
/** Attach a space to the connection, the way an authorized connector's token does — the server then offers its
|
|
19
19
|
* editing surface. Omitted, the endpoint is the guest one: no space, so only what works without one. */
|
|
20
20
|
spaceId?: number;
|
|
21
|
+
/** The deployment switch a consumer sets as `mcpAi.renderStreaming`. Omitted, the server's own default stands. */
|
|
22
|
+
renderStreaming?: boolean;
|
|
21
23
|
}
|
|
22
24
|
/** Start the endpoint and connect a real MCP client to it, as a remote connector does. */
|
|
23
|
-
export declare const startMcpEndpoint: ({ spaceId }?: McpEndpointOptions) => Promise<McpEndpoint>;
|
|
25
|
+
export declare const startMcpEndpoint: ({ spaceId, renderStreaming }?: McpEndpointOptions) => Promise<McpEndpoint>;
|
|
24
26
|
/** Read a ui:// resource. A page that came back as a blob is a bug a host would meet as a blank frame. */
|
|
25
27
|
export declare const readAppPage: (endpoint: McpEndpoint, uri: string) => Promise<AppPage>;
|
|
@@ -13,6 +13,11 @@ export interface RenderingHostOptions {
|
|
|
13
13
|
export interface RenderingHost {
|
|
14
14
|
bridge: AppBridge;
|
|
15
15
|
window: JSDOM['window'];
|
|
16
|
+
/** Push arguments the way a host does while the model is still writing the call: healed JSON, sent zero or more
|
|
17
|
+
* times, any field possibly missing. */
|
|
18
|
+
streamInput: (args: Record<string, unknown>) => Promise<void>;
|
|
19
|
+
/** The complete arguments, which the spec makes the host send once before any result. */
|
|
20
|
+
completeInput: (args: Record<string, unknown>) => Promise<void>;
|
|
16
21
|
/** Deliver a tool result and let the App paint before assertions run. */
|
|
17
22
|
showResult: (result: McpUiToolResultNotification['params']) => Promise<void>;
|
|
18
23
|
/** What the App reported back to the model with ui/update-model-context, in order. */
|
|
@@ -9,6 +9,8 @@ export type McpRequestOptions = {
|
|
|
9
9
|
preview?: PreviewClient;
|
|
10
10
|
screenshot?: ScreenshotClient;
|
|
11
11
|
logger?: ServerLogger;
|
|
12
|
+
/** Deployment switch for the plitzi_render view (see `mcpAi.renderStreaming`). Defaults to true. */
|
|
13
|
+
renderStreaming?: boolean;
|
|
12
14
|
};
|
|
13
15
|
export declare const readMcpBody: (req: IncomingMessage) => Promise<unknown>;
|
|
14
16
|
export declare const serveMcp: (raw: IncomingMessage, res: ServerResponse, server: McpServer) => Promise<string | undefined>;
|
|
@@ -19,5 +19,8 @@ export interface McpServerContext {
|
|
|
19
19
|
/** Structured request-log sink. When set, every tool call and resource read emits an McpLogEvent to it (the
|
|
20
20
|
* consumer renders them); otherwise logging falls back to the console when MCP_DEBUG=1. */
|
|
21
21
|
logger?: ServerLogger;
|
|
22
|
+
/** May the plitzi_render view paint from tool arguments the host is still streaming (see `mcpAi.renderStreaming`)?
|
|
23
|
+
* Defaults to true. */
|
|
24
|
+
renderStreaming?: boolean;
|
|
22
25
|
}
|
|
23
|
-
export declare const createMcpServer: ({ adapters, getSpaceId, preview, screenshot, logger }: McpServerContext) => Promise<McpServer>;
|
|
26
|
+
export declare const createMcpServer: ({ adapters, getSpaceId, preview, screenshot, logger, renderStreaming }: McpServerContext) => Promise<McpServer>;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { ValidationError } from '../../../types';
|
|
2
2
|
import { Style } from '@plitzi/sdk-shared';
|
|
3
3
|
export declare const RAW_CODE_TYPES: Set<string>;
|
|
4
|
+
/** What the batch is authoring. A `widget` (plitzi_render) is embedded in a host's chat UI we do not own, so its
|
|
5
|
+
* raw markup is held to a stricter rule than a space the account owns. */
|
|
6
|
+
export type ValidationMode = 'space' | 'widget';
|
|
4
7
|
/** Authoritative per-type metadata from the component catalog (default sdk-elements ∪ this space's plugins).
|
|
5
8
|
* `custom` drives strict-vs-lenient validation: a default type (custom:false) owns its full attribute set, so an
|
|
6
9
|
* unknown attribute/setState key on it is an error; a plugin type (custom:true) is best-effort (warnings). */
|
|
@@ -14,6 +17,7 @@ export interface TypeMeta {
|
|
|
14
17
|
};
|
|
15
18
|
}
|
|
16
19
|
export interface ValidationCtx {
|
|
20
|
+
mode: ValidationMode;
|
|
17
21
|
errors: ValidationError[];
|
|
18
22
|
warnings: string[];
|
|
19
23
|
warned: Set<string>;
|
|
@@ -3,4 +3,5 @@ import { ElementInput } from '../../operations';
|
|
|
3
3
|
import { InitialStateInput } from '../../operations/schema/shared';
|
|
4
4
|
export declare const checkTypeProps: (type: string, props: Record<string, unknown> | undefined, path: string, ctx: ValidationCtx) => void;
|
|
5
5
|
export declare const checkVariantApplication: (initialState: InitialStateInput | undefined, path: string, ctx: ValidationCtx) => void;
|
|
6
|
+
export declare const checkRawMarkup: (type: string, props: Record<string, unknown> | undefined, path: string, ctx: ValidationCtx) => void;
|
|
6
7
|
export declare const checkElementInput: (element: ElementInput, path: string, ctx: ValidationCtx, seen: Set<string>) => void;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { ValidationCtx } from './context';
|
|
1
|
+
import { ValidationCtx, ValidationMode } from './context';
|
|
2
2
|
import { Space } from '../../../helpers';
|
|
3
3
|
import { ValidationResult } from '../../../types';
|
|
4
4
|
import { Operation } from '../../operations';
|
|
5
5
|
/** The shared validation context, derived from a space (+ the batch's ops, for batch-declared names). Extracted so
|
|
6
6
|
* the post-apply resource audit (auditResources) can run the same checks against the resulting draft. */
|
|
7
|
-
export declare const buildValidationCtx: (space: Space, ops: Operation[]) => ValidationCtx;
|
|
8
|
-
export declare const validateOperations: (space: Space, ops: Operation[]) => ValidationResult;
|
|
7
|
+
export declare const buildValidationCtx: (space: Space, ops: Operation[], mode?: ValidationMode) => ValidationCtx;
|
|
8
|
+
export declare const validateOperations: (space: Space, ops: Operation[], mode?: ValidationMode) => ValidationResult;
|
|
@@ -7,7 +7,15 @@ export interface McpApp {
|
|
|
7
7
|
title: string;
|
|
8
8
|
/** Absolute path to the view: the app's browser entry. */
|
|
9
9
|
entry: string;
|
|
10
|
-
/**
|
|
10
|
+
/** CSS to inline in the page, in order. Lazy, and the app produces the text itself: what a view needs is not
|
|
11
|
+
* always a file as it sits on disk (the render app leaves the icon fonts out of its stylesheet). */
|
|
11
12
|
styles?: () => string[];
|
|
12
13
|
csp?: McpUiResourceCsp;
|
|
13
14
|
}
|
|
15
|
+
/** Deployment switches the page hands to the view. They travel in the HTML rather than the bundle so a server
|
|
16
|
+
* that serves both settings still builds the (expensive) browser bundle once. The view reads them off
|
|
17
|
+
* `window.__PLITZI_VIEW__`, and must treat every one as optional: an older page carries none. */
|
|
18
|
+
export interface McpViewSettings {
|
|
19
|
+
/** May the view paint from tool arguments the host is still streaming? See `mcpAi.renderStreaming`. */
|
|
20
|
+
streaming: boolean;
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plitzi/sdk-server",
|
|
3
|
-
"version": "0.32.
|
|
3
|
+
"version": "0.32.20",
|
|
4
4
|
"license": "AGPL-3.0",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@modelcontextprotocol/ext-apps": "^1.7.5",
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
33
|
-
"@plitzi/plitzi-sdk": "0.32.
|
|
34
|
-
"@plitzi/sdk-schema": "0.32.
|
|
35
|
-
"@plitzi/sdk-shared": "0.32.
|
|
33
|
+
"@plitzi/plitzi-sdk": "0.32.20",
|
|
34
|
+
"@plitzi/sdk-schema": "0.32.20",
|
|
35
|
+
"@plitzi/sdk-shared": "0.32.20",
|
|
36
36
|
"ejs": "^6.0.1",
|
|
37
37
|
"esbuild": "^0.28.1",
|
|
38
38
|
"zod": "^4.4.3"
|
|
@@ -131,8 +131,12 @@ Everything the widget needs travels in ONE call, as `operations`. Three ops carr
|
|
|
131
131
|
3. **Watch the SDK defaults.** Every container has `min-width`/`min-height: 50px` — set them to `0` for rails,
|
|
132
132
|
dividers, dots and any flex child that must shrink. Headings and paragraphs keep the browser's margins; zero
|
|
133
133
|
them and space with the parent's `gap`.
|
|
134
|
-
4. **
|
|
135
|
-
|
|
134
|
+
4. **Draw with inline SVG, on a budget.** A logo, a sparkline, a badge or a decorative shape goes in a `blockHtml`
|
|
135
|
+
element whose `props.content` is an `<svg>` — keep a `viewBox` with `width`/`height` `100%` so the element's
|
|
136
|
+
class sizes it, and `fill`/`stroke` `currentColor` so it follows the theme. A handful of paths, drawn once and
|
|
137
|
+
reused. Never a `data:` URI, and never a full illustration or a photo-real scene: that costs more than the rest
|
|
138
|
+
of the widget, so use an `https` image, a flat colour or a CSS gradient instead. Markup only — `<script>` and
|
|
139
|
+
inline `on*` handlers are rejected.
|
|
136
140
|
5. **Write CSS plainly.** Kebab-case properties, shorthands welcome (`padding: 8px 16px`, `border: 1px solid red`,
|
|
137
141
|
`font: bold 16px/1.5 Arial`) — they are expanded and stored as longhands, so a breakpoint or state can override
|
|
138
142
|
one property on its own.
|