@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/src/view-host.ts CHANGED
@@ -29,6 +29,7 @@
29
29
  */
30
30
  import {
31
31
  initialViewHostState,
32
+ resourceReadResponse,
32
33
  teardownMessage,
33
34
  toolCallResponse,
34
35
  viewHostElapsed,
@@ -38,6 +39,7 @@ import {
38
39
  type ViewHostPhase,
39
40
  type ViewHostState,
40
41
  } from "./view-host-protocol.js";
42
+ import type { McpResourceReadResult } from "./reader.js";
41
43
  import {
42
44
  unavailableToolCallResult,
43
45
  type McpToolCallResult,
@@ -107,6 +109,25 @@ export interface AttachViewHostConfig {
107
109
  * `<GuueyView>` fills it from the mount automatically.
108
110
  */
109
111
  resourceUri?: string;
112
+ /**
113
+ * The `resources/read` relay — a PRIVILEGE boundary like
114
+ * {@link onCallTool}, default off: with no hook, the machine refuses
115
+ * `resources/read` in-band and advertises no `serverResources`. The hook
116
+ * is structurally the SAME transport `createMcpUiResourceReader`
117
+ * assembles over ({@link CreateMcpUiResourceReaderDeps.readResource}) —
118
+ * a host with a locator reader wires the identical function here. Trust
119
+ * rules ride the reader discipline (`reader.ts`): enforcement lives
120
+ * INSIDE the transport; a miss, a deny, and a throw all answer the view
121
+ * with the one `Resource not found` error (deny == miss — no oracle).
122
+ */
123
+ onReadResource?: (uri: string) => Promise<McpResourceReadResult | undefined>;
124
+ /**
125
+ * The view reported its content size (`ui/notifications/size-changed` —
126
+ * spec notification). Whether and how to resize the frame is the
127
+ * embedder's layout decision; `<GuueyView autoResize>` is one wiring of
128
+ * exactly this callback.
129
+ */
130
+ onSizeChanged?: (size: { width?: number; height?: number }) => void;
110
131
  /** Observe phase transitions (see {@link ViewHostPhase}). */
111
132
  onPhaseChange?: (phase: ViewHostPhase) => void;
112
133
  /**
@@ -136,14 +157,37 @@ function hostContextFor(frame: ViewFrameLike, config: AttachViewHostConfig): Mcp
136
157
 
137
158
  function behaviorFor(frame: ViewFrameLike, config: AttachViewHostConfig): ViewHostBehavior {
138
159
  const relayWired = config.onCallTool !== undefined && config.resourceUri !== undefined;
160
+ const readWired = config.onReadResource !== undefined;
139
161
  return {
140
162
  hostInfo: config.hostInfo ?? DEFAULT_HOST_INFO,
141
163
  hostCapabilities: {
164
+ // A wired relay IS the implementation — advertise it; an explicit
165
+ // hostCapabilities entry still wins (the serverTools precedent).
142
166
  ...(relayWired ? { serverTools: {} } : {}),
167
+ ...(readWired ? { serverResources: {} } : {}),
143
168
  ...config.hostCapabilities,
144
169
  },
145
170
  hostContext: hostContextFor(frame, config),
146
171
  toolRelay: relayWired,
172
+ resourceRelay: readWired,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Re-narrow a read hook's answer at the trust boundary — hooks are embedder
178
+ * code (possibly plain JS), and the wire entry the view receives must be a
179
+ * real `contents[]` entry: `uri` required, a string payload arm required
180
+ * (a payload-less entry is a miss — the `createMcpUiResourceReader`
181
+ * discipline, applied to the WIRE entry rather than the mountable payload).
182
+ */
183
+ function narrowReadEntry(entry: McpResourceReadResult | undefined): McpResourceReadResult | undefined {
184
+ if (entry === undefined || typeof entry.uri !== "string") return undefined;
185
+ if (typeof entry.text !== "string" && typeof entry.blob !== "string") return undefined;
186
+ return {
187
+ uri: entry.uri,
188
+ ...(typeof entry.mimeType === "string" ? { mimeType: entry.mimeType } : {}),
189
+ ...(typeof entry.text === "string" ? { text: entry.text } : {}),
190
+ ...(typeof entry.blob === "string" ? { blob: entry.blob } : {}),
147
191
  };
148
192
  }
149
193
 
@@ -183,13 +227,31 @@ export function attachViewHost(frame: ViewFrameLike, config: AttachViewHostConfi
183
227
  );
184
228
  };
185
229
 
230
+ const relayRead = (id: number | string, uri: string): void => {
231
+ const { onReadResource } = config;
232
+ if (onReadResource === undefined) return; // machine-guarded invariant, as with `relay`
233
+ onReadResource(uri).then(
234
+ (entry) => post(resourceReadResponse(id, narrowReadEntry(entry))),
235
+ // A throwing hook still owes the view an answer — the same not-found
236
+ // the reader discipline gives a deny (deny == miss), never a hang.
237
+ () => post(resourceReadResponse(id, undefined)),
238
+ );
239
+ };
240
+
186
241
  const onMessage = (event: { data: unknown; source: unknown }): void => {
187
242
  if (frame.contentWindow === null || event.source !== frame.contentWindow) return;
188
243
  const { state: next, effects } = viewHostReceive(state, behaviorFor(frame, config), event.data);
189
244
  setState(next);
190
245
  for (const effect of effects) {
191
246
  if (effect.kind === "respond") post(effect.message);
192
- else relay(effect.id, effect.name, effect.arguments);
247
+ else if (effect.kind === "relay-tool-call") relay(effect.id, effect.name, effect.arguments);
248
+ else if (effect.kind === "relay-resource-read") relayRead(effect.id, effect.uri);
249
+ else {
250
+ config.onSizeChanged?.({
251
+ ...(effect.width !== undefined ? { width: effect.width } : {}),
252
+ ...(effect.height !== undefined ? { height: effect.height } : {}),
253
+ });
254
+ }
193
255
  }
194
256
  };
195
257