@plitzi/sdk-server 0.32.17 → 0.32.19
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 +20 -0
- package/README.md +18 -0
- package/dist/modules/mcp/apps/render/index.js +2 -1
- package/dist/modules/mcp/apps/render/view/heldBatch.ts +107 -0
- package/dist/modules/mcp/apps/render/view/index.tsx +212 -0
- package/dist/modules/mcp/apps/shared/assets.js +10 -0
- package/dist/modules/mcp/apps/shared/bundle.js +15 -14
- package/dist/modules/mcp/handler.js +1 -1
- package/dist/modules/mcp/helpers/guide.js +11 -3
- package/dist/modules/mcp/helpers/space.js +13 -1
- package/dist/modules/mcp/resources/register.js +8 -3
- package/dist/modules/mcp/resources/renderGuide.js +138 -24
- package/dist/modules/mcp/resources/router.js +13 -1
- package/dist/modules/mcp/server.js +31 -19
- package/dist/modules/mcp/tools/apply/dispatch.js +2 -0
- package/dist/modules/mcp/tools/apply/index.js +17 -3
- package/dist/modules/mcp/tools/operations/index.js +4 -0
- package/dist/modules/mcp/tools/operations/schema/elements/patchElement.js +1 -1
- package/dist/modules/mcp/tools/operations/schema/elements/repeatElement.js +148 -0
- package/dist/modules/mcp/tools/operations/schema/interactions/deleteInteraction.js +1 -1
- package/dist/modules/mcp/tools/operations/schema/interactions/patchInteractionNode.js +1 -1
- package/dist/modules/mcp/tools/operations/schema/interactions/upsertInteractionFlow.js +1 -1
- package/dist/modules/mcp/tools/operations/schema/operations.js +2 -0
- package/dist/modules/mcp/tools/operations/schema/pages/upsertPage.js +1 -1
- package/dist/modules/mcp/tools/operations/schema/shared.js +8 -5
- package/dist/modules/mcp/tools/operations/schemaIds.js +54 -0
- package/dist/modules/mcp/tools/operations/style/definitions/patchDefinition.js +1 -1
- package/dist/modules/mcp/tools/operations/style/definitions/upsertDefinition.js +1 -1
- package/dist/modules/mcp/tools/operations/style/definitions/upsertDefinitions.js +40 -0
- package/dist/modules/mcp/tools/operations/style/globalStyles/patchGlobalStyle.js +1 -1
- package/dist/modules/mcp/tools/operations/style/globalStyles/upsertGlobalStyle.js +1 -1
- package/dist/modules/mcp/tools/operations/style/idStyles/patchIdStyle.js +1 -1
- package/dist/modules/mcp/tools/operations/style/idStyles/upsertIdStyle.js +1 -1
- package/dist/modules/mcp/tools/operations/style/operations.js +2 -0
- package/dist/modules/mcp/tools/operations/style/shared.js +1 -1
- package/dist/modules/mcp/tools/read.js +21 -3
- package/dist/modules/mcp/tools/render.js +70 -8
- package/dist/modules/mcp/tools/shared/expandOperations.js +44 -0
- package/dist/modules/mcp/tools/shared/tool.js +16 -11
- package/dist/modules/mcp/tools/shared/validator/audit.js +3 -0
- package/dist/modules/mcp/tools/shared/validator/batch.js +13 -4
- package/dist/modules/mcp/tools/shared/validator/index.js +15 -0
- package/dist/modules/mcp/tools/validate.js +11 -3
- package/dist/src/modules/mcp/apps/shared/assets.d.ts +10 -0
- package/dist/src/modules/mcp/apps/shared/bundle.d.ts +3 -0
- package/dist/src/modules/mcp/apps/shared/index.d.ts +1 -0
- package/dist/src/modules/mcp/e2e/index.d.ts +2 -2
- package/dist/src/modules/mcp/e2e/mcpEndpoint.d.ts +6 -1
- package/dist/src/modules/mcp/e2e/renderingHost.d.ts +16 -1
- package/dist/src/modules/mcp/helpers/guide.d.ts +2 -1
- package/dist/src/modules/mcp/helpers/space.d.ts +9 -0
- package/dist/src/modules/mcp/resources/index.d.ts +2 -1
- package/dist/src/modules/mcp/resources/register.d.ts +6 -2
- package/dist/src/modules/mcp/resources/renderGuide.d.ts +5 -0
- package/dist/src/modules/mcp/resources/router.d.ts +4 -0
- package/dist/src/modules/mcp/server.d.ts +6 -5
- package/dist/src/modules/mcp/tools/apply/index.d.ts +50 -0
- package/dist/src/modules/mcp/tools/operations/index.d.ts +100 -0
- package/dist/src/modules/mcp/tools/operations/schema/elements/repeatElement.d.ts +55 -0
- package/dist/src/modules/mcp/tools/operations/schema/index.d.ts +1 -0
- package/dist/src/modules/mcp/tools/operations/schema/operations.d.ts +19 -0
- package/dist/src/modules/mcp/tools/operations/schema/shared.d.ts +15 -0
- package/dist/src/modules/mcp/tools/operations/schemaIds.d.ts +6 -0
- package/dist/src/modules/mcp/tools/operations/style/definitions/upsertDefinitions.d.ts +41 -0
- package/dist/src/modules/mcp/tools/operations/style/index.d.ts +1 -0
- package/dist/src/modules/mcp/tools/operations/style/operations.d.ts +33 -0
- package/dist/src/modules/mcp/tools/operations/style/shared.d.ts +4 -3
- package/dist/src/modules/mcp/tools/preview.d.ts +50 -0
- package/dist/src/modules/mcp/tools/read.d.ts +1 -0
- package/dist/src/modules/mcp/tools/render.d.ts +421 -363
- package/dist/src/modules/mcp/tools/screenshot.d.ts +50 -0
- package/dist/src/modules/mcp/tools/shared/expandOperations.d.ts +13 -0
- package/dist/src/modules/mcp/tools/shared/tool.d.ts +8 -0
- package/dist/src/modules/mcp/tools/validate.d.ts +50 -0
- package/package.json +6 -5
- package/skills/plitzi-render/SKILL.md +170 -0
- package/dist/modules/mcp/apps/render/view.tsx +0 -108
- /package/dist/modules/mcp/apps/example/{view.tsx → view/index.tsx} +0 -0
- /package/dist/src/modules/mcp/{apps/example/view.d.ts → tests/schemaIds.test.d.ts} +0 -0
- /package/dist/src/modules/mcp/{apps/render/view.d.ts → tests/skill.test.d.ts} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @plitzi/sdk-server
|
|
2
2
|
|
|
3
|
+
## 0.32.19
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- v0.32.19
|
|
8
|
+
- Updated dependencies
|
|
9
|
+
- @plitzi/plitzi-sdk@0.32.19
|
|
10
|
+
- @plitzi/sdk-schema@0.32.19
|
|
11
|
+
- @plitzi/sdk-shared@0.32.19
|
|
12
|
+
|
|
13
|
+
## 0.32.18
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- v0.32.18
|
|
18
|
+
- Updated dependencies
|
|
19
|
+
- @plitzi/plitzi-sdk@0.32.18
|
|
20
|
+
- @plitzi/sdk-schema@0.32.18
|
|
21
|
+
- @plitzi/sdk-shared@0.32.18
|
|
22
|
+
|
|
3
23
|
## 0.32.17
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -839,6 +839,24 @@ When `devMode: true`, per-phase timing is instrumented on every render and repor
|
|
|
839
839
|
|
|
840
840
|
In production (`devMode: false`) timing instrumentation is skipped entirely — no `Server-Timing` header, no console output.
|
|
841
841
|
|
|
842
|
+
## Agent skill
|
|
843
|
+
|
|
844
|
+
This package ships an [Agent Skill](https://agentskills.io/) for `plitzi_render`, the MCP tool that renders a
|
|
845
|
+
self-contained UI widget from a batch of operations. The skill teaches an agent when to show a widget instead of
|
|
846
|
+
writing prose, the shape of a good call, the layout/theme traps that make a widget look wrong in a chat panel, and
|
|
847
|
+
how to iterate on a widget it already rendered.
|
|
848
|
+
|
|
849
|
+
It lives in [`skills/plitzi-render`](./skills/plitzi-render/SKILL.md) and is a plain `SKILL.md`, so it installs by
|
|
850
|
+
copying that folder into the skills directory of the agent you use (Claude Code, VS Code / Copilot, Codex, Gemini
|
|
851
|
+
CLI, Cline, Goose…):
|
|
852
|
+
|
|
853
|
+
```bash
|
|
854
|
+
cp -R node_modules/@plitzi/sdk-server/skills/plitzi-render ~/.claude/skills/
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
The skill only pays off with the Plitzi MCP server connected — it defers every detail to the `plitzi://render/guide`
|
|
858
|
+
resource the server publishes, so the two never drift apart.
|
|
859
|
+
|
|
842
860
|
## Exported types
|
|
843
861
|
|
|
844
862
|
```ts
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { VIEW_DIR } from "../shared/assets.js";
|
|
1
2
|
import { require } from "../shared/resolve.js";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -9,7 +10,7 @@ var renderApp = {
|
|
|
9
10
|
name: "plitzi-render-app",
|
|
10
11
|
title: "Plitzi widget",
|
|
11
12
|
description: "Interactive view that renders a plitzi_render widget with the Plitzi SDK.",
|
|
12
|
-
entry: path.join(HERE, "
|
|
13
|
+
entry: path.join(HERE, VIEW_DIR, "index.tsx"),
|
|
13
14
|
styles: () => [path.join(path.dirname(require.resolve("@plitzi/plitzi-sdk")), "plitzi-sdk.css")]
|
|
14
15
|
};
|
|
15
16
|
//#endregion
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Where the batch a widget was built from survives between calls — on the HOST, so the server can keep nothing.
|
|
2
|
+
*
|
|
3
|
+
* It cannot live in the view's memory: the Apps spec binds a view to ONE tool call (`ui/notifications/tool-input`
|
|
4
|
+
* is sent "at most once", and the host renders a fresh iframe when a UI tool is called), so the patch call the
|
|
5
|
+
* model makes arrives in a brand-new instance whose memory is empty. localStorage keyed by an id the server hands
|
|
6
|
+
* back is the spec's own state-persistence pattern (`_meta.viewUUID` in the Apps docs); here the key is the
|
|
7
|
+
* renderId, which the model also carries, so a patch names the widget it means instead of assuming "the last one".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PREFIX = 'plitzi.render.';
|
|
11
|
+
const MAX_ENTRIES = 5;
|
|
12
|
+
// A renderId only lives in one conversation's context, so a batch outlives its usefulness the moment that
|
|
13
|
+
// conversation is over — and a conversation the user abandoned would otherwise leave its widget in the host's
|
|
14
|
+
// storage for good. Generous enough that no live conversation loses its widget, short enough to be housekeeping.
|
|
15
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
type Stored = { savedAt: number; operations: unknown[] };
|
|
18
|
+
|
|
19
|
+
// A sandboxed iframe without allow-same-origin throws on the ACCESS to localStorage, not on the call — so even
|
|
20
|
+
// reading the property has to be guarded. A host that denies storage simply gets no patching.
|
|
21
|
+
const localStore = (): Storage | undefined => {
|
|
22
|
+
try {
|
|
23
|
+
return window.localStorage;
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const parse = (raw: string | null): Stored | undefined => {
|
|
30
|
+
if (raw === null) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const value = JSON.parse(raw) as Stored;
|
|
36
|
+
|
|
37
|
+
return Array.isArray(value.operations) ? value : undefined;
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const ownKeys = (store: Storage): string[] => {
|
|
44
|
+
const keys: string[] = [];
|
|
45
|
+
for (let index = 0; index < store.length; index += 1) {
|
|
46
|
+
const key = store.key(index);
|
|
47
|
+
if (key !== null && key.startsWith(PREFIX)) {
|
|
48
|
+
keys.push(key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return keys;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const dropStale = (store: Storage, keep: string): void => {
|
|
56
|
+
const others = ownKeys(store)
|
|
57
|
+
.filter(key => key !== keep)
|
|
58
|
+
.map(key => ({ key, savedAt: parse(store.getItem(key))?.savedAt ?? 0 }))
|
|
59
|
+
.sort((a, b) => b.savedAt - a.savedAt);
|
|
60
|
+
const expired = Date.now() - MAX_AGE_MS;
|
|
61
|
+
|
|
62
|
+
for (const [index, entry] of others.entries()) {
|
|
63
|
+
if (index >= MAX_ENTRIES - 1 || entry.savedAt < expired) {
|
|
64
|
+
store.removeItem(entry.key);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const readHeldBatch = (renderId: string, store = localStore()): unknown[] | undefined => {
|
|
70
|
+
if (!store) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stored = parse(store.getItem(`${PREFIX}${renderId}`));
|
|
75
|
+
if (!stored || stored.savedAt < Date.now() - MAX_AGE_MS) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return stored.operations;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const writeHeldBatch = (renderId: string, operations: unknown[], store = localStore()): void => {
|
|
83
|
+
if (!store) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const key = `${PREFIX}${renderId}`;
|
|
88
|
+
const payload = JSON.stringify({ savedAt: Date.now(), operations } satisfies Stored);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
dropStale(store, key);
|
|
92
|
+
store.setItem(key, payload);
|
|
93
|
+
} catch {
|
|
94
|
+
// Out of quota: the widget just rendered is the only one still worth patching, so the rest go.
|
|
95
|
+
for (const other of ownKeys(store)) {
|
|
96
|
+
if (other !== key) {
|
|
97
|
+
store.removeItem(other);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
store.setItem(key, payload);
|
|
103
|
+
} catch {
|
|
104
|
+
// Storage is full or refused: patching degrades to "re-send the full batch", never to a wrong widget.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/* eslint-disable react-refresh/only-export-components -- one bundled entry: components cannot move out. */
|
|
2
|
+
import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react';
|
|
3
|
+
import PlitziSdk from '@plitzi/plitzi-sdk';
|
|
4
|
+
import { Component, useEffect, useRef, useState } from 'react';
|
|
5
|
+
import { createRoot } from 'react-dom/client';
|
|
6
|
+
|
|
7
|
+
import { readHeldBatch, writeHeldBatch } from './heldBatch';
|
|
8
|
+
|
|
9
|
+
import type { App, McpUiHostContext } from '@modelcontextprotocol/ext-apps';
|
|
10
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
+
import type { OfflineDataRaw } from '@plitzi/sdk-shared';
|
|
12
|
+
import type { ReactNode } from 'react';
|
|
13
|
+
|
|
14
|
+
/** The view for plitzi_render: the entry the server bundles (React, the MCP Apps runtime and the Plitzi SDK
|
|
15
|
+
* included) and inlines in the shared ui:// page shell — which is what lets it run in a sandbox that can fetch
|
|
16
|
+
* nothing. It mounts on `#app`, the shell's root. */
|
|
17
|
+
|
|
18
|
+
const panelStyle = { padding: 16, font: '13px/1.5 system-ui, sans-serif', color: '#b91c1c' } as const;
|
|
19
|
+
|
|
20
|
+
// Every failure ends up here: an iframe left on its placeholder would hide the reason in the sandbox's console.
|
|
21
|
+
const ErrorPanel = ({ title, details }: { title: string; details: string }) => (
|
|
22
|
+
<div style={panelStyle}>
|
|
23
|
+
<strong>{title}</strong>
|
|
24
|
+
<pre style={{ margin: '8px 0 0', whiteSpace: 'pre-wrap', color: '#7f1d1d', fontSize: 12 }}>{details}</pre>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
class RenderBoundary extends Component<{ children: ReactNode }, { error?: Error }> {
|
|
29
|
+
state: { error?: Error } = {};
|
|
30
|
+
|
|
31
|
+
static getDerivedStateFromError(error: Error) {
|
|
32
|
+
return { error };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
render() {
|
|
36
|
+
if (this.state.error) {
|
|
37
|
+
return <ErrorPanel title="Widget failed to render" details={this.state.error.message} />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return this.props.children;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The batch the widget on screen was built from. It lives on the HOST side — this ref plus localStorage — because
|
|
45
|
+
* the server keeps nothing between calls, which is what lets any replica (or an edge deployment) answer any
|
|
46
|
+
* request. A patch is merged into it and sent back through `callServerTool`, so the full batch never enters the
|
|
47
|
+
* model's context. The ref alone would not do: the host gives each tool call its own view, so a patch usually
|
|
48
|
+
* starts from an empty instance and reads the batch back from storage by renderId (see heldBatch.ts). */
|
|
49
|
+
type Held = { renderId?: string; operations: unknown[] };
|
|
50
|
+
|
|
51
|
+
const summarise = (result: CallToolResult): string => {
|
|
52
|
+
const text = result.content.find(entry => entry.type === 'text');
|
|
53
|
+
|
|
54
|
+
return text?.text ?? 'no summary';
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const RenderApp = () => {
|
|
58
|
+
const [result, setResult] = useState<CallToolResult | null>(null);
|
|
59
|
+
const [cancelled, setCancelled] = useState<string | undefined>(undefined);
|
|
60
|
+
const [context, setContext] = useState<McpUiHostContext | undefined>(undefined);
|
|
61
|
+
const held = useRef<Held>({ operations: [] });
|
|
62
|
+
const appRef = useRef<App | null>(null);
|
|
63
|
+
|
|
64
|
+
// A patch carries only what changed. Merging it onto the held batch and re-calling the tool is what keeps the
|
|
65
|
+
// server stateless: it re-renders the WHOLE widget (so refs, integrity and the audit are all checked as usual)
|
|
66
|
+
// from a payload that travelled host↔server, and the model hears the outcome through updateModelContext.
|
|
67
|
+
const applyPatch = async (renderId: string, delta: unknown[]): Promise<void> => {
|
|
68
|
+
const app = appRef.current;
|
|
69
|
+
if (!app) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const base = held.current.renderId === renderId ? held.current.operations : (readHeldBatch(renderId) ?? []);
|
|
74
|
+
if (base.length === 0) {
|
|
75
|
+
await app.updateModelContext({
|
|
76
|
+
content: [
|
|
77
|
+
{
|
|
78
|
+
type: 'text',
|
|
79
|
+
text: `The widget ${renderId} could not be recovered, so nothing was patched. Call plitzi_render again with the complete batch and without \`patch\`.`
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const merged = [...base, ...delta];
|
|
88
|
+
// The round trip can fail on its own: a host that forwards no tool calls, a connection dropped mid-patch, a
|
|
89
|
+
// view torn down while the answer is in flight. Unreported it would surface as an unhandled rejection inside
|
|
90
|
+
// the sandbox and the model would wait forever for a widget that is never coming.
|
|
91
|
+
try {
|
|
92
|
+
const rendered = await app.callServerTool({
|
|
93
|
+
name: 'plitzi_render',
|
|
94
|
+
arguments: { operations: merged, renderId }
|
|
95
|
+
});
|
|
96
|
+
const offlineData = rendered.structuredContent?.offlineData;
|
|
97
|
+
if (offlineData) {
|
|
98
|
+
const applied = (rendered.structuredContent?.operations as unknown[] | undefined) ?? merged;
|
|
99
|
+
held.current = { renderId, operations: applied };
|
|
100
|
+
writeHeldBatch(renderId, applied);
|
|
101
|
+
setResult(rendered);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await app.updateModelContext({
|
|
105
|
+
content: [
|
|
106
|
+
{
|
|
107
|
+
type: 'text',
|
|
108
|
+
text: offlineData
|
|
109
|
+
? `Widget updated: ${summarise(rendered)}`
|
|
110
|
+
: `The patch did not apply, the widget is unchanged: ${summarise(rendered)}`
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
});
|
|
114
|
+
} catch (reason) {
|
|
115
|
+
await app
|
|
116
|
+
.updateModelContext({
|
|
117
|
+
content: [
|
|
118
|
+
{
|
|
119
|
+
type: 'text',
|
|
120
|
+
text: `The patch could not be delivered (${reason instanceof Error ? reason.message : String(reason)}), so the widget is unchanged. Re-send the full batch without \`patch\` if it needs to change.`
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
})
|
|
124
|
+
.catch(() => undefined);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// useApp creates the App, runs onAppCreated so every handler is in place BEFORE the handshake, and connects.
|
|
129
|
+
const { app, error } = useApp({
|
|
130
|
+
appInfo: { name: 'Plitzi Widget', version: '1.0.0' },
|
|
131
|
+
capabilities: {},
|
|
132
|
+
onAppCreated: instance => {
|
|
133
|
+
appRef.current = instance;
|
|
134
|
+
instance.ontoolresult = toolResult => {
|
|
135
|
+
const renderId = toolResult.structuredContent?.renderId as string | undefined;
|
|
136
|
+
if (toolResult.structuredContent?.patch === true && renderId) {
|
|
137
|
+
void applyPatch(renderId, (toolResult.structuredContent.operations as unknown[] | undefined) ?? []);
|
|
138
|
+
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A result carrying no widget is a failed render or a refused patch. The model already reads its reasons
|
|
143
|
+
// as text, so it must not blank a widget that is on screen — nor drop the batch that widget was built
|
|
144
|
+
// from, which is the only copy of it anywhere. With nothing on screen yet, the error IS the view.
|
|
145
|
+
if (!toolResult.structuredContent?.offlineData && held.current.operations.length > 0) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const operations = (toolResult.structuredContent?.operations as unknown[] | undefined) ?? [];
|
|
150
|
+
held.current = { renderId, operations };
|
|
151
|
+
if (renderId && operations.length > 0) {
|
|
152
|
+
writeHeldBatch(renderId, operations);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
setResult(toolResult);
|
|
156
|
+
};
|
|
157
|
+
instance.ontoolcancelled = params => setCancelled(params.reason ?? 'The host cancelled the render.');
|
|
158
|
+
instance.onhostcontextchanged = params => setContext(previous => ({ ...previous, ...params }));
|
|
159
|
+
instance.onteardown = () => ({});
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// The context the host sent in its initialize result predates any change notification.
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (app) {
|
|
166
|
+
setContext(app.getHostContext());
|
|
167
|
+
}
|
|
168
|
+
}, [app]);
|
|
169
|
+
|
|
170
|
+
useHostStyles(app, context);
|
|
171
|
+
|
|
172
|
+
const failure = error?.message ?? cancelled;
|
|
173
|
+
if (failure) {
|
|
174
|
+
return <ErrorPanel title="Could not render the widget" details={failure} />;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Still connecting, or connected and waiting for the result: the page's CSS placeholder covers both.
|
|
178
|
+
if (!result) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The payload rides in structuredContent, which the protocol types as unknown-valued; plitzi_render is the one
|
|
183
|
+
// guaranteeing the shape, and the SDK validates it again when it renders.
|
|
184
|
+
const offlineData = result.structuredContent?.offlineData as OfflineDataRaw | undefined;
|
|
185
|
+
if (!offlineData) {
|
|
186
|
+
// A failed render answers with its (already compact) reasons as the tool's JSON text summary.
|
|
187
|
+
const text = result.content.find(entry => entry.type === 'text');
|
|
188
|
+
|
|
189
|
+
return <ErrorPanel title="Render failed" details={text?.text ?? 'The tool returned no widget data.'} />;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const insets = context?.safeAreaInsets;
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div
|
|
196
|
+
style={{
|
|
197
|
+
padding: `${insets?.top ?? 0}px ${insets?.right ?? 0}px ${insets?.bottom ?? 0}px ${insets?.left ?? 0}px`
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
<RenderBoundary>
|
|
201
|
+
<PlitziSdk offlineData={offlineData} offlineMode environment="main" renderMode="raw" />
|
|
202
|
+
</RenderBoundary>
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const root = document.getElementById('app');
|
|
208
|
+
if (root) {
|
|
209
|
+
// The SDK styles its tree from this container class down.
|
|
210
|
+
root.className = 'plitzi-root-container';
|
|
211
|
+
createRoot(root).render(<RenderApp />);
|
|
212
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
//#region src/modules/mcp/apps/shared/assets.ts
|
|
3
|
+
/** The one folder of an app that ships to dist as SOURCE: its browser entry (`view/index.tsx`) and every module
|
|
4
|
+
* that entry bundles. The server builds it with esbuild at request time, so none of it is in the module graph the
|
|
5
|
+
* library build compiles — a view-side module that does not travel verbatim is simply missing at runtime, which
|
|
6
|
+
* the package only finds out when a host asks for the page. Everything else in an app (its definition) compiles
|
|
7
|
+
* like any other module. */
|
|
8
|
+
var VIEW_DIR = "view";
|
|
9
|
+
//#endregion
|
|
10
|
+
export { VIEW_DIR };
|
|
@@ -2,21 +2,22 @@ import { require } from "./resolve.js";
|
|
|
2
2
|
import { zodEnglishOnly } from "./zodEnglishOnly.js";
|
|
3
3
|
import { build } from "esbuild";
|
|
4
4
|
//#region src/modules/mcp/apps/shared/bundle.ts
|
|
5
|
+
var options = (entry) => ({
|
|
6
|
+
entryPoints: [entry],
|
|
7
|
+
bundle: true,
|
|
8
|
+
write: false,
|
|
9
|
+
format: "iife",
|
|
10
|
+
platform: "browser",
|
|
11
|
+
target: "es2022",
|
|
12
|
+
jsx: "automatic",
|
|
13
|
+
minify: true,
|
|
14
|
+
alias: { "@plitzi/plitzi-sdk": require.resolve("@plitzi/plitzi-sdk") },
|
|
15
|
+
define: { "process.env.NODE_ENV": "\"production\"" },
|
|
16
|
+
plugins: [zodEnglishOnly],
|
|
17
|
+
logLevel: "silent"
|
|
18
|
+
});
|
|
5
19
|
var bundle = async (entry) => {
|
|
6
|
-
return (await build(
|
|
7
|
-
entryPoints: [entry],
|
|
8
|
-
bundle: true,
|
|
9
|
-
write: false,
|
|
10
|
-
format: "iife",
|
|
11
|
-
platform: "browser",
|
|
12
|
-
target: "es2022",
|
|
13
|
-
jsx: "automatic",
|
|
14
|
-
minify: true,
|
|
15
|
-
alias: { "@plitzi/plitzi-sdk": require.resolve("@plitzi/plitzi-sdk") },
|
|
16
|
-
define: { "process.env.NODE_ENV": "\"production\"" },
|
|
17
|
-
plugins: [zodEnglishOnly],
|
|
18
|
-
logLevel: "silent"
|
|
19
|
-
})).outputFiles[0].text;
|
|
20
|
+
return (await build(options(entry))).outputFiles[0].text;
|
|
20
21
|
};
|
|
21
22
|
//#endregion
|
|
22
23
|
export { bundle };
|
|
@@ -74,7 +74,7 @@ var serveMcp = async (raw, res, server) => {
|
|
|
74
74
|
await transport.close();
|
|
75
75
|
}
|
|
76
76
|
};
|
|
77
|
-
var handleMcp = (raw, res, req, adapters, options = {}) => serveMcp(raw, res, createMcpServer({
|
|
77
|
+
var handleMcp = async (raw, res, req, adapters, options = {}) => serveMcp(raw, res, await createMcpServer({
|
|
78
78
|
adapters,
|
|
79
79
|
getSpaceId: () => adapters.getSpaceId?.(req) ?? Promise.resolve(void 0),
|
|
80
80
|
...options
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
//#region src/modules/mcp/helpers/guide.ts
|
|
2
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 plain kebab-case — write shorthands freely (`border: 1px solid red`, `padding: 8px 16px`, `font: bold 16px/1.5 Arial`), they are expanded to longhands for you and STORED that way, so read-back shows the 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
|
+
var widgetsOnlyInstructions = "Plitzi widget server (widgets-only connection). This connection carries NO Plitzi space: nothing can be read or edited in one, and the space tools are not offered here — do not look for them. What it does do is build self-contained UI widgets fully offline, with no backend, account or setup: call plitzi_render to SHOW the user a real rendered layout (card, hero, pricing table, checklist, form, gallery) instead of describing one. Read plitzi://render/guide first — the element/prop table, the style model and a worked example; plitzi://render/types lists every element type you can author (plitzi_read fetches both). To change a widget you already rendered, call plitzi_render again with patch:true and its renderId. If the user wants to edit their real Plitzi space from here, that is a reconnection they make: the integration must be re-authorized and granted a space (the consent screen lists theirs) — you cannot do it from this connection, and no retry will change it.";
|
|
3
4
|
var guideQuickstart = `# Plitzi AI MCP — quickstart
|
|
4
5
|
This is the condensed guide; read \`plitzi://guide\` for the full reference (every resource, op and example).
|
|
5
6
|
|
|
@@ -202,7 +203,12 @@ pointed at the old name is repointed with it, so the element stays wired. You do
|
|
|
202
203
|
(prefer a unitless ratio like \`1.5\`, which tracks the font size). Changing one without the other leaves cramped or
|
|
203
204
|
loosely-spaced text — they are a joint change, not two separate ones.
|
|
204
205
|
- A definition lives in the **style schema**; an element's \`style.base\` (element schema) is the link that applies
|
|
205
|
-
it. Styling an element =
|
|
206
|
+
it. Styling an element = upsertDefinitions + upsertElement with that ref in \`style.base\`, in one batch.
|
|
207
|
+
- **Repeating siblings**: when a set of siblings shares a shape and differs only in data (a list, cards, rows,
|
|
208
|
+
steps), use \`repeatElement\` — the template once with \`{{item.field}}\` placeholders plus \`items\`, which
|
|
209
|
+
creates the wrapper and numbers each row's refs (\`step-1\`, \`step-2\`…). A list inside each row is the same op:
|
|
210
|
+
the wrapping node carries \`repeat: { items: "{{item.<list>}}", template: … }\` and its refs number both levels
|
|
211
|
+
(\`blk-2-3\`). Copy-pasting the subtree N times costs N times the tokens and drifts.
|
|
206
212
|
- CSS keys are **kebab-case** (\`background-color\`). camelCase is rejected — read \`plitzi://css-properties\`.
|
|
207
213
|
- **Write normal CSS — shorthands are accepted and expanded for you.** \`border\`, \`border-{side}\`,
|
|
208
214
|
\`border-width\`/\`-color\`/\`-style\`, \`border-radius\`, \`padding\`, \`margin\`, \`inset\`, \`gap\`, \`overflow\`,
|
|
@@ -232,7 +238,9 @@ pointed at the old name is repointed with it, so the element stays wired. You do
|
|
|
232
238
|
- **Three kinds of style live in the style schema — do not confuse them:**
|
|
233
239
|
- **Definitions** = reusable CSS **classes** (\`upsertDefinition\`/\`patchDefinition\`/\`deleteDefinition\`, keyed by a
|
|
234
240
|
class \`ref\`). Attach one to an element via \`style.base\` to style **that** element (and anything else that opts in).
|
|
235
|
-
This is the **default** way to style one element.
|
|
241
|
+
This is the **default** way to style one element. Declaring MORE than one class in a batch? Use
|
|
242
|
+
\`upsertDefinitions\` — one op carrying \`{ "<class>": { desktop: … }, … }\`, same result as the run of
|
|
243
|
+
\`upsertDefinition\` it replaces, without repeating the envelope once per class.
|
|
236
244
|
- **Global styles** = the CSS equivalent of a bare element selector like \`button { … }\`
|
|
237
245
|
(\`upsertGlobalStyle\`/\`patchGlobalStyle\`/\`deleteGlobalStyle\`, keyed by \`componentType\`). They style **every**
|
|
238
246
|
element of that type at once. Use these for site-wide intent — e.g. "all buttons rounded":
|
|
@@ -456,4 +464,4 @@ Space-level configuration lives in \`plitzi://settings/{env}\` and is edited wit
|
|
|
456
464
|
never omit it "to save a call".
|
|
457
465
|
`;
|
|
458
466
|
//#endregion
|
|
459
|
-
export { guideQuickstart, guideText, serverInstructions };
|
|
467
|
+
export { guideQuickstart, guideText, serverInstructions, widgetsOnlyInstructions };
|
|
@@ -280,9 +280,21 @@ var pageRefOfElement = (schema, el) => {
|
|
|
280
280
|
/** Total number of descendant elements under a subtree (excluding the root). */
|
|
281
281
|
var descendantCount = (schema, rootId) => descendantIds(schema, rootId).length;
|
|
282
282
|
var emptySpaceMessage = "Space data not available";
|
|
283
|
+
/** The code a space-dependent answer carries when the connection reaches no space, so both the agent and a host UI
|
|
284
|
+
* can tell "this connection cannot do that" from "the call failed". */
|
|
285
|
+
var noSpaceError = "NO_SPACE_ATTACHED";
|
|
283
286
|
var unauthorizedSpaceMessage = "This connection has no space attached (a guest or widgets-only grant, or a token that carries no space), so NOTHING in a space can be read or edited — every other space tool will fail the same way, do not retry them. Use plitzi_render instead: it builds a self-contained widget offline, with no space, backend or account (read plitzi://render/guide). To edit a real space, the user must reconnect the integration and grant access to one.";
|
|
287
|
+
/** Raised when a space-dependent operation runs on a connection that resolves no spaceId. A type of its own so the
|
|
288
|
+
* host answers it as a STATE of the connection — a plain result the agent reads — instead of letting it surface as
|
|
289
|
+
* a failed call, which hosts render to the user as "cannot connect to this server". */
|
|
290
|
+
var NoSpaceError = class extends Error {
|
|
291
|
+
constructor() {
|
|
292
|
+
super(unauthorizedSpaceMessage);
|
|
293
|
+
this.name = "NoSpaceError";
|
|
294
|
+
}
|
|
295
|
+
};
|
|
284
296
|
var generateObjectId = () => {
|
|
285
297
|
return `${Math.floor(Date.now() / 1e3).toString(16).padStart(8, "0")}${Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join("")}`;
|
|
286
298
|
};
|
|
287
299
|
//#endregion
|
|
288
|
-
export { cloneSpace, descendantCount, descendantIds, elementById, elementRefOf, emptySpace, emptySpaceMessage, findElementByRef, findFolderByRef, findPageByRef, folderAncestorIds, generateObjectId, getPageElements, indexAddElement, indexAddPage, indexInvalidateDetails, indexReRefElement, indexReRefPage, indexRemoveElements, indexRemovePage, isPageElement, nameOf, orderedChildren, pageFoldersOf, pageRefOf, pageRefOfElement, resolveRef, routeParamNames, slugRouteParams, slugify, sortFolders, spaceIndex, strOr, unauthorizedSpaceMessage };
|
|
300
|
+
export { NoSpaceError, cloneSpace, descendantCount, descendantIds, elementById, elementRefOf, emptySpace, emptySpaceMessage, findElementByRef, findFolderByRef, findPageByRef, folderAncestorIds, generateObjectId, getPageElements, indexAddElement, indexAddPage, indexInvalidateDetails, indexReRefElement, indexReRefPage, indexRemoveElements, indexRemovePage, isPageElement, nameOf, noSpaceError, orderedChildren, pageFoldersOf, pageRefOf, pageRefOfElement, resolveRef, routeParamNames, slugRouteParams, slugify, sortFolders, spaceIndex, strOr, unauthorizedSpaceMessage };
|
|
@@ -2,13 +2,17 @@ import { cssProperties, cssShorthands } from "../catalogs/cssCatalog/index.js";
|
|
|
2
2
|
import { guideText } from "../helpers/guide.js";
|
|
3
3
|
import { resourceErrorMessage } from "./canonical.js";
|
|
4
4
|
import { envelope, jsonContents } from "./envelope.js";
|
|
5
|
-
import { readResource } from "./router.js";
|
|
6
5
|
import { registerRenderResources } from "./renderGuide.js";
|
|
6
|
+
import { readResource } from "./router.js";
|
|
7
7
|
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
8
|
//#region src/modules/mcp/resources/register.ts
|
|
9
9
|
/** Register every resource on the MCP server: fixed listings plus templated per-item reads. The space is
|
|
10
|
-
* loaded lazily via getSpace, so listing resources never touches the store — only reading one does.
|
|
11
|
-
|
|
10
|
+
* loaded lazily via getSpace, so listing resources never touches the store — only reading one does.
|
|
11
|
+
*
|
|
12
|
+
* `hasSpace` is false when the connection reaches no space (a guest / widgets-only grant): the space-dependent
|
|
13
|
+
* families are then not registered at all, so the catalog the agent browses holds only what it can actually
|
|
14
|
+
* open — no listing that answers every read with the same refusal. */
|
|
15
|
+
var registerResources = (server, getSpace, env, log, hasSpace) => {
|
|
12
16
|
const emit = async (uri) => {
|
|
13
17
|
const start = performance.now();
|
|
14
18
|
try {
|
|
@@ -43,6 +47,7 @@ var registerResources = (server, getSpace, env, log) => {
|
|
|
43
47
|
shorthands: cssShorthands
|
|
44
48
|
}))));
|
|
45
49
|
registerRenderResources(server, log);
|
|
50
|
+
if (!hasSpace) return;
|
|
46
51
|
const fixed = [
|
|
47
52
|
[
|
|
48
53
|
"Primer",
|