1import { describe, expect, it, vi } from "vite-plus/test";
2
3import { makeMcpAppHost, McpAppHostRefusal, type McpAppHostContext } from "./host.ts";
4
5const app = {
6 attachmentId: "thread-1-app",
7 server: "weather",
8 tool: "get_weather",
9 resourceUri: "ui://weather/dashboard",
10 csp: { connectDomains: ["https://api.weather.test"] },
11};
12
13const context = (
14 theme: "light" | "dark" = "dark",
15 displayMode: "inline" | "fullscreen" = "inline",
16): McpAppHostContext => ({
17 theme,
18 styles: { variables: { "--color-background-primary": theme === "dark" ? "#000" : "#fff" } },
19 displayMode,
20 availableDisplayModes: ["inline", "fullscreen"],
21 containerDimensions: { width: 600, maxHeight: 2000 },
22 platform: "web",
23});
24
25// Lets the host settle the promises its callbacks return.
26const flush = () => new Promise<void>((resolve) => queueMicrotask(resolve)).then(() => undefined);
27
28const initialize = (
29 host: ReturnType<typeof makeMcpAppHost>,
30 appCapabilities: Record<string, unknown> = {},
31) => {
32 host.receive({
33 jsonrpc: "2.0",
34 id: "init",
35 method: "ui/initialize",
36 params: { appCapabilities },
37 });
38 host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized" });
39};
40
41function setup(overrides: Partial<Parameters<typeof makeMcpAppHost>[0]> = {}) {
42 const sent: Array<Record<string, unknown>> = [];
43 let theme: "light" | "dark" = "dark";
44 let mode: "inline" | "fullscreen" = "inline";
45 const host = makeMcpAppHost({
46 app,
47 hostVersion: "1.0.0",
48 post: (message) => sent.push(message as Record<string, unknown>),
49 hostContext: () => context(theme, mode),
50 callTool: async (input) => ({ content: [{ type: "text", text: `called ${input.name}` }] }),
51 readResource: async () => ({ contents: [] }),
52 openLink: async () => undefined,
53 sendMessage: async () => undefined,
54 updateModelContext: async () => undefined,
55 requestDisplayMode: async (next) => {
56 mode = next === "fullscreen" ? "fullscreen" : "inline";
57 return mode;
58 },
59 downloadFile: async () => undefined,
60 onRequestTeardown: () => undefined,
61 onSizeChanged: () => undefined,
62 ...overrides,
63 });
64 return {
65 host,
66 sent,
67 setTheme: (next: "light" | "dark") => {
68 theme = next;
69 },
70 };
71}
72
73describe("makeMcpAppHost", () => {
74 it("initializes, then replays the tool input and result after initialized", () => {
75 const { host, sent } = setup();
76 host.setToolCall({
77 arguments: { city: "Oslo" },
78 result: { content: [{ type: "text", text: "Sunny" }] },
79 });
80 host.receive({
81 jsonrpc: "2.0",
82 id: 1,
83 method: "ui/initialize",
84 params: { protocolVersion: "2026-01-26", appInfo: { name: "a", version: "1" } },
85 });
86 expect(sent[0]).toMatchObject({
87 id: 1,
88 result: {
89 protocolVersion: "2026-01-26",
90 hostCapabilities: { serverTools: {}, sandbox: { csp: app.csp } },
91 hostContext: { theme: "dark", displayMode: "inline" },
92 },
93 });
94 expect(sent).toHaveLength(1);
95
96 host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized", params: {} });
97 expect(sent.slice(1)).toEqual([
98 {
99 jsonrpc: "2.0",
100 method: "ui/notifications/tool-input",
101 params: { arguments: { city: "Oslo" } },
102 },
103 {
104 jsonrpc: "2.0",
105 method: "ui/notifications/tool-result",
106 params: { content: [{ type: "text", text: "Sunny" }] },
107 },
108 ]);
109 });
110
111 it("replays a tool call that arrives after initialization, once", () => {
112 const { host, sent } = setup();
113 host.receive({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: {} });
114 host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized" });
115 sent.length = 0;
116 host.setToolCall({ arguments: { city: "Oslo" }, result: undefined });
117 host.setToolCall({ arguments: { city: "Bergen" }, result: undefined });
118 expect(sent).toEqual([
119 {
120 jsonrpc: "2.0",
121 method: "ui/notifications/tool-input",
122 params: { arguments: { city: "Oslo" } },
123 },
124 ]);
125 });
126
127 it("drops null fields providers report, which the MCP Apps SDK rejects", async () => {
128 const { host, sent } = setup({
129 callTool: async () => ({ content: [], structuredContent: null, _meta: null }),
130 });
131 host.receive({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: {} });
132 host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized" });
133 host.setToolCall({
134 arguments: {},
135 result: {
136 content: [{ type: "text", text: "t" }],
137 structuredContent: { time: "t" },
138 _meta: null,
139 },
140 });
141 host.receive({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "get-time" } });
142 await flush();
143 expect(sent.find((m) => m.method === "ui/notifications/tool-result")?.params).toEqual({
144 content: [{ type: "text", text: "t" }],
145 structuredContent: { time: "t" },
146 });
147 expect(sent.find((m) => m.id === 2)?.result).toEqual({ content: [] });
148 });
149
150 it("accepts ui/message as one text block or the SDK's block array", async () => {
151 const messages: Array<string> = [];
152 const { host, sent } = setup({ sendMessage: async (text) => void messages.push(text) });
153 host.receive({
154 jsonrpc: "2.0",
155 id: 1,
156 method: "ui/message",
157 params: { role: "user", content: { type: "text", text: "one" } },
158 });
159 host.receive({
160 jsonrpc: "2.0",
161 id: 2,
162 method: "ui/message",
163 params: { role: "user", content: [{ type: "text", text: "two" }] },
164 });
165 host.receive({
166 jsonrpc: "2.0",
167 id: 3,
168 method: "ui/message",
169 params: { role: "user", content: [{ type: "image", data: "x" }] },
170 });
171 host.receive({
172 jsonrpc: "2.0",
173 id: 4,
174 method: "ui/message",
175 params: { role: "user", content: { type: "text", text: " " } },
176 });
177 await flush();
178 expect(messages).toEqual(["one", "two"]);
179 expect(sent.find((m) => m.id === 3)?.error).toMatchObject({ code: -32602 });
180 // Blank text would ask to send an empty message.
181 expect(sent.find((m) => m.id === 4)?.error).toMatchObject({ code: -32602 });
182 });
183
184 it("sends only changed context fields, and nothing before initialization", () => {
185 const { host, sent, setTheme } = setup();
186 setTheme("light");
187 host.updateHostContext();
188 expect(sent).toHaveLength(0);
189
190 host.receive({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: {} });
191 host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized" });
192 sent.length = 0;
193 setTheme("dark");
194 host.updateHostContext();
195 expect(sent).toEqual([
196 {
197 jsonrpc: "2.0",
198 method: "ui/notifications/host-context-changed",
199 params: { theme: "dark", styles: context("dark").styles },
200 },
201 ]);
202 host.updateHostContext();
203 expect(sent).toHaveLength(1);
204 });
205
206 it("catches the app up on context that changed while it was initializing", () => {
207 const { host, sent, setTheme } = setup();
208 host.receive({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: {} });
209 setTheme("light");
210 host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized" });
211 expect(sent.at(-1)).toEqual({
212 jsonrpc: "2.0",
213 method: "ui/notifications/host-context-changed",
214 params: { theme: "light", styles: context("light").styles },
215 });
216 });
217
218 it("proxies tools/call and reports a refusal as a JSON-RPC error", async () => {
219 const { host, sent } = setup({
220 callTool: async (input) => {
221 if (input.name === "delete_everything")
222 throw new McpAppHostRefusal("Declined by the user.");
223 return { content: [], structuredContent: { ok: true } };
224 },
225 });
226 host.receive({ jsonrpc: "2.0", id: 7, method: "tools/call", params: { name: "refresh" } });
227 host.receive({
228 jsonrpc: "2.0",
229 id: 8,
230 method: "tools/call",
231 params: { name: "delete_everything", arguments: {} },
232 });
233 await flush();
234 expect(sent).toContainEqual({
235 jsonrpc: "2.0",
236 id: 7,
237 result: { content: [], structuredContent: { ok: true } },
238 });
239 expect(sent).toContainEqual({
240 jsonrpc: "2.0",
241 id: 8,
242 error: { code: -32000, message: "Declined by the user." },
243 });
244 });
245
246 it("rejects malformed requests and unsupported methods", () => {
247 const { host, sent } = setup();
248 host.receive({
249 jsonrpc: "2.0",
250 id: 1,
251 method: "ui/open-link",
252 params: { url: "javascript:x" },
253 });
254 host.receive({ jsonrpc: "2.0", id: 3, method: "sampling/createMessage", params: {} });
255 host.receive({ jsonrpc: "2.0", id: 4, method: "ui/message", params: { role: "assistant" } });
256 expect(sent.map((message) => (message.error as { code: number }).code)).toEqual([
257 -32602, -32601, -32602,
258 ]);
259 });
260
261 it("refuses requests past the in-flight limit and posts nothing after dispose", async () => {
262 let release: () => void = () => undefined;
263 const gate = new Promise<void>((resolve) => {
264 release = resolve;
265 });
266 const { host, sent } = setup({
267 callTool: async () => {
268 await gate;
269 return { content: [] };
270 },
271 });
272 for (let id = 0; id < 17; id++) {
273 host.receive({ jsonrpc: "2.0", id, method: "tools/call", params: { name: "slow" } });
274 }
275 expect(sent).toEqual([
276 { jsonrpc: "2.0", id: 16, error: { code: -32000, message: "Too many requests in flight." } },
277 ]);
278 host.dispose();
279 release();
280 await flush();
281 expect(sent).toHaveLength(1);
282 });
283
284 it("switches display mode only to one both sides offer, and tells the app", async () => {
285 const { host, sent } = setup();
286 initialize(host, { availableDisplayModes: ["inline", "fullscreen"] });
287 sent.length = 0;
288 host.receive({
289 jsonrpc: "2.0",
290 id: 1,
291 method: "ui/request-display-mode",
292 params: { mode: "pip" },
293 });
294 host.receive({
295 jsonrpc: "2.0",
296 id: 2,
297 method: "ui/request-display-mode",
298 params: { mode: "fullscreen" },
299 });
300 await flush();
301 host.updateHostContext();
302 expect(sent.find((m) => m.id === 1)?.result).toEqual({ mode: "inline" });
303 expect(sent.find((m) => m.id === 2)?.result).toEqual({ mode: "fullscreen" });
304 expect(sent.find((m) => m.method === "ui/notifications/host-context-changed")?.params).toEqual({
305 displayMode: "fullscreen",
306 });
307
308 // An app that declared only inline stays inline.
309 const declined = setup();
310 initialize(declined.host, { availableDisplayModes: ["inline"] });
311 declined.host.receive({
312 jsonrpc: "2.0",
313 id: 3,
314 method: "ui/request-display-mode",
315 params: { mode: "fullscreen" },
316 });
317 await flush();
318 expect(declined.sent.find((m) => m.id === 3)?.result).toEqual({ mode: "inline" });
319 });
320
321 it("forwards model context and download requests, and declares both", async () => {
322 const contexts: Array<unknown> = [];
323 const downloads: Array<unknown> = [];
324 const { host, sent } = setup({
325 updateModelContext: async (update) => void contexts.push(update),
326 downloadFile: async (files) => {
327 downloads.push(files);
328 if (files.length > 1) throw new McpAppHostRefusal("Declined by the user.");
329 },
330 });
331 initialize(host);
332 const capabilities = (
333 sent[0]?.result as { hostCapabilities: Record<string, unknown> } | undefined
334 )?.hostCapabilities;
335 expect(capabilities?.updateModelContext).toEqual({ text: {}, structuredContent: {} });
336 expect(capabilities?.downloadFile).toEqual({});
337
338 host.receive({
339 jsonrpc: "2.0",
340 id: 1,
341 method: "ui/update-model-context",
342 params: { content: [{ type: "text", text: "2 overdue" }], structuredContent: { overdue: 2 } },
343 });
344 const embedded = {
345 type: "resource",
346 resource: { uri: "file:///report.csv", mimeType: "text/csv", text: "a,b" },
347 };
348 host.receive({
349 jsonrpc: "2.0",
350 id: 2,
351 method: "ui/download-file",
352 params: { contents: [embedded] },
353 });
354 host.receive({
355 jsonrpc: "2.0",
356 id: 3,
357 method: "ui/download-file",
358 params: {
359 contents: [embedded, { type: "resource_link", uri: "ui://todos/export", name: "x" }],
360 },
361 });
362 host.receive({ jsonrpc: "2.0", id: 4, method: "ui/download-file", params: { contents: [{}] } });
363 await flush();
364 expect(contexts).toEqual([
365 { content: [{ type: "text", text: "2 overdue" }], structuredContent: { overdue: 2 } },
366 ]);
367 expect(downloads[0]).toEqual([
368 {
369 _tag: "embedded",
370 name: "report.csv",
371 mimeType: "text/csv",
372 bytes: new TextEncoder().encode("a,b"),
373 },
374 ]);
375 expect(sent.find((m) => m.id === 2)?.result).toEqual({});
376 // A refused download is reported in the result, as the draft defines.
377 expect(sent.find((m) => m.id === 3)?.result).toEqual({ isError: true });
378 expect(sent.find((m) => m.id === 4)?.error).toMatchObject({ code: -32602 });
379 });
380
381 it("tears down after the app answers, or after the timeout, and passes on its own request", async () => {
382 vi.useFakeTimers();
383 try {
384 let closeRequested = false;
385 const { host, sent } = setup({ onRequestTeardown: () => (closeRequested = true) });
386 host.receive({ jsonrpc: "2.0", method: "ui/notifications/request-teardown" });
387 expect(closeRequested).toBe(true);
388
389 // Before initialization there is nothing to ask.
390 await host.teardown();
391 expect(sent.some((m) => m.method === "ui/resource-teardown")).toBe(false);
392
393 const answered = setup();
394 initialize(answered.host);
395 let done = false;
396 void answered.host.teardown().then(() => (done = true));
397 const request = answered.sent.find((m) => m.method === "ui/resource-teardown");
398 expect(request).toBeDefined();
399 answered.host.receive({ jsonrpc: "2.0", id: request?.id, result: {} });
400 await vi.runAllTimersAsync();
401 expect(done).toBe(true);
402
403 const silent = setup();
404 initialize(silent.host);
405 let timedOut = false;
406 void silent.host.teardown().then(() => (timedOut = true));
407 await vi.advanceTimersByTimeAsync(1999);
408 expect(timedOut).toBe(false);
409 await vi.advanceTimersByTimeAsync(1);
410 expect(timedOut).toBe(true);
411 // A torn-down host posts nothing more.
412 const before = silent.sent.length;
413 silent.host.updateHostContext();
414 expect(silent.sent).toHaveLength(before);
415 } finally {
416 vi.useRealTimers();
417 }
418 });
419});