@mcp-ts/sdk 1.3.10 → 1.5.0

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.
Files changed (86) hide show
  1. package/README.md +20 -27
  2. package/dist/adapters/agui-adapter.d.mts +16 -0
  3. package/dist/adapters/agui-adapter.d.ts +16 -0
  4. package/dist/adapters/agui-adapter.js +185 -0
  5. package/dist/adapters/agui-adapter.js.map +1 -1
  6. package/dist/adapters/agui-adapter.mjs +185 -0
  7. package/dist/adapters/agui-adapter.mjs.map +1 -1
  8. package/dist/adapters/agui-middleware.d.mts +2 -0
  9. package/dist/adapters/agui-middleware.d.ts +2 -0
  10. package/dist/adapters/agui-middleware.js.map +1 -1
  11. package/dist/adapters/agui-middleware.mjs.map +1 -1
  12. package/dist/adapters/ai-adapter.d.mts +21 -0
  13. package/dist/adapters/ai-adapter.d.ts +21 -0
  14. package/dist/adapters/ai-adapter.js +175 -0
  15. package/dist/adapters/ai-adapter.js.map +1 -1
  16. package/dist/adapters/ai-adapter.mjs +175 -0
  17. package/dist/adapters/ai-adapter.mjs.map +1 -1
  18. package/dist/adapters/langchain-adapter.d.mts +16 -0
  19. package/dist/adapters/langchain-adapter.d.ts +16 -0
  20. package/dist/adapters/langchain-adapter.js +179 -0
  21. package/dist/adapters/langchain-adapter.js.map +1 -1
  22. package/dist/adapters/langchain-adapter.mjs +179 -0
  23. package/dist/adapters/langchain-adapter.mjs.map +1 -1
  24. package/dist/client/index.d.mts +4 -190
  25. package/dist/client/index.d.ts +4 -190
  26. package/dist/client/index.js +218 -54
  27. package/dist/client/index.js.map +1 -1
  28. package/dist/client/index.mjs +215 -55
  29. package/dist/client/index.mjs.map +1 -1
  30. package/dist/client/react.d.mts +31 -17
  31. package/dist/client/react.d.ts +31 -17
  32. package/dist/client/react.js +447 -103
  33. package/dist/client/react.js.map +1 -1
  34. package/dist/client/react.mjs +443 -105
  35. package/dist/client/react.mjs.map +1 -1
  36. package/dist/client/vue.d.mts +5 -4
  37. package/dist/client/vue.d.ts +5 -4
  38. package/dist/client/vue.js +239 -63
  39. package/dist/client/vue.js.map +1 -1
  40. package/dist/client/vue.mjs +236 -64
  41. package/dist/client/vue.mjs.map +1 -1
  42. package/dist/index-DcYfpY3H.d.mts +295 -0
  43. package/dist/index-GfC_eNEv.d.ts +295 -0
  44. package/dist/index.d.mts +5 -3
  45. package/dist/index.d.ts +5 -3
  46. package/dist/index.js +1120 -59
  47. package/dist/index.js.map +1 -1
  48. package/dist/index.mjs +1097 -60
  49. package/dist/index.mjs.map +1 -1
  50. package/dist/server/index.d.mts +2 -2
  51. package/dist/server/index.d.ts +2 -2
  52. package/dist/server/index.js +18 -5
  53. package/dist/server/index.js.map +1 -1
  54. package/dist/server/index.mjs +18 -5
  55. package/dist/server/index.mjs.map +1 -1
  56. package/dist/shared/index.d.mts +86 -4
  57. package/dist/shared/index.d.ts +86 -4
  58. package/dist/shared/index.js +874 -0
  59. package/dist/shared/index.js.map +1 -1
  60. package/dist/shared/index.mjs +865 -1
  61. package/dist/shared/index.mjs.map +1 -1
  62. package/dist/tool-router-Bo8qZbsD.d.ts +325 -0
  63. package/dist/tool-router-XnWVxPzv.d.mts +325 -0
  64. package/dist/{types-CW6lghof.d.mts → types-CfCoIsWI.d.mts} +27 -1
  65. package/dist/{types-CW6lghof.d.ts → types-CfCoIsWI.d.ts} +27 -1
  66. package/package.json +15 -12
  67. package/src/adapters/agui-adapter.ts +79 -0
  68. package/src/adapters/ai-adapter.ts +75 -0
  69. package/src/adapters/langchain-adapter.ts +75 -1
  70. package/src/client/core/app-host.ts +252 -65
  71. package/src/client/core/constants.ts +30 -0
  72. package/src/client/index.ts +6 -1
  73. package/src/client/react/index.ts +3 -0
  74. package/src/client/react/use-app-host.ts +8 -15
  75. package/src/client/react/use-mcp-apps.tsx +262 -49
  76. package/src/client/react/use-mcp.ts +23 -12
  77. package/src/client/utils/app-host-utils.ts +62 -0
  78. package/src/client/vue/use-mcp.ts +23 -12
  79. package/src/server/index.ts +2 -0
  80. package/src/server/mcp/oauth-client.ts +34 -9
  81. package/src/shared/index.ts +36 -0
  82. package/src/shared/meta-tools.ts +387 -0
  83. package/src/shared/schema-compressor.ts +124 -0
  84. package/src/shared/tool-index.ts +499 -0
  85. package/src/shared/tool-router.ts +469 -0
  86. package/src/shared/types.ts +30 -0
