@guuey/mcp-apps-host 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action.d.ts +25 -2
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js +29 -2
- package/dist/block-ui.d.ts +18 -0
- package/dist/block-ui.d.ts.map +1 -1
- package/dist/block-ui.js +17 -0
- package/dist/card-mount.d.ts +4 -2
- package/dist/card-mount.d.ts.map +1 -1
- package/dist/card-mount.js +7 -5
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/narrowing.d.ts +1 -1
- package/dist/narrowing.d.ts.map +1 -1
- package/dist/narrowing.js +1 -1
- package/dist/react.d.ts +17 -2
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +35 -9
- package/dist/view-host-protocol.d.ts +44 -0
- package/dist/view-host-protocol.d.ts.map +1 -1
- package/dist/view-host-protocol.js +65 -3
- package/dist/view-host.d.ts +23 -0
- package/dist/view-host.d.ts.map +1 -1
- package/dist/view-host.js +43 -2
- package/package.json +2 -2
- package/src/action.ts +30 -2
- package/src/block-ui.ts +21 -0
- package/src/card-mount.ts +11 -7
- package/src/index.ts +4 -0
- package/src/narrowing.ts +1 -0
- package/src/react.tsx +56 -10
- package/src/view-host-protocol.ts +102 -2
- package/src/view-host.ts +63 -1
package/dist/view-host.d.ts
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* re-derived here against the pure machine + our own tests.
|
|
29
29
|
*/
|
|
30
30
|
import { type ViewHostPhase } from "./view-host-protocol.js";
|
|
31
|
+
import type { McpResourceReadResult } from "./reader.js";
|
|
31
32
|
import { type McpToolCallResult, type UiActionRequest } from "./action.js";
|
|
32
33
|
import type { McpUiResourcePayload } from "./block-ui.js";
|
|
33
34
|
import type { McpUiHostCapabilities, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
|
|
@@ -91,6 +92,28 @@ export interface AttachViewHostConfig {
|
|
|
91
92
|
* `<GuueyView>` fills it from the mount automatically.
|
|
92
93
|
*/
|
|
93
94
|
resourceUri?: string;
|
|
95
|
+
/**
|
|
96
|
+
* The `resources/read` relay — a PRIVILEGE boundary like
|
|
97
|
+
* {@link onCallTool}, default off: with no hook, the machine refuses
|
|
98
|
+
* `resources/read` in-band and advertises no `serverResources`. The hook
|
|
99
|
+
* is structurally the SAME transport `createMcpUiResourceReader`
|
|
100
|
+
* assembles over ({@link CreateMcpUiResourceReaderDeps.readResource}) —
|
|
101
|
+
* a host with a locator reader wires the identical function here. Trust
|
|
102
|
+
* rules ride the reader discipline (`reader.ts`): enforcement lives
|
|
103
|
+
* INSIDE the transport; a miss, a deny, and a throw all answer the view
|
|
104
|
+
* with the one `Resource not found` error (deny == miss — no oracle).
|
|
105
|
+
*/
|
|
106
|
+
onReadResource?: (uri: string) => Promise<McpResourceReadResult | undefined>;
|
|
107
|
+
/**
|
|
108
|
+
* The view reported its content size (`ui/notifications/size-changed` —
|
|
109
|
+
* spec notification). Whether and how to resize the frame is the
|
|
110
|
+
* embedder's layout decision; `<GuueyView autoResize>` is one wiring of
|
|
111
|
+
* exactly this callback.
|
|
112
|
+
*/
|
|
113
|
+
onSizeChanged?: (size: {
|
|
114
|
+
width?: number;
|
|
115
|
+
height?: number;
|
|
116
|
+
}) => void;
|
|
94
117
|
/** Observe phase transitions (see {@link ViewHostPhase}). */
|
|
95
118
|
onPhaseChange?: (phase: ViewHostPhase) => void;
|
|
96
119
|
/**
|
package/dist/view-host.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"view-host.d.ts","sourceRoot":"","sources":["../src/view-host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,OAAO,
|
|
1
|
+
{"version":3,"file":"view-host.d.ts","sourceRoot":"","sources":["../src/view-host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,OAAO,EASL,KAAK,aAAa,EAEnB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAEL,KAAK,iBAAiB,EAEtB,KAAK,eAAe,EACrB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,KAAK,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AAC9F,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAE5D;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,aAAa,EAAE;QAAE,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;IAC7F,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED,4DAA4D;AAC5D,MAAM,WAAW,cAAc;IAC7B,gBAAgB,CACd,IAAI,EAAE,SAAS,EACf,QAAQ,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,GAC5D,IAAI,CAAC;IACR,mBAAmB,CACjB,IAAI,EAAE,SAAS,EACf,QAAQ,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,GAC5D,IAAI,CAAC;CACT;AAED,MAAM,WAAW,oBAAoB;IACnC;;;;;;;;OAQG;IACH,gBAAgB,CAAC,EAAE,qBAAqB,CAAC;IACzC,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,gBAAgB,CAAC;IAC/B;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACtE;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;OAUG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,GAAG,SAAS,CAAC,CAAC;IAC7E;;;;;OAKG;IACH,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACpE,6DAA6D;IAC7D,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC/C;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,qEAAqE;IACrE,MAAM,CAAC,EAAE,cAAc,CAAC;CACzB;AAoDD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,aAAa,EAAE,MAAM,GAAE,oBAAyB,GAAG,MAAM,IAAI,CAkFlG;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,oBAAoB,GAAG,MAAM,GAAG,SAAS,CAcnF"}
|
package/dist/view-host.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* Seeded from ggui's console `surface-host.ts` (donated, guuey#186 audit);
|
|
28
28
|
* re-derived here against the pure machine + our own tests.
|
|
29
29
|
*/
|
|
30
|
-
import { initialViewHostState, teardownMessage, toolCallResponse, viewHostElapsed, viewHostReceive, } from "./view-host-protocol.js";
|
|
30
|
+
import { initialViewHostState, resourceReadResponse, teardownMessage, toolCallResponse, viewHostElapsed, viewHostReceive, } from "./view-host-protocol.js";
|
|
31
31
|
import { unavailableToolCallResult, } from "./action.js";
|
|
32
32
|
const DEFAULT_HOST_INFO = { name: "guuey-view-host", version: "1" };
|
|
33
33
|
const DEFAULT_NEGOTIATION_TIMEOUT_MS = 8000;
|
|
@@ -43,14 +43,38 @@ function hostContextFor(frame, config) {
|
|
|
43
43
|
}
|
|
44
44
|
function behaviorFor(frame, config) {
|
|
45
45
|
const relayWired = config.onCallTool !== undefined && config.resourceUri !== undefined;
|
|
46
|
+
const readWired = config.onReadResource !== undefined;
|
|
46
47
|
return {
|
|
47
48
|
hostInfo: config.hostInfo ?? DEFAULT_HOST_INFO,
|
|
48
49
|
hostCapabilities: {
|
|
50
|
+
// A wired relay IS the implementation — advertise it; an explicit
|
|
51
|
+
// hostCapabilities entry still wins (the serverTools precedent).
|
|
49
52
|
...(relayWired ? { serverTools: {} } : {}),
|
|
53
|
+
...(readWired ? { serverResources: {} } : {}),
|
|
50
54
|
...config.hostCapabilities,
|
|
51
55
|
},
|
|
52
56
|
hostContext: hostContextFor(frame, config),
|
|
53
57
|
toolRelay: relayWired,
|
|
58
|
+
resourceRelay: readWired,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Re-narrow a read hook's answer at the trust boundary — hooks are embedder
|
|
63
|
+
* code (possibly plain JS), and the wire entry the view receives must be a
|
|
64
|
+
* real `contents[]` entry: `uri` required, a string payload arm required
|
|
65
|
+
* (a payload-less entry is a miss — the `createMcpUiResourceReader`
|
|
66
|
+
* discipline, applied to the WIRE entry rather than the mountable payload).
|
|
67
|
+
*/
|
|
68
|
+
function narrowReadEntry(entry) {
|
|
69
|
+
if (entry === undefined || typeof entry.uri !== "string")
|
|
70
|
+
return undefined;
|
|
71
|
+
if (typeof entry.text !== "string" && typeof entry.blob !== "string")
|
|
72
|
+
return undefined;
|
|
73
|
+
return {
|
|
74
|
+
uri: entry.uri,
|
|
75
|
+
...(typeof entry.mimeType === "string" ? { mimeType: entry.mimeType } : {}),
|
|
76
|
+
...(typeof entry.text === "string" ? { text: entry.text } : {}),
|
|
77
|
+
...(typeof entry.blob === "string" ? { blob: entry.blob } : {}),
|
|
54
78
|
};
|
|
55
79
|
}
|
|
56
80
|
/**
|
|
@@ -84,6 +108,15 @@ export function attachViewHost(frame, config = {}) {
|
|
|
84
108
|
// same in-band unavailable the relay itself uses, never a hang.
|
|
85
109
|
() => post(toolCallResponse(id, unavailableToolCallResult())));
|
|
86
110
|
};
|
|
111
|
+
const relayRead = (id, uri) => {
|
|
112
|
+
const { onReadResource } = config;
|
|
113
|
+
if (onReadResource === undefined)
|
|
114
|
+
return; // machine-guarded invariant, as with `relay`
|
|
115
|
+
onReadResource(uri).then((entry) => post(resourceReadResponse(id, narrowReadEntry(entry))),
|
|
116
|
+
// A throwing hook still owes the view an answer — the same not-found
|
|
117
|
+
// the reader discipline gives a deny (deny == miss), never a hang.
|
|
118
|
+
() => post(resourceReadResponse(id, undefined)));
|
|
119
|
+
};
|
|
87
120
|
const onMessage = (event) => {
|
|
88
121
|
if (frame.contentWindow === null || event.source !== frame.contentWindow)
|
|
89
122
|
return;
|
|
@@ -92,8 +125,16 @@ export function attachViewHost(frame, config = {}) {
|
|
|
92
125
|
for (const effect of effects) {
|
|
93
126
|
if (effect.kind === "respond")
|
|
94
127
|
post(effect.message);
|
|
95
|
-
else
|
|
128
|
+
else if (effect.kind === "relay-tool-call")
|
|
96
129
|
relay(effect.id, effect.name, effect.arguments);
|
|
130
|
+
else if (effect.kind === "relay-resource-read")
|
|
131
|
+
relayRead(effect.id, effect.uri);
|
|
132
|
+
else {
|
|
133
|
+
config.onSizeChanged?.({
|
|
134
|
+
...(effect.width !== undefined ? { width: effect.width } : {}),
|
|
135
|
+
...(effect.height !== undefined ? { height: effect.height } : {}),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
97
138
|
}
|
|
98
139
|
};
|
|
99
140
|
// One listener, two subscription paths: the injectable seam for Node
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@guuey/mcp-apps-host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "The MCP Apps (SEP-1865) Host role for guuey's chat surfaces — view-mount narrowing across UI channels, ui:// locator rehydration by resources/read, and the sandbox-trust channel contract. Vendor-neutral: any spec-following MCP App mounts through it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@ggui-ai/protocol": "0.9.0",
|
|
37
37
|
"@modelcontextprotocol/ext-apps": "1.7.5",
|
|
38
|
-
"@silverprotocol/core": "0.
|
|
38
|
+
"@silverprotocol/core": "0.5.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"react": ">=18"
|
package/src/action.ts
CHANGED
|
@@ -47,11 +47,39 @@ export type McpToolCallResult = {
|
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* The runtime
|
|
51
|
-
* of the server allowlist (defense in depth: the proxy
|
|
50
|
+
* The runtime tools a card sandbox may RELAY over `tools/call` — the
|
|
51
|
+
* client-side twin of the server allowlist (defense in depth: the proxy
|
|
52
|
+
* enforces it again). Exactly ggui's iframe-runtime surface, all declared
|
|
53
|
+
* `_meta.ui.visibility: ['app']` (never model-callable):
|
|
54
|
+
*
|
|
55
|
+
* - `ggui_runtime_submit_action` — the user's gesture (the SEMANTIC one);
|
|
56
|
+
* - `ggui_runtime_refresh_ws_token` — live-channel credential refresh
|
|
57
|
+
* (the wsToken TTL is 180 s; without the relay a view on SSE/polling
|
|
58
|
+
* dies at that mark — guuey#220);
|
|
59
|
+
* - `ggui_runtime_pull` — the host-relayed polling rung where the
|
|
60
|
+
* sandbox's own transports are blocked (bridge-pull, terminal rung).
|
|
61
|
+
*
|
|
62
|
+
* Two of these are transport plumbing whose arguments are opaque runtime
|
|
63
|
+
* state — see {@link UI_SEMANTIC_ACTION_TOOLS} for the ONLY set a host may
|
|
64
|
+
* treat as "the user did something".
|
|
52
65
|
*/
|
|
53
66
|
export const UI_ACTION_TOOLS: ReadonlySet<string> = new Set([
|
|
54
67
|
"ggui_runtime_submit_action",
|
|
68
|
+
"ggui_runtime_refresh_ws_token",
|
|
69
|
+
"ggui_runtime_pull",
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The strict subset of {@link UI_ACTION_TOOLS} that carries a USER gesture —
|
|
74
|
+
* the only calls a host may project into user-facing affordances (composer
|
|
75
|
+
* staging, "the user selected X" copy, telemetry as intent). Every other
|
|
76
|
+
* relayed tool is runtime plumbing whose payload must NEVER reach the user
|
|
77
|
+
* verbatim (guuey#215/#218: a staged `sessionId …` was exactly that leak).
|
|
78
|
+
* A tool that is relayable is NOT thereby semantic; a host that widens
|
|
79
|
+
* this set is asserting the wire semantics of the new name.
|
|
80
|
+
*/
|
|
81
|
+
export const UI_SEMANTIC_ACTION_TOOLS: ReadonlySet<string> = new Set([
|
|
82
|
+
"ggui_runtime_submit_action",
|
|
55
83
|
]);
|
|
56
84
|
|
|
57
85
|
/** The in-band answer for anything the relay cannot (or will not) do. */
|
package/src/block-ui.ts
CHANGED
|
@@ -175,6 +175,27 @@ export function uiLocator(uiData: JsonValue | undefined): string | undefined {
|
|
|
175
175
|
return typeof uri === "string" && uri.startsWith("ui://") ? uri : undefined;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* The `ui://` locator a `tool-result` block carries, from EITHER channel it
|
|
180
|
+
* can arrive on — the single seam every locator reader goes through.
|
|
181
|
+
*
|
|
182
|
+
* AgJSON §2.1 routes a tool result's `structuredContent` by its `_meta.ui`
|
|
183
|
+
* sibling: WITH the sibling it is surface data and the normalizer stamps
|
|
184
|
+
* `uiData`; WITHOUT it, it is model-channel data and lands in
|
|
185
|
+
* `structuredContent`. A producer that withholds `_meta` (ggui's non-prod
|
|
186
|
+
* posture; any plain-locator MCP server) therefore delivers a locator that
|
|
187
|
+
* is byte-identical in shape but lives one field over — reading `uiData`
|
|
188
|
+
* alone renders NOTHING for it (dark, not "expired"), and the persistence
|
|
189
|
+
* projector minted no placeholder row (the read plane 404s). `uiData` wins
|
|
190
|
+
* when both carry one (guuey#209 route-A finding).
|
|
191
|
+
*/
|
|
192
|
+
export function toolResultLocator(block: {
|
|
193
|
+
uiData?: JsonValue;
|
|
194
|
+
structuredContent?: JsonValue;
|
|
195
|
+
}): string | undefined {
|
|
196
|
+
return uiLocator(block.uiData) ?? uiLocator(block.structuredContent);
|
|
197
|
+
}
|
|
198
|
+
|
|
178
199
|
export function snapshotUiResource(cardSnapshot: JsonValue): McpUiResourcePayload | undefined {
|
|
179
200
|
if (!isJsonObject(cardSnapshot)) return undefined;
|
|
180
201
|
const parts = cardSnapshot.parts;
|
package/src/card-mount.ts
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* keeps that one narrowing in one place — the alternative was for every host
|
|
34
34
|
* to re-run `toolResultGguiRender` beside this call and ask again.
|
|
35
35
|
*/
|
|
36
|
-
import { snapshotUiResource,
|
|
36
|
+
import { snapshotUiResource, toolResultLocator, toolResultUiResource, type McpUiResourcePayload } from "./block-ui.js";
|
|
37
37
|
import { GGUI_RENDER_META_KEY, gguiRenderResource, toolResultGguiRender } from "./ggui-render.js";
|
|
38
38
|
import type { AgBlock, JsonValue } from "@silverprotocol/core";
|
|
39
39
|
|
|
@@ -56,7 +56,9 @@ export type ViewMountChannel = "inline" | "ggui" | "locator";
|
|
|
56
56
|
* `"ggui"` — a shell that boots the ggui runtime from a platform-pinned
|
|
57
57
|
* origin, and therefore needs a host page whose CSP allows that origin.
|
|
58
58
|
* `"locator"` — no mount material in hand, only the durable `ui://`
|
|
59
|
-
* identity (guuey#122)
|
|
59
|
+
* identity (guuey#122) — read from `uiData` OR, for a producer that
|
|
60
|
+
* withheld `_meta` (AgJSON §2.1 then routes it to `structuredContent`),
|
|
61
|
+
* from `structuredContent` (guuey#209): the host resolves it with a fresh, authenticated
|
|
60
62
|
* `resources/read` of the uri ({@link UiResourceReader}) — the spec-consistent
|
|
61
63
|
* template fetch, vendor-neutral. (The spec defers persistence/restoration
|
|
62
64
|
* itself; a full remount additionally owes the View `ui/notifications/tool-input`
|
|
@@ -79,7 +81,7 @@ export interface ResolvedViewMount {
|
|
|
79
81
|
/** The durable-identity arm: no mount material, only the uri to re-fetch. */
|
|
80
82
|
export interface LocatorViewMount {
|
|
81
83
|
channel: "locator";
|
|
82
|
-
/** The
|
|
84
|
+
/** The durable `ui://` locator to re-fetch (`uiData.resourceUri`, else `structuredContent.resourceUri`). */
|
|
83
85
|
resourceUri: string;
|
|
84
86
|
}
|
|
85
87
|
|
|
@@ -108,8 +110,10 @@ export function toolResultViewMount(
|
|
|
108
110
|
const resource = ggui ? gguiRenderResource(ggui) : undefined;
|
|
109
111
|
if (resource) return { resource, channel: "ggui" };
|
|
110
112
|
// A live locator whose mount material didn't reach us (a fold that
|
|
111
|
-
// dropped `_meta
|
|
112
|
-
//
|
|
113
|
+
// dropped `_meta`, or a producer that never sent it — the locator then
|
|
114
|
+
// rides `structuredContent`, see `toolResultLocator`): re-fetch works on
|
|
115
|
+
// live turns too — the resource is freshly minted (guuey#122). One
|
|
116
|
+
// diagnostic when `_meta` DID carry the
|
|
113
117
|
// vendor key but failed validation — a producer bug would otherwise be
|
|
114
118
|
// indistinguishable from a meta-less fold (blank UI, zero errors).
|
|
115
119
|
if (ggui && !ggui.bootstrap && blockCarriesGguiMetaKey(block)) {
|
|
@@ -117,7 +121,7 @@ export function toolResultViewMount(
|
|
|
117
121
|
`mcp-apps-host: tool result ${block.toolCallId} carries a malformed ggui render bootstrap — degrading to the locator channel`,
|
|
118
122
|
);
|
|
119
123
|
}
|
|
120
|
-
const locator =
|
|
124
|
+
const locator = toolResultLocator(block);
|
|
121
125
|
return locator !== undefined ? { channel: "locator", resourceUri: locator } : undefined;
|
|
122
126
|
}
|
|
123
127
|
|
|
@@ -137,7 +141,7 @@ export function snapshotViewMount(cardSnapshot: JsonValue): ViewMount | undefine
|
|
|
137
141
|
for (const block of snapshotBlocks(cardSnapshot)) {
|
|
138
142
|
if (typeof block !== "object" || block === null || Array.isArray(block)) continue;
|
|
139
143
|
if (block.type !== "tool-result") continue;
|
|
140
|
-
const locator =
|
|
144
|
+
const locator = toolResultLocator(block);
|
|
141
145
|
if (locator !== undefined) return { channel: "locator", resourceUri: locator };
|
|
142
146
|
}
|
|
143
147
|
return undefined;
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export {
|
|
|
11
11
|
resourceHtml,
|
|
12
12
|
scanProviderRawForUiResource,
|
|
13
13
|
snapshotUiResource,
|
|
14
|
+
toolResultLocator,
|
|
14
15
|
toolResultUiResource,
|
|
15
16
|
uiLocator,
|
|
16
17
|
type McpUiResourcePayload,
|
|
@@ -49,6 +50,7 @@ export {
|
|
|
49
50
|
unavailableToolCallResult,
|
|
50
51
|
UI_ACTION_TOOLS,
|
|
51
52
|
UI_ACTION_UNAVAILABLE_TEXT,
|
|
53
|
+
UI_SEMANTIC_ACTION_TOOLS,
|
|
52
54
|
type CreateMcpUiActionRelayDeps,
|
|
53
55
|
type McpToolCallContent,
|
|
54
56
|
type McpToolCallResult,
|
|
@@ -58,6 +60,8 @@ export {
|
|
|
58
60
|
export {
|
|
59
61
|
initializeResult,
|
|
60
62
|
initialViewHostState,
|
|
63
|
+
resourceReadResponse,
|
|
64
|
+
RESOURCES_READ_METHOD,
|
|
61
65
|
teardownMessage,
|
|
62
66
|
toolCallResponse,
|
|
63
67
|
TOOLS_CALL_METHOD,
|
package/src/narrowing.ts
CHANGED
package/src/react.tsx
CHANGED
|
@@ -56,7 +56,13 @@ const DEFAULT_TITLE = "Generated view";
|
|
|
56
56
|
export interface GuueyViewProps
|
|
57
57
|
extends Pick<
|
|
58
58
|
AttachViewHostConfig,
|
|
59
|
-
|
|
59
|
+
| "hostCapabilities"
|
|
60
|
+
| "hostInfo"
|
|
61
|
+
| "hostContext"
|
|
62
|
+
| "onCallTool"
|
|
63
|
+
| "onReadResource"
|
|
64
|
+
| "onSizeChanged"
|
|
65
|
+
| "negotiationTimeoutMs"
|
|
60
66
|
> {
|
|
61
67
|
/** The resolved card to mount (see `toolResultViewMount`/`resolveViewMount`). */
|
|
62
68
|
mount: ResolvedViewMount;
|
|
@@ -75,8 +81,23 @@ export interface GuueyViewProps
|
|
|
75
81
|
* for its CSP + referrer checks to mean anything), never agent HTML.
|
|
76
82
|
* The page must be a genuinely different origin; a same-origin URL is
|
|
77
83
|
* refused with a labeled state, never mounted.
|
|
84
|
+
*
|
|
85
|
+
* `null` (as opposed to absent) means the two-origin mount is REQUIRED
|
|
86
|
+
* by the embedder's posture but no page is configured — the mount is
|
|
87
|
+
* refused with the same labeled state, and srcdoc is NEVER fallen back
|
|
88
|
+
* to (falling back would silently trade the caller's egress confinement
|
|
89
|
+
* for the page's CSP; the widget/Studio convergence posture).
|
|
78
90
|
*/
|
|
79
|
-
sandboxPageUrl?: string;
|
|
91
|
+
sandboxPageUrl?: string | null;
|
|
92
|
+
/**
|
|
93
|
+
* Apply the view's own size reports (`ui/notifications/size-changed` —
|
|
94
|
+
* spec surface) to the frame: a reported HEIGHT becomes the frame's
|
|
95
|
+
* height; width stays the container's (a transcript column owns its
|
|
96
|
+
* width). Default OFF — the primitive changes nothing for existing
|
|
97
|
+
* hosts; a caller's {@link AttachViewHostConfig.onSizeChanged} observer
|
|
98
|
+
* fires either way.
|
|
99
|
+
*/
|
|
100
|
+
autoResize?: boolean;
|
|
80
101
|
/**
|
|
81
102
|
* Sandbox flags appended to the safe default (`allow-scripts`). Every
|
|
82
103
|
* entry widens what agent-generated HTML may do — `allow-same-origin`
|
|
@@ -140,6 +161,7 @@ export function GuueyView(props: GuueyViewProps): ReactNode {
|
|
|
140
161
|
const {
|
|
141
162
|
mount,
|
|
142
163
|
sandboxPageUrl,
|
|
164
|
+
autoResize,
|
|
143
165
|
dangerouslyAddSandboxFlags,
|
|
144
166
|
allow,
|
|
145
167
|
title,
|
|
@@ -151,15 +173,19 @@ export function GuueyView(props: GuueyViewProps): ReactNode {
|
|
|
151
173
|
} = props;
|
|
152
174
|
const frameRef = useRef<HTMLIFrameElement>(null);
|
|
153
175
|
const [phase, setPhase] = useState<ViewHostPhase>("negotiating");
|
|
176
|
+
// The view's own size report, applied only under `autoResize`.
|
|
177
|
+
const [reportedHeight, setReportedHeight] = useState<number | undefined>(undefined);
|
|
154
178
|
const html = viewDocumentHtml(mount.resource);
|
|
155
179
|
|
|
156
180
|
// Vet the sandbox page once per URL. Same-origin is REFUSED (the widget's
|
|
157
181
|
// ResourceMount precedent, generalized): the whole point of the page is
|
|
158
182
|
// being a different origin — same-origin would hand the relay page (and
|
|
159
183
|
// through `allow-same-origin`, everything it can reach) the embedder's
|
|
160
|
-
// own origin.
|
|
184
|
+
// own origin. `null` — page mode required but unconfigured — refuses the
|
|
185
|
+
// same way: srcdoc is never a silent fallback for a confinement posture.
|
|
161
186
|
const sandboxPage: URL | "refused" | undefined = useMemo(() => {
|
|
162
187
|
if (sandboxPageUrl === undefined) return undefined;
|
|
188
|
+
if (sandboxPageUrl === null) return "refused";
|
|
163
189
|
let url: URL;
|
|
164
190
|
try {
|
|
165
191
|
url = new URL(sandboxPageUrl);
|
|
@@ -174,14 +200,15 @@ export function GuueyView(props: GuueyViewProps): ReactNode {
|
|
|
174
200
|
// The attachment is keyed to the mounted DOCUMENT, not to every render's
|
|
175
201
|
// fresh callback identities — host config rides a ref so the effect's
|
|
176
202
|
// dependency list is honestly just the document identity.
|
|
177
|
-
const latest = useRef({ hostConfig, onPhaseChange, dangerouslyAddSandboxFlags });
|
|
178
|
-
latest.current = { hostConfig, onPhaseChange, dangerouslyAddSandboxFlags };
|
|
203
|
+
const latest = useRef({ hostConfig, onPhaseChange, dangerouslyAddSandboxFlags, autoResize });
|
|
204
|
+
latest.current = { hostConfig, onPhaseChange, dangerouslyAddSandboxFlags, autoResize };
|
|
179
205
|
|
|
180
206
|
useEffect(() => {
|
|
181
207
|
// Keyed to the same identity the frame is (the resource uri): a new
|
|
182
208
|
// document boots fresh, and the previous negotiation's phase must not
|
|
183
|
-
// paper over it.
|
|
209
|
+
// paper over it — nor must the previous document's reported size.
|
|
184
210
|
setPhase("negotiating");
|
|
211
|
+
setReportedHeight(undefined);
|
|
185
212
|
const frame = frameRef.current;
|
|
186
213
|
if (frame === null || html === undefined) return;
|
|
187
214
|
if (sandboxPageUrl !== undefined && page === undefined) return; // refused config — nothing mounts
|
|
@@ -193,6 +220,12 @@ export function GuueyView(props: GuueyViewProps): ReactNode {
|
|
|
193
220
|
setPhase(next);
|
|
194
221
|
latest.current.onPhaseChange?.(next);
|
|
195
222
|
},
|
|
223
|
+
onSizeChanged: (size) => {
|
|
224
|
+
if (latest.current.autoResize === true && size.height !== undefined) {
|
|
225
|
+
setReportedHeight(size.height);
|
|
226
|
+
}
|
|
227
|
+
latest.current.hostConfig.onSizeChanged?.(size);
|
|
228
|
+
},
|
|
196
229
|
});
|
|
197
230
|
if (page === undefined) return detachHost;
|
|
198
231
|
// Two-origin mode: the page announces readiness, the document is
|
|
@@ -226,12 +259,17 @@ export function GuueyView(props: GuueyViewProps): ReactNode {
|
|
|
226
259
|
}
|
|
227
260
|
|
|
228
261
|
if (sandboxPageUrl !== undefined && page === undefined) {
|
|
229
|
-
// A malformed or SAME-ORIGIN sandbox page is a
|
|
230
|
-
// a property of the card — refused, labeled,
|
|
262
|
+
// A missing (null), malformed, or SAME-ORIGIN sandbox page is a
|
|
263
|
+
// configuration state, not a property of the card — refused, labeled,
|
|
264
|
+
// never mounted, and never silently downgraded to srcdoc. The copy
|
|
265
|
+
// names the configuration cause (an operator can act on it) without
|
|
266
|
+
// ever printing the offending URL.
|
|
231
267
|
return (
|
|
232
268
|
<div className={className} style={{ position: "relative", ...style }}>
|
|
233
269
|
<p role="alert" style={{ ...statusLineStyle, opacity: 1, pointerEvents: "auto" }}>
|
|
234
|
-
|
|
270
|
+
{sandboxPageUrl === null
|
|
271
|
+
? "Interactive view unavailable — no sandbox page is configured."
|
|
272
|
+
: "Interactive view unavailable — the sandbox page is not usable from this origin."}
|
|
235
273
|
</p>
|
|
236
274
|
</div>
|
|
237
275
|
);
|
|
@@ -260,7 +298,15 @@ export function GuueyView(props: GuueyViewProps): ReactNode {
|
|
|
260
298
|
: ["allow-scripts", ...(dangerouslyAddSandboxFlags ?? [])].join(" ")
|
|
261
299
|
}
|
|
262
300
|
allow={allow ?? "clipboard-write"}
|
|
263
|
-
|
|
301
|
+
// Under `autoResize`, the view's own height report wins over the
|
|
302
|
+
// fill-the-container default (width stays the container's — a
|
|
303
|
+
// transcript column owns its width).
|
|
304
|
+
style={{
|
|
305
|
+
display: "block",
|
|
306
|
+
width: "100%",
|
|
307
|
+
height: autoResize === true && reportedHeight !== undefined ? reportedHeight : "100%",
|
|
308
|
+
border: 0,
|
|
309
|
+
}}
|
|
264
310
|
/>
|
|
265
311
|
{renderStatus !== undefined ? renderStatus(phase) : defaultStatus(phase, mount.channel)}
|
|
266
312
|
</div>
|
|
@@ -34,11 +34,13 @@ import {
|
|
|
34
34
|
INITIALIZE_METHOD,
|
|
35
35
|
LATEST_PROTOCOL_VERSION,
|
|
36
36
|
RESOURCE_TEARDOWN_METHOD,
|
|
37
|
+
SIZE_CHANGED_METHOD,
|
|
37
38
|
type McpUiHostCapabilities,
|
|
38
39
|
type McpUiHostContext,
|
|
39
40
|
type McpUiInitializeResult,
|
|
40
41
|
} from "@modelcontextprotocol/ext-apps";
|
|
41
42
|
import type { McpToolStructuredContent } from "./action.js";
|
|
43
|
+
import type { McpResourceReadResult } from "./reader.js";
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* The host identity in the initialize result — structurally the spec's
|
|
@@ -56,6 +58,23 @@ const METHOD_NOT_SUPPORTED = -32601;
|
|
|
56
58
|
/** The standard MCP method a view uses to reach host-proxied tools. */
|
|
57
59
|
export const TOOLS_CALL_METHOD = "tools/call";
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The standard MCP method a view uses to read host-proxied resources —
|
|
63
|
+
* `ReadResourceRequest` in the spec's App→Host request union
|
|
64
|
+
* (`@modelcontextprotocol/ext-apps` `AppRequest`). A local constant, same
|
|
65
|
+
* as {@link TOOLS_CALL_METHOD}: the string is MCP-core vocabulary the
|
|
66
|
+
* ext-apps root does not re-export, and this package deliberately carries
|
|
67
|
+
* no `@modelcontextprotocol/sdk` dependency.
|
|
68
|
+
*/
|
|
69
|
+
export const RESOURCES_READ_METHOD = "resources/read";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* MCP's `Resource not found` JSON-RPC code — the one answer for a miss, a
|
|
73
|
+
* transport deny, AND a relay failure (deny == miss: the reader discipline,
|
|
74
|
+
* `reader.ts` — the view gets no oracle for which locators resolve).
|
|
75
|
+
*/
|
|
76
|
+
const RESOURCE_NOT_FOUND = -32002;
|
|
77
|
+
|
|
59
78
|
/** A JSON-RPC id as the wire allows it. */
|
|
60
79
|
export type ViewRequestId = number | string;
|
|
61
80
|
|
|
@@ -117,6 +136,33 @@ export type ViewHostEffect =
|
|
|
117
136
|
id: ViewRequestId;
|
|
118
137
|
name: string;
|
|
119
138
|
arguments?: McpToolStructuredContent;
|
|
139
|
+
}
|
|
140
|
+
| {
|
|
141
|
+
/**
|
|
142
|
+
* A `resources/read` the config accepted for relaying (spec surface:
|
|
143
|
+
* `ReadResourceRequest` rides the App→Host union, and the matching
|
|
144
|
+
* advertisement is `hostCapabilities.serverResources`). The glue runs
|
|
145
|
+
* the read hook and posts {@link resourceReadResponse}. Only emitted
|
|
146
|
+
* when {@link ViewHostBehavior.resourceRelay} is true — unwired, the
|
|
147
|
+
* machine refuses in-band like every other unsupported request.
|
|
148
|
+
*/
|
|
149
|
+
kind: "relay-resource-read";
|
|
150
|
+
id: ViewRequestId;
|
|
151
|
+
uri: string;
|
|
152
|
+
}
|
|
153
|
+
| {
|
|
154
|
+
/**
|
|
155
|
+
* The view reported its content size (`ui/notifications/size-changed`
|
|
156
|
+
* — spec notification, App → Host). At least one of the two fields is
|
|
157
|
+
* a finite number; a notification carrying neither is consumed
|
|
158
|
+
* silently instead. The glue forwards this to the embedder
|
|
159
|
+
* ({@link AttachViewHostConfig.onSizeChanged} in `view-host.ts`) —
|
|
160
|
+
* whether/how to resize the frame is the embedder's layout decision,
|
|
161
|
+
* never the machine's.
|
|
162
|
+
*/
|
|
163
|
+
kind: "size-changed";
|
|
164
|
+
width?: number;
|
|
165
|
+
height?: number;
|
|
120
166
|
};
|
|
121
167
|
|
|
122
168
|
/**
|
|
@@ -138,6 +184,8 @@ export interface ViewHostBehavior {
|
|
|
138
184
|
hostContext: McpUiHostContext;
|
|
139
185
|
/** Whether a `tools/call` relay hook is wired (see `view-host.ts`). */
|
|
140
186
|
toolRelay: boolean;
|
|
187
|
+
/** Whether a `resources/read` relay hook is wired (see `view-host.ts`). */
|
|
188
|
+
resourceRelay: boolean;
|
|
141
189
|
}
|
|
142
190
|
|
|
143
191
|
/** The result of feeding one inbound frame (or the timeout) to the machine. */
|
|
@@ -205,6 +253,26 @@ export function toolCallResponse(
|
|
|
205
253
|
return { jsonrpc: "2.0", id, result };
|
|
206
254
|
}
|
|
207
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Build the in-band response for a relayed `resources/read`. An entry
|
|
258
|
+
* becomes the spec's `ReadResourceResult` (`contents: [entry]`); `undefined`
|
|
259
|
+
* — a miss, a deny, or a relay failure alike — becomes the one
|
|
260
|
+
* `Resource not found` error (deny == miss, {@link RESOURCE_NOT_FOUND}).
|
|
261
|
+
*/
|
|
262
|
+
export function resourceReadResponse(
|
|
263
|
+
id: ViewRequestId,
|
|
264
|
+
entry: McpResourceReadResult | undefined,
|
|
265
|
+
): ViewHostOutbound {
|
|
266
|
+
if (entry === undefined) {
|
|
267
|
+
return {
|
|
268
|
+
jsonrpc: "2.0",
|
|
269
|
+
id,
|
|
270
|
+
error: { code: RESOURCE_NOT_FOUND, message: "resource unavailable" },
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return { jsonrpc: "2.0", id, result: { contents: [entry] } };
|
|
274
|
+
}
|
|
275
|
+
|
|
208
276
|
/**
|
|
209
277
|
* The spec-mannered farewell a detaching host posts (`ui/resource-teardown`).
|
|
210
278
|
* Sent WITHOUT an id — a host that is tearing the frame down cannot await a
|
|
@@ -237,10 +305,29 @@ export function viewHostReceive(
|
|
|
237
305
|
if (req === undefined) return { state, effects: [] };
|
|
238
306
|
|
|
239
307
|
if (req.id === undefined) {
|
|
240
|
-
// A notification. Track the one the handshake defines
|
|
308
|
+
// A notification. Track the one the handshake defines, surface the one
|
|
309
|
+
// the embedder may act on; consume the rest.
|
|
241
310
|
if (req.method === "ui/notifications/initialized" && !state.initializedSeen) {
|
|
242
311
|
return { state: { ...state, initializedSeen: true }, effects: [] };
|
|
243
312
|
}
|
|
313
|
+
if (req.method === SIZE_CHANGED_METHOD) {
|
|
314
|
+
const width = req.params?.["width"];
|
|
315
|
+
const height = req.params?.["height"];
|
|
316
|
+
const validWidth = typeof width === "number" && Number.isFinite(width);
|
|
317
|
+
const validHeight = typeof height === "number" && Number.isFinite(height);
|
|
318
|
+
if (validWidth || validHeight) {
|
|
319
|
+
return {
|
|
320
|
+
state,
|
|
321
|
+
effects: [
|
|
322
|
+
{
|
|
323
|
+
kind: "size-changed",
|
|
324
|
+
...(validWidth ? { width } : {}),
|
|
325
|
+
...(validHeight ? { height } : {}),
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
244
331
|
return { state, effects: [] };
|
|
245
332
|
}
|
|
246
333
|
|
|
@@ -271,6 +358,19 @@ export function viewHostReceive(
|
|
|
271
358
|
// fall through: a nameless tools/call is not a call we can relay.
|
|
272
359
|
}
|
|
273
360
|
|
|
361
|
+
if (req.method === RESOURCES_READ_METHOD && behavior.resourceRelay) {
|
|
362
|
+
const uri = req.params?.["uri"];
|
|
363
|
+
if (typeof uri === "string") {
|
|
364
|
+
return { state, effects: [{ kind: "relay-resource-read", id: req.id, uri }] };
|
|
365
|
+
}
|
|
366
|
+
// fall through: a uri-less read is not a read we can relay.
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const answered = [
|
|
370
|
+
INITIALIZE_METHOD,
|
|
371
|
+
...(behavior.toolRelay ? [TOOLS_CALL_METHOD] : []),
|
|
372
|
+
...(behavior.resourceRelay ? [RESOURCES_READ_METHOD] : []),
|
|
373
|
+
];
|
|
274
374
|
return {
|
|
275
375
|
state,
|
|
276
376
|
effects: [
|
|
@@ -281,7 +381,7 @@ export function viewHostReceive(
|
|
|
281
381
|
id: req.id,
|
|
282
382
|
error: {
|
|
283
383
|
code: METHOD_NOT_SUPPORTED,
|
|
284
|
-
message: `method_not_supported: ${req.method} — this host answers ${
|
|
384
|
+
message: `method_not_supported: ${req.method} — this host answers ${answered.join(", ")} only`,
|
|
285
385
|
},
|
|
286
386
|
},
|
|
287
387
|
},
|