Skip to content

Commit b47ee88

Browse files
claudegabrypavanello
authored andcommitted
feat(ui): add ext-apps v0.4.0 support
Add support for ext-apps v0.4.0 features: - Add updateModelContext() method to inform AI model about app state without triggering follow-up actions - MCP Apps: Uses native protocol feature - ChatGPT: Uses setState/setWidgetState (which exposes to AI context) - Add containerDimensions type for new viewport semantics (fixed vs flexible) - Add new HostCapabilities: updateModelContext, message, sandbox - Add useUpdateModelContext() React hook for model context updates - Add tests for all new functionality Breaking changes in ext-apps handled: - containerDimensions replaces viewport (we derive viewport for backward compat) - New capability types for content block modalities Note: On ChatGPT, both setState and updateModelContext expose state to the AI model. Use setState for persistence-focused use cases, updateModelContext for context-focused use cases.
1 parent 8023eaa commit b47ee88

12 files changed

Lines changed: 551 additions & 11 deletions

File tree

packages/ui-react/src/hooks.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
ModalResult,
1515
HostCapabilities,
1616
HostVersion,
17+
UpdateModelContextParams,
1718
} from "@mcp-apps-kit/ui";
1819
import { clientDebugLogger, type ClientDebugLogger } from "@mcp-apps-kit/ui";
1920
import { useAppsContext } from "./context";
@@ -250,6 +251,57 @@ export function useWidgetState<S>(defaultValue: S): [S, (newState: S | ((prev: S
250251
return [state, setState];
251252
}
252253

254+
// =============================================================================
255+
// MODEL CONTEXT HOOKS
256+
// =============================================================================
257+
258+
/**
259+
* Hook to update the host's model context
260+
*
261+
* Unlike sendMessage which triggers follow-up actions, context updates
262+
* inform the model about app state without triggering responses.
263+
*
264+
* On MCP Apps: Sends context to host via updateModelContext
265+
* On ChatGPT: Silent no-op (graceful degradation)
266+
*
267+
* @returns Function to update model context
268+
*
269+
* @example
270+
* ```tsx
271+
* function ShoppingCart({ items }) {
272+
* const updateContext = useUpdateModelContext();
273+
*
274+
* // Keep the model informed about cart state
275+
* useEffect(() => {
276+
* updateContext({
277+
* structuredContent: {
278+
* itemCount: items.length,
279+
* total: calculateTotal(items),
280+
* currency: "USD"
281+
* }
282+
* });
283+
* }, [items, updateContext]);
284+
*
285+
* return <CartUI items={items} />;
286+
* }
287+
* ```
288+
*/
289+
export function useUpdateModelContext(): (params: UpdateModelContextParams) => Promise<void> {
290+
const { client } = useAppsContext();
291+
292+
return useCallback(
293+
async (params: UpdateModelContextParams) => {
294+
if (!client) {
295+
// eslint-disable-next-line no-console
296+
console.warn("[useUpdateModelContext] Client not available");
297+
return;
298+
}
299+
await client.updateModelContext(params);
300+
},
301+
[client]
302+
);
303+
}
304+
253305
// =============================================================================
254306
// UTILITY HOOKS
255307
// =============================================================================

packages/ui-react/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export type {
1717
AppToolDefinition,
1818
CallToolHandler,
1919
ListToolsHandler,
20+
// Model context types (ext-apps v0.4.0+)
21+
ContainerDimensions,
22+
ContentBlock,
23+
UpdateModelContextParams,
2024
} from "@mcp-apps-kit/ui";
2125

2226
// Context
@@ -51,6 +55,8 @@ export {
5155
useHostCapabilities,
5256
useHostVersion,
5357
useSizeChangedNotifications,
58+
// Model context (ext-apps v0.4.0+)
59+
useUpdateModelContext,
5460
} from "./hooks";
5561

5662
// File operation types

packages/ui/src/adapters/mcp.ts

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import type {
4444
SizeChangedParams,
4545
CallToolHandler,
4646
ListToolsHandler,
47+
UpdateModelContextParams,
48+
ContainerDimensions,
4749
} from "../types";
4850

4951
/**
@@ -248,6 +250,7 @@ export class McpAdapter implements ProtocolAdapter {
248250
displayMode?: unknown;
249251
availableDisplayModes?: unknown;
250252
viewport?: unknown;
253+
containerDimensions?: unknown;
251254
locale?: unknown;
252255
timeZone?: unknown;
253256
platform?: unknown;
@@ -274,11 +277,22 @@ export class McpAdapter implements ProtocolAdapter {
274277
? ctx.availableDisplayModes.filter((m): m is string => typeof m === "string")
275278
: base.availableDisplayModes;
276279

277-
const isViewportObject = (v: unknown): v is Record<string, unknown> =>
280+
const isObjectLike = (v: unknown): v is Record<string, unknown> =>
278281
v !== null && typeof v === "object" && !Array.isArray(v);
279-
const viewport = isViewportObject(ctx.viewport)
280-
? { ...base.viewport, ...ctx.viewport }
281-
: base.viewport;
282+
283+
// Parse containerDimensions (ext-apps v0.4.0+)
284+
const containerDimensions = isObjectLike(ctx.containerDimensions)
285+
? (ctx.containerDimensions as ContainerDimensions)
286+
: undefined;
287+
288+
// Derive viewport from containerDimensions for backward compatibility,
289+
// or fall back to explicit viewport or defaults
290+
let viewport = base.viewport;
291+
if (containerDimensions) {
292+
viewport = this.deriveViewportFromContainerDimensions(containerDimensions, base.viewport);
293+
} else if (isObjectLike(ctx.viewport)) {
294+
viewport = { ...base.viewport, ...ctx.viewport };
295+
}
282296

283297
const locale = typeof ctx.locale === "string" ? ctx.locale : base.locale;
284298
const timeZone = typeof ctx.timeZone === "string" ? ctx.timeZone : base.timeZone;
@@ -292,6 +306,7 @@ export class McpAdapter implements ProtocolAdapter {
292306
displayMode,
293307
availableDisplayModes,
294308
viewport,
309+
containerDimensions,
295310
locale,
296311
timeZone,
297312
platform,
@@ -303,6 +318,23 @@ export class McpAdapter implements ProtocolAdapter {
303318
};
304319
}
305320

321+
/**
322+
* Derive viewport dimensions from containerDimensions for backward compatibility.
323+
* containerDimensions uses fixed (height/width) vs flexible (maxHeight/maxWidth) semantics.
324+
*/
325+
private deriveViewportFromContainerDimensions(
326+
dims: ContainerDimensions,
327+
defaults: HostContext["viewport"]
328+
): HostContext["viewport"] {
329+
const d = dims as Record<string, unknown>;
330+
return {
331+
width: typeof d.width === "number" ? d.width : defaults.width,
332+
height: typeof d.height === "number" ? d.height : defaults.height,
333+
maxWidth: typeof d.maxWidth === "number" ? d.maxWidth : undefined,
334+
maxHeight: typeof d.maxHeight === "number" ? d.maxHeight : undefined,
335+
};
336+
}
337+
306338
private extractToolMeta(rawHostContext: unknown): Record<string, unknown> | undefined {
307339
if (rawHostContext === null || typeof rawHostContext !== "object") return undefined;
308340
const hc = rawHostContext as { toolInfo?: unknown };
@@ -388,6 +420,42 @@ export class McpAdapter implements ProtocolAdapter {
388420
});
389421
}
390422

423+
// === Model Context ===
424+
425+
async updateModelContext(params: UpdateModelContextParams): Promise<void> {
426+
if (!this.app) {
427+
throw new UIError(UIErrorCode.NOT_CONNECTED, "MCP Apps adapter not connected");
428+
}
429+
430+
// Map our content blocks to ext-apps format
431+
const content = params.content?.map((block) => {
432+
switch (block.type) {
433+
case "text":
434+
return { type: "text" as const, text: block.text ?? "" };
435+
case "image":
436+
return {
437+
type: "image" as const,
438+
data: block.data ?? "",
439+
mimeType: block.mimeType ?? "image/png",
440+
};
441+
case "audio":
442+
return {
443+
type: "audio" as const,
444+
data: block.data ?? "",
445+
mimeType: block.mimeType ?? "audio/wav",
446+
};
447+
default:
448+
// For resource types, fall back to text representation
449+
return { type: "text" as const, text: block.text ?? block.uri ?? "" };
450+
}
451+
});
452+
453+
await this.app.updateModelContext({
454+
content,
455+
structuredContent: params.structuredContent,
456+
});
457+
}
458+
391459
// === Navigation ===
392460

393461
async openLink(url: string): Promise<void> {
@@ -548,19 +616,19 @@ export class McpAdapter implements ProtocolAdapter {
548616
// Map MCP Apps SDK capabilities to our unified interface.
549617
// MCP SDK already provides: logging, openLinks, serverResources, serverTools
550618
// We augment with common abstraction fields for protocol-agnostic usage.
551-
const sdkCaps = mcpCaps as HostCapabilities;
619+
const sdkCaps = mcpCaps as Record<string, unknown>;
552620

553621
// Extract available display modes from host context if provided
554622
const hostContext = this.app.getHostContext();
555623
const availableModes = hostContext?.availableDisplayModes as string[] | undefined;
556624

557625
return {
558626
// MCP SDK native capabilities (priority)
559-
logging: sdkCaps.logging,
560-
openLinks: sdkCaps.openLinks,
561-
serverResources: sdkCaps.serverResources,
562-
serverTools: sdkCaps.serverTools,
563-
experimental: sdkCaps.experimental,
627+
logging: sdkCaps.logging as HostCapabilities["logging"],
628+
openLinks: sdkCaps.openLinks as HostCapabilities["openLinks"],
629+
serverResources: sdkCaps.serverResources as HostCapabilities["serverResources"],
630+
serverTools: sdkCaps.serverTools as HostCapabilities["serverTools"],
631+
experimental: sdkCaps.experimental as HostCapabilities["experimental"],
564632

565633
// Common capabilities derived from host context when available
566634
theming: {
@@ -578,6 +646,11 @@ export class McpAdapter implements ProtocolAdapter {
578646
sizeNotifications: {},
579647
partialToolInput: {},
580648
appTools: { listChanged: false },
649+
650+
// ext-apps v0.4.0+ capabilities
651+
updateModelContext: sdkCaps.updateModelContext as HostCapabilities["updateModelContext"],
652+
message: sdkCaps.message as HostCapabilities["message"],
653+
sandbox: sdkCaps.sandbox as HostCapabilities["sandbox"],
581654
};
582655
}
583656

packages/ui/src/adapters/mock.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
SizeChangedParams,
1818
CallToolHandler,
1919
ListToolsHandler,
20+
UpdateModelContextParams,
2021
} from "../types";
2122

2223
/**
@@ -135,6 +136,24 @@ export class MockAdapter implements ProtocolAdapter {
135136
console.log("[MockAdapter] sendMessage:", content);
136137
}
137138

139+
// === Model Context ===
140+
141+
/** Last model context params (for testing) */
142+
private lastModelContext?: UpdateModelContextParams;
143+
144+
async updateModelContext(params: UpdateModelContextParams): Promise<void> {
145+
this.lastModelContext = params;
146+
// eslint-disable-next-line no-console
147+
console.log("[MockAdapter] updateModelContext:", params);
148+
}
149+
150+
/**
151+
* Get the last model context params (for testing)
152+
*/
153+
getLastModelContext(): UpdateModelContextParams | undefined {
154+
return this.lastModelContext;
155+
}
156+
138157
// === Navigation ===
139158

140159
async openLink(url: string): Promise<void> {

packages/ui/src/adapters/openai.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
SizeChangedParams,
1717
CallToolHandler,
1818
ListToolsHandler,
19+
UpdateModelContextParams,
1920
} from "../types";
2021
import type { DebugTransport, LogEntry } from "../debug/logger";
2122
import { clientDebugLogger } from "../debug/logger";
@@ -523,6 +524,45 @@ export class OpenAIAdapter implements ProtocolAdapter {
523524
}
524525
}
525526

527+
// === Model Context ===
528+
529+
/**
530+
* Update model context (ext-apps v0.4.0+)
531+
*
532+
* On ChatGPT, we use setState (which calls setWidgetState) to expose data
533+
* to the model. setWidgetState in ChatGPT flows into AI context - anything
534+
* passed to it will be shown to the model.
535+
*
536+
* Note: Unlike MCP Apps where this is purely context, on ChatGPT
537+
* this also persists the state for the widget session.
538+
*/
539+
async updateModelContext(params: UpdateModelContextParams): Promise<void> {
540+
// Build a context object to send to the model via setState/setWidgetState
541+
const modelContext: Record<string, unknown> = {
542+
_type: "modelContext",
543+
};
544+
545+
// Add structured content directly
546+
if (params.structuredContent) {
547+
Object.assign(modelContext, params.structuredContent);
548+
}
549+
550+
// Convert content blocks to text representation for the model
551+
if (params.content && params.content.length > 0) {
552+
const textContent = params.content
553+
.filter((block) => block.type === "text" && block.text)
554+
.map((block) => block.text)
555+
.join("\n");
556+
if (textContent) {
557+
modelContext._textContent = textContent;
558+
}
559+
}
560+
561+
// Use existing setState which calls setWidgetState
562+
this.setState(modelContext);
563+
clientDebugLogger.debug("[OpenAI Adapter] updateModelContext via setState:", modelContext);
564+
}
565+
526566
// === Navigation ===
527567

528568
async openLink(url: string): Promise<void> {

packages/ui/src/adapters/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
SizeChangedParams,
1616
CallToolHandler,
1717
ListToolsHandler,
18+
UpdateModelContextParams,
1819
} from "../types";
1920
import type { LogEntry } from "../debug/logger";
2021

@@ -68,6 +69,18 @@ export interface ProtocolAdapter {
6869
*/
6970
sendMessage(content: { type: string; text: string }): Promise<void>;
7071

72+
// === Model Context ===
73+
74+
/**
75+
* Update the host's model context with app state (ext-apps v0.4.0+)
76+
*
77+
* On MCP Apps: Calls app.updateModelContext()
78+
* On ChatGPT: Silent no-op (graceful degradation)
79+
*
80+
* @param params - Context content and/or structured content
81+
*/
82+
updateModelContext(params: UpdateModelContextParams): Promise<void>;
83+
7184
// === Navigation ===
7285

7386
/**

packages/ui/src/client.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
CallToolHandler,
2121
ListToolsHandler,
2222
ToolMethods,
23+
UpdateModelContextParams,
2324
} from "./types";
2425
import type { ProtocolAdapter } from "./adapters/types";
2526

@@ -117,6 +118,12 @@ export function createAppsClient<T extends ToolDefs = ToolDefs>(
117118
await adapter.sendMessage({ type: "text", text: prompt });
118119
},
119120

121+
// === Model Context ===
122+
123+
async updateModelContext(params: UpdateModelContextParams): Promise<void> {
124+
await adapter.updateModelContext(params);
125+
},
126+
120127
// === Navigation ===
121128

122129
async openLink(url: string): Promise<void> {

packages/ui/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
export type {
2828
Viewport,
29+
ContainerDimensions,
2930
SafeAreaInsets,
3031
DeviceCapabilities,
3132
HostStyles,
@@ -52,6 +53,9 @@ export type {
5253
AppToolDefinition,
5354
CallToolHandler,
5455
ListToolsHandler,
56+
// Model context types (ext-apps v0.4.0+)
57+
ContentBlock,
58+
UpdateModelContextParams,
5559
} from "./types";
5660

5761
// Constants

0 commit comments

Comments
 (0)