| 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | |
| 3 | import { makeMcpAppHost, McpAppHostRefusal, type McpAppHostContext } from "./host.ts"; |
| 4 | |
| 5 | const 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 | |
| 13 | const 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. |
| 26 | const flush = () => new Promise<void>((resolve) => queueMicrotask(resolve)).then(() => undefined); |
| 27 | |
| 28 | const 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 | |
| 41 | function 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 | |
| 73 | describe("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 | }); |