@@ -2,6 +2,8 @@ import { MCPClient } from '../server/mcp/oauth-client';
2
2
  import { MultiSessionClient } from '../server/mcp/multi-session-client';
3
3
  import type { JSONSchema7 } from 'json-schema';
4
4
  import type { ToolSet } from 'ai';
5
+ import { ToolRouter } from '../shared/tool-router.js';
6
+ import { executeMetaTool, isMetaTool } from '../shared/meta-tools.js';
5
7
 
6
8
  export interface AIAdapterOptions {
7
9
  /**
@@ -9,6 +11,17 @@ export interface AIAdapterOptions {
9
11
  * Defaults to the client's serverId.
10
12
  */
11
13
  prefix?: string;
14
+
15
+ /**
16
+ * Optional ToolRouter for intelligent tool selection.
17
+ *
18
+ * When provided with `strategy: 'search'`, the adapter exposes only
19
+ * meta-tools (search_tools, get_tool_schema) instead of all tool schemas,
20
+ * reducing context window usage by 80–95%.
21
+ *
22
+ * When not provided, all tools are returned as before (backward-compatible).
23
+ */
24
+ toolRouter?: ToolRouter;
12
25
  }
13
26
 
14
27
  /**
@@ -80,6 +93,11 @@ export class AIAdapter {
80
93
  async getTools(): Promise<ToolSet> {
81
94
  await this.ensureJsonSchema();
82
95
 
96
+ // If a ToolRouter is provided, use its filtered output
97
+ if (this.options.toolRouter) {
98
+ return this.getToolsViaRouter(this.options.toolRouter);
99
+ }
100
+
83
101
  // Use duck typing instead of instanceof to handle module bundling issues
84
102
  // MultiSessionClient has getClients(), MCPClient does not
85
103
  const isMultiSession = typeof (this.client as any).getClients === 'function';
@@ -106,6 +124,63 @@ export class AIAdapter {
106
124
  return results.reduce((acc, tools) => ({ ...acc, ...tools }), {});
107
125
  }
108
126
 
127
+ /**
128
+ * Build a ToolSet from a ToolRouter's filtered output.
129
+ *
130
+ * In `search` strategy, only meta-tools are registered with the framework.
131
+ * Real tool execution is proxied through `mcp_execute_tool` which uses
132
+ * `router.callTool()` to route to the correct MCP client.
133
+ */
134
+ private async getToolsViaRouter(router: ToolRouter): Promise<ToolSet> {
135
+ const filteredTools = await router.getFilteredTools();
136
+
137
+ // @ts-ignore: ToolSet type inference can be tricky with dynamic imports
138
+ return Object.fromEntries(
139
+ filteredTools.map((tool) => {
140
+ const routedTool = tool as typeof tool & { sessionId?: string; serverName?: string };
141
+ const namespace = routedTool.serverName ?? routedTool.sessionId;
142
+ const toolKey = isMetaTool(tool.name)
143
+ ? tool.name
144
+ : this.getRouterToolKey(tool.name, routedTool.sessionId, routedTool.serverName);
145
+
146
+ return [
147
+ toolKey,
148
+ {
149
+ description: tool.description,
150
+ inputSchema: this.jsonSchema!(tool.inputSchema as JSONSchema7),
151
+ execute: async (args: any) => {
152
+ // Handle meta-tool calls via the router
153
+ if (isMetaTool(tool.name)) {
154
+ const result = await executeMetaTool(
155
+ tool.name,
156
+ args,
157
+ router,
158
+ (name, toolArgs, targetNamespace) => router.callTool(name, toolArgs, targetNamespace)
159
+ );
160
+ if (result) {
161
+ return result;
162
+ }
163
+ }
164
+
165
+ // For non-meta tools in 'all' or 'groups' strategy,
166
+ // route directly to the correct MCP client
167
+ return await router.callTool(tool.name, args, namespace);
168
+ },
169
+ },
170
+ ];
171
+ })
172
+ );
173
+ }
174
+
175
+ private getRouterToolKey(toolName: string, sessionId?: string, serverName?: string): string {
176
+ const namespace = sessionId ?? serverName ?? 'mcp';
177
+ const normalized = namespace
178
+ .toLowerCase()
179
+ .replace(/[^a-z0-9]+/g, '_')
180
+ .replace(/^_+|_+$/g, '') || 'mcp';
181
+ return `tool_${normalized}_${toolName}`;
182
+ }
183
+
109
184
  /**
110
185
  * Convenience static method to fetch tools in a single line.
111
186
  */
