@plitzi/sdk-server 0.32.7 → 0.32.8
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/http/stages/static.js +2 -0
- package/dist/core/services/mcp.js +1 -1
- package/dist/core/services/registry.js +5 -1
- package/dist/modules/mcp/handler.js +3 -2
- package/dist/modules/mcp/helpers/guide.js +1 -1
- package/dist/modules/mcp/resources/register.js +2 -0
- package/dist/modules/mcp/resources/renderApp.js +174 -0
- package/dist/modules/mcp/resources/renderGuide.js +186 -0
- package/dist/modules/mcp/server.js +6 -3
- package/dist/modules/mcp/tools/render.js +3 -1
- package/dist/modules/mcp/tools/shared/tool.js +1 -0
- package/dist/src/modules/mcp/handler.d.ts +4 -1
- package/dist/src/modules/mcp/resources/renderApp.d.ts +3 -0
- package/dist/src/modules/mcp/resources/renderGuide.d.ts +4 -0
- package/dist/src/modules/mcp/server.d.ts +9 -1
- package/dist/src/modules/mcp/tools/shared/tool.d.ts +8 -0
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,8 @@ var configStaticStage = (ctx) => {
|
|
|
18
18
|
for (const [prefix, rootDir] of Object.entries(mounts)) {
|
|
19
19
|
const normalizedPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`;
|
|
20
20
|
if (ctx.req.path === prefix || ctx.req.path.startsWith(normalizedPrefix)) {
|
|
21
|
+
ctx.res.setHeader("Access-Control-Allow-Origin", "*");
|
|
22
|
+
ctx.res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
|
|
21
23
|
if (serveStatic({
|
|
22
24
|
...ctx.req,
|
|
23
25
|
path: ctx.req.path.slice(prefix.length) || "/"
|
|
@@ -7,7 +7,7 @@ var serveMcp = (ctx) => {
|
|
|
7
7
|
const { previewClient, screenshot } = ctx.config;
|
|
8
8
|
const preview = previewClient ? createHttpPreviewClient(previewClient) : void 0;
|
|
9
9
|
const screenshotClient = screenshot ? createHttpScreenshotClient(screenshot) : void 0;
|
|
10
|
-
return handleMcp(ctx.raw, ctx.rawRes, ctx.req, ctx.config.adapters, preview, screenshotClient, ctx.config.mcpLogger);
|
|
10
|
+
return handleMcp(ctx.raw, ctx.rawRes, ctx.req, ctx.config.adapters, preview, screenshotClient, ctx.config.mcpLogger, ctx.config.renderApp);
|
|
11
11
|
};
|
|
12
12
|
var mcpStage = async (ctx) => {
|
|
13
13
|
if (!ctx.req.path.startsWith(mcpPathOf(ctx.config.mcp?.path))) return false;
|
|
@@ -25,6 +25,10 @@ var buildSSRPipeline = (services) => {
|
|
|
25
25
|
stages.push(services.ssr ? ssrStage : notFoundStage);
|
|
26
26
|
return stages;
|
|
27
27
|
};
|
|
28
|
-
var buildMCPPipeline = () => [
|
|
28
|
+
var buildMCPPipeline = () => [
|
|
29
|
+
healthStage,
|
|
30
|
+
configStaticStage,
|
|
31
|
+
mcpOnlyStage
|
|
32
|
+
];
|
|
29
33
|
//#endregion
|
|
30
34
|
export { buildMCPPipeline, buildSSRPipeline };
|
|
@@ -64,12 +64,13 @@ var serveMcp = async (raw, res, server) => {
|
|
|
64
64
|
await transport.close();
|
|
65
65
|
}
|
|
66
66
|
};
|
|
67
|
-
var handleMcp = (raw, res, req, adapters, preview, screenshot, logger) => serveMcp(raw, res, createMcpServer({
|
|
67
|
+
var handleMcp = (raw, res, req, adapters, preview, screenshot, logger, renderApp) => serveMcp(raw, res, createMcpServer({
|
|
68
68
|
adapters,
|
|
69
69
|
getSpaceId: () => adapters.getSpaceId?.(req) ?? Promise.resolve(void 0),
|
|
70
70
|
preview,
|
|
71
71
|
screenshot,
|
|
72
|
-
logger
|
|
72
|
+
logger,
|
|
73
|
+
renderApp
|
|
73
74
|
}));
|
|
74
75
|
//#endregion
|
|
75
76
|
export { createMcpServer, handleMcp, readMcpBody, serveMcp };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region src/modules/mcp/helpers/guide.ts
|
|
2
|
-
var serverInstructions = "Plitzi AI server: read-then-write editing of a Plitzi space. Reads follow a filesystem model — list cheap, read one item in detail on demand; never fetch a whole tree you do not need. Workflow: (1) read plitzi://primer/{env} once — it bundles the guide, types, css-properties and page/definition/variable summaries in a single call; (2) plitzi_search with include:\"detail\" to jump to elements — each hit then carries its uri, stateVersion AND full style/resolvedStyle, so an edit needs no per-element read; open a page skeleton or element only when you need its tree/detail (the skeleton already lists the style classes of each node, and plitzi_read fetches many uris at once); (3) plitzi_apply with dryRun to preview a batch; (4) plitzi_apply to persist, passing expectedResourceVersions to guard against concurrent edits — apply and search both hand back the versions you need for the next edit. Use patchElement / patchDefinition to change only some props / CSS (the upsert variants replace them all). An element read (and search include:\"detail\") inlines the CSS of the definitions it attaches under resolvedStyle, so you rarely need a separate definition read. Refs accept a semantic idRef ([A-Za-z0-9_-] starting with a letter, unique, chosen by you) or the raw id — the idRef is ALSO the runtime wiring key, so a provider source is `<type>_<idRef>.<field>`, visible to the provider’s DESCENDANTS only (bind inside its subtree). CSS is kebab-case and ATOMIC (shorthands like border/padding expanded; compound ones like flex/background/font rejected — use longhands); style vars are var(--name), schema vars are {{name}}. READERS — do not confuse them: MCP *resources* are the browsable catalog (list them, or open one by URI); plitzi_search FINDS refs by label/type/attribute; plitzi_read BATCH-fetches URIs you already hold. Reach for search/read to work; browse resources to discover. Elements also carry applied style variants + visibility (initialState), data bindings and interaction flows: edit them with patchElement (initialState), upsertBinding/patchBinding/deleteBinding, and upsertInteractionFlow/patchInteractionNode/deleteInteraction. An element read shows all three plus availableVariants (which variant each of its classes offers).";
|
|
2
|
+
var serverInstructions = "Plitzi AI server: read-then-write editing of a Plitzi space. Reads follow a filesystem model — list cheap, read one item in detail on demand; never fetch a whole tree you do not need. Workflow: (1) read plitzi://primer/{env} once — it bundles the guide, types, css-properties and page/definition/variable summaries in a single call; (2) plitzi_search with include:\"detail\" to jump to elements — each hit then carries its uri, stateVersion AND full style/resolvedStyle, so an edit needs no per-element read; open a page skeleton or element only when you need its tree/detail (the skeleton already lists the style classes of each node, and plitzi_read fetches many uris at once); (3) plitzi_apply with dryRun to preview a batch; (4) plitzi_apply to persist, passing expectedResourceVersions to guard against concurrent edits — apply and search both hand back the versions you need for the next edit. Use patchElement / patchDefinition to change only some props / CSS (the upsert variants replace them all). An element read (and search include:\"detail\") inlines the CSS of the definitions it attaches under resolvedStyle, so you rarely need a separate definition read. Refs accept a semantic idRef ([A-Za-z0-9_-] starting with a letter, unique, chosen by you) or the raw id — the idRef is ALSO the runtime wiring key, so a provider source is `<type>_<idRef>.<field>`, visible to the provider’s DESCENDANTS only (bind inside its subtree). CSS is kebab-case and ATOMIC (shorthands like border/padding expanded; compound ones like flex/background/font rejected — use longhands); style vars are var(--name), schema vars are {{name}}. READERS — do not confuse them: MCP *resources* are the browsable catalog (list them, or open one by URI); plitzi_search FINDS refs by label/type/attribute; plitzi_read BATCH-fetches URIs you already hold. Reach for search/read to work; browse resources to discover. Elements also carry applied style variants + visibility (initialState), data bindings and interaction flows: edit them with patchElement (initialState), upsertBinding/patchBinding/deleteBinding, and upsertInteractionFlow/patchInteractionNode/deleteInteraction. An element read shows all three plus availableVariants (which variant each of its classes offers). Separately, to SHOW the user a small self-contained widget (offline, no space or backend) instead of editing the space — a card, hero, pricing table, a visual answer — use plitzi_render; read plitzi://render/guide for it.";
|
|
3
3
|
var guideQuickstart = `# Plitzi AI MCP — quickstart
|
|
4
4
|
This is the condensed guide; read \`plitzi://guide\` for the full reference (every resource, op and example).
|
|
5
5
|
|
|
@@ -3,6 +3,7 @@ import { guideText } from "../helpers/guide.js";
|
|
|
3
3
|
import { resourceErrorMessage } from "./canonical.js";
|
|
4
4
|
import { envelope, jsonContents } from "./envelope.js";
|
|
5
5
|
import { readResource } from "./router.js";
|
|
6
|
+
import { registerRenderResources } from "./renderGuide.js";
|
|
6
7
|
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp";
|
|
7
8
|
//#region src/modules/mcp/resources/register.ts
|
|
8
9
|
/** Register every resource on the MCP server: fixed listings plus templated per-item reads. The space is
|
|
@@ -32,6 +33,7 @@ var registerResources = (server, getSpace, env, log) => {
|
|
|
32
33
|
description: "Valid kebab-case CSS property keys",
|
|
33
34
|
mimeType: "application/json"
|
|
34
35
|
}, () => jsonContents("plitzi://css-properties", envelope(cssProperties)));
|
|
36
|
+
registerRenderResources(server);
|
|
35
37
|
const fixed = [
|
|
36
38
|
[
|
|
37
39
|
"Primer",
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
//#region src/modules/mcp/resources/renderApp.ts
|
|
2
|
+
var RENDER_APP_URI = "ui://plitzi/render.html";
|
|
3
|
+
var RENDER_APP_MIME = "text/html;profile=mcp-app";
|
|
4
|
+
var RENDER_APP_CSP = {
|
|
5
|
+
resourceDomains: [
|
|
6
|
+
"*",
|
|
7
|
+
"data:",
|
|
8
|
+
"blob:"
|
|
9
|
+
],
|
|
10
|
+
connectDomains: ["*"]
|
|
11
|
+
};
|
|
12
|
+
var appHtml = (sdkBase, devMode) => {
|
|
13
|
+
const js = `${sdkBase}/sdk-assets/plitzi-sdk.js`;
|
|
14
|
+
const vendor = `${sdkBase}/sdk-assets/${devMode ? "plitzi-sdk-dev-vendor.js" : "plitzi-sdk-vendor.js"}`;
|
|
15
|
+
return `<!doctype html>
|
|
16
|
+
<html lang="en">
|
|
17
|
+
<head>
|
|
18
|
+
<meta charset="utf-8" />
|
|
19
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
20
|
+
<title>Plitzi widget</title>
|
|
21
|
+
<link rel="modulepreload" href="${vendor}" crossorigin />
|
|
22
|
+
<script type="importmap">
|
|
23
|
+
{
|
|
24
|
+
"imports": {
|
|
25
|
+
"react": "${vendor}",
|
|
26
|
+
"react-dom": "${vendor}",
|
|
27
|
+
"react-dom/client": "${vendor}",
|
|
28
|
+
"react/jsx-runtime": "${vendor}",
|
|
29
|
+
"react/compiler-runtime": "${vendor}",
|
|
30
|
+
"@plitzi/plitzi-sdk": "${js}"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
<\/script>
|
|
34
|
+
<link href="${`${sdkBase}/sdk-assets/plitzi-sdk.css`}" rel="stylesheet" />
|
|
35
|
+
<style>
|
|
36
|
+
html,
|
|
37
|
+
body {
|
|
38
|
+
margin: 0;
|
|
39
|
+
}
|
|
40
|
+
#plitzi:empty::after {
|
|
41
|
+
content: 'Rendering…';
|
|
42
|
+
display: block;
|
|
43
|
+
padding: 16px;
|
|
44
|
+
font: 14px system-ui, sans-serif;
|
|
45
|
+
color: #64748b;
|
|
46
|
+
}
|
|
47
|
+
</style>
|
|
48
|
+
</head>
|
|
49
|
+
<body>
|
|
50
|
+
<div id="plitzi" class="plitzi-root-container"></div>
|
|
51
|
+
<script type="module">
|
|
52
|
+
const send = msg => window.parent.postMessage(msg, '*');
|
|
53
|
+
const root = document.getElementById('plitzi');
|
|
54
|
+
let mounted = false;
|
|
55
|
+
|
|
56
|
+
// Replace the 'Rendering…' placeholder with an explicit failure panel — otherwise a tool that returns
|
|
57
|
+
// { rendered:false } (no offlineData) or an SDK that fails to load would leave the iframe stuck forever.
|
|
58
|
+
const showError = (title, details) => {
|
|
59
|
+
mounted = true;
|
|
60
|
+
root.textContent = '';
|
|
61
|
+
const box = document.createElement('div');
|
|
62
|
+
box.setAttribute('style', 'padding:16px;font:13px/1.5 system-ui,sans-serif;color:#b91c1c');
|
|
63
|
+
const h = document.createElement('strong');
|
|
64
|
+
h.textContent = title;
|
|
65
|
+
box.appendChild(h);
|
|
66
|
+
if (details) {
|
|
67
|
+
const pre = document.createElement('pre');
|
|
68
|
+
pre.setAttribute('style', 'margin:8px 0 0;white-space:pre-wrap;color:#7f1d1d;font-size:12px');
|
|
69
|
+
pre.textContent = details;
|
|
70
|
+
box.appendChild(pre);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
root.appendChild(box);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// The failed-render text is the tool's JSON summary ({ rendered:false, errors:[{path,message,hint}] }).
|
|
77
|
+
const errorText = params => {
|
|
78
|
+
const item = Array.isArray(params && params.content) ? params.content.find(c => c.type === 'text') : undefined;
|
|
79
|
+
if (!item) {
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(item.text);
|
|
85
|
+
if (Array.isArray(parsed.errors)) {
|
|
86
|
+
return parsed.errors
|
|
87
|
+
.map(e => '• ' + (e.path ? e.path + ': ' : '') + e.message + (e.hint ? ' — ' + e.hint : ''))
|
|
88
|
+
.join('\\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return item.text;
|
|
92
|
+
} catch {
|
|
93
|
+
return item.text;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const mount = async params => {
|
|
98
|
+
if (mounted) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const offlineData = params && params.structuredContent && params.structuredContent.offlineData;
|
|
103
|
+
if (!offlineData) {
|
|
104
|
+
showError('Render failed', errorText(params) || 'The tool returned no widget data.');
|
|
105
|
+
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
mounted = true;
|
|
110
|
+
try {
|
|
111
|
+
const { render } = await import('@plitzi/plitzi-sdk');
|
|
112
|
+
render('plitzi', { offlineData, offlineMode: true, environment: 'main', renderMode: 'raw' }, {}, false, false);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
showError('SDK failed to load', String((err && err.message) || err));
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
window.addEventListener('message', event => {
|
|
119
|
+
const msg = event.data;
|
|
120
|
+
if (!msg || msg.jsonrpc !== '2.0') {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Reply to the initialize result: the host withholds tool data until it gets our 'initialized' notification.
|
|
125
|
+
if (msg.id === 1 && 'result' in msg) {
|
|
126
|
+
send({ jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} });
|
|
127
|
+
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (msg.method === 'ui/notifications/tool-result') {
|
|
132
|
+
void mount(msg.params);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Start the handshake immediately — nothing external blocks it, so the iframe is 'ready' at once.
|
|
137
|
+
send({
|
|
138
|
+
jsonrpc: '2.0',
|
|
139
|
+
id: 1,
|
|
140
|
+
method: 'ui/initialize',
|
|
141
|
+
params: {
|
|
142
|
+
protocolVersion: '2025-06-18',
|
|
143
|
+
appInfo: { name: 'Plitzi Widget', version: '1.0.0' },
|
|
144
|
+
appCapabilities: { availableDisplayModes: ['inline'] }
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
<\/script>
|
|
148
|
+
</body>
|
|
149
|
+
</html>`;
|
|
150
|
+
};
|
|
151
|
+
var appCache = /* @__PURE__ */ new Map();
|
|
152
|
+
var getAppHtml = (sdkBase, devMode) => {
|
|
153
|
+
const key = `${devMode ? "dev" : "prod"}|${sdkBase}`;
|
|
154
|
+
let html = appCache.get(key);
|
|
155
|
+
if (!html) {
|
|
156
|
+
html = appHtml(sdkBase, devMode);
|
|
157
|
+
appCache.set(key, html);
|
|
158
|
+
}
|
|
159
|
+
return html;
|
|
160
|
+
};
|
|
161
|
+
var registerRenderApp = (server, sdkBase, devMode) => {
|
|
162
|
+
const html = getAppHtml(sdkBase, devMode);
|
|
163
|
+
server.registerResource("plitzi-render-app", RENDER_APP_URI, {
|
|
164
|
+
description: "Interactive view that renders a plitzi_render widget with the Plitzi SDK (client-side).",
|
|
165
|
+
mimeType: RENDER_APP_MIME,
|
|
166
|
+
_meta: { ui: { csp: RENDER_APP_CSP } }
|
|
167
|
+
}, () => ({ contents: [{
|
|
168
|
+
uri: RENDER_APP_URI,
|
|
169
|
+
mimeType: RENDER_APP_MIME,
|
|
170
|
+
text: html
|
|
171
|
+
}] }));
|
|
172
|
+
};
|
|
173
|
+
//#endregion
|
|
174
|
+
export { RENDER_APP_URI, registerRenderApp };
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { BUILTIN_COMPONENTS } from "../catalogs/builtinComponents.js";
|
|
2
|
+
import { envelope, jsonContents } from "./envelope.js";
|
|
3
|
+
//#region src/modules/mcp/resources/renderGuide.ts
|
|
4
|
+
var RENDER_GUIDE_URI = "plitzi://render/guide";
|
|
5
|
+
var RENDER_TYPES_URI = "plitzi://render/types";
|
|
6
|
+
var RENDER_TYPE_CATEGORIES = /* @__PURE__ */ new Set([
|
|
7
|
+
"structure",
|
|
8
|
+
"basic",
|
|
9
|
+
"media",
|
|
10
|
+
"form",
|
|
11
|
+
"provider"
|
|
12
|
+
]);
|
|
13
|
+
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
|
+
var renderTypes = () => {
|
|
15
|
+
const types = {};
|
|
16
|
+
for (const [name, info] of Object.entries(BUILTIN_COMPONENTS)) if (info && info.category && RENDER_TYPE_CATEGORIES.has(info.category)) types[name] = {
|
|
17
|
+
label: info.label,
|
|
18
|
+
category: info.category,
|
|
19
|
+
description: info.description
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
note: renderTypesNote,
|
|
23
|
+
types
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
var renderGuideText = `# plitzi_render — authoring guide
|
|
27
|
+
|
|
28
|
+
\`plitzi_render\` shows the user a live UI widget. You build it as a list of \`operations\` (applied IN ORDER) that
|
|
29
|
+
assemble an element tree under one pre-seeded root page whose ref is **"render"**. It renders fully offline — no
|
|
30
|
+
space, no backend, no account. Same operation vocabulary as the editing tool plitzi_apply, aimed at one page.
|
|
31
|
+
|
|
32
|
+
Most widgets are **presentation only** — the structure + styling below is all you need, so start there. The widget
|
|
33
|
+
runs the live Plitzi SDK though, so it can also **fetch data** (an \`apiContainer\`) and **react to events**
|
|
34
|
+
(interaction flows); see "Data & interactivity" at the end when a widget needs them.
|
|
35
|
+
|
|
36
|
+
Each call renders a **fresh** widget with no memory of previous calls — always send **every** operation the widget
|
|
37
|
+
needs in the one call. To change a widget, re-send the whole thing.
|
|
38
|
+
|
|
39
|
+
## Build the whole widget in ONE upsertElement (nest with \`children\`)
|
|
40
|
+
|
|
41
|
+
The simplest, least error-prone path: a single \`upsertElement\` whose \`element\` carries a nested \`children\` tree —
|
|
42
|
+
so you describe the layout the way it renders, no ref-juggling. \`pageRef: "render"\` is required; each element in the
|
|
43
|
+
tree needs a unique \`ref\` — a name you choose from letters, digits, \`-\` and \`_\`, **starting with a letter and with
|
|
44
|
+
no dots** (e.g. \`price-row\`, \`cta_button\`).
|
|
45
|
+
|
|
46
|
+
\`\`\`json
|
|
47
|
+
{ "type": "upsertElement", "pageRef": "render", "element": {
|
|
48
|
+
"ref": "card", "type": "container", "style": { "base": ["card"] },
|
|
49
|
+
"children": [
|
|
50
|
+
{ "ref": "plan", "type": "heading", "subType": "h3", "props": { "content": "Pro" } },
|
|
51
|
+
{ "ref": "amount", "type": "text", "props": { "content": "$29/mo" }, "style": { "base": ["price"] } },
|
|
52
|
+
{ "ref": "buy", "type": "button", "props": { "content": "Start free trial" }, "style": { "base": ["cta"] } }
|
|
53
|
+
]
|
|
54
|
+
} }
|
|
55
|
+
\`\`\`
|
|
56
|
+
|
|
57
|
+
An element is \`{ ref, type, subType?, props?, style?, children? }\`. Children render in array order. (You *can* also
|
|
58
|
+
add elements one-by-one with a top-level \`parentRef: "<existing ref>"\` and optional \`position\` — useful to append to
|
|
59
|
+
or restructure something you already created — but for a fresh widget the inline \`children\` tree is easier.)
|
|
60
|
+
|
|
61
|
+
## Style with reusable classes — upsertDefinition
|
|
62
|
+
|
|
63
|
+
Styling is separate from structure: declare a class, then attach it by ref.
|
|
64
|
+
|
|
65
|
+
\`\`\`json
|
|
66
|
+
{ "type": "upsertDefinition", "ref": "card", "desktop": { "display": "flex", "flex-direction": "column", "gap": "8px", "padding": "24px", "border-radius": "12px" } }
|
|
67
|
+
\`\`\`
|
|
68
|
+
- CSS properties in **kebab-case** (\`background-color\`, \`font-size\`, \`border-radius\`), values as plain strings.
|
|
69
|
+
- Attach to an element via \`style: { "base": ["card"] }\`. Stack classes: \`"base": ["card", "shadow"]\`.
|
|
70
|
+
- One \`ref\` can name both an element and its class (as above) — they live in different namespaces.
|
|
71
|
+
- Lay containers out with flexbox: \`{ "display": "flex", "flex-direction": "column", "gap": "12px" }\`.
|
|
72
|
+
- **Mind the intrinsic display.** Some types start non-block: \`text\` is \`display: inline\`, so to stack or size it,
|
|
73
|
+
wrap it in a \`container\` (or set \`display: block\`). \`heading\` and \`paragraph\` are already block.
|
|
74
|
+
- **Use atomic longhands.** \`padding\`, \`margin\`, \`border\`, \`border-radius\` are fine (they expand cleanly), but
|
|
75
|
+
\`flex\`, \`background\` and \`font\` are rejected — write \`display\`+\`flex-direction\`, \`background-color\`,
|
|
76
|
+
\`font-size\`+\`font-weight\` instead. An unknown property errors with the correct kebab-case key suggested.
|
|
77
|
+
- **Responsive:** add \`tablet\` and/or \`mobile\` blocks next to \`desktop\` (same shape); they override desktop on
|
|
78
|
+
smaller screens — \`{ "desktop": { "font-size": "36px" }, "mobile": { "font-size": "24px" } }\`.
|
|
79
|
+
- **Interactive states:** nest under \`states\` keyed by pseudo-class, each with its own breakpoint block —
|
|
80
|
+
\`{ "desktop": { "background-color": "#3b82f6" }, "states": { "hover": { "desktop": { "background-color": "#2563eb" } } } }\`
|
|
81
|
+
(\`hover\`, \`active\`, \`focus\`).
|
|
82
|
+
|
|
83
|
+
## Element types (type → what to set)
|
|
84
|
+
|
|
85
|
+
| type | renders | set |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| \`container\` | layout box (a div) | nothing — style it with flex/grid |
|
|
88
|
+
| \`heading\` | h1–h6 title | \`props.content\`, element \`subType\`: "h1"…"h6" |
|
|
89
|
+
| \`paragraph\` | body text block (\`<p>\`) | \`props.content\` |
|
|
90
|
+
| \`text\` | inline text | \`props.content\` |
|
|
91
|
+
| \`button\` | clickable button | \`props.content\` |
|
|
92
|
+
| \`link\` | anchor | \`props.content\` (label), \`props.href\`, \`props.target\`: "self" \\| "blank" |
|
|
93
|
+
| \`image\` | \`<img>\` | \`props.src\`, \`props.alt\` |
|
|
94
|
+
| \`video\` | embedded video | \`props.src\` |
|
|
95
|
+
| \`list\` / \`listItem\` | \`<ul>\` / \`<li>\` | nesting only |
|
|
96
|
+
| \`markdown\` | rendered markdown | \`props.content\` |
|
|
97
|
+
|
|
98
|
+
\`subType\` is an element-level field (not a prop). Guessing is safe: an unknown prop for a type comes back as a
|
|
99
|
+
**warning naming the right one**, not an error. This table covers the everyday types; the resource
|
|
100
|
+
**plitzi://render/types** lists every built-in type you can use (with descriptions) — read it when you need one
|
|
101
|
+
that is not here (lists, tabs, dialogs, forms, icons…).
|
|
102
|
+
|
|
103
|
+
\`image\`/\`video\` \`src\` accepts any \`https\` URL, or a \`data:\`/\`blob:\` URI for a fully self-contained graphic
|
|
104
|
+
(e.g. an inline SVG icon or a base64 image) — both render with no extra setup.
|
|
105
|
+
|
|
106
|
+
## Full worked example — a pricing card
|
|
107
|
+
|
|
108
|
+
\`\`\`json
|
|
109
|
+
{
|
|
110
|
+
"operations": [
|
|
111
|
+
{ "type": "upsertDefinition", "ref": "card", "desktop": { "display": "flex", "flex-direction": "column", "gap": "8px", "padding": "24px", "background-color": "#ffffff", "border-radius": "12px", "width": "260px", "text-align": "center", "box-shadow": "0 4px 20px rgba(0,0,0,0.08)" } },
|
|
112
|
+
{ "type": "upsertDefinition", "ref": "price", "desktop": { "font-size": "36px", "font-weight": "800", "color": "#3b82f6" } },
|
|
113
|
+
{ "type": "upsertDefinition", "ref": "cta", "desktop": { "background-color": "#3b82f6", "color": "#ffffff", "padding": "12px 20px", "border-radius": "8px", "font-weight": "600" }, "states": { "hover": { "desktop": { "background-color": "#2563eb" } } } },
|
|
114
|
+
{ "type": "upsertElement", "pageRef": "render", "element": {
|
|
115
|
+
"ref": "card", "type": "container", "style": { "base": ["card"] },
|
|
116
|
+
"children": [
|
|
117
|
+
{ "ref": "plan", "type": "heading", "subType": "h3", "props": { "content": "Pro" } },
|
|
118
|
+
{ "ref": "amount", "type": "text", "props": { "content": "$29/mo" }, "style": { "base": ["price"] } },
|
|
119
|
+
{ "ref": "feat", "type": "paragraph", "props": { "content": "Unlimited projects" } },
|
|
120
|
+
{ "ref": "buy", "type": "button", "props": { "content": "Start free trial" }, "style": { "base": ["cta"] } }
|
|
121
|
+
]
|
|
122
|
+
} }
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
\`\`\`
|
|
126
|
+
|
|
127
|
+
## Data & interactivity (optional)
|
|
128
|
+
|
|
129
|
+
The widget runs the live SDK, so beyond static layout it can fetch data and respond to events. Both are wired by
|
|
130
|
+
\`ref\`, just like styling. (Full reference: **plitzi://guide** — sections Data bindings and Interactions.)
|
|
131
|
+
|
|
132
|
+
### Fetch data — a provider + a binding
|
|
133
|
+
An \`apiContainer\` fetches at runtime and exposes the result as the source **\`apiContainer_<idRef>.data\`**, visible
|
|
134
|
+
to its **DESCENDANTS only** — the bound element must live inside the container's subtree. \`upsertBinding\` then
|
|
135
|
+
connects that source to a descendant's field.
|
|
136
|
+
|
|
137
|
+
\`\`\`json
|
|
138
|
+
{
|
|
139
|
+
"operations": [
|
|
140
|
+
{ "type": "upsertElement", "pageRef": "render", "element": {
|
|
141
|
+
"ref": "products", "type": "apiContainer", "props": { "query": "<your data query — see plitzi://guide>" },
|
|
142
|
+
"children": [ { "ref": "title", "type": "heading", "subType": "h3", "props": { "content": "…" } } ]
|
|
143
|
+
} },
|
|
144
|
+
{ "type": "upsertBinding", "pageRef": "render", "ref": "title", "category": "attributes", "binding": { "to": "content", "source": "apiContainer_products.data.0.name" } }
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
\`\`\`
|
|
148
|
+
- \`category\`: \`"attributes"\` (a prop such as \`content\`/\`src\`), \`"style"\` (a CSS value) or \`"initialState"\`.
|
|
149
|
+
- \`binding.to\` is the target field; \`binding.source\` is the \`<type>_<idRef>.path\` into the provider's data.
|
|
150
|
+
- \`mockData\` on the provider is builder-only — set a real \`query\` for the widget to actually fetch.
|
|
151
|
+
|
|
152
|
+
### React to events — an interaction flow
|
|
153
|
+
\`upsertInteractionFlow\` attaches an ordered \`nodes\` list to an element; the FIRST node is a \`trigger\` (e.g.
|
|
154
|
+
\`onClick\`), the rest run after it. Each following step is one of:
|
|
155
|
+
- **\`globalCallback\`** (OMIT \`elementId\`) — a built-in app action: \`addNotification\` (\`params.content\`),
|
|
156
|
+
\`navigate\`, \`setState\` (\`key\`/\`type\`/\`value\`), \`authLogin\`/\`authLogout\`…
|
|
157
|
+
- **\`callback\`** (\`elementId\` = an element, defaults to the trigger's) — that ELEMENT's OWN callback: e.g. an
|
|
158
|
+
\`apiContainer\` re-fetches, a \`form\` submits. Each type's own callback action names are in plitzi://guide.
|
|
159
|
+
|
|
160
|
+
\`\`\`json
|
|
161
|
+
{ "type": "upsertInteractionFlow", "pageRef": "render", "ref": "buy", "nodes": [
|
|
162
|
+
{ "title": "On click", "nodeType": "trigger", "action": "onClick" },
|
|
163
|
+
{ "title": "Toast", "nodeType": "globalCallback", "action": "addNotification", "params": { "content": "Added to cart" } }
|
|
164
|
+
] }
|
|
165
|
+
\`\`\`
|
|
166
|
+
|
|
167
|
+
## When it fails
|
|
168
|
+
The tool returns \`rendered: false\` with \`errors: [{ path, message, hint }]\`. Read the hint, fix that one op, and
|
|
169
|
+
retry — you never lose the rest of the batch.
|
|
170
|
+
`;
|
|
171
|
+
var registerRenderResources = (server) => {
|
|
172
|
+
server.registerResource("Render guide", RENDER_GUIDE_URI, {
|
|
173
|
+
description: "How to author a plitzi_render widget: operations, element types, styling, examples.",
|
|
174
|
+
mimeType: "text/markdown"
|
|
175
|
+
}, () => ({ contents: [{
|
|
176
|
+
uri: RENDER_GUIDE_URI,
|
|
177
|
+
mimeType: "text/markdown",
|
|
178
|
+
text: renderGuideText
|
|
179
|
+
}] }));
|
|
180
|
+
server.registerResource("Render element types", RENDER_TYPES_URI, {
|
|
181
|
+
description: "Built-in element types a plitzi_render widget can use, with descriptions.",
|
|
182
|
+
mimeType: "application/json"
|
|
183
|
+
}, () => jsonContents(RENDER_TYPES_URI, envelope(renderTypes())));
|
|
184
|
+
};
|
|
185
|
+
//#endregion
|
|
186
|
+
export { RENDER_GUIDE_URI, RENDER_TYPES_URI, registerRenderResources };
|
|
@@ -2,6 +2,7 @@ import { emptySpace, emptySpaceMessage, unauthorizedSpaceMessage } from "./helpe
|
|
|
2
2
|
import { serverInstructions } from "./helpers/guide.js";
|
|
3
3
|
import { createMcpLog } from "./helpers/log.js";
|
|
4
4
|
import { registerResources } from "./resources/register.js";
|
|
5
|
+
import { registerRenderApp } from "./resources/renderApp.js";
|
|
5
6
|
import { tools } from "./tools/index.js";
|
|
6
7
|
import { isCallToolResult } from "../ai/toolkit.js";
|
|
7
8
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";
|
|
@@ -11,7 +12,7 @@ var asText = (data) => ({ content: [{
|
|
|
11
12
|
type: "text",
|
|
12
13
|
text: JSON.stringify(data)
|
|
13
14
|
}] });
|
|
14
|
-
var createMcpServer = ({ adapters, getSpaceId, preview, screenshot, logger }) => {
|
|
15
|
+
var createMcpServer = ({ adapters, getSpaceId, preview, screenshot, logger, renderApp }) => {
|
|
15
16
|
const log = createMcpLog(logger);
|
|
16
17
|
let spaceIdPromise;
|
|
17
18
|
const requireSpaceId = () => spaceIdPromise ??= getSpaceId().then((id) => {
|
|
@@ -41,9 +42,10 @@ var createMcpServer = ({ adapters, getSpaceId, preview, screenshot, logger }) =>
|
|
|
41
42
|
const getSpace = () => spacePromise ??= loadSpace();
|
|
42
43
|
const server = new McpServer({
|
|
43
44
|
name: "plitzi-mcp",
|
|
44
|
-
version: "0.32.
|
|
45
|
+
version: "0.32.8"
|
|
45
46
|
}, { instructions: serverInstructions });
|
|
46
47
|
registerResources(server, getSpace, MCP_ENV, log);
|
|
48
|
+
if (renderApp) registerRenderApp(server, renderApp.sdkBase, renderApp.devMode ?? false);
|
|
47
49
|
const toolContext = async () => ({
|
|
48
50
|
space: await getSpace(),
|
|
49
51
|
env: MCP_ENV,
|
|
@@ -64,7 +66,8 @@ var createMcpServer = ({ adapters, getSpaceId, preview, screenshot, logger }) =>
|
|
|
64
66
|
server.registerTool(tool.name, {
|
|
65
67
|
title: tool.title,
|
|
66
68
|
description: tool.description,
|
|
67
|
-
inputSchema: tool.inputShape
|
|
69
|
+
inputSchema: tool.inputShape,
|
|
70
|
+
...tool.ui && renderApp ? { _meta: { ui: tool.ui } } : {}
|
|
68
71
|
}, async (args) => {
|
|
69
72
|
const start = performance.now();
|
|
70
73
|
try {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { emptySpace } from "../helpers/space.js";
|
|
2
|
+
import { RENDER_APP_URI } from "../resources/renderApp.js";
|
|
2
3
|
import { operations } from "./operations/index.js";
|
|
3
4
|
import { applyOperations } from "./apply/dispatch.js";
|
|
4
5
|
import { defineTool } from "./shared/tool.js";
|
|
@@ -104,10 +105,11 @@ var toRenderResult = (res) => {
|
|
|
104
105
|
var renderTool = defineTool({
|
|
105
106
|
name: "plitzi_render",
|
|
106
107
|
title: "Render widget",
|
|
107
|
-
description: "
|
|
108
|
+
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.)\n2. STYLE — declare reusable classes with upsertDefinition { ref, desktop:{ …CSS props in kebab-case… } }, then attach via the element style:{ base:[\"<class ref>\"] }. Lay containers out with flex/grid.\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.\nReturns a compact summary (the widget is shown to the user); on failure it returns teachable errors (path + hint) — read them and retry.",
|
|
108
109
|
inputShape: renderShape,
|
|
109
110
|
access: "read",
|
|
110
111
|
spaceless: true,
|
|
112
|
+
ui: { resourceUri: RENDER_APP_URI },
|
|
111
113
|
run: (input) => toRenderResult(render(input))
|
|
112
114
|
});
|
|
113
115
|
//#endregion
|
|
@@ -5,5 +5,8 @@ import { SSRAdapters, SSRRequest, McpLogger } from '@plitzi/sdk-shared';
|
|
|
5
5
|
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
6
|
export declare const readMcpBody: (req: IncomingMessage) => Promise<unknown>;
|
|
7
7
|
export declare const serveMcp: (raw: IncomingMessage, res: ServerResponse, server: McpServer) => Promise<void>;
|
|
8
|
-
export declare const handleMcp: (raw: IncomingMessage, res: ServerResponse, req: SSRRequest, adapters: SSRAdapters, preview?: PreviewClient, screenshot?: ScreenshotClient, logger?: McpLogger
|
|
8
|
+
export declare const handleMcp: (raw: IncomingMessage, res: ServerResponse, req: SSRRequest, adapters: SSRAdapters, preview?: PreviewClient, screenshot?: ScreenshotClient, logger?: McpLogger, renderApp?: {
|
|
9
|
+
sdkBase: string;
|
|
10
|
+
devMode?: boolean;
|
|
11
|
+
}) => Promise<void>;
|
|
9
12
|
export { createMcpServer };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
|
2
|
+
export declare const RENDER_GUIDE_URI = "plitzi://render/guide";
|
|
3
|
+
export declare const RENDER_TYPES_URI = "plitzi://render/types";
|
|
4
|
+
export declare const registerRenderResources: (server: McpServer) => void;
|
|
@@ -18,5 +18,13 @@ export interface McpServerContext {
|
|
|
18
18
|
/** Structured request-log sink. When set, every tool call and resource read emits an McpLogEvent to it (the
|
|
19
19
|
* consumer renders them); otherwise logging falls back to the console when MCP_DEBUG=1. */
|
|
20
20
|
logger?: McpLogger;
|
|
21
|
+
/** MCP Apps: the interactive render view for plitzi_render. `sdkBase` is this server's absolute origin, which
|
|
22
|
+
* serves the Plitzi SDK bundle under /sdk-assets; the iframe imports it and renders the widget client-side.
|
|
23
|
+
* `devMode` picks the SDK vendor bundle name (dev vs prod split) to match the served bundle. Absent → the
|
|
24
|
+
* ui:// view is not registered (the tool still returns its text summary + offlineData). */
|
|
25
|
+
renderApp?: {
|
|
26
|
+
sdkBase: string;
|
|
27
|
+
devMode?: boolean;
|
|
28
|
+
};
|
|
21
29
|
}
|
|
22
|
-
export declare const createMcpServer: ({ adapters, getSpaceId, preview, screenshot, logger }: McpServerContext) => McpServer;
|
|
30
|
+
export declare const createMcpServer: ({ adapters, getSpaceId, preview, screenshot, logger, renderApp }: McpServerContext) => McpServer;
|
|
@@ -34,6 +34,11 @@ export interface ToolSpec<Shape extends ZodRawShape> {
|
|
|
34
34
|
/** The tool operates on no space (plitzi_render authors a throwaway one), so the host must NOT resolve a spaceId
|
|
35
35
|
* or load a space for it — that keeps it callable with no auth, on the public surface. */
|
|
36
36
|
spaceless?: boolean;
|
|
37
|
+
/** MCP Apps: link this tool to an interactive UI resource (a `ui://` HTML page the host renders in a sandboxed
|
|
38
|
+
* iframe, receiving the tool result). The `_meta.ui` the host reads is built from this. */
|
|
39
|
+
ui?: {
|
|
40
|
+
resourceUri: string;
|
|
41
|
+
};
|
|
37
42
|
run: (input: z.infer<ZodObject<Shape>>, ctx: ToolContext) => unknown;
|
|
38
43
|
}
|
|
39
44
|
/** What the registry holds and the hosts register from: the same metadata plus a type-erased `execute` that
|
|
@@ -46,6 +51,9 @@ export interface ToolDef {
|
|
|
46
51
|
access: 'read' | 'write';
|
|
47
52
|
requires?: ToolRequires;
|
|
48
53
|
spaceless?: boolean;
|
|
54
|
+
ui?: {
|
|
55
|
+
resourceUri: string;
|
|
56
|
+
};
|
|
49
57
|
execute: (args: unknown, ctx: ToolContext) => unknown;
|
|
50
58
|
}
|
|
51
59
|
/** Author a tool: give it its metadata, its input shape and a typed `run`. The returned descriptor parses the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plitzi/sdk-server",
|
|
3
|
-
"version": "0.32.
|
|
3
|
+
"version": "0.32.8",
|
|
4
4
|
"license": "AGPL-3.0",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist"
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"sideEffects": false,
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
26
|
-
"@plitzi/plitzi-sdk": "0.32.
|
|
27
|
-
"@plitzi/sdk-schema": "0.32.
|
|
28
|
-
"@plitzi/sdk-shared": "0.32.
|
|
26
|
+
"@plitzi/plitzi-sdk": "0.32.8",
|
|
27
|
+
"@plitzi/sdk-schema": "0.32.8",
|
|
28
|
+
"@plitzi/sdk-shared": "0.32.8",
|
|
29
29
|
"ejs": "^6.0.1",
|
|
30
30
|
"esbuild": "^0.28.1",
|
|
31
31
|
"zod": "^4.4.3"
|