@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/core/services/mcp.js +2 -1
  3. package/dist/modules/mcp/apps/index.js +7 -3
  4. package/dist/modules/mcp/apps/render/index.js +3 -3
  5. package/dist/modules/mcp/apps/render/styles.js +54 -0
  6. package/dist/modules/mcp/apps/render/view/index.tsx +106 -4
  7. package/dist/modules/mcp/apps/render/view/streamProgress.ts +137 -0
  8. package/dist/modules/mcp/apps/shared/page.js +17 -6
  9. package/dist/modules/mcp/apps/shared/registerApp.js +2 -2
  10. package/dist/modules/mcp/apps/shared/shell.ejs +5 -0
  11. package/dist/modules/mcp/resources/renderGuide.js +51 -6
  12. package/dist/modules/mcp/server.js +3 -3
  13. package/dist/modules/mcp/tools/render.js +7 -3
  14. package/dist/modules/mcp/tools/shared/validator/elements.js +11 -1
  15. package/dist/modules/mcp/tools/shared/validator/index.js +9 -5
  16. package/dist/src/modules/mcp/apps/index.d.ts +6 -3
  17. package/dist/src/modules/mcp/apps/render/index.d.ts +1 -0
  18. package/dist/src/modules/mcp/apps/render/styles.d.ts +10 -0
  19. package/dist/src/modules/mcp/apps/render/styles.test.d.ts +1 -0
  20. package/dist/src/modules/mcp/apps/shared/page.d.ts +2 -2
  21. package/dist/src/modules/mcp/apps/shared/registerApp.d.ts +2 -2
  22. package/dist/src/modules/mcp/e2e/mcpEndpoint.d.ts +3 -1
  23. package/dist/src/modules/mcp/e2e/renderingHost.d.ts +5 -0
  24. package/dist/src/modules/mcp/handler.d.ts +2 -0
  25. package/dist/src/modules/mcp/server.d.ts +4 -1
  26. package/dist/src/modules/mcp/tools/shared/validator/context.d.ts +4 -0
  27. package/dist/src/modules/mcp/tools/shared/validator/elements.d.ts +1 -0
  28. package/dist/src/modules/mcp/tools/shared/validator/index.d.ts +3 -3
  29. package/dist/src/modules/mcp/types/appTypes.d.ts +9 -1
  30. package/package.json +4 -4
  31. package/skills/plitzi-render/SKILL.md +6 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @plitzi/sdk-server
2
2
 
3
+ ## 0.32.20
4
+
5
+ ### Patch Changes
6
+
7
+ - v0.32.20
8
+ - Updated dependencies
9
+ - @plitzi/plitzi-sdk@0.32.20
10
+ - @plitzi/sdk-schema@0.32.20
11
+ - @plitzi/sdk-shared@0.32.20
12
+
3
13
  ## 0.32.19
4
14
 
5
15
  ### Patch Changes
@@ -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 { require } from "../shared/resolve.js";
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: () => [path.join(path.dirname(require.resolve("@plitzi/plitzi-sdk")), "plitzi-sdk.css")]
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
- setResult(rendered);
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
- setResult(toolResult);
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
- // Still connecting, or connected and waiting for the result: the page's CSS placeholder covers both.
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 page = (app) => {
11
- let html = pages.get(app.uri);
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 = bundle(app.entry).then((script) => template()({
23
+ html = script(app.entry).then((code) => template()({
14
24
  title: app.title,
15
- app: script,
16
- css: (app.styles?.() ?? []).map((file) => readFileSync(file, "utf-8")).join("\n")
25
+ app: code,
26
+ css: (app.styles?.() ?? []).join("\n"),
27
+ settings: JSON.stringify(settings)
17
28
  }));
18
- pages.set(app.uri, html);
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
- (e.g. an inline SVG icon or a base64 image) — both render with no extra setup.
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
- - **Do not draw pictures in \`data:\` URIs.** A hand-written SVG scene (a castle, a skyline, a logo) costs more than
274
- the entire rest of the widget and renders worse than nothing at all. Use an \`https\` image the user gave you, a
275
- flat colour or a two-stop \`linear-gradient\` as a banner, an emoji or an \`fontAwesome\` icon for a glyph — or drop
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.19"
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 (not per property), and never hand-draw a scene in a data: URI — it costs more than the whole widget; use an https image, a flat colour or a gradient.\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.",
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) checkTypeProps(target.definition.type, op.props, base, ctx);
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';
@@ -1,3 +1,4 @@
1
1
  import { McpApp } from '../../types';
2
2
  export declare const RENDER_APP_URI = "ui://plitzi/render.html";
3
3
  export declare const renderApp: McpApp;
4
+ export { iconFontCss } from './styles';
@@ -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
- /** Stylesheets to inline, by absolute path lazy so a missing one only fails on read. */
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.19",
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.19",
34
- "@plitzi/sdk-schema": "0.32.19",
35
- "@plitzi/sdk-shared": "0.32.19",
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. **Never hand-draw a scene in a `data:` URI.** An inline SVG illustration costs more tokens than the entire rest
135
- of the widget. Use an `https` image, a flat colour, or a CSS gradient.
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.