@@ -2,6 +2,8 @@ import { MCPClient } from '../server/mcp/oauth-client';
2
2
  import { MultiSessionClient } from '../server/mcp/multi-session-client';
3
3
  import type { DynamicStructuredTool, StructuredTool } from '@langchain/core/tools';
4
4
  import type { z } from 'zod';
5
+ import { ToolRouter } from '../shared/tool-router.js';
6
+ import { executeMetaTool, isMetaTool } from '../shared/meta-tools.js';
5
7
 
6
8
  export interface LangChainAdapterOptions {
7
9
  /**
@@ -16,6 +18,12 @@ export interface LangChainAdapterOptions {
16
18
  * @default false
17
19
  */
18
20
  simplifyErrors?: boolean;
21
+
22
+ /**
23
+ * Optional ToolRouter for intelligent tool selection.
24
+ * See AIAdapterOptions.toolRouter for details.
25
+ */
26
+ toolRouter?: ToolRouter;
19
27
  }
20
28
 
21
29
  /**
@@ -37,7 +45,7 @@ export class LangChainAdapter {
37
45
  if (!this.DynamicStructuredTool) {
38
46
  try {
39
47
  const langchain = await import('@langchain/core/tools');
40
- this.DynamicStructuredTool = langchain.DynamicStructuredTool;
48
+ this.DynamicStructuredTool = langchain.DynamicStructuredTool as any;
41
49
 
42
50
  const zod = await import('zod');
43
51
  this.z = zod.z;
@@ -99,6 +107,11 @@ export class LangChainAdapter {
99
107
  * Fetches tools from the MCP server and converts them to LangChain StructuredTools.
100
108
  */
101
109
  async getTools(): Promise<StructuredTool[]> {
110
+ // If a ToolRouter is provided, use its filtered output
111
+ if (this.options.toolRouter) {
112
+ return this.getToolsViaRouter(this.options.toolRouter);
113
+ }
114
+
102
115
  // Use duck typing instead of instanceof to handle module bundling issues
103
116
  const isMultiSession = typeof (this.client as any).getClients === 'function';
104
117
  const clients = isMultiSession
@@ -118,6 +131,67 @@ export class LangChainAdapter {
118
131
  return results.flat();
119
132
  }
120
133
 
134
+ /**
135
+ * Build StructuredTools from a ToolRouter's filtered output.
136
+ *
137
+ * In `search` strategy, only meta-tools are registered with the framework.
138
+ * Real tool execution is proxied through `mcp_execute_tool` which uses
139
+ * `router.callTool()` to route to the correct MCP client.
140
+ */
141
+ private async getToolsViaRouter(router: ToolRouter): Promise<StructuredTool[]> {
142
+ await this.ensureDependencies();
143
+
144
+ const filteredTools = await router.getFilteredTools();
145
+
146
+ return filteredTools.map((tool) => {
147
+ const routedTool = tool as typeof tool & { sessionId?: string; serverName?: string };
148
+ const namespace = routedTool.serverName ?? routedTool.sessionId;
149
+ const schema = this.jsonSchemaToZod(tool.inputSchema);
150
+
151
+ return new this.DynamicStructuredTool!({
152
+ name: isMetaTool(tool.name)
153
+ ? tool.name
154
+ : this.getRouterToolKey(tool.name, routedTool.sessionId, routedTool.serverName),
155
+ description: tool.description || `Tool ${tool.name}`,
156
+ schema: schema,
157
+ func: async (args: any) => {
158
+ try {
159
+ // Handle meta-tool calls via the router
160
+ if (isMetaTool(tool.name)) {
161
+ const result = await executeMetaTool(
162
+ tool.name,
163
+ args,
164
+ router,
165
+ (name, toolArgs, namespace) => router.callTool(name, toolArgs, namespace)
166
+ );
167
+ if (result) {
168
+ return result.content.map((c: any) => c.text ?? '').join('\n');
169
+ }
170
+ }
171
+
172
+ // For non-meta tools in 'all' or 'groups' strategy,
173
+ // route directly to the correct MCP client
174
+ return await router.callTool(tool.name, args, namespace);
175
+ } catch (error: any) {
176
+ if (this.options.simplifyErrors) {
177
+ return `Error: ${error.message}`;
178
+ }
179
+ throw error;
180
+ }
181
+ },
182
+ });
183
+ });
184
+ }
185
+
186
+ private getRouterToolKey(toolName: string, sessionId?: string, serverName?: string): string {
187
+ const namespace = sessionId ?? serverName ?? 'mcp';
188
+ const normalized = namespace
189
+ .toLowerCase()
190
+ .replace(/[^a-z0-9]+/g, '_')
191
+ .replace(/^_+|_+$/g, '') || 'mcp';
192
+ return `tool_${normalized}_${toolName}`;
193
+ }
194
+
121
195
  /**
122
196
  * Convenience static method to fetch tools in a single line.
123
197
  */
@@ -6,22 +6,119 @@
6
6
  * communication via the AppBridge protocol.
7
7
  *
8
8
  * Key features:
9
- * - Secure iframe sandboxing with minimal permissions
9
+ * - Secure iframe sandboxing with minimal permissions (proxy-based)
10
10
  * - Resource preloading for instant MCP App UI loading
11
11
  * - Cache-aware resource fetching (SSEClient cache → local cache → direct fetch)
12
12
  * - Support for ui:// and mcp-app:// resource URIs
13
13
  */
14
14
 
15
- import { AppBridge, PostMessageTransport } from '@modelcontextprotocol/ext-apps/app-bridge';
15
+ import {
16
+ AppBridge,
17
+ PostMessageTransport
18
+ } from '@modelcontextprotocol/ext-apps/app-bridge';
19
+ import type { LoggingMessageNotification } from '@modelcontextprotocol/sdk/types.js';
16
20
  import type { AppHostClient } from './types';
21
+ import { setupSandboxProxyIframe } from '../utils/app-host-utils.js';
22
+ import { APP_HOST_DEFAULTS } from './constants.js';
23
+
24
+ export type McpUiResourceCsp = Record<string, string>;
25
+ export type McpUiHostContext = Record<string, unknown>;
26
+
27
+ // Define types dynamically from AppBridge properties instead of direct imports
28
+ // which seem to fail in this tsconfig environment
29
+ type OnMessageHandler = NonNullable<AppBridge['onmessage']>;
30
+ export type McpUiMessageParams = Parameters<OnMessageHandler>[0];
31
+ export type RequestHandlerExtra = Parameters<OnMessageHandler>[1];
32
+ export type McpUiMessageResult = ReturnType<OnMessageHandler> extends Promise<infer R> ? R : never;
33
+
34
+ type OnOpenLinkHandler = NonNullable<AppBridge['onopenlink']>;
35
+ export type McpUiOpenLinkParams = Parameters<OnOpenLinkHandler>[0];
36
+ export type McpUiOpenLinkResult = ReturnType<OnOpenLinkHandler> extends Promise<infer R> ? R : never;
37
+
38
+ type OnSizeChangeHandler = NonNullable<AppBridge['onsizechange']>;
39
+ export type McpUiSizeChangedParams = Parameters<OnSizeChangeHandler>[0];
40
+
41
+ type OnRequestDisplayModeHandler = NonNullable<AppBridge['onrequestdisplaymode']>;
42
+ export type McpUiRequestDisplayModeParams = Parameters<OnRequestDisplayModeHandler>[0];
43
+ export type McpUiRequestDisplayModeResult = ReturnType<OnRequestDisplayModeHandler> extends Promise<infer R> ? R : never;
44
+
17
45
 
18
46
  // ============================================
19
47
  // Types & Interfaces
20
48
  // ============================================
21
49
 
50
+ export interface SandboxConfig {
51
+ url: URL | string;
52
+ permissions?: string;
53
+ csp?: McpUiResourceCsp;
54
+ }
55
+
56
+ /**
57
+ * Default Content-Security-Policy for MCP App iframes.
58
+ *
59
+ * Allows inline scripts/styles (required by most MCP App frameworks),
60
+ * outbound network connections, and common asset sources, while blocking
61
+ * nested frames and plugin objects.
62
+ *
63
+ * Pass this (or a spread of it) as `sandbox.csp` to enforce it:
64
+ * @example
65
+ * sandbox={{ url: '/sandbox.html', csp: DEFAULT_MCP_APP_CSP }}
66
+ * // or to extend:
67
+ * sandbox={{ url: '/sandbox.html', csp: { ...DEFAULT_MCP_APP_CSP, 'connect-src': "'self' https://api.example.com" } }}
68
+ */
69
+ export const DEFAULT_MCP_APP_CSP: McpUiResourceCsp = {
70
+ 'default-src': "'self'",
71
+ 'script-src': "'self' 'unsafe-inline' 'unsafe-eval' https: blob:",
72
+ 'style-src': "'self' 'unsafe-inline' https:",
73
+ 'connect-src': "'self' https: wss:",
74
+ 'img-src': "'self' data: https: blob:",
75
+ 'font-src': "'self' data: https:",
76
+ 'media-src': "'self' https: blob:",
77
+ 'frame-src': "'none'",
78
+ 'object-src': "'none'",
79
+ 'base-uri': "'self'",
80
+ };
81
+
22
82
  export interface AppHostOptions {
23
83
  /** Enable debug logging @default false */
24
84
  debug?: boolean;
85
+ /** Sandbox proxy configuration */
86
+ sandbox?: SandboxConfig;
87
+ /** Host context for theming, viewport, locale */
88
+ hostContext?: McpUiHostContext;
89
+ /** Custom handler for call tool requests, overriding automatic client forwarding */
90
+ onCallTool?: (params: ToolCallParams) => Promise<unknown>;
91
+ /** Custom handler for resources/read */
92
+ onReadResource?: (uri: string) => Promise<ResourceResponse>;
93
+ /** Custom handler for fallback JSON-RPC requests */
94
+ onFallbackRequest?: (request: any) => Promise<any>;
95
+
96
+ /** Handler for open-link requests from the guest UI */
97
+ onOpenLink?: (
98
+ params: McpUiOpenLinkParams,
99
+ extra: RequestHandlerExtra,
100
+ ) => Promise<McpUiOpenLinkResult>;
101
+
102
+ /** Handler for message requests from the guest UI */
103
+ onMessage?: (
104
+ params: McpUiMessageParams,
105
+ extra: RequestHandlerExtra,
106
+ ) => Promise<McpUiMessageResult>;
107
+
108
+ /** Handler for logging messages from the guest UI */
109
+ onLoggingMessage?: (params: LoggingMessageNotification['params']) => void;
110
+
111
+ /** Handler for size change notifications from the guest UI */
112
+ onSizeChanged?: (params: McpUiSizeChangedParams) => void;
113
+
114
+ /** Callback invoked when an error occurs during setup or message handling */
115
+ onError?: (error: Error) => void;
116
+
117
+ /** Handler for display mode change requests from the guest UI */
118
+ onRequestDisplayMode?: (
119
+ params: McpUiRequestDisplayModeParams,
120
+ extra: RequestHandlerExtra,
121
+ ) => Promise<McpUiRequestDisplayModeResult>;
25
122
  }
26
123
 
27
124
  export interface AppMessageParams {
@@ -47,20 +144,11 @@ interface ResourceResponse {
47
144
  // Constants
48
145
  // ============================================
49
146
 
50
- const HOST_INFO = { name: 'mcp-ts-host', version: '1.0.0' };
147
+ const HOST_INFO = APP_HOST_DEFAULTS.HOST_INFO;
51
148
 
52
- /** Sandbox permissions - minimal set required for MCP Apps to function */
53
- const SANDBOX_PERMISSIONS = [
54
- 'allow-scripts', // Required for app JavaScript execution
55
- 'allow-forms', // Required for form submissions
56
- 'allow-same-origin', // Required for Blob URL correctness
57
- 'allow-modals', // Required for dialogs/alerts
58
- 'allow-popups', // Required for opening links
59
- 'allow-downloads' // Required for file downloads
60
- ].join(' ');
61
149
 
62
150
  /** Supported MCP App URI schemes */
63
- const MCP_URI_SCHEMES = ['ui://', 'mcp-app://'] as const;
151
+ const MCP_URI_SCHEMES = APP_HOST_DEFAULTS.URI_SCHEMES;
64
152
 
65
153
  // ============================================
66
154
  // AppHost Class
@@ -76,16 +164,19 @@ export class AppHost {
76
164
  private resourceCache = new Map<string, Promise<ResourceResponse | null>>();
77
165
  private debug: boolean;
78
166
 
79
- /** Callback for app messages (e.g., chat messages from the app) */
167
+ private sandboxConfig?: SandboxConfig;
168
+ private options: AppHostOptions;
80
169
  public onAppMessage?: (params: AppMessageParams) => void;
81
170
 
82
171
  constructor(
83
- private readonly client: AppHostClient,
172
+ private readonly client: AppHostClient | null,
84
173
  private readonly iframe: HTMLIFrameElement,
85
174
  options?: AppHostOptions
86
175
  ) {
87
- this.debug = options?.debug ?? false;
88
- this.configureSandbox();
176
+ this.options = options || {};
177
+ this.debug = this.options.debug ?? false;
178
+ this.sandboxConfig = this.options.sandbox;
179
+
89
180
  this.bridge = this.initializeBridge();
90
181
  }
91
182
 
@@ -119,29 +210,42 @@ export class AppHost {
119
210
  }
120
211
 
121
212
  /**
122
- * Launch an MCP App from a URL or MCP resource URI.
213
+ * Launch an MCP App from a URL, MCP resource URI, or RAW HTML.
123
214
  * Loads the HTML first, then establishes bridge connection.
124
215
  */
125
- async launch(url: string, sessionId?: string): Promise<void> {
216
+ async launch(source: { uri?: string; html?: string }, sessionId?: string): Promise<void> {
126
217
  if (sessionId) this.sessionId = sessionId;
127
218
 
128
- // Set up initialization promise BEFORE connecting
129
219
  const initializedPromise = this.onAppReady();
130
220
 
131
- // Load HTML into iframe first
132
- if (this.isMcpUri(url)) {
133
- await this.launchMcpApp(url);
134
- } else {
135
- this.iframe.src = url;
136
- }
221
+ let htmlToRender = source.html;
137
222
 
138
- // Wait for iframe to load before connecting bridge
139
- await this.onIframeReady();
223
+ if (!htmlToRender && source.uri) {
224
+ if (this.isMcpUri(source.uri)) {
225
+ htmlToRender = await this.readMcpAppHtml(source.uri);
226
+ }
227
+ }
140
228
 
141
- // Connect the bridge (HTML is loaded, contentWindow is ready)
142
- await this.connectBridge();
229
+ if (!htmlToRender && source.uri && !this.isMcpUri(source.uri)) {
230
+ // Fallback for regular urls without proxy
231
+ this.iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-modals allow-popups allow-downloads');
232
+ this.iframe.src = source.uri;
233
+ await this.onIframeReady();
234
+ await this.connectBridge();
235
+ } else if (htmlToRender) {
236
+ if (!this.sandboxConfig) {
237
+ throw new Error("Sandbox configuration requires a proxy URL to render HTML safely.");
238
+ }
239
+ await this.launchSandboxedHtml(htmlToRender, this.sandboxConfig);
240
+ await this.connectBridge();
241
+
242
+ this.log('Sending HTML resource to sandbox proxy (MCP Apps notification)');
243
+ await this.bridge.sendSandboxResourceReady({
244
+ html: htmlToRender,
245
+ csp: this.sandboxConfig.csp,
246
+ });
247
+ }
143
248
 
144
- // Wait for app to signal it's initialized (with timeout)
145
249
  this.log('Waiting for app initialization');
146
250
  await Promise.race([
147
251
  initializedPromise,
@@ -153,6 +257,21 @@ export class AppHost {
153
257
  this.log('App launched and ready');
154
258
  }
155
259
 
260
+ // Set host context manually
261
+ setHostContext(context: McpUiHostContext): void {
262
+ this.options.hostContext = context;
263
+ if (this.bridge) {
264
+ this.bridge.setHostContext(context);
265
+ }
266
+ }
267
+
268
+ // Send streaming inputs manually
269
+ sendToolInputPartial(params: any): void {
270
+ if (this.bridge) {
271
+ (this.bridge as any).sendToolInputPartial(params);
272
+ }
273
+ }
274
+
156
275
  /**
157
276
  * Wait for app to signal initialization complete
158
277
  */
@@ -208,15 +327,19 @@ export class AppHost {
208
327
  this.bridge.sendToolCancelled({ reason });
209
328
  }
210
329
 
330
+ /**
331
+ * Tell the guest UI the resource is being torn down (unload / cleanup).
332
+ * Forwards to {@link AppBridge.teardownResource} on `@modelcontextprotocol/ext-apps/app-bridge`.
333
+ */
334
+ teardownResource(params: Record<string, unknown> = {}): void {
335
+ this.log('Sending resource teardown to app');
336
+ this.bridge.teardownResource(params as never);
337
+ }
338
+
211
339
  // ============================================
212
340
  // Private: Initialization
213
341
  // ============================================
214
342
 
215
- private configureSandbox(): void {
216
- if (this.iframe.sandbox.value !== SANDBOX_PERMISSIONS) {
217
- this.iframe.sandbox.value = SANDBOX_PERMISSIONS;
218
- }
219
- }
220
343
 
221
344
  private initializeBridge(): AppBridge {
222
345
  const bridge = new AppBridge(
@@ -226,12 +349,10 @@ export class AppHost {
226
349
  openLinks: {},
227
350
  serverTools: {},
228
351
  logging: {},
229
- // Declare support for model context updates
230
352
  updateModelContext: { text: {} },
231
353
  },
232
354
  {
233
- // Initial host context
234
- hostContext: {
355
+ hostContext: this.options.hostContext || {
235
356
  theme: 'dark',
236
357
  platform: 'web',
237
358
  containerDimensions: { maxHeight: 6000 },
@@ -241,20 +362,59 @@ export class AppHost {
241
362
  }
242
363
  );
243
364
 
244
- // Register handlers - must be done BEFORE connect()
365
+ ;(bridge as any).fallbackRequestHandler = this.options.onFallbackRequest;
366
+
245
367
  bridge.oncalltool = (params) => this.handleToolCall(params);
246
- bridge.onopenlink = this.handleOpenLink.bind(this);
247
- bridge.onmessage = this.handleMessage.bind(this);
248
- bridge.onloggingmessage = (params) => this.log(`App log [${params.level}]: ${params.data}`);
368
+ if (this.options.onReadResource) {
369
+ bridge.onreadresource = async (params) => {
370
+ const resp = await this.options.onReadResource!(params.uri);
371
+ return {
372
+ contents: resp.contents.map(c => ({
373
+ uri: params.uri,
374
+ text: c.text as string,
375
+ blob: c.blob as string,
376
+ }))
377
+ };
378
+ };
379
+ }
380
+
381
+ bridge.onopenlink = async (params, extra) => {
382
+ if (this.options.onOpenLink) {
383
+ return await this.options.onOpenLink(params, extra as any);
384
+ }
385
+ return this.handleOpenLink(params);
386
+ };
387
+ bridge.onmessage = async (params, extra) => {
388
+ if (this.options.onMessage) {
389
+ return await this.options.onMessage(params, extra as any);
390
+ }
391
+ return this.handleMessage(params as any);
392
+ };
393
+ bridge.onloggingmessage = (params) => {
394
+ this.log(`App log [${params.level}]: ${params.data}`);
395
+ if (this.options.onLoggingMessage) {
396
+ this.options.onLoggingMessage(params);
397
+ }
398
+ };
249
399
  bridge.onupdatemodelcontext = async () => ({});
250
- bridge.onsizechange = async ({ width, height }) => {
251
- if (height !== undefined) this.iframe.style.height = `${height}px`;
252
- if (width !== undefined) this.iframe.style.minWidth = `min(${width}px, 100%)`;
400
+ bridge.onsizechange = async (params) => {
401
+ const { width, height } = params;
402
+ // Guard: ignore transient 0px resize events (e.g. fired by guest during viewport transitions)
403
+ if (height !== undefined && height > 0) {
404
+ this.iframe.style.height = `${height}px`;
405
+ }
406
+ if (width !== undefined && width > 0) this.iframe.style.minWidth = `min(${width}px, 100%)`;
407
+ if (this.options.onSizeChanged) {
408
+ this.options.onSizeChanged(params);
409
+ }
253
410
  return {};
254
411
  };
255
- bridge.onrequestdisplaymode = async (params) => ({
256
- mode: params.mode === 'fullscreen' ? 'fullscreen' : 'inline'
257
- });
412
+ bridge.onrequestdisplaymode = async (params, extra) => {
413
+ if (this.options.onRequestDisplayMode) {
414
+ return await this.options.onRequestDisplayMode(params, extra as any);
415
+ }
416
+ return { mode: params.mode === 'fullscreen' ? 'fullscreen' : 'inline' };
417
+ };
258
418
 
259
419
  return bridge;
260
420
  }
@@ -272,6 +432,9 @@ export class AppHost {
272
432
  this.log('Bridge connected successfully');
273
433
  } catch (error) {
274
434
  this.log('Bridge connection failed', 'error');
435
+ if (this.options.onError) {
436
+ this.options.onError(error instanceof Error ? error : new Error(String(error)));
437
+ }
275
438
  throw error;
276
439
  }
277
440
  }
@@ -281,8 +444,12 @@ export class AppHost {
281
444
  // ============================================
282
445
 
283
446
  private async handleToolCall(params: ToolCallParams) {
284
- if (!this.client.isConnected()) {
285
- throw new Error('Client disconnected');
447
+ if (this.options.onCallTool) {
448
+ return await this.options.onCallTool(params);
449
+ }
450
+
451
+ if (!this.client || !this.client.isConnected()) {
452
+ throw new Error('Client disconnected or not provided');
286
453
  }
287
454
 
288
455
  const sessionId = await this.getSessionId();
@@ -312,34 +479,49 @@ export class AppHost {
312
479
  // Private: Resource Loading
313
480
  // ============================================
314
481
 
315
- private async launchMcpApp(uri: string): Promise<void> {
316
- if (!this.client.isConnected()) {
317
- throw new Error('Client must be connected');
482
+ private async launchSandboxedHtml(html: string, config: SandboxConfig): Promise<void> {
483
+ const sandboxUrlString = config.url instanceof URL ? config.url.href : config.url;
484
+ const url = new URL(sandboxUrlString, globalThis.location?.href);
485
+ if (config.csp && Object.keys(config.csp).length > 0) {
486
+ url.searchParams.set('csp', JSON.stringify(config.csp));
318
487
  }
319
488
 
489
+ const { onReady } = await setupSandboxProxyIframe(this.iframe, url);
490
+ await onReady;
491
+ }
492
+
493
+
494
+ private async readMcpAppHtml(uri: string): Promise<string> {
320
495
  const sessionId = await this.getSessionId();
321
- if (!sessionId) {
322
- throw new Error('No active session');
496
+ if (!sessionId && !this.options.onReadResource) {
497
+ throw new Error('No active session.');
323
498
  }
324
-
325
- // Fetch resource using cache hierarchy: SSEClient cache → local cache → direct fetch
326
499
  const response = await this.fetchResourceWithCache(sessionId, uri);
327
500
  if (!response?.contents?.length) {
328
501
  throw new Error(`Empty resource: ${uri}`);
329
502
  }
330
-
503
+
331
504
  const content = response.contents[0];
332
505
  const html = this.decodeContent(content);
333
506
  if (!html) {
334
507
  throw new Error(`Invalid content in resource: ${uri}`);
335
508
  }
336
-
337
- // Render via Blob URL for clean isolation
338
- const blob = new Blob([html], { type: 'text/html' });
339
- this.iframe.src = URL.createObjectURL(blob);
509
+ return html;
340
510
  }
341
511
 
342
- private async fetchResourceWithCache(sessionId: string, uri: string): Promise<ResourceResponse> {
512
+ private async fetchResourceWithCache(sessionId: string | undefined, uri: string): Promise<ResourceResponse> {
513
+ if (this.options.onReadResource) {
514
+ return await this.options.onReadResource(uri);
515
+ }
516
+
517
+ if (!sessionId) {
518
+ throw new Error('No active session');
519
+ }
520
+
521
+ if (!this.client) {
522
+ throw new Error('No client to read resource from');
523
+ }
524
+
343
525
  // Priority 1: SSEClient's built-in cache (best performance)
344
526
  if (this.hasClientCache()) {
345
527
  return (this.client as any).getOrFetchResource(sessionId, uri);
@@ -358,8 +540,11 @@ export class AppHost {
358
540
 
359
541
  private async preloadResource(uri: string): Promise<ResourceResponse | null> {
360
542
  try {
543
+ if (this.options.onReadResource) {
544
+ return await this.options.onReadResource(uri);
545
+ }
361
546
  const sessionId = await this.getSessionId();
362
- if (!sessionId) return null;
547
+ if (!sessionId || !this.client) return null;
363
548
  return await this.client.readResource(sessionId, uri) as ResourceResponse;
364
549
  } catch (error) {
365
550
  this.log(`Preload failed for ${uri}`, 'warn');
@@ -373,6 +558,7 @@ export class AppHost {
373
558
 
374
559
  private async getSessionId(): Promise<string | undefined> {
375
560
  if (this.sessionId) return this.sessionId;
561
+ if (!this.client) return undefined;
376
562
  const result = await this.client.getSessions();
377
563
  return result.sessions?.[0]?.sessionId;
378
564
  }
@@ -382,6 +568,7 @@ export class AppHost {
382
568
  }
383
569
 
384
570
  private hasClientCache(): boolean {
571
+ if (!this.client) return false;
385
572
  return 'getOrFetchResource' in this.client &&
386
573
  typeof (this.client as any).getOrFetchResource === 'function';
387
574
  }