diff --git a/.gitignore b/.gitignore index 9c6a1374..02d30380 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,32 @@ docs/api/ tmp/ intermediate-findings/ +# Environment + logs +.env +.env.* +!.env.example +!.env.sample +!.env.template +*.log + +# Coverage + caches +coverage/ +.nyc_output/ +.cache/ +.eslintcache +.stylelintcache +.turbo/ + # Playwright playwright-report/ test-results/ __pycache__/ -*.pyc \ No newline at end of file +*.pyc + +# Browser test screenshots +tests/browser/__screenshots__/ + +# Planning/proposal docs (not for commit) +proposal/ +docs/http-adapter-error-handling-plan.md +docs/project-notes.md diff --git a/README.md b/README.md index e1429330..60855fad 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,10 @@ The [`examples/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/exa | [![Basic](examples/basic-server-react/grid-cell.png "Starter template")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) | The same app built with different frameworks — pick your favorite!

[React](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) · [Vue](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vue) · [Svelte](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-svelte) · [Preact](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-preact) · [Solid](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-solid) · [Vanilla JS](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) | +Additional example: + +- [**Hono React Server**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/hono-react-server) — Dual-mode HTTP pattern (Hono + React, type-safe `hc` client). + ### Running the Examples #### With basic-host @@ -414,6 +418,13 @@ Then configure your MCP client to build and run the local server. Replace `~/cod "cd ~/code/ext-apps/examples/customer-segmentation-server && npm run build >&2 && node dist/index.js --stdio" ] }, + "hono-react": { + "command": "bash", + "args": [ + "-c", + "cd ~/code/ext-apps/examples/hono-react-server && npm run build >&2 && node dist/main.js --stdio" + ] + }, "map": { "command": "bash", "args": [ diff --git a/build.bun.ts b/build.bun.ts index 0e534189..96a53a45 100644 --- a/build.bun.ts +++ b/build.bun.ts @@ -26,6 +26,10 @@ function buildJs( } await Promise.all([ + buildJs("src/generated/schema.ts", { + outdir: "dist/src/generated", + external: ["zod"], + }), buildJs("src/app.ts", { outdir: "dist/src", external: ["@modelcontextprotocol/sdk"], @@ -51,4 +55,16 @@ await Promise.all([ outdir: "dist/src/server", external: ["@modelcontextprotocol/sdk"], }), + buildJs("src/http-adapter/init.ts", { + outdir: "dist/src/http-adapter", + external: ["@modelcontextprotocol/sdk"], + }), + buildJs("src/http-adapter/fetch-wrapper/fetch.ts", { + outdir: "dist/src/http-adapter/fetch-wrapper", + external: ["@modelcontextprotocol/sdk"], + }), + buildJs("src/http-adapter/xhr-wrapper/xhr.ts", { + outdir: "dist/src/http-adapter/xhr-wrapper", + external: ["@modelcontextprotocol/sdk"], + }), ]); diff --git a/examples/basic-host/README.md b/examples/basic-host/README.md index c64cda75..5c782f62 100644 --- a/examples/basic-host/README.md +++ b/examples/basic-host/README.md @@ -9,6 +9,7 @@ This basic host can also be used to test MCP Apps during local development. - [`index.html`](index.html) / [`src/index.tsx`](src/index.tsx) - React UI host with tool selection, parameter input, and iframe management - [`sandbox.html`](sandbox.html) / [`src/sandbox.ts`](src/sandbox.ts) - Outer iframe proxy with security validation and bidirectional message relay - [`src/implementation.ts`](src/implementation.ts) - Core logic: server connection, tool calling, and AppBridge setup +- [`http-adapter-host.test.html`](http-adapter-host.test.html) / [`http-adapter-app.test.html`](http-adapter-app.test.html) - Test-only harness for HTTP adapter E2E coverage ## Getting Started diff --git a/examples/basic-host/http-adapter-app.test.html b/examples/basic-host/http-adapter-app.test.html new file mode 100644 index 00000000..28ec3686 --- /dev/null +++ b/examples/basic-host/http-adapter-app.test.html @@ -0,0 +1,22 @@ + + + + + + HTTP Adapter App + + + +
Booting HTTP adapter app…
+ + + diff --git a/examples/basic-host/http-adapter-host.test.html b/examples/basic-host/http-adapter-host.test.html new file mode 100644 index 00000000..a399a24a --- /dev/null +++ b/examples/basic-host/http-adapter-host.test.html @@ -0,0 +1,30 @@ + + + + + + HTTP Adapter Host Test + + + +
Booting HTTP adapter host…
+ + + + diff --git a/examples/basic-host/package.json b/examples/basic-host/package.json index b2c5a006..56b86d1e 100644 --- a/examples/basic-host/package.json +++ b/examples/basic-host/package.json @@ -4,8 +4,8 @@ "version": "1.0.1", "type": "module", "scripts": { - "build": "tsc --noEmit && concurrently \"cross-env INPUT=index.html vite build\" \"cross-env INPUT=sandbox.html vite build\"", - "watch": "concurrently \"cross-env INPUT=index.html vite build --watch\" \"cross-env INPUT=sandbox.html vite build --watch\"", + "build": "tsc --noEmit && concurrently \"cross-env INPUT=index.html vite build\" \"cross-env INPUT=sandbox.html vite build\" \"cross-env INPUT=http-adapter-host.test.html vite build\" \"cross-env INPUT=http-adapter-app.test.html vite build\"", + "watch": "concurrently \"cross-env INPUT=index.html vite build --watch\" \"cross-env INPUT=sandbox.html vite build --watch\" \"cross-env INPUT=http-adapter-host.test.html vite build --watch\" \"cross-env INPUT=http-adapter-app.test.html vite build --watch\"", "serve": "bun --watch serve.ts", "start": "cross-env NODE_ENV=development npm run build && npm run serve", "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"" @@ -26,6 +26,7 @@ "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", + "msw": "^2.12.7", "prettier": "^3.6.2", "typescript": "^5.9.3", "vite": "^6.0.0", diff --git a/examples/basic-host/public/mockServiceWorker.js b/examples/basic-host/public/mockServiceWorker.js new file mode 100644 index 00000000..a2e8a537 --- /dev/null +++ b/examples/basic-host/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = "2.12.7"; +const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82"; +const IS_MOCKED_RESPONSE = Symbol("isMockedResponse"); +const activeClientIds = new Set(); + +addEventListener("install", function () { + self.skipWaiting(); +}); + +addEventListener("activate", function (event) { + event.waitUntil(self.clients.claim()); +}); + +addEventListener("message", async function (event) { + const clientId = Reflect.get(event.source || {}, "id"); + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: "window", + }); + + switch (event.data) { + case "KEEPALIVE_REQUEST": { + sendToClient(client, { + type: "KEEPALIVE_RESPONSE", + }); + break; + } + + case "INTEGRITY_CHECK_REQUEST": { + sendToClient(client, { + type: "INTEGRITY_CHECK_RESPONSE", + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case "MOCK_ACTIVATE": { + activeClientIds.add(clientId); + + sendToClient(client, { + type: "MOCKING_ENABLED", + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }); + break; + } + + case "CLIENT_CLOSED": { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +addEventListener("fetch", function (event) { + const requestInterceptedAt = Date.now(); + + // Bypass navigation requests. + if (event.request.mode === "navigate") { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === "only-if-cached" && + event.request.mode !== "same-origin" + ) { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); +}); + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event); + const requestCloneForEvents = event.request.clone(); + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents); + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone(); + + sendToClient( + client, + { + type: "RESPONSE", + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ); + } + + return response; +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (activeClientIds.has(event.clientId)) { + return client; + } + + if (client?.frameType === "top-level") { + return client; + } + + const allClients = await self.clients.matchAll({ + type: "window", + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === "visible"; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone(); + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers); + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get("accept"); + if (acceptHeader) { + const values = acceptHeader.split(",").map((value) => value.trim()); + const filteredValues = values.filter( + (value) => value !== "msw/passthrough", + ); + + if (filteredValues.length > 0) { + headers.set("accept", filteredValues.join(", ")); + } else { + headers.delete("accept"); + } + } + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request); + const clientMessage = await sendToClient( + client, + { + type: "REQUEST", + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ); + + switch (clientMessage.type) { + case "MOCK_RESPONSE": { + return respondWithMock(clientMessage.data); + } + + case "PASSTHROUGH": { + return passthrough(); + } + } + + return passthrough(); +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]); + }); +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + }; +} diff --git a/examples/basic-host/src/http-adapter-app.ts b/examples/basic-host/src/http-adapter-app.ts new file mode 100644 index 00000000..61c60803 --- /dev/null +++ b/examples/basic-host/src/http-adapter-app.ts @@ -0,0 +1,129 @@ +import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; +import { initMcpHttp } from "@modelcontextprotocol/ext-apps/http-adapter"; + +const statusEl = document.getElementById("status"); + +function setStatus(message: string) { + if (statusEl) { + statusEl.textContent = message; + } +} + +const app = new App( + { name: "HTTP Adapter App", version: "0.0.0" }, + { tools: { listChanged: true } }, +); + +type TransportMode = "fetch" | "xhr"; + +let activeProxyMode: "direct" | "proxy" | null = null; +let activeTransport: TransportMode | null = null; +let httpHandle: { restore: () => void } | null = null; + +function setProxyEnabled( + mode: "direct" | "proxy", + transport: TransportMode, +) { + if (activeProxyMode === mode && activeTransport === transport) { + return; + } + + httpHandle?.restore(); + httpHandle = null; + + if (mode === "proxy") { + httpHandle = initMcpHttp(app, { + patchFetch: transport === "fetch", + patchXhr: transport === "xhr", + }); + } + + activeProxyMode = mode; + activeTransport = transport; +} + +async function runFetchScenario( + mode: "direct" | "proxy", + payload?: Record, +) { + setProxyEnabled(mode, "fetch"); + + const body = payload ?? { mode, id: crypto.randomUUID() }; + const response = await fetch("/api/echo", { + method: "POST", + headers: { + "content-type": "application/json", + "x-test-id": String(body.id ?? "missing"), + }, + body: JSON.stringify(body), + }); + + const json = await response.json(); + return { status: response.status, json, body }; +} + +async function runXhrScenario( + mode: "direct" | "proxy", + payload?: Record, +) { + setProxyEnabled(mode, "xhr"); + + const body = payload ?? { mode, id: crypto.randomUUID() }; + const { responseText, status } = await new Promise<{ + responseText: string; + status: number; + }>((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/api/echo"); + xhr.setRequestHeader("content-type", "application/json"); + xhr.setRequestHeader("x-test-id", String(body.id ?? "missing")); + xhr.onload = () => + resolve({ responseText: xhr.responseText ?? "", status: xhr.status }); + xhr.onerror = () => reject(new Error("XHR request failed")); + xhr.send(JSON.stringify(body)); + }); + + let json: unknown = null; + try { + json = JSON.parse(responseText); + } catch { + json = null; + } + + return { status, json, body }; +} + +const globalState = window as typeof window & { + __appReady?: boolean; + __appError?: string; + __runScenario?: typeof runScenario; + __setProxyEnabled?: typeof setProxyEnabled; +}; + +async function runScenario( + transport: TransportMode, + mode: "direct" | "proxy", + payload?: Record, +) { + return transport === "xhr" + ? runXhrScenario(mode, payload) + : runFetchScenario(mode, payload); +} + +globalState.__runScenario = runScenario; + +globalState.__setProxyEnabled = setProxyEnabled; + +async function main() { + setStatus("Connecting to host…"); + await app.connect(new PostMessageTransport(window.parent, window.parent)); + globalState.__appReady = true; + setStatus("Ready"); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + globalState.__appError = message; + setStatus(`Error: ${message}`); + console.error(error); +}); diff --git a/examples/basic-host/src/http-adapter-host.ts b/examples/basic-host/src/http-adapter-host.ts new file mode 100644 index 00000000..711e0b94 --- /dev/null +++ b/examples/basic-host/src/http-adapter-host.ts @@ -0,0 +1,150 @@ +import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge"; +import { createHttpRequestToolHandler } from "@modelcontextprotocol/ext-apps/fetch-wrapper"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { HttpResponse, http } from "msw"; +import { setupWorker } from "msw/browser"; +import { z } from "zod"; + +const statusEl = document.getElementById("status"); +const appFrameElement = document.getElementById("app-frame"); +if (!(appFrameElement instanceof HTMLIFrameElement)) { + throw new Error("Missing app iframe"); +} +const appFrame = appFrameElement; + +function setStatus(message: string) { + if (statusEl) { + statusEl.textContent = message; + } +} + +type MswRequestLog = { + method: string; + url: string; + headers: Record; + body: string; +}; + +const mswRequests: MswRequestLog[] = []; + +const globalState = window as typeof window & { + __hostReady?: boolean; + __hostError?: string; + __mswRequests?: typeof mswRequests; +}; + +globalState.__hostReady = false; + +globalState.__mswRequests = mswRequests; + +function normalizeUrl(url: string) { + const parsed = new URL(url); + return `${parsed.pathname}${parsed.search}`; +} + +async function waitForServiceWorkerControl() { + if (navigator.serviceWorker.controller) { + return; + } + await new Promise((resolve) => { + navigator.serviceWorker.addEventListener( + "controllerchange", + () => resolve(), + { once: true }, + ); + }); +} + +async function startServiceWorker() { + const worker = setupWorker( + http.post("/api/echo", async ({ request }) => { + let payload: unknown = null; + try { + payload = await request.json(); + } catch { + payload = null; + } + return HttpResponse.json({ ok: true, body: payload }); + }), + ); + + worker.events.on("request:start", ({ request }: { request: Request }) => { + void (async () => { + const bodyText = await request.clone().text().catch(() => ""); + mswRequests.push({ + method: request.method, + url: normalizeUrl(request.url), + headers: Object.fromEntries(request.headers.entries()), + body: bodyText, + }); + })(); + }); + + await worker.start({ onUnhandledRequest: "bypass" }); + await waitForServiceWorkerControl(); +} + +async function startInMemoryMcp(): Promise { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "Test Client", version: "0.0.0" }); + const server = new McpServer({ name: "Test Server", version: "0.0.0" }); + + const proxyHandler = createHttpRequestToolHandler({ allowPaths: ["/"] }); + server.registerTool( + "http_request", + { + title: "http_request", + description: "Proxy HTTP requests through the host", + inputSchema: z.object({}).passthrough(), + }, + (args, extra) => + proxyHandler({ name: "http_request", arguments: args }, extra), + ); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + return client; +} + +async function connectBridge(client: Client) { + appFrame.src = "/http-adapter-app.test.html"; + await new Promise((resolve) => { + appFrame.addEventListener("load", () => resolve(), { once: true }); + }); + + const bridge = new AppBridge( + client, + { name: "HTTP Adapter Host", version: "0.0.0" }, + { serverTools: {} }, + ); + + bridge.oninitialized = () => { + globalState.__hostReady = true; + setStatus("Ready"); + }; + + await bridge.connect( + new PostMessageTransport(appFrame.contentWindow!, appFrame.contentWindow!), + ); +} + +async function main() { + setStatus("Starting service worker…"); + await startServiceWorker(); + + setStatus("Starting MCP bridge…"); + const client = await startInMemoryMcp(); + + setStatus("Connecting app…"); + await connectBridge(client); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + globalState.__hostError = message; + setStatus(`Error: ${message}`); + console.error(error); +}); diff --git a/examples/basic-server-vanillajs/README.md b/examples/basic-server-vanillajs/README.md index 5168f20b..5ed72b56 100644 --- a/examples/basic-server-vanillajs/README.md +++ b/examples/basic-server-vanillajs/README.md @@ -1,6 +1,6 @@ # Example: Basic Server (Vanilla JS) -An MCP App example with a vanilla JavaScript UI (no framework). +An MCP App example with a vanilla JavaScript UI (no framework). This variant highlights the MCP HTTP adapter by routing `fetch()` and XHR calls through `http_request`. > [!TIP] > Looking for a React-based example? See [`basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react)! @@ -48,7 +48,10 @@ To test local modifications, use this configuration (replace `~/code/ext-apps` w - Tool registration with a linked UI resource - Vanilla JS UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class directly -- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink) +- HTTP adapter demo using `initMcpHttp` to proxy `fetch()` and `XMLHttpRequest` +- In-process routing (no upstream HTTP server required) +- Demo endpoints: `/api/time`, `/api/items`, `/api/items/xhr` +- App communication APIs: [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink) - Theme integration via [`applyDocumentTheme()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/app.applyDocumentTheme.html), [`applyHostStyleVariables()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/app.applyHostStyleVariables.html), and [`applyHostFonts()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/app.applyHostFonts.html) ## Key Files @@ -67,7 +70,8 @@ npm run dev 1. The server registers a `get-time` tool with metadata linking it to a UI HTML resource (`ui://get-time/mcp-app.html`). 2. When the tool is invoked, the Host renders the UI from the resource. -3. The UI uses the MCP App SDK API to communicate with the host and call server tools. +3. The UI initializes the MCP HTTP adapter so `fetch()` and XHR calls to `/api/*` are routed through the `http_request` tool. +4. The `http_request` handler serves `/api/time` and `/api/items` responses **in-process** (a simple switch statement), so there is **no upstream HTTP server** required for this example. ## Build System diff --git a/examples/basic-server-vanillajs/mcp-app.html b/examples/basic-server-vanillajs/mcp-app.html index 5b642801..b56ed68a 100644 --- a/examples/basic-server-vanillajs/mcp-app.html +++ b/examples/basic-server-vanillajs/mcp-app.html @@ -4,31 +4,66 @@ - Get Time App + HTTP Adapter Demo
+
+

HTTP Adapter Demo

+

Fetch and XHR requests are proxied through the MCP http_request tool.

+
+

Watch activity in the DevTools console!

-
-

Server Time: Loading...

- -
+
+

Fetch -> http_request

+
+

Server Time: Loading...

+ +
+
+ + +
+
+ +
+
+ +
+

XHR -> http_request

+
+ + +
+
+ +
+
+ +
+

Items

+

Last request: Idle

+
[]
+
-
- - -
+
+

Host Actions

+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
+
diff --git a/examples/basic-server-vanillajs/package.json b/examples/basic-server-vanillajs/package.json index 0a48de26..45c57377 100644 --- a/examples/basic-server-vanillajs/package.json +++ b/examples/basic-server-vanillajs/package.json @@ -22,7 +22,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", + "@modelcontextprotocol/ext-apps": "file:../..", "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/basic-server-vanillajs/server.ts b/examples/basic-server-vanillajs/server.ts index 5b2daf70..fa89a5ec 100644 --- a/examples/basic-server-vanillajs/server.ts +++ b/examples/basic-server-vanillajs/server.ts @@ -9,6 +9,201 @@ const DIST_DIR = import.meta.filename.endsWith(".ts") ? path.join(import.meta.dirname, "dist") : import.meta.dirname; +const httpRequestInputSchema = z.object({ + method: z.string().default("GET"), + url: z.string(), + headers: z.record(z.string(), z.string()).optional(), + body: z.any().optional(), + bodyType: z + .enum(["none", "json", "text", "formData", "urlEncoded", "base64"]) + .optional(), + redirect: z.enum(["follow", "error", "manual"]).optional(), + cache: z + .enum([ + "default", + "no-store", + "reload", + "no-cache", + "force-cache", + "only-if-cached", + ]) + .optional(), + credentials: z.enum(["omit", "same-origin", "include"]).optional(), + timeoutMs: z.number().optional(), +}); + +const httpRequestOutputSchema = z.object({ + status: z.number(), + statusText: z.string().optional(), + headers: z.record(z.string(), z.string()), + body: z.any().optional(), + bodyType: z + .enum(["none", "json", "text", "formData", "urlEncoded", "base64"]) + .optional(), + url: z.string().optional(), + redirected: z.boolean().optional(), + ok: z.boolean().optional(), +}); + +type HttpRequestArgs = z.infer; + +type DemoItem = { + id: number; + name: string; + source: string; + createdAt: string; +}; + +const items: DemoItem[] = [ + { + id: 1, + name: "alpha", + source: "seed", + createdAt: "2026-01-01T00:00:00.000Z", + }, + { + id: 2, + name: "bravo", + source: "seed", + createdAt: "2026-01-02T00:00:00.000Z", + }, +]; +let nextItemId = 3; + +function getCurrentTime(): string { + return new Date().toISOString(); +} + +function buildHttpResponse( + status: number, + body: unknown, + init: { + statusText?: string; + headers?: Record; + bodyType?: "json" | "text" | "formData" | "urlEncoded" | "base64" | "none"; + } = {}, +): CallToolResult { + const headers = { + "content-type": "application/json", + ...init.headers, + }; + const bodyType = init.bodyType ?? "json"; + const statusText = init.statusText; + return { + content: [{ type: "text", text: JSON.stringify(body) }], + structuredContent: { + status, + statusText, + headers, + body, + bodyType, + ok: status >= 200 && status < 300, + }, + }; +} + +function normalizeRequestUrl(url: string): string { + if (!url) { + return url; + } + if (url.startsWith("http://") || url.startsWith("https://")) { + try { + const parsed = new URL(url); + return `${parsed.pathname}${parsed.search}`; + } catch { + return url; + } + } + return url; +} + +function addItem(name: string, source: string): DemoItem { + const item: DemoItem = { + id: nextItemId++, + name, + source, + createdAt: new Date().toISOString(), + }; + items.push(item); + return item; +} + +function getHeader( + headers: Record | undefined, + name: string, +): string | undefined { + if (!headers) { + return undefined; + } + const target = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === target) { + return value; + } + } + return undefined; +} + +function parseFormDataFields(body: unknown): Record { + if (Array.isArray(body)) { + const record: Record = {}; + for (const entry of body) { + if (!entry || typeof entry !== "object") { + continue; + } + const field = entry as { name?: string; value?: string; data?: string }; + if (!field.name) { + continue; + } + record[field.name] = field.value ?? field.data ?? ""; + } + return record; + } + if (body && typeof body === "object") { + return body as Record; + } + return {}; +} + +function parseRequestBody(args: HttpRequestArgs): Record { + const { body, bodyType } = args; + if (body == null || bodyType === "none" || !bodyType) { + return {}; + } + + if (bodyType === "json") { + if (typeof body === "string") { + try { + return JSON.parse(body) as Record; + } catch { + return {}; + } + } + if (typeof body === "object") { + return body as Record; + } + } + + if (bodyType === "urlEncoded") { + if (typeof body === "string") { + return Object.fromEntries(new URLSearchParams(body)); + } + if (body && typeof body === "object") { + return body as Record; + } + } + + if (bodyType === "formData") { + return parseFormDataFields(body); + } + + if (bodyType === "text") { + return { text: String(body) }; + } + + return { body }; +} + /** * Creates a new MCP server instance with tools and resources registered. */ @@ -36,7 +231,7 @@ export function createServer(): McpServer { _meta: { ui: { resourceUri } }, // Links this tool to its UI resource }, async (): Promise => { - const time = new Date().toISOString(); + const time = getCurrentTime(); return { content: [{ type: "text", text: time }], structuredContent: { time }, @@ -44,6 +239,109 @@ export function createServer(): McpServer { }, ); + server.registerTool( + "http_request", + { + description: "Proxy HTTP requests from the app to backend routes.", + inputSchema: httpRequestInputSchema, + outputSchema: httpRequestOutputSchema, + _meta: { ui: { visibility: ["app"] } }, + }, + async (args: HttpRequestArgs): Promise => { + const method = (args.method ?? "GET").toUpperCase(); + const url = normalizeRequestUrl(args.url); + const pathname = url.split("?")[0]; + const client = getHeader(args.headers, "x-demo-client") ?? "unknown"; + const withClient = (body: Record) => ({ + client, + ...body, + }); + + if (pathname === "/api/time") { + if (method !== "GET" && method !== "HEAD") { + return buildHttpResponse( + 405, + withClient({ error: "Method Not Allowed" }), + { statusText: "Method Not Allowed" }, + ); + } + const time = getCurrentTime(); + return buildHttpResponse(200, withClient({ time }), { statusText: "OK" }); + } + + if (pathname === "/api/items") { + if (method === "GET" || method === "HEAD") { + return buildHttpResponse( + 200, + withClient({ items }), + { statusText: "OK" }, + ); + } + if (method === "POST") { + const payload = parseRequestBody(args); + const name = + (typeof payload.name === "string" && payload.name) || + (typeof payload.item === "string" && payload.item) || + (typeof payload.text === "string" && payload.text) || + undefined; + if (!name) { + return buildHttpResponse( + 400, + withClient({ error: "Missing item name" }), + { statusText: "Bad Request" }, + ); + } + const item = addItem(name, client); + return buildHttpResponse( + 201, + withClient({ item, items }), + { statusText: "Created" }, + ); + } + return buildHttpResponse( + 405, + withClient({ error: "Method Not Allowed" }), + { statusText: "Method Not Allowed" }, + ); + } + + if (pathname === "/api/items/xhr") { + if (method !== "POST") { + return buildHttpResponse( + 405, + withClient({ error: "Method Not Allowed" }), + { statusText: "Method Not Allowed" }, + ); + } + const payload = parseRequestBody(args); + const name = + (typeof payload.name === "string" && payload.name) || + (typeof payload.item === "string" && payload.item) || + (typeof payload.text === "string" && payload.text) || + undefined; + if (!name) { + return buildHttpResponse( + 400, + withClient({ error: "Missing item name" }), + { statusText: "Bad Request" }, + ); + } + const item = addItem(name, client); + return buildHttpResponse( + 201, + withClient({ item, items }), + { statusText: "Created" }, + ); + } + + return buildHttpResponse( + 404, + withClient({ error: "Not Found" }), + { statusText: "Not Found" }, + ); + }, + ); + // Register the resource, which returns the bundled HTML/JavaScript for the UI. registerAppResource(server, resourceUri, diff --git a/examples/basic-server-vanillajs/src/mcp-app.css b/examples/basic-server-vanillajs/src/mcp-app.css index b23bd5e3..50e5523d 100644 --- a/examples/basic-server-vanillajs/src/mcp-app.css +++ b/examples/basic-server-vanillajs/src/mcp-app.css @@ -1,6 +1,13 @@ .main { + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-notice-bg: #eff6ff; + --color-panel-bg: #f8fafc; + --color-border: #e2e8f0; + --color-muted: #64748b; + width: 100%; - max-width: 425px; + max-width: 720px; box-sizing: border-box; > * { @@ -9,71 +16,106 @@ } > * + * { - margin-top: var(--spacing-lg); + margin-top: 1.5rem; } } -.action { - > * { - margin-top: 0; - margin-bottom: 0; - width: 100%; +.hero { + h1 { + margin: 0 0 0.25rem; + font-size: 1.5rem; } - > * + * { - margin-top: var(--spacing-sm); + p { + margin: 0; + color: var(--color-muted); + } +} + +.panel { + padding: 1rem; + border-radius: 10px; + border: 1px solid var(--color-border); + background-color: var(--color-panel-bg); + display: grid; + gap: 0.75rem; + + h2 { + margin: 0; + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.04em; + } +} + +.row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + + > * { + margin: 0; + flex: 1 1 200px; + min-width: 0; } - /* Server time row: flex layout for consistent mask width in E2E tests */ > p { display: flex; align-items: baseline; - gap: var(--spacing-xs); + gap: 0.25em; } /* Consistent font for form inputs (inherits from global.css) */ textarea, input { - display: block; font-family: inherit; font-size: inherit; } button { - padding: var(--spacing-sm) var(--spacing-md); + padding: 0.5rem 1rem; border: none; - border-radius: var(--border-radius-md); - color: var(--color-text-on-accent); - font-weight: var(--font-weight-bold); - background-color: var(--color-accent); + border-radius: 6px; + color: white; + font-weight: bold; + background-color: var(--color-primary); cursor: pointer; + flex: 0 0 auto; - &:hover { - background-color: color-mix(in srgb, var(--color-accent) 85%, var(--color-background-inverse)); - } - + &:hover, &:focus-visible { - outline: calc(var(--border-width-regular) * 2) solid var(--color-ring-primary); - outline-offset: var(--border-width-regular); + background-color: var(--color-primary-hover); } } } +.meta { + margin: 0; + color: var(--color-muted); + font-size: 0.9rem; +} + +.code-block { + margin: 0; + padding: 0.75rem; + border-radius: 8px; + border: 1px dashed var(--color-border); + background: white; + white-space: pre-wrap; + word-break: break-word; + min-height: 4rem; +} + .notice { - padding: var(--spacing-sm) var(--spacing-md); - color: var(--color-text-info); + padding: 0.5rem 0.75rem; + color: var(--color-primary); text-align: center; font-style: italic; - background-color: var(--color-background-info); + background-color: var(--color-notice-bg); &::before { content: "ℹ️ "; font-style: normal; } } - -/* Server time fills remaining width for consistent E2E screenshot masking */ -#server-time { - flex: 1; - min-width: 0; -} diff --git a/examples/basic-server-vanillajs/src/mcp-app.ts b/examples/basic-server-vanillajs/src/mcp-app.ts index c3168c21..9770f20f 100644 --- a/examples/basic-server-vanillajs/src/mcp-app.ts +++ b/examples/basic-server-vanillajs/src/mcp-app.ts @@ -8,20 +8,38 @@ import { applyHostStyleVariables, type McpUiHostContext, } from "@modelcontextprotocol/ext-apps"; +import { initMcpHttp } from "@modelcontextprotocol/ext-apps/http-adapter"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import "./global.css"; import "./mcp-app.css"; - function extractTime(result: CallToolResult): string { const { time } = (result.structuredContent as { time?: string }) ?? {}; return time ?? "[ERROR]"; } +function safeParseJson(text: string): Record { + if (!text) { + return {}; + } + try { + return JSON.parse(text) as Record; + } catch { + return { text }; + } +} const mainEl = document.querySelector(".main") as HTMLElement; const serverTimeEl = document.getElementById("server-time")!; const getTimeBtn = document.getElementById("get-time-btn")!; +const fetchItemInput = document.getElementById("fetch-item-input") as HTMLInputElement; +const fetchAddBtn = document.getElementById("fetch-add-btn")!; +const fetchListBtn = document.getElementById("fetch-list-btn")!; +const xhrItemInput = document.getElementById("xhr-item-input") as HTMLInputElement; +const xhrAddBtn = document.getElementById("xhr-add-btn")!; +const xhrListBtn = document.getElementById("xhr-list-btn")!; +const itemsOutputEl = document.getElementById("items-output")!; +const lastRequestEl = document.getElementById("last-request")!; const messageText = document.getElementById("message-text") as HTMLTextAreaElement; const sendMessageBtn = document.getElementById("send-message-btn")!; const logText = document.getElementById("log-text") as HTMLInputElement; @@ -29,7 +47,6 @@ const sendLogBtn = document.getElementById("send-log-btn")!; const linkUrl = document.getElementById("link-url") as HTMLInputElement; const openLinkBtn = document.getElementById("open-link-btn")!; - function handleHostContextChanged(ctx: McpUiHostContext) { if (ctx.theme) { applyDocumentTheme(ctx.theme); @@ -48,10 +65,82 @@ function handleHostContextChanged(ctx: McpUiHostContext) { } } +function setItemsOutput(items: unknown): void { + itemsOutputEl.textContent = JSON.stringify(items ?? [], null, 2); +} -// 1. Create app instance -const app = new App({ name: "Get Time App", version: "1.0.0" }); +function updateLastRequest( + label: string, + status?: number, + client?: string, +): void { + const parts = [label]; + if (status !== undefined) { + parts.push(String(status)); + } + if (client) { + parts.push(`via ${client}`); + } + lastRequestEl.textContent = parts.join(" | "); +} + +function handleRequestError(label: string, error: unknown): void { + console.error(error); + lastRequestEl.textContent = `${label} | failed`; +} +async function parseResponsePayload( + response: Response, +): Promise> { + const text = await response.text(); + return safeParseJson(text); +} + +async function fetchJson( + url: string, + init?: RequestInit, +): Promise<{ status: number; payload: Record }> { + const response = await fetch(url, init); + const payload = await parseResponsePayload(response); + return { status: response.status, payload }; +} + +async function xhrJson( + method: string, + url: string, + body?: Document | XMLHttpRequestBodyInit | null, + headers: Record = {}, +): Promise<{ status: number; payload: Record }> { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open(method, url, true); + Object.entries(headers).forEach(([name, value]) => { + xhr.setRequestHeader(name, value); + }); + + xhr.onload = () => { + const payload = safeParseJson(xhr.responseText ?? ""); + resolve({ status: xhr.status, payload }); + }; + + xhr.onerror = () => reject(new Error("XHR request failed")); + xhr.ontimeout = () => reject(new Error("XHR request timed out")); + xhr.send(body ?? null); + }); +} + +function readItemName(input: HTMLInputElement, label: string): string | null { + const value = input.value.trim(); + if (!value) { + lastRequestEl.textContent = `${label} | missing item name`; + return null; + } + return value; +} + +// 1. Create app instance +const app = new App({ name: "HTTP Adapter Demo", version: "1.0.0" }); +initMcpHttp(app, { interceptPaths: ["/api/"] }); // 2. Register handlers BEFORE connecting app.onteardown = async () => { @@ -73,22 +162,107 @@ app.ontoolcancelled = (params) => { }; app.onerror = console.error; - app.onhostcontextchanged = handleHostContextChanged; - getTimeBtn.addEventListener("click", async () => { + const label = "fetch GET /api/time"; try { - console.info("Calling get-time tool..."); - const result = await app.callServerTool({ name: "get-time", arguments: {} }); - console.info("get-time result:", result); - serverTimeEl.textContent = extractTime(result); + console.info("Fetching /api/time via MCP wrapper..."); + const { status, payload } = await fetchJson("/api/time", { + headers: { "x-demo-client": "fetch" }, + }); + updateLastRequest(label, status, payload.client as string | undefined); + if (status < 200 || status >= 300) { + throw new Error(payload.error as string | undefined ?? "Request failed"); + } + serverTimeEl.textContent = (payload.time as string | undefined) ?? "[ERROR]"; } catch (e) { - console.error(e); + handleRequestError(label, e); serverTimeEl.textContent = "[ERROR]"; } }); +fetchAddBtn.addEventListener("click", async () => { + const label = "fetch POST /api/items"; + const name = readItemName(fetchItemInput, label); + if (!name) { + return; + } + try { + const { status, payload } = await fetchJson("/api/items", { + method: "POST", + headers: { + "content-type": "application/json", + "x-demo-client": "fetch", + }, + body: JSON.stringify({ name }), + }); + updateLastRequest(label, status, payload.client as string | undefined); + if (status < 200 || status >= 300) { + throw new Error(payload.error as string | undefined ?? "Request failed"); + } + setItemsOutput(payload.items); + fetchItemInput.value = ""; + } catch (e) { + handleRequestError(label, e); + } +}); + +fetchListBtn.addEventListener("click", async () => { + const label = "fetch GET /api/items"; + try { + const { status, payload } = await fetchJson("/api/items", { + headers: { "x-demo-client": "fetch" }, + }); + updateLastRequest(label, status, payload.client as string | undefined); + if (status < 200 || status >= 300) { + throw new Error(payload.error as string | undefined ?? "Request failed"); + } + setItemsOutput(payload.items); + } catch (e) { + handleRequestError(label, e); + } +}); + +xhrAddBtn.addEventListener("click", async () => { + const label = "xhr POST /api/items/xhr"; + const name = readItemName(xhrItemInput, label); + if (!name) { + return; + } + try { + const body = new URLSearchParams({ name }).toString(); + const { status, payload } = await xhrJson("POST", "/api/items/xhr", body, { + "content-type": "application/x-www-form-urlencoded", + "x-demo-client": "xhr", + }); + updateLastRequest(label, status, payload.client as string | undefined); + if (status < 200 || status >= 300) { + throw new Error(payload.error as string | undefined ?? "Request failed"); + } + setItemsOutput(payload.items); + xhrItemInput.value = ""; + } catch (e) { + handleRequestError(label, e); + } +}); + +xhrListBtn.addEventListener("click", async () => { + const label = "xhr GET /api/items"; + try { + const { status, payload } = await xhrJson("GET", "/api/items", null, { + "x-demo-client": "xhr", + }); + updateLastRequest(label, status, payload.client as string | undefined); + if (status < 200 || status >= 300) { + throw new Error(payload.error as string | undefined ?? "Request failed"); + } + setItemsOutput(payload.items); + } catch (e) { + handleRequestError(label, e); + } +}); + sendMessageBtn.addEventListener("click", async () => { const signal = AbortSignal.timeout(5000); try { @@ -114,7 +288,6 @@ openLinkBtn.addEventListener("click", async () => { console.info("Open link request", isError ? "rejected" : "accepted"); }); - // 3. Connect to host app.connect().then(() => { const ctx = app.getHostContext(); diff --git a/examples/hono-react-server/.gitignore b/examples/hono-react-server/.gitignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/examples/hono-react-server/.gitignore @@ -0,0 +1 @@ +dist diff --git a/examples/hono-react-server/README.md b/examples/hono-react-server/README.md new file mode 100644 index 00000000..053825c4 --- /dev/null +++ b/examples/hono-react-server/README.md @@ -0,0 +1,252 @@ +# Example: Hono React Server + +Reference implementation of the HTTP adapter pattern. [Source](.) | [HTTP Adapter docs](../../docs/http-adapter.md) + +## Motivation + +MCP Apps that need server-side data have two options today: expose data through MCP resources, or define tools that the app calls via `callServerTool()`. For UI-specific operations where the model has no visibility (`visibility: ["app"]`), tools become the default pattern. + +### The Per-Endpoint Tool Pattern + +A typical CRUD interface requires one tool definition per operation: + +```typescript +// server.ts — Four tools for a simple items API +server.registerTool( + "get_items", + { + _meta: { ui: { visibility: ["app"] } }, + inputSchema: {}, + }, + async () => { + return { items: db.getItems() }; + }, +); + +server.registerTool( + "create_item", + { + _meta: { ui: { visibility: ["app"] } }, + inputSchema: { name: z.string() }, + }, + async ({ name }) => { + return { item: db.createItem(name) }; + }, +); + +server.registerTool( + "delete_item", + { + _meta: { ui: { visibility: ["app"] } }, + inputSchema: { id: z.number() }, + }, + async ({ id }) => { + db.deleteItem(id); + return { success: true }; + }, +); + +server.registerTool( + "get_item_details", + { + _meta: { ui: { visibility: ["app"] } }, + inputSchema: { id: z.number() }, + }, + async ({ id }) => { + return { item: db.getItem(id) }; + }, +); +``` + +```typescript +// app.tsx — Each operation requires a separate callServerTool +const items = await app.callServerTool("get_items", {}); +await app.callServerTool("create_item", { name: "New Item" }); +await app.callServerTool("delete_item", { id: 123 }); +``` + +This pattern works. The operations execute correctly through the MCP tools/call mechanism. + +### Where It Breaks Down + +1. **Tool proliferation.** A realistic app with 10-20 endpoints requires 10-20 tool definitions. Each needs schema definitions, handler implementations, and client-side call sites. The tool list becomes a parallel API surface that duplicates what HTTP already provides. + +2. **No standard HTTP semantics.** The app cannot use `fetch()`, Axios, or any HTTP client library. Existing code that makes HTTP calls must be rewritten to use `callServerTool()`. Libraries that assume HTTP (authentication flows, file uploads, SDKs) cannot be used directly. + +3. **Development friction.** Testing requires an MCP host. There is no way to run the UI standalone against a development server. Hot reload requires restarting the MCP server. Browser DevTools network inspection does not apply. + +4. **Porting cost.** Converting an existing web application to an MCP App requires rewriting every HTTP call as a tool definition and corresponding `callServerTool()` invocation. + +## Mechanism + +This example demonstrates an alternative: a single `http_request` tool that proxies standard HTTP requests from the app to a backend server. + +```typescript +// server.ts — One tool handles all HTTP operations +registerAppTool( + server, + "http_request", + { + visibility: ["app"], + inputSchema: McpHttpRequestSchema, + }, + createHttpRequestToolHandler({ + baseUrl: BACKEND_URL, + allowPaths: ["/api/"], + }), +); +``` + +The app uses standard `fetch()` calls. The SDK's HTTP adapter intercepts these calls and routes them through the `http_request` tool when running inside an MCP host: + +```typescript +// app.tsx — Standard fetch, or type-safe Hono client +const client = hc(baseUrl); +const res = await client.api.items.$get(); +const { items } = await res.json(); + +await client.api.items.$post({ json: { name: "New Item" } }); +await client.api.items[":id"].$delete({ param: { id: "123" } }); +``` + +When running standalone (outside an MCP host), the same code makes direct HTTP requests to the backend. + +### Toggling Proxying (without reinstalling wrappers) + +```typescript +const proxyEnabledRef = { current: true }; + +initMcpHttp(app, { + interceptPaths: ["/api/"], + allowAbsoluteUrls: true, + interceptEnabled: () => proxyEnabledRef.current, + fallbackToNative: true, +}); +``` + +When proxying is disabled, the app can still reach the backend directly inside +the MCP host. The server exposes the backend URL via tool `_meta.demo.backendUrl` +in host context, and sets CSP `connectDomains` to allow direct HTTP when the +proxy is off. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ React App (mcp-app.tsx) │ +│ │ +│ const client = hc(baseUrl); │ +│ await client.api.items.$get(); │ +│ │ │ +│ ▼ │ +│ initMcpHttp() intercepts fetch() │ +└────────────│────────────────────────────────────────────────────┘ + │ + │ Standalone: direct HTTP to backend + │ MCP host: tools/call http_request + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Hono Backend (hono-backend.ts) │ +│ │ +│ app.get("/api/items", ...) │ +│ app.post("/api/items", ...) │ +│ app.delete("/api/items/:id", ...) │ +│ │ +│ export type AppType = typeof app; │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Running the Example + +Unlike most MCP app examples, this one runs in **two modes with the same code**: + +1. **Standalone mode** — Run it like any web app. Use browser DevTools, hot reload, direct HTTP. +2. **MCP mode** — Run it inside an MCP host. HTTP calls route through the `http_request` tool. + +This is the point: you don't have to choose. Build with normal web tooling, deploy as an MCP app. + +### Standalone Mode + +```bash +npm install +npm run dev +``` + +Starts Vite dev server, Hono backend, and MCP server. Open the URL shown in the console (default: `http://localhost:3000/mcp-app.html`). The UI displays "Direct HTTP" mode. Requests appear in browser DevTools network tab. + +### MCP Mode + +```bash +# From ext-apps root +npm start +``` + +Open the host URL shown in the console, select "hono-react-server". The UI displays "MCP Proxied" mode. Requests route through the `http_request` tool. + +## Rationale + +**Why a generic HTTP tool instead of per-endpoint tools?** + +Per-endpoint tools require O(n) definitions for n endpoints. The HTTP tool requires O(1) definitions regardless of endpoint count. The schema is fixed (method, url, headers, body) rather than per-operation. + +**Why intercept fetch() rather than provide a custom client?** + +Intercepting `fetch()` allows existing HTTP client libraries (Axios, Hono client, ky) to work without modification. Apps can be ported by adding the `initMcpHttp()` call without rewriting HTTP operations. + +**Why does the app need to detect standalone vs MCP mode?** + +The app must connect to the MCP host before HTTP interception is available. In standalone mode, there is no host to connect to. The SDK detects this condition and falls back to native `fetch()`, allowing the same code to run in both contexts. + +**Why is the tool visibility restricted to `["app"]`?** + +The model has no use for raw HTTP request capability. Restricting visibility to `app` prevents the tool from appearing in the model's tool list while keeping it available for UI operations. + +## Files + +| File | Purpose | +| -------------------------------------------- | ------------------------------------------ | +| [`src/hono-backend.ts`](src/hono-backend.ts) | HTTP server with API routes | +| [`src/mcp-app.tsx`](src/mcp-app.tsx) | React app using Hono type-safe client | +| [`server.ts`](server.ts) | MCP server registering `http_request` tool | +| [`main.ts`](main.ts) | Entry point starting both servers | + +## Ports + +| Service | Default | Environment Variable | +| --------------- | ------- | -------------------- | +| Vite Dev Server | 3000 | `DEV_PORT` | +| MCP Server | 3001 | `PORT` or `MCP_PORT` | +| Hono Backend | 3102 | `BACKEND_PORT` | + +When `PORT` is set, the backend defaults to `PORT + 1000`. + +## MCP Client Configuration + +### stdio + +```json +{ + "mcpServers": { + "hono-react": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-hono-react", "--stdio"] + } + } +} +``` + +### Local Development + +```json +{ + "mcpServers": { + "hono-react": { + "command": "bash", + "args": [ + "-c", + "cd ~/ext-apps/examples/hono-react-server && npm run build >&2 && node dist/main.js --stdio" + ] + } + } +} +``` diff --git a/examples/hono-react-server/main.ts b/examples/hono-react-server/main.ts new file mode 100644 index 00000000..513319c5 --- /dev/null +++ b/examples/hono-react-server/main.ts @@ -0,0 +1,91 @@ +/** + * @file Starts the Hono backend and MCP server. + */ +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import { honoApp } from "./src/hono-backend.js"; +import { createServer } from "./server.js"; +import { resolveBackendPort, resolveMcpPort } from "./ports.js"; + +const BACKEND_PORT = resolveBackendPort(); +const MCP_PORT = resolveMcpPort(); + +function startBackend() { + const server = serve({ + fetch: honoApp.fetch, + port: BACKEND_PORT, + }); + + console.log(`Hono backend listening on http://localhost:${BACKEND_PORT}`); + return server; +} + +async function startMcpHttpServer(createServerFn: () => McpServer) { + const mcpApp = new Hono(); + + mcpApp.use( + "*", + cors({ + origin: "*", + allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], + allowHeaders: [ + "Content-Type", + "mcp-session-id", + "Last-Event-ID", + "mcp-protocol-version", + ], + exposeHeaders: ["mcp-session-id", "mcp-protocol-version"], + }), + ); + + const server = createServerFn(); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + await server.connect(transport); + + mcpApp.all("/mcp", (c) => transport.handleRequest(c.req.raw)); + + serve({ + fetch: mcpApp.fetch, + port: MCP_PORT, + }); + + console.log(`MCP server listening on http://localhost:${MCP_PORT}/mcp`); +} + +async function startMcpStdioServer(createServerFn: () => McpServer) { + const server = createServerFn(); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("MCP server running on stdio"); +} + +async function main() { + const useStdio = process.argv.includes("--stdio"); + + if (useStdio) { + await startMcpStdioServer(createServer); + } else { + startBackend(); + await startMcpHttpServer(createServer); + + console.log("\nDual-mode HTTP demo ready:"); + console.log(` Hono backend: http://localhost:${BACKEND_PORT}/api/time`); + console.log(` MCP endpoint: http://localhost:${MCP_PORT}/mcp`); + console.log("\nStandalone development (with Vite hot reload):"); + console.log(" npm run dev"); + console.log(" Open http://localhost:3000/mcp-app.html"); + } +} + +main().catch((error) => { + console.error("Failed to start server:", error); + process.exit(1); +}); diff --git a/examples/hono-react-server/mcp-app.html b/examples/hono-react-server/mcp-app.html new file mode 100644 index 00000000..8374f548 --- /dev/null +++ b/examples/hono-react-server/mcp-app.html @@ -0,0 +1,13 @@ + + + + + + + Hono React Demo + + +
+ + + diff --git a/examples/hono-react-server/package.json b/examples/hono-react-server/package.json new file mode 100644 index 00000000..ee0390b7 --- /dev/null +++ b/examples/hono-react-server/package.json @@ -0,0 +1,55 @@ +{ + "name": "@modelcontextprotocol/server-hono-react", + "version": "1.0.0", + "type": "module", + "description": "MCP App example demonstrating dual-mode HTTP pattern with Hono and React", + "repository": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/ext-apps", + "directory": "examples/hono-react-server" + }, + "license": "MIT", + "main": "dist/server.js", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", + "dev:ui": "vite", + "serve": "bun --watch main.ts", + "start": "cross-env NODE_ENV=development npm run build && npm run serve", + "dev": "cross-env NODE_ENV=development concurrently 'npm run dev:ui' 'npm run serve'", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@hono/node-server": "^1.14.3", + "@modelcontextprotocol/ext-apps": "file:../..", + "@modelcontextprotocol/sdk": "^1.24.0", + "hono": "^4.7.10", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/node": "22.10.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.4.1", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + }, + "types": "dist/server.d.ts", + "exports": { + ".": { + "types": "./dist/server.d.ts", + "default": "./dist/server.js" + } + }, + "bin": { + "mcp-server-hono-react": "dist/index.js" + } +} diff --git a/examples/hono-react-server/ports.ts b/examples/hono-react-server/ports.ts new file mode 100644 index 00000000..961941a1 --- /dev/null +++ b/examples/hono-react-server/ports.ts @@ -0,0 +1,18 @@ +export function resolveMcpPort(): number { + return Number.parseInt( + process.env.PORT ?? process.env.MCP_PORT ?? "3001", + 10, + ); +} + +export function resolveBackendPort(): number { + const portEnv = process.env.PORT; + const mcpPort = resolveMcpPort(); + const backendPort = + process.env.BACKEND_PORT ?? (portEnv ? String(mcpPort + 1000) : "3102"); + return Number.parseInt(backendPort, 10); +} + +export function resolveBackendUrl(): string { + return `http://localhost:${resolveBackendPort()}`; +} diff --git a/examples/hono-react-server/server.ts b/examples/hono-react-server/server.ts new file mode 100644 index 00000000..7f14ac87 --- /dev/null +++ b/examples/hono-react-server/server.ts @@ -0,0 +1,108 @@ +/** + * @file MCP server exposing the Hono demo UI and http_request proxy tool. + */ +import { + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; +import { + createHttpRequestToolHandler, + McpHttpRequestSchema, + McpHttpResponseSchema, +} from "@modelcontextprotocol/ext-apps/fetch-wrapper"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { resolveBackendUrl } from "./ports.js"; + +const DIST_DIR = import.meta.filename.endsWith(".ts") + ? path.join(import.meta.dirname, "dist") + : import.meta.dirname; + +const BACKEND_URL = resolveBackendUrl(); + +/** + * Creates a new MCP server instance with http_request tool and UI resource. + */ +export function createServer(): McpServer { + const server = new McpServer({ + name: "Hono React Server", + version: "1.0.0", + }); + + const resourceUri = "ui://hono-demo/mcp-app.html"; + // CSP intentionally omits connectDomains to demonstrate security boundary. + // Direct HTTP will be blocked by CSP in sandboxed iframes; use MCP proxy instead. + const cspMeta = { + ui: {}, + }; + + registerAppTool( + server, + "hono-demo", + { + title: "Hono Demo", + description: + "Interactive demo showing dual-mode HTTP pattern with Hono backend", + inputSchema: {}, + outputSchema: z.object({ + message: z.string(), + }), + _meta: { ui: { resourceUri }, demo: { backendUrl: BACKEND_URL } }, + }, + async (): Promise => { + return { + content: [{ type: "text", text: "Hono React demo loaded" }], + structuredContent: { message: "Demo UI is ready" }, + }; + }, + ); + + const proxyHandler = createHttpRequestToolHandler({ + baseUrl: BACKEND_URL, + allowOrigins: [BACKEND_URL], + allowPaths: ["/api/"], + }); + + server.registerTool( + "http_request", + { + description: "Proxy HTTP requests from the app to the Hono backend", + inputSchema: McpHttpRequestSchema, + outputSchema: McpHttpResponseSchema, + _meta: { ui: { visibility: ["app"] } }, + }, + async (args) => proxyHandler({ name: "http_request", arguments: args }), + ); + + registerAppResource( + server, + resourceUri, + resourceUri, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + return { + contents: [ + { + uri: resourceUri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: cspMeta, + }, + ], + }; + }, + ); + + return server; +} diff --git a/examples/hono-react-server/src/css.d.ts b/examples/hono-react-server/src/css.d.ts new file mode 100644 index 00000000..96ac9c7e --- /dev/null +++ b/examples/hono-react-server/src/css.d.ts @@ -0,0 +1,4 @@ +declare module "*.module.css" { + const classes: { readonly [key: string]: string }; + export default classes; +} diff --git a/examples/hono-react-server/src/global.css b/examples/hono-react-server/src/global.css new file mode 100644 index 00000000..671026dc --- /dev/null +++ b/examples/hono-react-server/src/global.css @@ -0,0 +1,50 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Oxygen, + Ubuntu, + sans-serif; + font-size: 16px; + line-height: 1.5; + color-scheme: light dark; +} + +body { + margin: 0; + padding: 0; + background: var(--color-background, #fff); + color: var(--color-text, #1a1a1a); +} + +@media (prefers-color-scheme: dark) { + body { + background: var(--color-background, #1a1a1a); + color: var(--color-text, #f5f5f5); + } +} + +button { + cursor: pointer; + font-family: inherit; + font-size: inherit; +} + +input { + font-family: inherit; + font-size: inherit; +} + +code { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; +} diff --git a/examples/hono-react-server/src/hono-backend.ts b/examples/hono-react-server/src/hono-backend.ts new file mode 100644 index 00000000..c664a4ac --- /dev/null +++ b/examples/hono-react-server/src/hono-backend.ts @@ -0,0 +1,65 @@ +/** + * @file Pure Hono HTTP backend with no MCP dependencies. + */ +import { Hono } from "hono"; +import { cors } from "hono/cors"; + +export type Item = { + id: number; + name: string; + createdAt: string; +}; + +const items: Item[] = [ + { id: 1, name: "Alpha", createdAt: "2026-01-01T00:00:00.000Z" }, + { id: 2, name: "Bravo", createdAt: "2026-01-02T00:00:00.000Z" }, +]; +let nextId = 3; + +export const honoApp = new Hono() + .use("*", cors()) + .get("/api/time", (c) => + c.json({ + time: new Date().toISOString(), + source: "hono-backend", + }), + ) + .get("/api/items", (c) => c.json({ items })) + .post("/api/items", async (c) => { + const body = await c.req.json<{ name?: string }>(); + const name = body?.name?.trim(); + + if (!name) { + return c.json({ error: "Missing item name" }, 400); + } + + const item: Item = { + id: nextId++, + name, + createdAt: new Date().toISOString(), + }; + items.push(item); + + return c.json({ item, items }, 201); + }) + .delete("/api/items/:id", (c) => { + const id = parseInt(c.req.param("id"), 10); + const index = items.findIndex((item) => item.id === id); + + if (index === -1) { + return c.json({ error: "Item not found" }, 404); + } + + items.splice(index, 1); + return c.json({ items }); + }) + .get("/api/mode", (c) => { + const headers = Object.fromEntries(c.req.raw.headers.entries()); + return c.json({ + message: "Request received by Hono backend", + userAgent: headers["user-agent"] ?? "unknown", + timestamp: new Date().toISOString(), + }); + }); + +export type AppType = typeof honoApp; diff --git a/examples/hono-react-server/src/mcp-app.module.css b/examples/hono-react-server/src/mcp-app.module.css new file mode 100644 index 00000000..36ff40db --- /dev/null +++ b/examples/hono-react-server/src/mcp-app.module.css @@ -0,0 +1,231 @@ +.main { + max-width: 480px; + margin: 0 auto; + padding: 1.5rem; +} + +.header { + text-align: center; + margin-bottom: 1.5rem; +} + +.header h1 { + margin: 0 0 0.25rem; + font-size: 1.5rem; + font-weight: 600; +} + +.subtitle { + margin: 0; + color: var(--color-text-secondary, #666); + font-size: 0.875rem; +} + +.card { + background: var(--color-surface, #f5f5f5); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; +} + +@media (prefers-color-scheme: dark) { + .card { + background: var(--color-surface, #2a2a2a); + } +} + +.card h2 { + margin: 0 0 0.75rem; + font-size: 1rem; + font-weight: 600; +} + +.modeCard { + composes: card; + text-align: center; + border: 2px solid var(--color-primary, #3b82f6); +} + +.modeLabel { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-secondary, #666); + margin-bottom: 0.25rem; +} + +.modeValue { + font-size: 1.25rem; + font-weight: 700; + color: var(--color-primary, #3b82f6); +} + +.modeDescription { + font-size: 0.75rem; + color: var(--color-text-secondary, #666); + margin-top: 0.5rem; + font-family: ui-monospace, monospace; +} + +.row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.75rem; +} + +.button { + padding: 0.5rem 1rem; + background: var(--color-primary, #3b82f6); + color: #fff; + border: none; + border-radius: 6px; + font-weight: 500; + transition: opacity 0.15s; +} + +.button:hover:not(:disabled) { + opacity: 0.9; +} + +.button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.input { + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border, #ddd); + border-radius: 6px; + background: var(--color-background, #fff); + color: inherit; +} + +@media (prefers-color-scheme: dark) { + .input { + border-color: var(--color-border, #444); + background: var(--color-background, #1a1a1a); + } +} + +.input:focus { + outline: 2px solid var(--color-primary, #3b82f6); + outline-offset: -1px; +} + +.itemList { + list-style: none; + margin: 0 0 0.75rem; + padding: 0; +} + +.item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--color-border, #eee); +} + +@media (prefers-color-scheme: dark) { + .item { + border-color: var(--color-border, #333); + } +} + +.item:last-child { + border-bottom: none; +} + +.itemName { + flex: 1; + font-weight: 500; +} + +.itemDate { + font-size: 0.75rem; + color: var(--color-text-secondary, #666); +} + +.deleteButton { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + background: var(--color-danger, #ef4444); + color: #fff; + border: none; + border-radius: 4px; +} + +.deleteButton:hover:not(:disabled) { + opacity: 0.9; +} + +.deleteButton:disabled { + opacity: 0.5; +} + +.empty { + text-align: center; + color: var(--color-text-secondary, #666); + font-style: italic; + margin: 1rem 0; +} + +/* Toggle switch */ +.toggle { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; + cursor: pointer; + user-select: none; +} + +.toggle input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.toggleSlider { + position: relative; + display: inline-block; + width: 40px; + height: 22px; + background: var(--color-border, #ccc); + border-radius: 11px; + transition: background 0.2s; +} + +.toggleSlider::before { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; +} + +.toggle input:checked + .toggleSlider { + background: var(--color-primary, #3b82f6); +} + +.toggle input:checked + .toggleSlider::before { + transform: translateX(18px); +} + +.toggleLabel { + font-size: 0.75rem; + font-weight: 500; + color: var(--color-text-secondary, #666); +} + +/* Error text */ +.errorText { + color: var(--color-warning, #f59e0b); +} diff --git a/examples/hono-react-server/src/mcp-app.tsx b/examples/hono-react-server/src/mcp-app.tsx new file mode 100644 index 00000000..791b91ee --- /dev/null +++ b/examples/hono-react-server/src/mcp-app.tsx @@ -0,0 +1,337 @@ +/** + * @file React app demonstrating the dual-mode HTTP pattern. + */ +import { StrictMode, useEffect, useMemo, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { + useApp, + useHostStyleVariables, + useDocumentTheme, +} from "@modelcontextprotocol/ext-apps/react"; +import { initMcpHttp } from "@modelcontextprotocol/ext-apps/http-adapter"; +import { hc } from "hono/client"; +import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; + +/** + * Extracts the backend URL from MCP host context metadata. + */ +function getBackendUrl(context?: McpUiHostContext): string | undefined { + if (!context?.toolInfo?.tool?._meta) { + return undefined; + } + const meta = context.toolInfo.tool._meta as { + demo?: { backendUrl?: unknown }; + }; + return typeof meta.demo?.backendUrl === "string" + ? meta.demo.backendUrl + : undefined; +} +import type { AppType, Item } from "./hono-backend.js"; + +import "./global.css"; +import styles from "./mcp-app.module.css"; + +declare const __BACKEND_URL__: string; +const DEFAULT_BACKEND_URL = + typeof __BACKEND_URL__ !== "undefined" + ? __BACKEND_URL__ + : "http://localhost:3102"; + +function getDirectBaseUrl(): string { + return import.meta.env.VITE_API_BASE_URL ?? DEFAULT_BACKEND_URL; +} + +const isInIframe = () => + typeof window !== "undefined" && window.self !== window.top; + +type Mode = "connecting" | "mcp" | "direct"; + +type ModeCopy = { + mode: Mode; + value: string; + description: string; + showToggle: boolean; +}; + +function getModeCopy( + hasApp: boolean, + isMcp: boolean, + proxyEnabled: boolean, + inIframe: boolean, +): ModeCopy { + const ctx = inIframe ? "(iframe)" : "(standalone)"; + + if (!hasApp) { + return { + mode: "connecting", + value: "Connecting...", + description: ctx, + showToggle: false, + }; + } + + if (isMcp) { + return proxyEnabled + ? { + mode: "mcp", + value: "MCP Proxied", + description: `fetch() → MCP http_request tool → Hono backend ${ctx}`, + showToggle: true, + } + : { + mode: "mcp", + value: "Direct HTTP (proxy disabled)", + description: `fetch() → Hono backend (proxy bypassed) ${ctx}`, + showToggle: true, + }; + } + + return { + mode: "direct", + value: "Direct HTTP", + description: `fetch() → Hono backend (no MCP) ${ctx}`, + showToggle: false, + }; +} + +type AppError = { + type: "csp-blocked" | "network" | "unknown"; + message: string; + hint?: string; +}; + +function detectCspError(error: unknown): AppError | null { + // Fetch API throws TypeError for all network-level failures including CSP + if (error instanceof TypeError) { + return { + type: "csp-blocked", + message: "Request blocked (CSP or network error)", + }; + } + return null; +} + +function HonoReactApp() { + const httpHandleRef = useRef | null>(null); + const proxyEnabledRef = useRef(true); + const [proxyEnabled, setProxyEnabled] = useState(true); + const [backendUrl, setBackendUrl] = useState(getDirectBaseUrl()); + const [items, setItems] = useState([]); + const [newItemName, setNewItemName] = useState(""); + const [error, setError] = useState(null); + + const { app } = useApp({ + appInfo: { name: "Hono React Demo", version: "1.0.0" }, + capabilities: {}, + }); + + useHostStyleVariables(app); + useDocumentTheme(); + + useEffect(() => { + if (!app) return; + + const previousHandler = app.onhostcontextchanged; + + const updateBackendUrl = (context?: McpUiHostContext) => { + const nextUrl = getBackendUrl(context ?? app.getHostContext()); + if (nextUrl) { + setBackendUrl(nextUrl); + } + }; + + updateBackendUrl(); + app.onhostcontextchanged = (context) => { + previousHandler?.(context); + updateBackendUrl(context); + }; + + return () => { + app.onhostcontextchanged = previousHandler ?? (() => {}); + }; + }, [app]); + + useEffect(() => { + if (!app || httpHandleRef.current) return; + + httpHandleRef.current = initMcpHttp(app, { + interceptPaths: ["/api/"], + allowAbsoluteUrls: true, + interceptEnabled: () => proxyEnabledRef.current, + fallbackToNative: true, + }); + + return () => { + httpHandleRef.current?.restore(); + httpHandleRef.current = null; + }; + }, [app]); + + useEffect(() => { + proxyEnabledRef.current = proxyEnabled; + }, [proxyEnabled]); + + const hasApp = Boolean(app); + const isMcp = Boolean(app?.getHostCapabilities()?.serverTools); + const isProxying = isMcp && proxyEnabled; + const baseUrl = isProxying ? "/" : backendUrl; + const modeCopy = getModeCopy(hasApp, isMcp, proxyEnabled, isInIframe()); + const client = useMemo(() => hc(baseUrl), [baseUrl]); + + async function fetchItems() { + try { + setError(null); + const res = await client.api.items.$get(); + const data = await res.json(); + setItems(data.items); + } catch (err) { + const cspError = detectCspError(err); + if (cspError) { + setError(cspError); + } else { + setError({ + type: "unknown", + message: err instanceof Error ? err.message : "Request failed", + }); + } + } + } + + async function addItem() { + const trimmedName = newItemName.trim(); + if (!trimmedName) return; + + try { + setError(null); + const res = await client.api.items.$post({ + json: { name: trimmedName }, + }); + const data = await res.json(); + if ("items" in data) { + setItems(data.items); + setNewItemName(""); + } + } catch (err) { + const cspError = detectCspError(err); + if (cspError) { + setError(cspError); + } else { + setError({ + type: "unknown", + message: err instanceof Error ? err.message : "Request failed", + }); + } + } + } + + async function deleteItem(id: number) { + try { + setError(null); + const res = await client.api.items[":id"].$delete({ + param: { id: id.toString() }, + }); + const data = await res.json(); + if ("items" in data) setItems(data.items); + } catch (err) { + const cspError = detectCspError(err); + if (cspError) { + setError(cspError); + } else { + setError({ + type: "unknown", + message: err instanceof Error ? err.message : "Request failed", + }); + } + } + } + + useEffect(() => { + if (app) fetchItems(); + }, [app, baseUrl]); + + return ( +
+
+

Hono React Demo

+

Dual-mode HTTP pattern demonstration

+
+ +
+
Current Mode
+
{modeCopy.value}
+
+ {error ? ( + ⚠️ {error.message} + ) : ( + modeCopy.description + )} +
+ {modeCopy.showToggle && ( + + )} +
+ +
+

Items

+
+ setNewItemName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && addItem()} + placeholder="New item name" + className={styles.input} + /> + +
+ + {items.length === 0 ? ( +

No items yet

+ ) : ( +
    + {items.map((item) => ( +
  • + {item.name} + + {new Date(item.createdAt).toLocaleDateString()} + + +
  • + ))} +
+ )} + + +
+
+ ); +} + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/hono-react-server/src/vite-env.d.ts b/examples/hono-react-server/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/hono-react-server/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/hono-react-server/tsconfig.json b/examples/hono-react-server/tsconfig.json new file mode 100644 index 00000000..b9fb7fac --- /dev/null +++ b/examples/hono-react-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "server.ts"] +} diff --git a/examples/hono-react-server/tsconfig.server.json b/examples/hono-react-server/tsconfig.server.json new file mode 100644 index 00000000..05ddd8ec --- /dev/null +++ b/examples/hono-react-server/tsconfig.server.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["server.ts"] +} diff --git a/examples/hono-react-server/vite.config.ts b/examples/hono-react-server/vite.config.ts new file mode 100644 index 00000000..d49653e1 --- /dev/null +++ b/examples/hono-react-server/vite.config.ts @@ -0,0 +1,48 @@ +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; +import react from "@vitejs/plugin-react"; + +export default defineConfig(({ command }) => { + const input = process.env.INPUT; + if (command === "build" && !input) { + throw new Error("INPUT env var required for build"); + } + + const portEnv = process.env.PORT; + const mcpPort = Number.parseInt( + portEnv ?? process.env.MCP_PORT ?? "3001", + 10, + ); + const backendPort = Number.parseInt( + process.env.BACKEND_PORT ?? (portEnv ? String(mcpPort + 1000) : "3102"), + 10, + ); + const devPort = Number.parseInt(process.env.DEV_PORT ?? "3000", 10); + + return { + plugins: [react(), viteSingleFile()], + build: { + outDir: "dist", + sourcemap: process.env.NODE_ENV === "development", + minify: process.env.NODE_ENV !== "development", + rollupOptions: input ? { input } : undefined, + target: "esnext", + }, + server: { + port: devPort, + strictPort: true, + proxy: { + "/api": { + target: `http://localhost:${backendPort}`, + changeOrigin: true, + }, + }, + }, + define: { + "process.env.NODE_ENV": JSON.stringify( + process.env.NODE_ENV ?? "production", + ), + __BACKEND_URL__: JSON.stringify(`http://localhost:${backendPort}`), + }, + }; +}); diff --git a/package-lock.json b/package-lock.json index 7aa9ef93..1e9da8e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,8 @@ "@types/node": "20.19.27", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", + "@vitest/browser": "4.0.18", + "@vitest/browser-playwright": "4.0.18", "caniuse-lite": "1.0.30001763", "cheerio": "1.1.2", "concurrently": "^9.2.1", @@ -29,6 +31,7 @@ "esbuild": "^0.25.12", "express": "^5.1.0", "husky": "^9.1.7", + "msw": "^2.12.7", "nodemon": "^3.1.0", "playwright": "1.57.0", "playwright-core": "1.57.0", @@ -41,6 +44,7 @@ "typedoc": "^0.28.14", "typedoc-github-theme": "^0.3.1", "typescript": "^5.9.3", + "vitest": "4.0.18", "zod": "^4.1.13" }, "optionalDependencies": { @@ -96,6 +100,7 @@ "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", + "msw": "^2.12.7", "prettier": "^3.6.2", "typescript": "^5.9.3", "vite": "^6.0.0", @@ -113,6 +118,57 @@ "undici-types": "~6.20.0" } }, + "examples/basic-host/node_modules/@vitest/browser": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.4.tgz", + "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/user-event": "^14.6.1", + "@vitest/mocker": "3.2.4", + "@vitest/utils": "3.2.4", + "magic-string": "^0.30.17", + "sirv": "^3.0.1", + "tinyrainbow": "^2.0.0", + "ws": "^8.18.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "3.2.4", + "webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, + "examples/basic-host/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "examples/basic-host/node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", @@ -120,6 +176,79 @@ "dev": true, "license": "MIT" }, + "examples/basic-host/node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "examples/basic-server-preact": { "name": "@modelcontextprotocol/server-basic-preact", "version": "1.0.1", @@ -304,7 +433,7 @@ "version": "1.0.1", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", + "@modelcontextprotocol/ext-apps": "file:../..", "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", @@ -324,6 +453,10 @@ "vite-plugin-singlefile": "^2.3.0" } }, + "examples/basic-server-vanillajs/node_modules/@modelcontextprotocol/ext-apps": { + "resolved": "", + "link": true + }, "examples/basic-server-vanillajs/node_modules/@types/node": { "version": "22.10.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", @@ -557,6 +690,55 @@ "dev": true, "license": "MIT" }, + "examples/hono-react-server": { + "name": "@modelcontextprotocol/server-hono-react", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.14.3", + "@modelcontextprotocol/ext-apps": "file:../..", + "@modelcontextprotocol/sdk": "^1.24.0", + "hono": "^4.7.10", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^4.1.13" + }, + "bin": { + "mcp-server-hono-react": "dist/index.js" + }, + "devDependencies": { + "@types/node": "22.10.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.4.1", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } + }, + "examples/hono-react-server/node_modules/@modelcontextprotocol/ext-apps": { + "resolved": "", + "link": true + }, + "examples/hono-react-server/node_modules/@types/node": { + "version": "22.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", + "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "examples/hono-react-server/node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, "examples/integration-server": { "version": "1.0.0", "dependencies": { @@ -1404,6 +1586,18 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2476,6 +2670,132 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2657,6 +2977,10 @@ "resolved": "examples/debug-server", "link": true }, + "node_modules/@modelcontextprotocol/server-hono-react": { + "resolved": "examples/hono-react-server", + "link": true + }, "node_modules/@modelcontextprotocol/server-map": { "resolved": "examples/map-server", "link": true @@ -2705,13 +3029,31 @@ "resolved": "examples/wiki-explorer-server", "link": true }, - "node_modules/@napi-rs/canvas": { - "version": "0.1.88", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz", - "integrity": "sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==", + "node_modules/@mswjs/interceptors": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", + "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", + "dev": true, "license": "MIT", - "optional": true, - "workspaces": [ + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz", + "integrity": "sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==", + "license": "MIT", + "optional": true, + "workspaces": [ "e2e/*" ], "engines": { @@ -3011,6 +3353,31 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@oven/bun-darwin-aarch64": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.6.tgz", @@ -3170,6 +3537,13 @@ "node": ">=18" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@preact/preset-vite": { "version": "2.10.2", "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.2.tgz", @@ -3647,6 +4021,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", @@ -3696,6 +4077,56 @@ "vite": "^6.0.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", @@ -3703,6 +4134,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3934,6 +4374,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/three": { "version": "0.181.0", "resolved": "https://registry.npmjs.org/@types/three/-/three-0.181.0.tgz", @@ -4012,6 +4459,194 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/browser": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.18.tgz", + "integrity": "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/mocker": "4.0.18", + "@vitest/utils": "4.0.18", + "magic-string": "^0.30.21", + "pixelmatch": "7.1.0", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.0.3", + "ws": "^8.18.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.18" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.18.tgz", + "integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.0.18", + "@vitest/mocker": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/browser/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/browser/node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -4956,6 +5591,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -5469,6 +6114,18 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5485,6 +6142,15 @@ "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -6169,6 +6835,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphql": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", + "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6213,12 +6889,18 @@ "he": "bin/he" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/hono": { "version": "4.11.6", "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.6.tgz", "integrity": "sha512-ofIiiHyl34SV6AuhE3YT2mhO5HRWokce+eUYE82TsP6z0/H3JeJcjVWEMSIAiw2QkjDOEpES/lYsg8eEbsLtdw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -6433,6 +7115,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6818,6 +7507,18 @@ "dev": true, "license": "MIT" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6970,28 +7671,130 @@ "node": "*" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/msw": { + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.7.tgz", + "integrity": "sha512-retd5i3xCZDVWMYjHEVuKTmhqY8lSsxujjVrZiGbbdoxxIBg5S7rCuYy/YQpfrTYIxpd/o0Kyb/3H+1udBMoYg==", + "dev": true, + "hasInstallScript": true, "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.40.0", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.7.0", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, "bin": { - "nanoid": "bin/nanoid.cjs" + "msw": "cli/index.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/msw/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/type-fest": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.3.tgz", + "integrity": "sha512-AXSAQJu79WGc79/3e9/CR77I/KQgeY1AhNvcShIH4PTcGYyC4xv6H4R4AUOwkPS5799KlVDAu8zExeCrkGquiA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, "node_modules/negotiator": { @@ -7116,6 +7919,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -7153,6 +7967,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -7278,6 +8099,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -7334,6 +8168,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -7388,6 +8232,50 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7494,6 +8382,15 @@ "react": "^19.2.3" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -7563,6 +8460,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rettime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", + "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", + "dev": true, + "license": "MIT" + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -7932,6 +8836,21 @@ "node": ">=10" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -8060,6 +8979,13 @@ "dev": true, "license": "MIT" }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -8212,6 +9138,19 @@ "url": "https://www.buymeacoffee.com/systeminfo" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/text-camel-case": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/text-camel-case/-/text-camel-case-1.2.9.tgz", @@ -8529,6 +9468,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.21", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.21.tgz", + "integrity": "sha512-Plu6V8fF/XU6d2k8jPtlQf5F4Xx2hAin4r2C2ca7wR8NK5MbRTo9huLUWRe28f3Uk8bYZfg74tit/dSjc18xnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.21" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.21", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.21.tgz", + "integrity": "sha512-oVOMdHvgjqyzUZH1rOESgJP1uNe2bVrfK0jUHHmiM2rpEiRbf3j4BrsIc6JigJRbHGanQwuZv/R+LTcHsw+bLA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8551,6 +9510,16 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/touch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", @@ -8561,6 +9530,19 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -9301,6 +10283,16 @@ "node": ">= 0.8" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -9551,51 +10543,50 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, @@ -9603,13 +10594,19 @@ "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { "optional": true }, "@vitest/ui": { @@ -9623,6 +10620,137 @@ } } }, + "node_modules/vitest/node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -9636,6 +10764,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/vue": { "version": "3.5.27", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", @@ -9778,6 +10926,28 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -9840,6 +11010,19 @@ "node": ">=12" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index 221d428e..1d5a14bd 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,18 @@ "types": "./dist/src/server/index.d.ts", "default": "./dist/src/server/index.js" }, + "./fetch-wrapper": { + "types": "./dist/src/http-adapter/fetch-wrapper/fetch.d.ts", + "default": "./dist/src/http-adapter/fetch-wrapper/fetch.js" + }, + "./xhr-wrapper": { + "types": "./dist/src/http-adapter/xhr-wrapper/xhr.d.ts", + "default": "./dist/src/http-adapter/xhr-wrapper/xhr.js" + }, + "./http-adapter": { + "types": "./dist/src/http-adapter/init.d.ts", + "default": "./dist/src/http-adapter/init.js" + }, "./schema.json": "./dist/src/generated/schema.json" }, "files": [ @@ -52,6 +64,8 @@ "prepack": "npm run build", "build:all": "npm run examples:build", "test": "bun test src", + "test:browser": "node node_modules/vitest/vitest.mjs run", + "test:fetch-proxy": "node node_modules/vitest/vitest.mjs run tests/browser/fetch-wrapper.browser.test.ts", "test:e2e": "playwright test", "test:e2e:update": "playwright test --update-snapshots", "test:e2e:ui": "playwright test --ui", @@ -77,9 +91,11 @@ "@modelcontextprotocol/sdk": "1.25.2", "@playwright/test": "1.57.0", "@types/bun": "^1.3.2", + "@types/node": "20.19.27", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "@types/node": "20.19.27", + "@vitest/browser": "4.0.18", + "@vitest/browser-playwright": "4.0.18", "caniuse-lite": "1.0.30001763", "cheerio": "1.1.2", "concurrently": "^9.2.1", @@ -89,6 +105,7 @@ "esbuild": "^0.25.12", "express": "^5.1.0", "husky": "^9.1.7", + "msw": "^2.12.7", "nodemon": "^3.1.0", "playwright": "1.57.0", "playwright-core": "1.57.0", @@ -101,6 +118,7 @@ "typedoc": "^0.28.14", "typedoc-github-theme": "^0.3.1", "typescript": "^5.9.3", + "vitest": "4.0.18", "zod": "^4.1.13" }, "peerDependencies": { @@ -142,5 +160,10 @@ "solid-js": "1.9.10", "@hono/node-server": "1.19.7", "@types/node": "20.19.27" + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 00000000..a2e8a537 --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = "2.12.7"; +const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82"; +const IS_MOCKED_RESPONSE = Symbol("isMockedResponse"); +const activeClientIds = new Set(); + +addEventListener("install", function () { + self.skipWaiting(); +}); + +addEventListener("activate", function (event) { + event.waitUntil(self.clients.claim()); +}); + +addEventListener("message", async function (event) { + const clientId = Reflect.get(event.source || {}, "id"); + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: "window", + }); + + switch (event.data) { + case "KEEPALIVE_REQUEST": { + sendToClient(client, { + type: "KEEPALIVE_RESPONSE", + }); + break; + } + + case "INTEGRITY_CHECK_REQUEST": { + sendToClient(client, { + type: "INTEGRITY_CHECK_RESPONSE", + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case "MOCK_ACTIVATE": { + activeClientIds.add(clientId); + + sendToClient(client, { + type: "MOCKING_ENABLED", + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }); + break; + } + + case "CLIENT_CLOSED": { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +addEventListener("fetch", function (event) { + const requestInterceptedAt = Date.now(); + + // Bypass navigation requests. + if (event.request.mode === "navigate") { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === "only-if-cached" && + event.request.mode !== "same-origin" + ) { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); +}); + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event); + const requestCloneForEvents = event.request.clone(); + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents); + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone(); + + sendToClient( + client, + { + type: "RESPONSE", + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ); + } + + return response; +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (activeClientIds.has(event.clientId)) { + return client; + } + + if (client?.frameType === "top-level") { + return client; + } + + const allClients = await self.clients.matchAll({ + type: "window", + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === "visible"; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone(); + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers); + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get("accept"); + if (acceptHeader) { + const values = acceptHeader.split(",").map((value) => value.trim()); + const filteredValues = values.filter( + (value) => value !== "msw/passthrough", + ); + + if (filteredValues.length > 0) { + headers.set("accept", filteredValues.join(", ")); + } else { + headers.delete("accept"); + } + } + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request); + const clientMessage = await sendToClient( + client, + { + type: "REQUEST", + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ); + + switch (clientMessage.type) { + case "MOCK_RESPONSE": { + return respondWithMock(clientMessage.data); + } + + case "PASSTHROUGH": { + return passthrough(); + } + } + + return passthrough(); +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]); + }); +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + }; +} diff --git a/src/generated/schema.json b/src/generated/schema.json index 3a3005a2..cff5ee1b 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -4,6 +4,1156 @@ "title": "MCP Apps Protocol", "description": "JSON Schema for MCP Apps UI protocol messages", "$defs": { + "McpHttpBodyType": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "type": "string", + "const": "none" + }, + { + "type": "string", + "const": "json" + }, + { + "type": "string", + "const": "text" + }, + { + "type": "string", + "const": "formData" + }, + { + "type": "string", + "const": "urlEncoded" + }, + { + "type": "string", + "const": "base64" + } + ] + }, + "McpHttpFormFieldBinary": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Field name." + }, + "data": { + "type": "string", + "description": "Base64-encoded binary data." + }, + "filename": { + "description": "Original filename (for file uploads).", + "type": "string" + }, + "contentType": { + "description": "MIME type of the file.", + "type": "string" + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, + "McpHttpFormField": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Field name." + }, + "value": { + "type": "string", + "description": "Text value for this field." + } + }, + "required": ["name", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Field name." + }, + "data": { + "type": "string", + "description": "Base64-encoded binary data." + }, + "filename": { + "description": "Original filename (for file uploads).", + "type": "string" + }, + "contentType": { + "description": "MIME type of the file.", + "type": "string" + } + }, + "required": ["name", "data"], + "additionalProperties": false + } + ], + "description": "Form field for formData body type.\nEither a text field with `value`, or a binary/file field with `data`." + }, + "McpHttpFormFieldText": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Field name." + }, + "value": { + "type": "string", + "description": "Text value for this field." + } + }, + "required": ["name", "value"], + "additionalProperties": false + }, + "McpHttpMethod": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "type": "string", + "const": "GET" + }, + { + "type": "string", + "const": "POST" + }, + { + "type": "string", + "const": "PUT" + }, + { + "type": "string", + "const": "DELETE" + }, + { + "type": "string", + "const": "PATCH" + }, + { + "type": "string", + "const": "HEAD" + }, + { + "type": "string", + "const": "OPTIONS" + } + ], + "description": "Standard HTTP methods." + }, + "McpHttpRequestBase": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "method": { + "description": "HTTP method. Defaults to \"GET\".", + "type": "string" + }, + "url": { + "type": "string", + "description": "Request URL." + }, + "headers": { + "description": "Request headers as key-value pairs.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "description": "Request headers as key-value pairs." + } + }, + "redirect": { + "description": "Redirect handling.", + "anyOf": [ + { + "type": "string", + "const": "follow" + }, + { + "type": "string", + "const": "error" + }, + { + "type": "string", + "const": "manual" + } + ] + }, + "cache": { + "description": "Cache mode.", + "anyOf": [ + { + "type": "string", + "const": "default" + }, + { + "type": "string", + "const": "no-store" + }, + { + "type": "string", + "const": "reload" + }, + { + "type": "string", + "const": "no-cache" + }, + { + "type": "string", + "const": "force-cache" + }, + { + "type": "string", + "const": "only-if-cached" + } + ] + }, + "credentials": { + "description": "Credentials mode.", + "anyOf": [ + { + "type": "string", + "const": "omit" + }, + { + "type": "string", + "const": "same-origin" + }, + { + "type": "string", + "const": "include" + } + ] + }, + "timeoutMs": { + "description": "Request timeout in milliseconds.", + "type": "number" + } + }, + "required": ["url"], + "additionalProperties": false + }, + "McpHttpRequestBody": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "type": "object", + "properties": { + "bodyType": { + "anyOf": [ + { + "type": "string", + "const": "none" + }, + {} + ] + }, + "body": {} + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "json" + }, + "body": {} + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "text" + }, + "body": { + "type": "string" + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "base64" + }, + "body": { + "type": "string" + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "urlEncoded" + }, + "body": { + "type": "string" + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "formData" + }, + "body": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Field name." + }, + "value": { + "type": "string", + "description": "Text value for this field." + } + }, + "required": ["name", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Field name." + }, + "data": { + "type": "string", + "description": "Base64-encoded binary data." + }, + "filename": { + "description": "Original filename (for file uploads).", + "type": "string" + }, + "contentType": { + "description": "MIME type of the file.", + "type": "string" + } + }, + "required": ["name", "data"], + "additionalProperties": false + } + ], + "description": "Form field for formData body type.\nEither a text field with `value`, or a binary/file field with `data`." + } + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + } + ], + "description": "Discriminated union for type-safe HTTP request body construction.\nUse these types when you want compile-time enforcement of body/bodyType correlation." + }, + "McpHttpRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "method": { + "description": "HTTP method. Defaults to \"GET\".\nStandard HTTP methods or custom strings.", + "type": "string" + }, + "url": { + "type": "string", + "description": "Request URL." + }, + "headers": { + "description": "Request headers as key-value pairs.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "description": "Request headers as key-value pairs." + } + }, + "body": { + "description": "Request body. Type depends on bodyType." + }, + "bodyType": { + "description": "Body encoding type.", + "anyOf": [ + { + "type": "string", + "const": "none" + }, + { + "type": "string", + "const": "json" + }, + { + "type": "string", + "const": "text" + }, + { + "type": "string", + "const": "formData" + }, + { + "type": "string", + "const": "urlEncoded" + }, + { + "type": "string", + "const": "base64" + } + ] + }, + "redirect": { + "description": "Redirect handling. Matches browser RequestInit.redirect semantics.", + "anyOf": [ + { + "type": "string", + "const": "follow" + }, + { + "type": "string", + "const": "error" + }, + { + "type": "string", + "const": "manual" + } + ] + }, + "cache": { + "description": "Cache mode. Matches browser RequestInit.cache semantics.", + "anyOf": [ + { + "type": "string", + "const": "default" + }, + { + "type": "string", + "const": "no-store" + }, + { + "type": "string", + "const": "reload" + }, + { + "type": "string", + "const": "no-cache" + }, + { + "type": "string", + "const": "force-cache" + }, + { + "type": "string", + "const": "only-if-cached" + } + ] + }, + "credentials": { + "description": "Credentials mode. Matches browser RequestInit.credentials semantics.", + "anyOf": [ + { + "type": "string", + "const": "omit" + }, + { + "type": "string", + "const": "same-origin" + }, + { + "type": "string", + "const": "include" + } + ] + }, + "timeoutMs": { + "description": "Request timeout in milliseconds.", + "type": "number" + } + }, + "required": ["url"], + "additionalProperties": {} + }, + "McpHttpRequestStrict": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "type": "object", + "properties": { + "method": { + "description": "HTTP method. Defaults to \"GET\".", + "type": "string" + }, + "url": { + "type": "string", + "description": "Request URL." + }, + "headers": { + "description": "Request headers as key-value pairs.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "description": "Request headers as key-value pairs." + } + }, + "redirect": { + "description": "Redirect handling.", + "anyOf": [ + { + "type": "string", + "const": "follow" + }, + { + "type": "string", + "const": "error" + }, + { + "type": "string", + "const": "manual" + } + ] + }, + "cache": { + "description": "Cache mode.", + "anyOf": [ + { + "type": "string", + "const": "default" + }, + { + "type": "string", + "const": "no-store" + }, + { + "type": "string", + "const": "reload" + }, + { + "type": "string", + "const": "no-cache" + }, + { + "type": "string", + "const": "force-cache" + }, + { + "type": "string", + "const": "only-if-cached" + } + ] + }, + "credentials": { + "description": "Credentials mode.", + "anyOf": [ + { + "type": "string", + "const": "omit" + }, + { + "type": "string", + "const": "same-origin" + }, + { + "type": "string", + "const": "include" + } + ] + }, + "timeoutMs": { + "description": "Request timeout in milliseconds.", + "type": "number" + } + }, + "required": ["url"], + "additionalProperties": false + }, + { + "anyOf": [ + { + "type": "object", + "properties": { + "bodyType": { + "anyOf": [ + { + "type": "string", + "const": "none" + }, + {} + ] + }, + "body": {} + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "json" + }, + "body": {} + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "text" + }, + "body": { + "type": "string" + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "base64" + }, + "body": { + "type": "string" + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "urlEncoded" + }, + "body": { + "type": "string" + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "formData" + }, + "body": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Field name." + }, + "value": { + "type": "string", + "description": "Text value for this field." + } + }, + "required": ["name", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Field name." + }, + "data": { + "type": "string", + "description": "Base64-encoded binary data." + }, + "filename": { + "description": "Original filename (for file uploads).", + "type": "string" + }, + "contentType": { + "description": "MIME type of the file.", + "type": "string" + } + }, + "required": ["name", "data"], + "additionalProperties": false + } + ], + "description": "Form field for formData body type.\nEither a text field with `value`, or a binary/file field with `data`." + } + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + } + ], + "description": "Discriminated union for type-safe HTTP request body construction.\nUse these types when you want compile-time enforcement of body/bodyType correlation." + } + ], + "description": "Type-safe HTTP request with enforced body/bodyType correlation.\nPrefer this over McpHttpRequest when constructing requests programmatically." + }, + "McpHttpResponseBase": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "status": { + "type": "number", + "description": "HTTP status code." + }, + "statusText": { + "description": "HTTP status text.", + "type": "string" + }, + "headers": { + "description": "Response headers as key-value pairs.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "description": "Response headers as key-value pairs." + } + }, + "url": { + "description": "Final URL after redirects.", + "type": "string" + }, + "redirected": { + "description": "True if the request was redirected.", + "type": "boolean" + }, + "ok": { + "description": "True if status is 200-299.", + "type": "boolean" + } + }, + "required": ["status"], + "additionalProperties": false + }, + "McpHttpResponseBody": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "type": "object", + "properties": { + "bodyType": { + "anyOf": [ + { + "type": "string", + "const": "none" + }, + {} + ] + }, + "body": {} + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "json" + }, + "body": {} + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "text" + }, + "body": { + "type": "string" + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "base64" + }, + "body": { + "type": "string" + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "urlEncoded" + }, + "body": { + "type": "string" + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "formData" + }, + "body": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Field name." + }, + "value": { + "type": "string", + "description": "Text value for this field." + } + }, + "required": ["name", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Field name." + }, + "data": { + "type": "string", + "description": "Base64-encoded binary data." + }, + "filename": { + "description": "Original filename (for file uploads).", + "type": "string" + }, + "contentType": { + "description": "MIME type of the file.", + "type": "string" + } + }, + "required": ["name", "data"], + "additionalProperties": false + } + ], + "description": "Form field for formData body type.\nEither a text field with `value`, or a binary/file field with `data`." + } + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + } + ], + "description": "Discriminated union for type-safe HTTP response body handling.\nUse these types when you want compile-time enforcement of body/bodyType correlation." + }, + "McpHttpResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "status": { + "type": "number", + "description": "HTTP status code." + }, + "statusText": { + "description": "HTTP status text (e.g., \"OK\", \"Not Found\").", + "type": "string" + }, + "headers": { + "description": "Response headers as key-value pairs.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "description": "Response headers as key-value pairs." + } + }, + "body": { + "description": "Response body. Type depends on bodyType." + }, + "bodyType": { + "description": "Body encoding type.", + "anyOf": [ + { + "type": "string", + "const": "none" + }, + { + "type": "string", + "const": "json" + }, + { + "type": "string", + "const": "text" + }, + { + "type": "string", + "const": "formData" + }, + { + "type": "string", + "const": "urlEncoded" + }, + { + "type": "string", + "const": "base64" + } + ] + }, + "url": { + "description": "Final URL after redirects.", + "type": "string" + }, + "redirected": { + "description": "True if the request was redirected.", + "type": "boolean" + }, + "ok": { + "description": "True if status is 200-299.", + "type": "boolean" + } + }, + "required": ["status"], + "additionalProperties": {} + }, + "McpHttpResponseStrict": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "number", + "description": "HTTP status code." + }, + "statusText": { + "description": "HTTP status text.", + "type": "string" + }, + "headers": { + "description": "Response headers as key-value pairs.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "description": "Response headers as key-value pairs." + } + }, + "url": { + "description": "Final URL after redirects.", + "type": "string" + }, + "redirected": { + "description": "True if the request was redirected.", + "type": "boolean" + }, + "ok": { + "description": "True if status is 200-299.", + "type": "boolean" + } + }, + "required": ["status"], + "additionalProperties": false + }, + { + "anyOf": [ + { + "type": "object", + "properties": { + "bodyType": { + "anyOf": [ + { + "type": "string", + "const": "none" + }, + {} + ] + }, + "body": {} + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "json" + }, + "body": {} + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "text" + }, + "body": { + "type": "string" + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "base64" + }, + "body": { + "type": "string" + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "urlEncoded" + }, + "body": { + "type": "string" + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "bodyType": { + "type": "string", + "const": "formData" + }, + "body": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Field name." + }, + "value": { + "type": "string", + "description": "Text value for this field." + } + }, + "required": ["name", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Field name." + }, + "data": { + "type": "string", + "description": "Base64-encoded binary data." + }, + "filename": { + "description": "Original filename (for file uploads).", + "type": "string" + }, + "contentType": { + "description": "MIME type of the file.", + "type": "string" + } + }, + "required": ["name", "data"], + "additionalProperties": false + } + ], + "description": "Form field for formData body type.\nEither a text field with `value`, or a binary/file field with `data`." + } + } + }, + "required": ["bodyType", "body"], + "additionalProperties": false + } + ], + "description": "Discriminated union for type-safe HTTP response body handling.\nUse these types when you want compile-time enforcement of body/bodyType correlation." + } + ], + "description": "Type-safe HTTP response with enforced body/bodyType correlation.\nPrefer this over McpHttpResponse when handling responses programmatically." + }, "McpUiAppCapabilities": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/src/generated/schema.test.ts b/src/generated/schema.test.ts index 95ec2f21..538c57ff 100644 --- a/src/generated/schema.test.ts +++ b/src/generated/schema.test.ts @@ -123,6 +123,58 @@ export type McpUiClientCapabilitiesSchemaInferredType = z.infer< typeof generated.McpUiClientCapabilitiesSchema >; +export type McpHttpBodyTypeSchemaInferredType = z.infer< + typeof generated.McpHttpBodyTypeSchema +>; + +export type McpHttpMethodSchemaInferredType = z.infer< + typeof generated.McpHttpMethodSchema +>; + +export type McpHttpFormFieldTextSchemaInferredType = z.infer< + typeof generated.McpHttpFormFieldTextSchema +>; + +export type McpHttpFormFieldBinarySchemaInferredType = z.infer< + typeof generated.McpHttpFormFieldBinarySchema +>; + +export type McpHttpFormFieldSchemaInferredType = z.infer< + typeof generated.McpHttpFormFieldSchema +>; + +export type McpHttpRequestSchemaInferredType = z.infer< + typeof generated.McpHttpRequestSchema +>; + +export type McpHttpResponseSchemaInferredType = z.infer< + typeof generated.McpHttpResponseSchema +>; + +export type McpHttpRequestBodySchemaInferredType = z.infer< + typeof generated.McpHttpRequestBodySchema +>; + +export type McpHttpResponseBodySchemaInferredType = z.infer< + typeof generated.McpHttpResponseBodySchema +>; + +export type McpHttpRequestBaseSchemaInferredType = z.infer< + typeof generated.McpHttpRequestBaseSchema +>; + +export type McpHttpResponseBaseSchemaInferredType = z.infer< + typeof generated.McpHttpResponseBaseSchema +>; + +export type McpHttpRequestStrictSchemaInferredType = z.infer< + typeof generated.McpHttpRequestStrictSchema +>; + +export type McpHttpResponseStrictSchemaInferredType = z.infer< + typeof generated.McpHttpResponseStrictSchema +>; + export type McpUiMessageRequestSchemaInferredType = z.infer< typeof generated.McpUiMessageRequestSchema >; @@ -287,6 +339,56 @@ expectType( expectType( {} as spec.McpUiClientCapabilities, ); +expectType({} as McpHttpBodyTypeSchemaInferredType); +expectType({} as spec.McpHttpBodyType); +expectType({} as McpHttpMethodSchemaInferredType); +expectType({} as spec.McpHttpMethod); +expectType( + {} as McpHttpFormFieldTextSchemaInferredType, +); +expectType( + {} as spec.McpHttpFormFieldText, +); +expectType( + {} as McpHttpFormFieldBinarySchemaInferredType, +); +expectType( + {} as spec.McpHttpFormFieldBinary, +); +expectType({} as McpHttpFormFieldSchemaInferredType); +expectType({} as spec.McpHttpFormField); +expectType({} as McpHttpRequestSchemaInferredType); +expectType({} as spec.McpHttpRequest); +expectType({} as McpHttpResponseSchemaInferredType); +expectType({} as spec.McpHttpResponse); +expectType({} as McpHttpRequestBodySchemaInferredType); +expectType({} as spec.McpHttpRequestBody); +expectType( + {} as McpHttpResponseBodySchemaInferredType, +); +expectType( + {} as spec.McpHttpResponseBody, +); +expectType({} as McpHttpRequestBaseSchemaInferredType); +expectType({} as spec.McpHttpRequestBase); +expectType( + {} as McpHttpResponseBaseSchemaInferredType, +); +expectType( + {} as spec.McpHttpResponseBase, +); +expectType( + {} as McpHttpRequestStrictSchemaInferredType, +); +expectType( + {} as spec.McpHttpRequestStrict, +); +expectType( + {} as McpHttpResponseStrictSchemaInferredType, +); +expectType( + {} as spec.McpHttpResponseStrict, +); expectType( {} as McpUiMessageRequestSchemaInferredType, ); diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 3439e704..5cde1e46 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -700,6 +700,346 @@ export const McpUiClientCapabilitiesSchema = z.object({ ), }); +/** + * @description Body encoding type for http_request tool. + * + * - `"none"` - No body (explicit empty body) + * - `"json"` - JSON-serializable value, sent as `application/json` + * - `"text"` - Plain text string + * - `"formData"` - Array of {@link McpHttpFormField} entries + * - `"urlEncoded"` - URL-encoded string (key=value&key2=value2) + * - `"base64"` - Binary data as base64-encoded string + */ +export const McpHttpBodyTypeSchema = z.union([ + z.literal("none"), + z.literal("json"), + z.literal("text"), + z.literal("formData"), + z.literal("urlEncoded"), + z.literal("base64"), +]); + +/** + * @description Standard HTTP methods. + */ +export const McpHttpMethodSchema = z + .union([ + z.literal("GET"), + z.literal("POST"), + z.literal("PUT"), + z.literal("DELETE"), + z.literal("PATCH"), + z.literal("HEAD"), + z.literal("OPTIONS"), + ]) + .describe("Standard HTTP methods."); + +/** + * @description Text form field for multipart/form-data requests. + */ +export const McpHttpFormFieldTextSchema = z.object({ + /** @description Field name. */ + name: z.string().describe("Field name."), + /** @description Text value for this field. */ + value: z.string().describe("Text value for this field."), +}); + +/** + * @description Binary/file form field for multipart/form-data requests. + */ +export const McpHttpFormFieldBinarySchema = z.object({ + /** @description Field name. */ + name: z.string().describe("Field name."), + /** @description Base64-encoded binary data. */ + data: z.string().describe("Base64-encoded binary data."), + /** @description Original filename (for file uploads). */ + filename: z + .string() + .optional() + .describe("Original filename (for file uploads)."), + /** @description MIME type of the file. */ + contentType: z.string().optional().describe("MIME type of the file."), +}); + +/** + * @description Form field for formData body type. + * Either a text field with `value`, or a binary/file field with `data`. + */ +export const McpHttpFormFieldSchema = z + .union([McpHttpFormFieldTextSchema, McpHttpFormFieldBinarySchema]) + .describe( + "Form field for formData body type.\nEither a text field with `value`, or a binary/file field with `data`.", + ); + +/** + * @description HTTP request payload for http_request tool. + * Uses browser-compatible types where possible. + */ +export const McpHttpRequestSchema = z + .object({ + /** + * @description HTTP method. Defaults to "GET". + * Standard HTTP methods or custom strings. + */ + method: z + .string() + .optional() + .describe( + 'HTTP method. Defaults to "GET".\nStandard HTTP methods or custom strings.', + ), + /** @description Request URL. */ + url: z.string().describe("Request URL."), + /** @description Request headers as key-value pairs. */ + headers: z + .record( + z.string(), + z.string().describe("Request headers as key-value pairs."), + ) + .optional() + .describe("Request headers as key-value pairs."), + /** @description Request body. Type depends on bodyType. */ + body: z + .unknown() + .optional() + .describe("Request body. Type depends on bodyType."), + /** @description Body encoding type. */ + bodyType: McpHttpBodyTypeSchema.optional().describe("Body encoding type."), + /** @description Redirect handling. Matches browser RequestInit.redirect semantics. */ + redirect: z + .union([z.literal("follow"), z.literal("error"), z.literal("manual")]) + .optional() + .describe( + "Redirect handling. Matches browser RequestInit.redirect semantics.", + ), + /** @description Cache mode. Matches browser RequestInit.cache semantics. */ + cache: z + .union([ + z.literal("default"), + z.literal("no-store"), + z.literal("reload"), + z.literal("no-cache"), + z.literal("force-cache"), + z.literal("only-if-cached"), + ]) + .optional() + .describe("Cache mode. Matches browser RequestInit.cache semantics."), + /** @description Credentials mode. Matches browser RequestInit.credentials semantics. */ + credentials: z + .union([ + z.literal("omit"), + z.literal("same-origin"), + z.literal("include"), + ]) + .optional() + .describe( + "Credentials mode. Matches browser RequestInit.credentials semantics.", + ), + /** @description Request timeout in milliseconds. */ + timeoutMs: z + .number() + .optional() + .describe("Request timeout in milliseconds."), + }) + .passthrough(); + +/** + * @description HTTP response payload from http_request tool. + */ +export const McpHttpResponseSchema = z + .object({ + /** @description HTTP status code. */ + status: z.number().describe("HTTP status code."), + /** @description HTTP status text (e.g., "OK", "Not Found"). */ + statusText: z + .string() + .optional() + .describe('HTTP status text (e.g., "OK", "Not Found").'), + /** @description Response headers as key-value pairs. */ + headers: z + .record( + z.string(), + z.string().describe("Response headers as key-value pairs."), + ) + .optional() + .describe("Response headers as key-value pairs."), + /** @description Response body. Type depends on bodyType. */ + body: z + .unknown() + .optional() + .describe("Response body. Type depends on bodyType."), + /** @description Body encoding type. */ + bodyType: McpHttpBodyTypeSchema.optional().describe("Body encoding type."), + /** @description Final URL after redirects. */ + url: z.string().optional().describe("Final URL after redirects."), + /** @description True if the request was redirected. */ + redirected: z + .boolean() + .optional() + .describe("True if the request was redirected."), + /** @description True if status is 200-299. */ + ok: z.boolean().optional().describe("True if status is 200-299."), + }) + .passthrough(); + +/** + * @description Discriminated union for type-safe HTTP request body construction. + * Use these types when you want compile-time enforcement of body/bodyType correlation. + */ +export const McpHttpRequestBodySchema = z + .union([ + z.object({ + bodyType: z.union([z.literal("none"), z.undefined()]).optional(), + body: z.undefined().optional(), + }), + z.object({ + bodyType: z.literal("json"), + body: z.unknown(), + }), + z.object({ + bodyType: z.literal("text"), + body: z.string(), + }), + z.object({ + bodyType: z.literal("base64"), + body: z.string(), + }), + z.object({ + bodyType: z.literal("urlEncoded"), + body: z.string(), + }), + z.object({ + bodyType: z.literal("formData"), + body: z.array(McpHttpFormFieldSchema), + }), + ]) + .describe( + "Discriminated union for type-safe HTTP request body construction.\nUse these types when you want compile-time enforcement of body/bodyType correlation.", + ); + +/** + * @description Discriminated union for type-safe HTTP response body handling. + * Use these types when you want compile-time enforcement of body/bodyType correlation. + */ +export const McpHttpResponseBodySchema = z + .union([ + z.object({ + bodyType: z.union([z.literal("none"), z.undefined()]).optional(), + body: z.undefined().optional(), + }), + z.object({ + bodyType: z.literal("json"), + body: z.unknown(), + }), + z.object({ + bodyType: z.literal("text"), + body: z.string(), + }), + z.object({ + bodyType: z.literal("base64"), + body: z.string(), + }), + z.object({ + bodyType: z.literal("urlEncoded"), + body: z.string(), + }), + z.object({ + bodyType: z.literal("formData"), + body: z.array(McpHttpFormFieldSchema), + }), + ]) + .describe( + "Discriminated union for type-safe HTTP response body handling.\nUse these types when you want compile-time enforcement of body/bodyType correlation.", + ); + +/** + * @description Base request fields without body (for use with discriminated union). + */ +export const McpHttpRequestBaseSchema = z.object({ + /** @description HTTP method. Defaults to "GET". */ + method: z.string().optional().describe('HTTP method. Defaults to "GET".'), + /** @description Request URL. */ + url: z.string().describe("Request URL."), + /** @description Request headers as key-value pairs. */ + headers: z + .record( + z.string(), + z.string().describe("Request headers as key-value pairs."), + ) + .optional() + .describe("Request headers as key-value pairs."), + /** @description Redirect handling. */ + redirect: z + .union([z.literal("follow"), z.literal("error"), z.literal("manual")]) + .optional() + .describe("Redirect handling."), + /** @description Cache mode. */ + cache: z + .union([ + z.literal("default"), + z.literal("no-store"), + z.literal("reload"), + z.literal("no-cache"), + z.literal("force-cache"), + z.literal("only-if-cached"), + ]) + .optional() + .describe("Cache mode."), + /** @description Credentials mode. */ + credentials: z + .union([z.literal("omit"), z.literal("same-origin"), z.literal("include")]) + .optional() + .describe("Credentials mode."), + /** @description Request timeout in milliseconds. */ + timeoutMs: z.number().optional().describe("Request timeout in milliseconds."), +}); + +/** + * @description Base response fields without body (for use with discriminated union). + */ +export const McpHttpResponseBaseSchema = z.object({ + /** @description HTTP status code. */ + status: z.number().describe("HTTP status code."), + /** @description HTTP status text. */ + statusText: z.string().optional().describe("HTTP status text."), + /** @description Response headers as key-value pairs. */ + headers: z + .record( + z.string(), + z.string().describe("Response headers as key-value pairs."), + ) + .optional() + .describe("Response headers as key-value pairs."), + /** @description Final URL after redirects. */ + url: z.string().optional().describe("Final URL after redirects."), + /** @description True if the request was redirected. */ + redirected: z + .boolean() + .optional() + .describe("True if the request was redirected."), + /** @description True if status is 200-299. */ + ok: z.boolean().optional().describe("True if status is 200-299."), +}); + +/** + * @description Type-safe HTTP request with enforced body/bodyType correlation. + * Prefer this over McpHttpRequest when constructing requests programmatically. + */ +export const McpHttpRequestStrictSchema = McpHttpRequestBaseSchema.and( + McpHttpRequestBodySchema, +).describe( + "Type-safe HTTP request with enforced body/bodyType correlation.\nPrefer this over McpHttpRequest when constructing requests programmatically.", +); + +/** + * @description Type-safe HTTP response with enforced body/bodyType correlation. + * Prefer this over McpHttpResponse when handling responses programmatically. + */ +export const McpHttpResponseStrictSchema = McpHttpResponseBaseSchema.and( + McpHttpResponseBodySchema, +).describe( + "Type-safe HTTP response with enforced body/bodyType correlation.\nPrefer this over McpHttpResponse when handling responses programmatically.", +); + /** * @description Request to send a message to the host's chat interface. * @see {@link app!App.sendMessage `App.sendMessage`} for the method that sends this request diff --git a/src/http-adapter/fetch-wrapper/fetch-options.ts b/src/http-adapter/fetch-wrapper/fetch-options.ts new file mode 100644 index 00000000..35bff3a3 --- /dev/null +++ b/src/http-adapter/fetch-wrapper/fetch-options.ts @@ -0,0 +1,68 @@ +/** + * Fetch wrapper options. + */ +import type { + FetchFunction, + McpHttpBaseOptions, + McpHttpHandleBase, + McpHttpProxyOptions, +} from "../http-options.js"; + +/** + * Options for initializing the MCP fetch wrapper. + */ +export interface McpFetchOptions extends McpHttpBaseOptions { + /** + * Custom function to determine if a request should be intercepted. + * Takes precedence over `interceptPaths` if provided. + * + * Receives rich objects (URL and Request) for full access to request details. + * + * @param url - Parsed URL object + * @param request - Full Request object with headers, method, body, etc. + * @returns `true` to intercept and proxy through MCP, `false` for native fetch + * + * @example + * ```ts source="./fetch.examples.ts#McpFetchOptions_shouldIntercept_basic" + * const shouldIntercept = (url: URL, request: Request) => { + * // Only intercept POST requests to /api + * return request.method === "POST" && url.pathname.startsWith("/api"); + * }; + * ``` + */ + shouldIntercept?: (url: URL, request: Request) => boolean; + /** Custom fetch function to use as the native fallback */ + fetch?: FetchFunction; +} + +/** + * Handle returned from initMcpFetch for controlling the fetch wrapper lifecycle. + * + * Extends {@link McpHttpHandleBase} with the wrapped fetch function. + * + * @example + * ```ts source="./fetch.examples.ts#McpFetchHandle_lifecycle_basic" + * const handle = initMcpFetch(app); + * + * // Temporarily disable interception + * handle.stop(); + * await fetch("/api/direct"); // Uses native fetch + * handle.start(); + * + * // Permanent cleanup (e.g., on unmount) + * handle.restore(); // Cannot restart after this + * ``` + */ +export interface McpFetchHandle extends McpHttpHandleBase { + /** + * The wrapped fetch function. + * Use this directly instead of global when `installGlobal: false` is set, + * or for explicit control in testing scenarios. + */ + fetch: FetchFunction; +} + +/** + * Options for the fetch proxy handler (server-side). + */ +export type McpFetchProxyOptions = McpHttpProxyOptions; diff --git a/src/http-adapter/fetch-wrapper/fetch.examples.ts b/src/http-adapter/fetch-wrapper/fetch.examples.ts new file mode 100644 index 00000000..183ffb01 --- /dev/null +++ b/src/http-adapter/fetch-wrapper/fetch.examples.ts @@ -0,0 +1,92 @@ +/** + * Type-checked examples for the fetch wrapper. + * + * @module + */ +import type { + CallToolRequest, + CallToolResult, +} from "@modelcontextprotocol/sdk/types.js"; +import { App } from "../../app.js"; +import { + createHttpRequestToolHandler, + initMcpFetch, + wrapCallToolHandlerWithFetchProxy, +} from "./fetch.js"; + +async function initMcpFetch_basicUsage() { + //#region initMcpFetch_basicUsage + const app = new App({ name: "MyApp", version: "1.0.0" }, {}); + await app.connect(); + + // Initialize fetch wrapper (installs globally by default) + const handle = initMcpFetch(app, { interceptPaths: ["/api/"] }); + + // Now fetch calls to /api/* are proxied through MCP + const response = await fetch("/api/users"); + console.log(await response.json()); + + // Restore original fetch when done + handle.restore(); + //#endregion initMcpFetch_basicUsage +} + +function McpFetchOptions_shouldIntercept_basic() { + //#region McpFetchOptions_shouldIntercept_basic + const shouldIntercept = (url: URL, request: Request) => { + // Only intercept POST requests to /api + return request.method === "POST" && url.pathname.startsWith("/api"); + }; + //#endregion McpFetchOptions_shouldIntercept_basic + return shouldIntercept; +} + +async function McpFetchHandle_lifecycle_basic(app: App) { + //#region McpFetchHandle_lifecycle_basic + const handle = initMcpFetch(app); + + // Temporarily disable interception + handle.stop(); + await fetch("/api/direct"); // Uses native fetch + handle.start(); + + // Permanent cleanup (e.g., on unmount) + handle.restore(); // Cannot restart after this + //#endregion McpFetchHandle_lifecycle_basic +} + +async function createHttpRequestToolHandler_basicUsage() { + //#region createHttpRequestToolHandler_basicUsage + const handler = createHttpRequestToolHandler({ + baseUrl: "https://api.example.com", + allowOrigins: ["https://api.example.com"], + allowPaths: ["/api/"], + }); + + const result = await handler({ + name: "http_request", + arguments: { method: "GET", url: "/api/time" }, + }); + + if (!result.isError) { + console.log(result.structuredContent); + } + //#endregion createHttpRequestToolHandler_basicUsage +} + +async function wrapCallToolHandlerWithFetchProxy_basicUsage( + baseHandler: ( + params: CallToolRequest["params"], + extra: { signal?: AbortSignal }, + ) => Promise, +) { + //#region wrapCallToolHandlerWithFetchProxy_basicUsage + const wrapped = wrapCallToolHandlerWithFetchProxy(baseHandler, { + baseUrl: "https://api.example.com", + allowOrigins: ["https://api.example.com"], + allowPaths: ["/api/"], + }); + + await wrapped({ name: "http_request", arguments: { url: "/api/time" } }, {}); + //#endregion wrapCallToolHandlerWithFetchProxy_basicUsage +} diff --git a/src/http-adapter/fetch-wrapper/fetch.ts b/src/http-adapter/fetch-wrapper/fetch.ts new file mode 100644 index 00000000..f4ac457e --- /dev/null +++ b/src/http-adapter/fetch-wrapper/fetch.ts @@ -0,0 +1,881 @@ +/** + * Fetch wrapper for MCP Apps. + * + * Converts fetch() calls into MCP server tool calls (default: "http_request") + * when running inside a host. + */ +import type { App } from "../../app.js"; +import type { + CallToolRequest, + CallToolResult, +} from "@modelcontextprotocol/sdk/types.js"; +import type { + McpFetchHandle, + McpFetchOptions, + McpFetchProxyOptions, +} from "./fetch-options.js"; +import { + DEFAULT_INTERCEPT_PATHS, + DEFAULT_MAX_BODY_SIZE, + FORBIDDEN_REQUEST_HEADERS, + type FetchFunction, +} from "../http-options.js"; +import { + HTTP_REQUEST_TOOL_NAME, + type McpHttpBodyType, + type McpHttpFormField, + type McpHttpRequest, + type McpHttpResponse, +} from "../../types.js"; + +// Re-export schemas for server-side tool registration +export { + McpHttpRequestSchema, + McpHttpResponseSchema, +} from "../../generated/schema.js"; + +import { + buildMcpHttpRequestPayloadFromRequest, + defaultShouldIntercept, + getBaseOrigin, + getBaseUrl, + getRequestUrlInfo, + headersToRecord, + normalizePath, + warnNativeFallback, +} from "../shared/request.js"; +import { + extractHttpResponse, + extractToolError, + fromBase64, + serializeBodyFromRequest, + serializeBodyInit, + toBase64, +} from "../shared/body.js"; +import { safeJsonParse } from "../shared/json.js"; + +/** + * Initialize the MCP fetch wrapper for transparent HTTP-to-MCP proxying. + * + * When running inside an MCP host, this wrapper intercepts fetch calls to + * specified paths and routes them through the MCP server tool (default: "http_request"). + * When not connected to an MCP host, it falls back to native fetch (configurable). + * + * @param app - The connected App instance used to call server tools + * @param options - Configuration options for the fetch wrapper + * @returns A handle containing the wrapped fetch function and restore method + * + * @throws {Error} If fetch is not available (neither global fetch nor `options.fetch` provided) + * + * @example + * ```ts source="./fetch.examples.ts#initMcpFetch_basicUsage" + * const app = new App({ name: "MyApp", version: "1.0.0" }, {}); + * await app.connect(); + * + * // Initialize fetch wrapper (installs globally by default) + * const handle = initMcpFetch(app, { interceptPaths: ["/api/"] }); + * + * // Now fetch calls to /api/* are proxied through MCP + * const response = await fetch("/api/users"); + * console.log(await response.json()); + * + * // Restore original fetch when done + * handle.restore(); + * ``` + */ +export function initMcpFetch( + app: App, + options: McpFetchOptions = {}, +): McpFetchHandle { + const nativeFetch: FetchFunction = options.fetch ?? globalThis.fetch; + if (!nativeFetch) { + throw new Error("global fetch is not available in this environment"); + } + + let active = true; + let restored = false; + + const mcpFetch = createMcpFetch(app, nativeFetch, options, () => active); + if (options.installGlobal ?? true) { + (globalThis as { fetch: FetchFunction }).fetch = mcpFetch; + } + + return { + fetch: mcpFetch, + stop: () => { + if (restored) { + return; + } + active = false; + }, + start: () => { + if (restored) { + return; + } + active = true; + }, + isActive: () => active && !restored, + restore: () => { + if (restored) { + return; + } + restored = true; + active = false; + if (globalThis.fetch === mcpFetch) { + (globalThis as { fetch: FetchFunction }).fetch = nativeFetch; + } + }, + }; +} + +/** + * Create a tool handler for the http_request transport. + * + * @param options - Proxy configuration for http_request tool calls + * @returns A handler suitable for server.registerTool or tools/call routing + * + * @example + * ```ts source="./fetch.examples.ts#createHttpRequestToolHandler_basicUsage" + * const handler = createHttpRequestToolHandler({ + * baseUrl: "https://api.example.com", + * allowOrigins: ["https://api.example.com"], + * allowPaths: ["/api/"], + * }); + * + * const result = await handler({ + * name: "http_request", + * arguments: { method: "GET", url: "/api/time" }, + * }); + * + * if (!result.isError) { + * console.log(result.structuredContent); + * } + * ``` + */ +export function createHttpRequestToolHandler( + options: McpFetchProxyOptions = {}, +): ( + params: CallToolRequest["params"], + extra?: { signal?: AbortSignal }, +) => Promise { + const toolName = options.toolName ?? HTTP_REQUEST_TOOL_NAME; + return async (params, extra) => { + if (params.name !== toolName) { + throw new Error(`Unsupported tool: ${params.name}`); + } + const args = (params.arguments ?? {}) as McpHttpRequest; + const response = await handleProxyRequest(args, options, extra?.signal); + return { + content: [{ type: "text", text: JSON.stringify(response) }], + structuredContent: response, + }; + }; +} + +/** + * Wrap an existing call tool handler with http_request proxy support. + * + * @param handler - Existing tools/call handler to delegate non-http_request calls + * @param options - Proxy configuration for http_request tool calls + * @returns A handler that routes http_request calls through the proxy + * + * @example + * ```ts source="./fetch.examples.ts#wrapCallToolHandlerWithFetchProxy_basicUsage" + * const wrapped = wrapCallToolHandlerWithFetchProxy(baseHandler, { + * baseUrl: "https://api.example.com", + * allowOrigins: ["https://api.example.com"], + * allowPaths: ["/api/"], + * }); + * + * await wrapped({ name: "http_request", arguments: { url: "/api/time" } }, {}); + * ``` + */ +export function wrapCallToolHandlerWithFetchProxy( + handler: ( + params: CallToolRequest["params"], + extra: { signal?: AbortSignal }, + ) => Promise, + options: McpFetchProxyOptions = {}, +): ( + params: CallToolRequest["params"], + extra: { signal?: AbortSignal }, +) => Promise { + const toolName = options.toolName ?? HTTP_REQUEST_TOOL_NAME; + const proxyHandler = createHttpRequestToolHandler(options); + return async (params, extra) => { + if (params.name === toolName) { + return proxyHandler(params, extra); + } + return handler(params, extra); + }; +} + +function createMcpFetch( + app: App, + nativeFetch: FetchFunction, + options: McpFetchOptions, + isActive: () => boolean, +): FetchFunction { + const toolName = options.toolName ?? HTTP_REQUEST_TOOL_NAME; + const interceptPaths = options.interceptPaths ?? DEFAULT_INTERCEPT_PATHS; + const allowAbsoluteUrls = options.allowAbsoluteUrls ?? false; + const interceptEnabled = options.interceptEnabled ?? (() => true); + const fallbackToNative = options.fallbackToNative ?? true; + const isMcpApp = + options.isMcpApp ?? (() => Boolean(app.getHostCapabilities()?.serverTools)); + + return async (input: RequestInfo | URL, init?: RequestInit) => { + if (!isActive()) { + return nativeFetch(input, init); + } + + const request = new Request(input, init); + const requestSignal = + init?.signal ?? (input instanceof Request ? input.signal : undefined); + + if (requestSignal?.aborted) { + throw createAbortError(); + } + + if (!interceptEnabled()) { + if (options.debug) { + console.debug( + "[MCP HTTP] interceptEnabled() returned false, using native fetch for:", + request.url, + ); + } + return nativeFetch(input, init); + } + const { resolvedUrl, isSameOrigin, path, toolUrl } = getRequestUrlInfo( + request.url, + allowAbsoluteUrls, + ); + + const shouldIntercept = options.shouldIntercept + ? options.shouldIntercept(resolvedUrl, request) + : defaultShouldIntercept({ + isMcpApp: isMcpApp(), + allowAbsoluteUrls, + isSameOrigin, + path, + interceptPaths, + }); + + if (!shouldIntercept) { + return nativeFetch(input, init); + } + + if (!isMcpApp()) { + if (fallbackToNative) { + warnNativeFallback("fetch", request.url); + return nativeFetch(input, init); + } + throw new Error( + "MCP host connection is not available for fetch proxying", + ); + } + + const { body, bodyType } = await serializeRequestBody( + request, + init?.body, + options.debug, + ); + const payload = buildMcpHttpRequestPayloadFromRequest({ + request, + toolUrl, + body, + bodyType, + timeoutMs: options.timeoutMs, + }); + + const callOptions = requestSignal ? { signal: requestSignal } : undefined; + const result = await app.callServerTool( + { name: toolName, arguments: payload }, + callOptions, + ); + + if (result.isError) { + throw new Error(extractToolError(result)); + } + + const responsePayload = extractHttpResponse(result, { + debug: options.debug, + }); + return buildResponse(responsePayload, options.debug); + }; +} + +async function serializeRequestBody( + request: Request, + initBody: BodyInit | null | undefined, + debug?: boolean, +): Promise<{ body?: unknown; bodyType?: McpHttpBodyType }> { + const method = request.method.toUpperCase(); + if (method === "GET" || method === "HEAD") { + return { bodyType: "none", body: undefined }; + } + + if (initBody !== undefined) { + return await serializeBodyInit( + initBody, + request.headers.get("content-type"), + { debug }, + ); + } + + if (!request.body) { + return { bodyType: "none", body: undefined }; + } + + return await serializeBodyFromRequest( + request.clone(), + request.headers.get("content-type"), + { debug }, + ); +} + +function buildResponse(payload: McpHttpResponse, debug?: boolean): Response { + const headers = new Headers(payload.headers ?? {}); + const status = payload.status ?? 200; + const statusText = payload.statusText ?? ""; + const body = decodeResponseBody(payload.body, payload.bodyType, debug); + return new Response(body, { status, statusText, headers }); +} + +function decodeResponseBody( + body: unknown, + bodyType?: McpHttpBodyType, + debug?: boolean, +): BodyInit | null { + if (!bodyType || bodyType === "none") { + return null; + } + + switch (bodyType) { + case "json": + if (typeof body === "string") { + return body; + } + return JSON.stringify(body ?? null); + case "text": + return body == null ? "" : String(body); + case "urlEncoded": + if (typeof body === "string") { + return body; + } + if (body && typeof body === "object") { + return new URLSearchParams(body as Record).toString(); + } + return ""; + case "formData": + if (typeof FormData === "undefined") { + return body == null ? "" : JSON.stringify(body); + } + return fieldsToFormData(body, debug); + case "base64": + return decodeBase64Body(body); + default: + return body == null ? null : String(body); + } +} + +function fieldsToFormData(body: unknown, debug?: boolean): FormData { + const formData = new FormData(); + if (Array.isArray(body)) { + let skipped = 0; + for (let index = 0; index < body.length; index += 1) { + const entry = body[index]; + if (!entry || typeof entry !== "object") { + if (debug) { + console.debug( + `[MCP HTTP] Skipping invalid form field at index ${index}: not an object.`, + ); + } + skipped += 1; + continue; + } + const field = entry as McpHttpFormField; + if (!field.name) { + if (debug) { + console.debug( + `[MCP HTTP] Skipping form field at index ${index}: missing name.`, + ); + } + skipped += 1; + continue; + } + if ("data" in field) { + const bytes = fromBase64(field.data); + const blob = new Blob([bytes.slice().buffer], { + type: field.contentType ?? "application/octet-stream", + }); + if (field.filename) { + formData.append(field.name, blob, field.filename); + } else { + formData.append(field.name, blob); + } + continue; + } + formData.append(field.name, field.value ?? ""); + } + if (skipped > 0 && debug) { + console.debug(`[MCP HTTP] Skipped ${skipped} invalid form field(s).`); + } + return formData; + } + + if (body && typeof body === "object") { + for (const [name, value] of Object.entries( + body as Record, + )) { + formData.append(name, value == null ? "" : String(value)); + } + } + + return formData; +} + +function decodeBase64Body(body: unknown): Blob | null { + let bytes: Uint8Array | null = null; + if (typeof body === "string") { + bytes = fromBase64(body); + } else if (body && typeof body === "object" && "data" in body) { + const data = (body as { data?: string }).data; + if (typeof data === "string") { + bytes = fromBase64(data); + } + } + if (bytes) { + return new Blob([bytes.slice().buffer]); + } + return null; +} + +function createAbortError(): Error { + try { + return new DOMException("The operation was aborted.", "AbortError"); + } catch (e) { + const error = new Error("The operation was aborted."); + (error as Error & { name: string }).name = "AbortError"; + return error; + } +} + +async function handleProxyRequest( + args: McpHttpRequest, + options: McpFetchProxyOptions, + signal?: AbortSignal, +): Promise { + if (!args.url) { + throw new Error("Missing url for http_request"); + } + + const fetchImpl = options.fetch ?? globalThis.fetch; + if (!fetchImpl) { + throw new Error("global fetch is not available in this environment"); + } + + const baseUrl = options.baseUrl ?? getBaseOrigin(new URL(getBaseUrl())); + const { resolvedUrl, fetchUrl } = resolveProxyUrl(args.url, baseUrl); + + enforceProxyAllowlist(resolvedUrl, options); + enforceBodySizeLimit(args, options); + + const headers = buildProxyHeaders(args, options); + const body = buildProxyBody(args, headers, options.debug); + + const timeoutMs = args.timeoutMs ?? options.timeoutMs; + const timeout = timeoutMs ? createTimeoutSignal(timeoutMs) : undefined; + const { signal: mergedSignal, cleanup: signalCleanup } = mergeSignals( + signal, + timeout?.signal, + ); + const cleanup = () => { + signalCleanup(); + timeout?.cleanup(); + }; + + try { + const response = await fetchImpl(fetchUrl, { + method: args.method ?? "GET", + headers, + body, + redirect: args.redirect, + cache: args.cache, + credentials: args.credentials ?? options.credentials, + signal: mergedSignal, + }); + + return await serializeProxyResponse(response, options.debug); + } finally { + cleanup(); + } +} + +function resolveProxyUrl( + url: string, + baseUrl?: string, +): { resolvedUrl: URL; fetchUrl: string } { + if (isAbsoluteUrl(url)) { + return { resolvedUrl: new URL(url), fetchUrl: url }; + } + + const base = baseUrl ?? "http://localhost"; + const resolvedUrl = new URL(url, base); + const fetchUrl = baseUrl ? resolvedUrl.toString() : url; + return { resolvedUrl, fetchUrl }; +} + +function isAbsoluteUrl(url: string): boolean { + return /^https?:\/\//i.test(url); +} + +function enforceProxyAllowlist(url: URL, options: McpFetchProxyOptions): void { + if (options.allowOrigins && options.allowOrigins.length > 0) { + if (!options.allowOrigins.includes(url.origin)) { + throw new Error(`Origin not allowed: ${url.origin}`); + } + } + + const allowPaths = options.allowPaths ?? DEFAULT_INTERCEPT_PATHS; + if (allowPaths.length === 0) { + throw new Error("No paths are permitted for http_request"); + } + + const path = normalizePath(url.pathname); + const allowed = allowPaths.some((prefix) => + path.startsWith(normalizePath(prefix)), + ); + if (!allowed) { + throw new Error(`Path not allowed: ${path}`); + } +} + +function enforceBodySizeLimit( + args: McpHttpRequest, + options: McpFetchProxyOptions, +): void { + const maxSize = options.maxBodySize ?? DEFAULT_MAX_BODY_SIZE; + if (maxSize <= 0) { + return; + } + + const bodySize = estimateBodySize(args.body, args.bodyType); + if (bodySize > maxSize) { + throw new Error( + `Request body exceeds maximum allowed size (${bodySize} > ${maxSize} bytes)`, + ); + } +} + +function estimateBodySize(body: unknown, bodyType?: McpHttpBodyType): number { + if (body == null || bodyType === "none") { + return 0; + } + + if (typeof body === "string") { + if (bodyType === "base64") { + return Math.ceil(body.length * 0.75); + } + return new TextEncoder().encode(body).length; + } + + if ( + bodyType === "base64" && + body && + typeof body === "object" && + "data" in body + ) { + const data = (body as { data?: unknown }).data; + if (typeof data === "string") { + return Math.ceil(data.length * 0.75); + } + } + + if (Array.isArray(body)) { + return JSON.stringify(body).length; + } + + if (typeof body === "object") { + return JSON.stringify(body).length; + } + + return String(body).length; +} + +function buildProxyHeaders( + args: McpHttpRequest, + options: McpFetchProxyOptions, +): Headers { + const baseHeaders = + typeof options.headers === "function" + ? options.headers(args) + : (options.headers ?? {}); + const headers = new Headers(); + const applyHeaders = (source: HeadersInit | undefined) => { + if (!source) { + return; + } + const normalized = new Headers(source); + normalized.forEach((value, key) => { + headers.set(key, value); + }); + }; + applyHeaders(baseHeaders); + applyHeaders(args.headers); + + const forbiddenHeaders = + options.forbiddenHeaders ?? FORBIDDEN_REQUEST_HEADERS; + for (const forbidden of forbiddenHeaders) { + if (headers.has(forbidden)) { + console.warn(`Refused to set unsafe header "${forbidden}"`); + } + headers.delete(forbidden); + } + + if (args.bodyType === "formData") { + headers.delete("content-type"); + headers.delete("content-length"); + } + + if (args.bodyType === "json" && !headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + + if (args.bodyType === "urlEncoded" && !headers.has("content-type")) { + headers.set("content-type", "application/x-www-form-urlencoded"); + } + + return headers; +} + +function buildProxyBody( + args: McpHttpRequest, + headers: Headers, + debug?: boolean, +): BodyInit | undefined { + const method = (args.method ?? "GET").toUpperCase(); + if (method === "GET" || method === "HEAD") { + return undefined; + } + + if (args.body == null && args.bodyType !== "text") { + return undefined; + } + + const bodyType = args.bodyType ?? inferBodyType(args.body); + switch (bodyType) { + case "json": { + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + return typeof args.body === "string" + ? args.body + : JSON.stringify(args.body ?? null); + } + case "text": + return args.body == null ? "" : String(args.body); + case "urlEncoded": { + if (!headers.has("content-type")) { + headers.set("content-type", "application/x-www-form-urlencoded"); + } + if (typeof args.body === "string") { + return args.body; + } + if (args.body && typeof args.body === "object") { + return new URLSearchParams( + args.body as Record, + ).toString(); + } + return ""; + } + case "formData": { + if (typeof FormData === "undefined") { + return undefined; + } + return fieldsToFormData(args.body, debug); + } + case "base64": { + let bytes: Uint8Array | null = null; + if (typeof args.body === "string") { + bytes = fromBase64(args.body); + } else if ( + args.body && + typeof args.body === "object" && + "data" in args.body + ) { + const data = (args.body as { data?: string }).data; + if (typeof data === "string") { + bytes = fromBase64(data); + } + } + return bytes ? new Blob([bytes.slice().buffer]) : undefined; + } + case "none": + return undefined; + default: + return args.body == null ? undefined : String(args.body); + } +} + +function inferBodyType(body: unknown): McpHttpBodyType { + if (body == null) { + return "none"; + } + if (typeof body === "string") { + return "text"; + } + if (typeof body === "object") { + return "json"; + } + return "text"; +} + +async function serializeProxyResponse( + response: Response, + debug?: boolean, +): Promise { + const headers = headersToRecord(response.headers); + const status = response.status; + const statusText = response.statusText; + const redirected = response.redirected; + const ok = response.ok; + const url = response.url; + + if ([204, 205, 304].includes(status)) { + return { + status, + statusText, + headers, + bodyType: "none", + url, + redirected, + ok, + }; + } + + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; + + if (contentType.includes("application/json")) { + const text = await response.text(); + const parsed = safeJsonParse(text, { + context: "proxy response", + debug, + }); + const isJson = parsed !== undefined; + return { + status, + statusText, + headers, + body: isJson ? parsed : text, + bodyType: isJson ? "json" : "text", + url, + redirected, + ok, + }; + } + + if (contentType.includes("application/x-www-form-urlencoded")) { + return { + status, + statusText, + headers, + body: await response.text(), + bodyType: "urlEncoded", + url, + redirected, + ok, + }; + } + + if ( + contentType.startsWith("text/") || + contentType.includes("application/xml") || + contentType.includes("application/javascript") + ) { + return { + status, + statusText, + headers, + body: await response.text(), + bodyType: "text", + url, + redirected, + ok, + }; + } + + const buffer = await response.arrayBuffer(); + return { + status, + statusText, + headers, + body: toBase64(new Uint8Array(buffer)), + bodyType: "base64", + url, + redirected, + ok, + }; +} + +interface MergedSignal { + signal: AbortSignal | undefined; + cleanup: () => void; +} + +function mergeSignals( + primary?: AbortSignal, + secondary?: AbortSignal, +): MergedSignal { + const noop = () => {}; + + if (!primary && !secondary) { + return { signal: undefined, cleanup: noop }; + } + if (primary && !secondary) { + return { signal: primary, cleanup: noop }; + } + if (!primary && secondary) { + return { signal: secondary, cleanup: noop }; + } + + if (typeof AbortSignal !== "undefined" && "any" in AbortSignal) { + const signal = ( + AbortSignal as { any: (signals: AbortSignal[]) => AbortSignal } + ).any([primary!, secondary!]); + return { signal, cleanup: noop }; + } + + const controller = new AbortController(); + const abort = () => controller.abort(); + + if (primary?.aborted || secondary?.aborted) { + controller.abort(); + return { signal: controller.signal, cleanup: noop }; + } + + primary?.addEventListener("abort", abort); + secondary?.addEventListener("abort", abort); + + const cleanup = () => { + primary?.removeEventListener("abort", abort); + secondary?.removeEventListener("abort", abort); + }; + + return { signal: controller.signal, cleanup }; +} + +function createTimeoutSignal(timeoutMs: number): { + signal: AbortSignal; + cleanup: () => void; +} { + if (typeof AbortSignal !== "undefined" && "timeout" in AbortSignal) { + return { signal: AbortSignal.timeout(timeoutMs), cleanup: () => {} }; + } + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + return { + signal: controller.signal, + cleanup: () => clearTimeout(timeoutId), + }; +} diff --git a/src/http-adapter/http-options.examples.ts b/src/http-adapter/http-options.examples.ts new file mode 100644 index 00000000..fccf9fe2 --- /dev/null +++ b/src/http-adapter/http-options.examples.ts @@ -0,0 +1,21 @@ +/** + * Type-checked examples for HTTP adapter option types. + * + * @module + */ +import type { App } from "../app.js"; +import { initMcpHttp } from "./init.js"; + +async function McpHttpHandle_lifecycle_basic(app: App) { + //#region McpHttpHandle_lifecycle_basic + const handle = initMcpHttp(app); + + // Temporarily disable all interception + handle.stop(); + await fetch("/api/direct"); // Uses native fetch + handle.start(); + + // Permanent cleanup (e.g., on unmount) + handle.restore(); // Cannot restart after this + //#endregion McpHttpHandle_lifecycle_basic +} diff --git a/src/http-adapter/http-options.ts b/src/http-adapter/http-options.ts new file mode 100644 index 00000000..a0e2729d --- /dev/null +++ b/src/http-adapter/http-options.ts @@ -0,0 +1,251 @@ +/** + * HTTP Adapter Options + * + * Implementation types for the HTTP adapter wrappers. + * Uses browser types (RequestCredentials, HeadersInit) where applicable + * because the http-adapter is browser-specific code that wraps fetch and XMLHttpRequest. + * + * @module @modelcontextprotocol/ext-apps/http-adapter + */ + +import type { McpHttpRequest } from "../types.js"; + +/** + * Standard fetch function signature. + * Uses explicit function type instead of `typeof fetch` for compatibility + * across environments (some have `fetch.preconnect`, others don't). + */ +export type FetchFunction = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +/** + * Headers that should be stripped from proxied requests. + * These could be used to exfiltrate credentials or spoof identity. + */ +export const FORBIDDEN_REQUEST_HEADERS: ReadonlySet = new Set([ + "cookie", + "set-cookie", + "authorization", + "proxy-authorization", + "host", + "origin", + "referer", +]); + +export const DEFAULT_INTERCEPT_PATHS = ["/"]; +export const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024; + +/** + * Base options shared by fetch and XHR wrappers. + */ +export interface McpHttpBaseOptions { + /** + * Name of the MCP tool to call for HTTP requests. + * @default "http_request" + */ + toolName?: string; + + /** + * URL path prefixes to intercept. + * Only requests matching these prefixes will be proxied through MCP. + * @default ["/"] + */ + interceptPaths?: string[]; + + /** + * Whether to allow absolute URLs (different origins) to be proxied. + * @default false + */ + allowAbsoluteUrls?: boolean; + + /** + * Optional gate to enable/disable interception without uninstalling wrappers. + * Returning false forces native transport for that request. + * @default () => true + */ + interceptEnabled?: () => boolean; + + /** + * Whether to fall back to native implementations when not connected to MCP host. + * @default true + */ + fallbackToNative?: boolean; + + /** + * Default timeout in milliseconds for requests. + */ + timeoutMs?: number; + + /** + * Enable debug-level logging for parsing and response validation. + * @default false + */ + debug?: boolean; + + /** + * Custom function to check if running in MCP app context. + * If not provided, checks app.getHostCapabilities()?.serverTools. + */ + isMcpApp?: () => boolean; + + /** + * Whether to install the wrapper globally. + * @default true + */ + installGlobal?: boolean; +} + +/** + * Options for the HTTP proxy tool handler (server-side). + * + * Uses browser types where applicable (RequestCredentials, HeadersInit, typeof fetch) + * because the handler adapts HTTP-like requests and may run in browser or server environments. + */ +export interface McpHttpProxyOptions { + /** + * Name of the MCP tool for HTTP requests. + * @default "http_request" + */ + toolName?: string; + + /** + * Allowed origins for requests. Security allow-list. + */ + allowOrigins?: string[]; + + /** + * Allowed path prefixes. Security allow-list. + */ + allowPaths?: string[]; + + /** + * Base URL for resolving relative request URLs. + */ + baseUrl?: string; + + /** + * Credentials mode for requests. + * Uses browser RequestCredentials type. + */ + credentials?: RequestCredentials; + + /** + * Request timeout in milliseconds. + */ + timeoutMs?: number; + + /** + * Custom fetch function to use for making requests. + * Defaults to global fetch. + */ + fetch?: FetchFunction; + + /** + * Headers to include in all requests, or a function to compute them. + * Uses browser HeadersInit type for flexibility. + */ + headers?: HeadersInit | ((request: McpHttpRequest) => HeadersInit); + + /** + * Headers that should be stripped from proxied requests. + * Defaults to {@link FORBIDDEN_REQUEST_HEADERS}. + */ + forbiddenHeaders?: Set; + + /** + * Maximum body size in bytes. + * Set to `0` or a negative value to disable size enforcement. + * @default 10485760 (10MB) + */ + maxBodySize?: number; + + /** + * Enable debug-level logging for parsing and response validation. + * @default false + */ + debug?: boolean; +} + +/** + * Base interface for HTTP wrapper handles. + * + * Provides common lifecycle methods shared by {@link McpFetchHandle}, + * {@link McpXhrHandle}, and {@link McpHttpHandle}. + * + * ## State Machine + * + * ``` + * [active] --stop()--> [inactive] --start()--> [active] + * | | + * +-----restore()-------+-----> [terminated] + * ``` + * + * - **active** (initial): Requests are proxied through MCP + * - **inactive**: Requests use native implementations (reversible with `start()`) + * - **terminated**: Wrapper is permanently uninstalled (irreversible) + */ +export interface McpHttpHandleBase { + /** + * Pause interception. Requests will use native implementations until `start()` is called. + * This is reversible, unlike `restore()`. + */ + stop: () => void; + /** + * Resume interception after `stop()` was called. + * Has no effect if already active or after `restore()`. + */ + start: () => void; + /** + * Check if currently intercepting requests. + * Returns `false` after `stop()` or `restore()`. + */ + isActive: () => boolean; + /** + * Permanently uninstall the wrapper and restore native implementations. + * **This is irreversible** - calling `start()` after `restore()` has no effect. + * Use for cleanup when the MCP app is being unmounted. + */ + restore: () => void; +} + +/** + * Options for the unified MCP HTTP wrapper. + */ +export interface McpHttpOptions extends McpHttpBaseOptions { + /** + * Whether to patch the Fetch API. + * Set to `false` to only patch XMLHttpRequest. + * @default true + */ + patchFetch?: boolean; + + /** + * Whether to patch XMLHttpRequest. + * Set to `false` to only patch the Fetch API. + * @default true + */ + patchXhr?: boolean; +} + +/** + * Handle returned from initMcpHttp for controlling both fetch and XHR wrappers. + * + * Extends {@link McpHttpHandleBase} with combined control over both wrappers. + * Operations apply to both fetch and XHR wrappers simultaneously. + * + * @example + * ```ts source="./http-options.examples.ts#McpHttpHandle_lifecycle_basic" + * const handle = initMcpHttp(app); + * + * // Temporarily disable all interception + * handle.stop(); + * await fetch("/api/direct"); // Uses native fetch + * handle.start(); + * + * // Permanent cleanup (e.g., on unmount) + * handle.restore(); // Cannot restart after this + * ``` + */ +export interface McpHttpHandle extends McpHttpHandleBase {} diff --git a/src/http-adapter/init.examples.ts b/src/http-adapter/init.examples.ts new file mode 100644 index 00000000..1acbb27e --- /dev/null +++ b/src/http-adapter/init.examples.ts @@ -0,0 +1,22 @@ +/** + * Type-checked examples for initMcpHttp. + * + * @module + */ +import { App } from "../app.js"; +import { initMcpHttp } from "./init.js"; + +async function initMcpHttp_basicUsage() { + //#region initMcpHttp_basicUsage + const app = new App({ name: "MyApp", version: "1.0.0" }, {}); + await app.connect(); + + const handle = initMcpHttp(app, { + interceptPaths: ["/api/"], + fallbackToNative: true, + }); + + await fetch("/api/time"); + handle.restore(); + //#endregion initMcpHttp_basicUsage +} diff --git a/src/http-adapter/init.ts b/src/http-adapter/init.ts new file mode 100644 index 00000000..2116cefa --- /dev/null +++ b/src/http-adapter/init.ts @@ -0,0 +1,66 @@ +/** + * Unified HTTP wrapper for MCP Apps. + * + * Patches both `fetch()` and `XMLHttpRequest` to route HTTP requests + * through MCP server tools. + * + * @module @modelcontextprotocol/ext-apps/http-adapter + */ +import type { App } from "../app.js"; +import { initMcpFetch } from "./fetch-wrapper/fetch.js"; +import { initMcpXhr } from "./xhr-wrapper/xhr.js"; +import type { McpHttpHandle, McpHttpOptions } from "./http-options.js"; +import type { McpFetchHandle } from "./fetch-wrapper/fetch-options.js"; +import type { McpXhrHandle } from "./xhr-wrapper/xhr-options.js"; + +/** + * Initialize the unified MCP HTTP wrapper. + * + * @example + * ```ts source="./init.examples.ts#initMcpHttp_basicUsage" + * const app = new App({ name: "MyApp", version: "1.0.0" }, {}); + * await app.connect(); + * + * const handle = initMcpHttp(app, { + * interceptPaths: ["/api/"], + * fallbackToNative: true, + * }); + * + * await fetch("/api/time"); + * handle.restore(); + * ``` + */ +export function initMcpHttp( + app: App, + options: McpHttpOptions = {}, +): McpHttpHandle { + const handles: Array = []; + + if (options.patchFetch !== false) { + handles.push(initMcpFetch(app, options)); + } + + if (options.patchXhr !== false) { + handles.push(initMcpXhr(app, options)); + } + + return { + stop: () => { + for (const handle of handles) { + handle.stop(); + } + }, + start: () => { + for (const handle of handles) { + handle.start(); + } + }, + isActive: () => + handles.length > 0 && handles.some((handle) => handle.isActive()), + restore: () => { + for (const handle of handles) { + handle.restore(); + } + }, + }; +} diff --git a/src/http-adapter/shared/body.ts b/src/http-adapter/shared/body.ts new file mode 100644 index 00000000..0e6903d8 --- /dev/null +++ b/src/http-adapter/shared/body.ts @@ -0,0 +1,337 @@ +/** + * Body serialization and response extraction helpers shared across HTTP adapters. + */ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + McpHttpResponseSchema, + type McpHttpBodyType, + type McpHttpFormField, + type McpHttpResponse, +} from "../../types.js"; +import { safeJsonParse } from "./json.js"; + +export interface SerializeBodyOptions { + allowDocument?: boolean; + debug?: boolean; +} + +/** + * Encodes a Uint8Array to a base64 string. + */ +export function toBase64(bytes: Uint8Array): string { + if (bytes.length === 0) { + return ""; + } + if (typeof btoa === "function") { + let binary = ""; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + return btoa(binary); + } + if (typeof Buffer !== "undefined") { + return Buffer.from(bytes).toString("base64"); + } + throw new Error("Base64 encoding is not supported in this environment"); +} + +/** + * Decodes a base64 string to a Uint8Array. + */ +export function fromBase64(base64: string): Uint8Array { + if (!base64) { + return new Uint8Array(); + } + if (typeof atob === "function") { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } + if (typeof Buffer !== "undefined") { + return new Uint8Array(Buffer.from(base64, "base64")); + } + throw new Error("Base64 decoding is not supported in this environment"); +} + +/** + * Converts FormData to an array of serializable form fields. + */ +export async function formDataToFields( + formData: FormData, +): Promise { + const fields: McpHttpFormField[] = []; + for (const [name, value] of formData.entries()) { + if (typeof value === "string") { + fields.push({ name, value }); + continue; + } + const file = value as File; + const buffer = await file.arrayBuffer(); + fields.push({ + name, + data: toBase64(new Uint8Array(buffer)), + filename: file.name, + contentType: file.type || undefined, + }); + } + return fields; +} + +/** + * Serializes a string body based on content-type. + * @internal + */ +export function serializeStringBody( + body: string, + contentType: string | null, + options: SerializeBodyOptions = {}, +): { body?: unknown; bodyType?: McpHttpBodyType } { + const normalizedType = (contentType ?? "").toLowerCase(); + if (normalizedType.includes("application/json")) { + const parsed = safeJsonParse(body, { + context: "request body", + debug: options.debug, + }); + if (parsed !== undefined) { + return { bodyType: "json", body: parsed }; + } + } + if (normalizedType.includes("application/x-www-form-urlencoded")) { + return { bodyType: "urlEncoded", body }; + } + return { bodyType: "text", body }; +} + +/** + * Serializes a request body from a body init value. + */ +export async function serializeBodyInit( + body: BodyInit | XMLHttpRequestBodyInit | Document | null, + contentType: string | null, + options: SerializeBodyOptions = {}, +): Promise<{ body?: unknown; bodyType?: McpHttpBodyType }> { + if (body == null) { + return { bodyType: "none", body: undefined }; + } + + if (typeof body === "string") { + return serializeStringBody(body, contentType, options); + } + + if ( + options.allowDocument && + typeof Document !== "undefined" && + body instanceof Document + ) { + const serializer = new XMLSerializer(); + return { + bodyType: "text", + body: serializer.serializeToString(body), + }; + } + + if ( + options.allowDocument && + body && + typeof body === "object" && + "documentElement" in body && + typeof XMLSerializer !== "undefined" + ) { + const serializer = new XMLSerializer(); + return { + bodyType: "text", + body: serializer.serializeToString(body as Document), + }; + } + + if ( + typeof URLSearchParams !== "undefined" && + body instanceof URLSearchParams + ) { + return { bodyType: "urlEncoded", body: body.toString() }; + } + + if (typeof FormData !== "undefined" && body instanceof FormData) { + return { bodyType: "formData", body: await formDataToFields(body) }; + } + + if (typeof Blob !== "undefined" && body instanceof Blob) { + const buffer = await body.arrayBuffer(); + return { bodyType: "base64", body: toBase64(new Uint8Array(buffer)) }; + } + + if (body instanceof ArrayBuffer) { + return { bodyType: "base64", body: toBase64(new Uint8Array(body)) }; + } + + if (ArrayBuffer.isView(body)) { + const view = new Uint8Array(body.buffer, body.byteOffset, body.byteLength); + return { bodyType: "base64", body: toBase64(view) }; + } + + if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) { + const buffer = await new Response(body).arrayBuffer(); + return { bodyType: "base64", body: toBase64(new Uint8Array(buffer)) }; + } + + return { bodyType: "text", body: String(body) }; +} + +/** + * Serializes a request body from a Request instance. + */ +export async function serializeBodyFromRequest( + request: Request, + contentType: string | null, + options: SerializeBodyOptions = {}, +): Promise<{ body?: unknown; bodyType?: McpHttpBodyType }> { + const normalizedType = (contentType ?? "").toLowerCase(); + + if (normalizedType.includes("application/json")) { + const text = await request.text(); + const parsed = safeJsonParse(text, { + context: "request body", + debug: options.debug, + }); + if (parsed !== undefined) { + return { bodyType: "json", body: parsed }; + } + return { bodyType: "text", body: text }; + } + + if (normalizedType.includes("application/x-www-form-urlencoded")) { + return { bodyType: "urlEncoded", body: await request.text() }; + } + + if ( + normalizedType.includes("multipart/form-data") && + typeof request.formData === "function" + ) { + try { + const formData = await request.formData(); + return { bodyType: "formData", body: await formDataToFields(formData) }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + "[MCP HTTP] Failed to parse multipart/form-data. " + + "The request body could not be serialized. " + + "Verify the multipart boundary is valid. " + + `Underlying error: ${detail}`, + ); + } + } + + if ( + normalizedType.startsWith("text/") || + normalizedType.includes("application/xml") || + normalizedType.includes("application/javascript") + ) { + return { bodyType: "text", body: await request.text() }; + } + + const buffer = await request.arrayBuffer(); + return { bodyType: "base64", body: toBase64(new Uint8Array(buffer)) }; +} + +export interface ExtractHttpResponseOptions { + debug?: boolean; +} + +/** + * Extracts text content from a CallToolResult. + * @internal + */ +export function extractTextContent(result: CallToolResult): string | undefined { + const blocks = ( + result as { content?: Array<{ type: string; text?: string }> } + ).content; + if (!blocks) { + return undefined; + } + for (const block of blocks) { + if (block.type === "text" && typeof block.text === "string") { + return block.text; + } + } + return undefined; +} + +/** + * Extracts error message from a CallToolResult. + */ +export function extractToolError(result: CallToolResult): string { + return extractTextContent(result) ?? "MCP tool returned an error"; +} + +/** + * Extracts McpHttpResponse from a CallToolResult. + * Uses Zod schema for validation. + */ +export function extractHttpResponse( + result: CallToolResult, + options: ExtractHttpResponseOptions = {}, +): McpHttpResponse { + const structured = (result as { structuredContent?: unknown }) + .structuredContent; + if (structured && typeof structured === "object") { + const parseResult = McpHttpResponseSchema.safeParse(structured); + if (parseResult.success) { + return parseResult.data; + } + if (options.debug) { + console.debug( + "[MCP HTTP] Schema validation failed for structuredContent:", + parseResult.error.message, + ); + } + throw new Error( + "http_request returned invalid response: " + + parseResult.error.issues.map((i) => i.message).join(", "), + ); + } + + const text = extractTextContent(result); + if (text) { + const parsed = safeJsonParse(text, { + context: "http_request response", + debug: options.debug, + }); + if (parsed && typeof parsed === "object") { + const parseResult = McpHttpResponseSchema.safeParse(parsed); + if (parseResult.success) { + return parseResult.data; + } + if (options.debug) { + console.debug( + "[MCP HTTP] Schema validation failed for parsed JSON:", + parseResult.error.message, + ); + } + throw new Error( + "http_request returned invalid response: " + + parseResult.error.issues.map((i) => i.message).join(", "), + ); + } + throw new Error( + `http_request returned invalid response: expected object, got ${typeof parsed}. ` + + `Raw text (truncated): ${truncateText(text)}`, + ); + } + + throw new Error( + "http_request did not return structured content. " + + `structuredContent type: ${typeof structured}, ` + + `content blocks: ${result.content?.length ?? 0}`, + ); +} + +function truncateText(text: string, maxLength = 200): string { + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, maxLength)}...`; +} diff --git a/src/http-adapter/shared/json.ts b/src/http-adapter/shared/json.ts new file mode 100644 index 00000000..3c818c23 --- /dev/null +++ b/src/http-adapter/shared/json.ts @@ -0,0 +1,42 @@ +/** + * JSON helpers shared across HTTP adapters. + */ + +/** + * Options for safe JSON parsing. + */ +export interface JsonParseOptions { + context?: string; + debug?: boolean; +} + +/** + * Safely parses JSON, returning undefined on failure. + * + * **Side effect:** Always logs a warning on parse failure (or debug-level log + * when `options.debug` is true). Check the console if you suspect JSON parsing issues. + * + * @param value - The string to parse as JSON + * @param options - Optional configuration for error context and debug logging + * @returns The parsed JSON value, or `undefined` if parsing fails + */ +export function safeJsonParse( + value: string, + options: JsonParseOptions = {}, +): unknown | undefined { + try { + return JSON.parse(value); + } catch (error) { + const context = options.context ?? "JSON value"; + const detail = error instanceof Error ? error.message : String(error); + const preview = value.length > 100 ? `${value.slice(0, 100)}...` : value; + if (options.debug) { + console.debug(`[MCP HTTP] JSON parse failed for ${context}:`, detail); + } else { + console.warn( + `[MCP HTTP] JSON parse failed for ${context}: ${detail}. Value preview: ${preview}`, + ); + } + return undefined; + } +} diff --git a/src/http-adapter/shared/request.ts b/src/http-adapter/shared/request.ts new file mode 100644 index 00000000..368d2b5c --- /dev/null +++ b/src/http-adapter/shared/request.ts @@ -0,0 +1,202 @@ +/** + * Request utilities shared across HTTP adapters. + */ +import type { McpHttpBodyType, McpHttpRequest } from "../../types.js"; + +/** + * Gets the base URL for resolving relative URLs. + */ +export function getBaseUrl(): string { + if (typeof window !== "undefined" && window.location?.href) { + return window.location.href; + } + return "http://localhost"; +} + +/** + * Resolves a URL against the current base URL. + * @internal + */ +export function resolveUrl(url: string | URL): URL { + if (url instanceof URL) { + return url; + } + return new URL(url, getBaseUrl()); +} + +/** + * Gets the base origin for same-origin checks. + */ +export function getBaseOrigin(url?: URL): string | undefined { + if (typeof window !== "undefined" && window.location?.origin) { + return window.location.origin; + } + return url?.origin; +} + +/** + * Normalizes a URL path to ensure it starts with "/". + */ +export function normalizePath(path: string): string { + if (!path) { + return "/"; + } + return path.startsWith("/") ? path : `/${path}`; +} + +/** + * Removes undefined values from an object. + * @internal + */ +export function stripUndefined>(value: T): T { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined), + ) as T; +} + +/** + * Computes normalized URL info for MCP HTTP requests. + */ +export function getRequestUrlInfo( + url: string | URL, + allowAbsoluteUrls: boolean, +): { + resolvedUrl: URL; + isSameOrigin: boolean; + path: string; + toolUrl: string; +} { + const resolvedUrl = resolveUrl(url); + const baseOrigin = getBaseOrigin(resolvedUrl); + const isSameOrigin = baseOrigin ? resolvedUrl.origin === baseOrigin : true; + const path = normalizePath(resolvedUrl.pathname); + const toolUrl = + isSameOrigin || !allowAbsoluteUrls + ? `${resolvedUrl.pathname}${resolvedUrl.search}` + : resolvedUrl.toString(); + return { resolvedUrl, isSameOrigin, path, toolUrl }; +} + +/** + * Default interception logic shared by fetch and XHR wrappers. + */ +export function defaultShouldIntercept({ + isMcpApp, + allowAbsoluteUrls, + isSameOrigin, + path, + interceptPaths, +}: { + isMcpApp: boolean; + allowAbsoluteUrls: boolean; + isSameOrigin: boolean; + path: string; + interceptPaths: string[]; +}): boolean { + if (!isMcpApp) { + return false; + } + if (!isSameOrigin && !allowAbsoluteUrls) { + return false; + } + if (interceptPaths.length === 0) { + return false; + } + const normalizedPath = normalizePath(path); + return interceptPaths.some((prefix) => + normalizedPath.startsWith(normalizePath(prefix)), + ); +} + +/** + * Converts Headers to a record. + */ +export function headersToRecord(headers: Headers): Record { + const record: Record = {}; + headers.forEach((value, key) => { + record[key] = value; + }); + return record; +} + +/** + * Builds an MCP HTTP request payload. + */ +export function buildMcpHttpRequestPayload({ + method, + url, + headers, + body, + bodyType, + redirect, + cache, + credentials, + timeoutMs, +}: { + method?: string; + url: string; + headers?: Record; + body?: unknown; + bodyType?: McpHttpBodyType; + redirect?: RequestRedirect; + cache?: RequestCache; + credentials?: RequestCredentials; + timeoutMs?: number; +}): McpHttpRequest { + return stripUndefined({ + method, + url, + headers, + body, + bodyType, + redirect, + cache, + credentials, + timeoutMs, + }); +} + +/** + * Builds an MCP HTTP request payload from a Fetch Request. + */ +export function buildMcpHttpRequestPayloadFromRequest({ + request, + toolUrl, + body, + bodyType, + timeoutMs, +}: { + request: Request; + toolUrl: string; + body?: unknown; + bodyType?: McpHttpBodyType; + timeoutMs?: number; +}): McpHttpRequest { + return buildMcpHttpRequestPayload({ + method: request.method, + url: toolUrl, + headers: headersToRecord(request.headers), + body, + bodyType, + redirect: request.redirect, + cache: request.cache, + credentials: request.credentials, + timeoutMs, + }); +} + +/** + * Logs a warning when falling back to native HTTP due to MCP host unavailability. + * @internal + */ +export function warnNativeFallback( + adapter: "fetch" | "XHR", + url: string, +): void { + console.warn( + `[MCP ${adapter}] Falling back to native ${adapter.toLowerCase()} for ${url}: ` + + `MCP host connection not available. ` + + `Ensure app.connect() completed and the host supports serverTools capability. ` + + `Set fallbackToNative: false to throw instead.`, + ); +} diff --git a/src/http-adapter/xhr-wrapper/xhr-options.ts b/src/http-adapter/xhr-wrapper/xhr-options.ts new file mode 100644 index 00000000..eb6feab6 --- /dev/null +++ b/src/http-adapter/xhr-wrapper/xhr-options.ts @@ -0,0 +1,60 @@ +/** + * XHR wrapper options. + */ +import type { McpHttpBaseOptions, McpHttpHandleBase } from "../http-options.js"; + +/** + * Options for initializing the MCP XHR wrapper. + */ +export interface McpXhrOptions extends McpHttpBaseOptions { + /** + * Custom function to determine if a request should be intercepted. + * Takes precedence over `interceptPaths` if provided. + * + * **Note:** Unlike {@link McpFetchOptions.shouldIntercept}, this receives raw strings + * rather than URL/Request objects due to XHR API constraints (method and URL are + * known at `open()` time, before headers are set). + * + * @param method - HTTP method (e.g., "GET", "POST") + * @param url - Request URL string (may be relative or absolute) + * @returns `true` to intercept and proxy through MCP, `false` for native XHR + * + * @example + * ```ts source="./xhr.examples.ts#McpXhrOptions_shouldIntercept_basic" + * const shouldIntercept = (method: string, url: string) => { + * // Only intercept POST requests to /api + * return method === "POST" && url.startsWith("/api"); + * }; + * ``` + */ + shouldIntercept?: (method: string, url: string) => boolean; +} + +/** + * Handle returned from initMcpXhr for controlling the XHR wrapper lifecycle. + * + * Extends {@link McpHttpHandleBase} with the wrapped XMLHttpRequest class. + * + * @example + * ```ts source="./xhr.examples.ts#McpXhrHandle_lifecycle_basic" + * const handle = initMcpXhr(app); + * + * // Temporarily disable interception + * handle.stop(); + * const xhr = new XMLHttpRequest(); // Uses native XHR + * xhr.open("GET", "/api/direct"); + * xhr.send(); + * handle.start(); + * + * // Permanent cleanup (e.g., on unmount) + * handle.restore(); // Cannot restart after this + * ``` + */ +export interface McpXhrHandle extends McpHttpHandleBase { + /** + * The wrapped XMLHttpRequest class. + * Use this directly instead of global when `installGlobal: false` is set, + * or for explicit control in testing scenarios. + */ + XMLHttpRequest: typeof XMLHttpRequest; +} diff --git a/src/http-adapter/xhr-wrapper/xhr.examples.ts b/src/http-adapter/xhr-wrapper/xhr.examples.ts new file mode 100644 index 00000000..0488f62c --- /dev/null +++ b/src/http-adapter/xhr-wrapper/xhr.examples.ts @@ -0,0 +1,50 @@ +/** + * Type-checked examples for the XHR wrapper. + * + * @module + */ +import { App } from "../../app.js"; +import { initMcpXhr } from "./xhr.js"; + +function initMcpXhr_basicUsage() { + //#region initMcpXhr_basicUsage + const app = new App({ name: "My App", version: "1.0.0" }, {}); + const handle = initMcpXhr(app); + + // Now XHR calls are proxied through MCP + const xhr = new XMLHttpRequest(); + xhr.open("GET", "/api/data"); + xhr.send(); + + // Later, restore original XHR + handle.restore(); + //#endregion initMcpXhr_basicUsage + + return handle; +} + +function McpXhrOptions_shouldIntercept_basic() { + //#region McpXhrOptions_shouldIntercept_basic + const shouldIntercept = (method: string, url: string) => { + // Only intercept POST requests to /api + return method === "POST" && url.startsWith("/api"); + }; + //#endregion McpXhrOptions_shouldIntercept_basic + return shouldIntercept; +} + +async function McpXhrHandle_lifecycle_basic(app: App) { + //#region McpXhrHandle_lifecycle_basic + const handle = initMcpXhr(app); + + // Temporarily disable interception + handle.stop(); + const xhr = new XMLHttpRequest(); // Uses native XHR + xhr.open("GET", "/api/direct"); + xhr.send(); + handle.start(); + + // Permanent cleanup (e.g., on unmount) + handle.restore(); // Cannot restart after this + //#endregion McpXhrHandle_lifecycle_basic +} diff --git a/src/http-adapter/xhr-wrapper/xhr.ts b/src/http-adapter/xhr-wrapper/xhr.ts new file mode 100644 index 00000000..880fcbfa --- /dev/null +++ b/src/http-adapter/xhr-wrapper/xhr.ts @@ -0,0 +1,860 @@ +/** + * XHR wrapper for MCP Apps. + * + * Converts XMLHttpRequest calls into MCP server tool calls (default: "http_request") + * when running inside a host. + */ +import type { App } from "../../app.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + HTTP_REQUEST_TOOL_NAME, + type McpHttpBodyType, + type McpHttpRequest, + type McpHttpResponse, +} from "../../types.js"; +import type { McpXhrHandle, McpXhrOptions } from "./xhr-options.js"; +import { + DEFAULT_INTERCEPT_PATHS, + FORBIDDEN_REQUEST_HEADERS, +} from "../http-options.js"; +import { + buildMcpHttpRequestPayload, + defaultShouldIntercept, + getRequestUrlInfo, + warnNativeFallback, +} from "../shared/request.js"; +import { + extractHttpResponse, + extractToolError, + fromBase64, + serializeBodyInit, +} from "../shared/body.js"; + +/** + * Initialize the MCP XHR wrapper. + * + * @param app - The MCP App instance + * @param options - Configuration options + * @returns Handle with XMLHttpRequest class and restore function + * + * @example + * ```ts source="./xhr.examples.ts#initMcpXhr_basicUsage" + * const app = new App({ name: "My App", version: "1.0.0" }, {}); + * const handle = initMcpXhr(app); + * + * // Now XHR calls are proxied through MCP + * const xhr = new XMLHttpRequest(); + * xhr.open("GET", "/api/data"); + * xhr.send(); + * + * // Later, restore original XHR + * handle.restore(); + * ``` + */ +export function initMcpXhr( + app: App, + options: McpXhrOptions = {}, +): McpXhrHandle { + const NativeXMLHttpRequest = globalThis.XMLHttpRequest; + if (!NativeXMLHttpRequest) { + throw new Error("XMLHttpRequest is not available in this environment"); + } + + let active = true; + let restored = false; + + const McpXhr = createMcpXhrClass( + app, + NativeXMLHttpRequest, + options, + () => active, + ); + + if (options.installGlobal ?? true) { + (globalThis as { XMLHttpRequest: typeof XMLHttpRequest }).XMLHttpRequest = + McpXhr; + } + + return { + XMLHttpRequest: McpXhr, + stop: () => { + if (restored) { + return; + } + active = false; + }, + start: () => { + if (restored) { + return; + } + active = true; + }, + isActive: () => active && !restored, + restore: () => { + if (restored) { + return; + } + restored = true; + active = false; + if (globalThis.XMLHttpRequest === McpXhr) { + ( + globalThis as { XMLHttpRequest: typeof XMLHttpRequest } + ).XMLHttpRequest = NativeXMLHttpRequest; + } + }, + }; +} + +/** + * Creates a proxy XMLHttpRequest class bound to the given app and options. + */ +function createMcpXhrClass( + app: App, + NativeXMLHttpRequest: typeof XMLHttpRequest, + options: McpXhrOptions, + isActive: () => boolean, +): typeof XMLHttpRequest { + const toolName = options.toolName ?? HTTP_REQUEST_TOOL_NAME; + const interceptPaths = options.interceptPaths ?? DEFAULT_INTERCEPT_PATHS; + const allowAbsoluteUrls = options.allowAbsoluteUrls ?? false; + const interceptEnabled = options.interceptEnabled ?? (() => true); + const fallbackToNative = options.fallbackToNative ?? true; + const isMcpApp = + options.isMcpApp ?? (() => Boolean(app.getHostCapabilities()?.serverTools)); + + /** + * Determines if a URL should be intercepted. + */ + function shouldIntercept(method: string, url: string): boolean { + if (!interceptEnabled()) { + if (options.debug) { + console.debug( + "[MCP XHR] interceptEnabled() returned false, using native XHR for:", + url, + ); + } + return false; + } + + if (options.shouldIntercept) { + return options.shouldIntercept(method, url); + } + + const { isSameOrigin, path } = getRequestUrlInfo(url, allowAbsoluteUrls); + return defaultShouldIntercept({ + isMcpApp: isMcpApp(), + allowAbsoluteUrls, + isSameOrigin, + path, + interceptPaths, + }); + } + + function shouldUseNativeTransport(willIntercept: boolean): boolean { + if (!isActive()) { + return true; + } + return !willIntercept || (!isMcpApp() && fallbackToNative); + } + + /** + * The proxy XMLHttpRequest class. + */ + class McpXMLHttpRequest extends EventTarget implements XMLHttpRequest { + static readonly UNSENT = 0; + static readonly OPENED = 1; + static readonly HEADERS_RECEIVED = 2; + static readonly LOADING = 3; + static readonly DONE = 4; + + readonly UNSENT = 0; + readonly OPENED = 1; + readonly HEADERS_RECEIVED = 2; + readonly LOADING = 3; + readonly DONE = 4; + + private _method = "GET"; + private _url = ""; + private _async = true; + private _user: string | null = null; + private _password: string | null = null; + private _requestHeaders: Record = {}; + private _responseHeaders: Record = {}; + private _readyState = 0; + private _status = 0; + private _statusText = ""; + private _response: unknown = null; + private _responseText = ""; + private _responseURL = ""; + private _aborted = false; + private _sent = false; + private _abortController: AbortController | null = null; + private _lastError: unknown = null; + private _timeoutId: ReturnType | null = null; + private _timedOut = false; + private _nativeXhr: XMLHttpRequest | null = null; + private _useNative = false; + + responseType: XMLHttpRequestResponseType = ""; + timeout = 0; + withCredentials = false; + upload: XMLHttpRequestUpload = new McpXMLHttpRequestUpload(); + + onreadystatechange: ((this: XMLHttpRequest, ev: Event) => unknown) | null = + null; + onload: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null = + null; + onerror: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null = + null; + onabort: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null = + null; + ontimeout: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null = + null; + onloadstart: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null = + null; + onloadend: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null = + null; + onprogress: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null = + null; + + get readyState(): number { + return this._useNative && this._nativeXhr + ? this._nativeXhr.readyState + : this._readyState; + } + + get status(): number { + return this._useNative && this._nativeXhr + ? this._nativeXhr.status + : this._status; + } + + get statusText(): string { + return this._useNative && this._nativeXhr + ? this._nativeXhr.statusText + : this._statusText; + } + + get response(): unknown { + return this._useNative && this._nativeXhr + ? this._nativeXhr.response + : this._response; + } + + get responseText(): string { + if (this._useNative && this._nativeXhr) { + return this._nativeXhr.responseText; + } + if (this.responseType !== "" && this.responseType !== "text") { + throw new DOMException( + "Failed to read the 'responseText' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'text'", + "InvalidStateError", + ); + } + return this._responseText; + } + + get responseXML(): Document | null { + if (this._useNative && this._nativeXhr) { + return this._nativeXhr.responseXML; + } + return null; + } + + get responseURL(): string { + return this._useNative && this._nativeXhr + ? this._nativeXhr.responseURL + : this._responseURL; + } + + open( + method: string, + url: string | URL, + async: boolean = true, + user?: string | null, + password?: string | null, + ): void { + const urlString = url instanceof URL ? url.toString() : url; + + const willIntercept = shouldIntercept(method, urlString); + + if (!async && willIntercept) { + throw new DOMException( + "Synchronous XMLHttpRequest is not supported in MCP Apps. Use async: true.", + "InvalidAccessError", + ); + } + + this._useNative = shouldUseNativeTransport(willIntercept); + + if (this._useNative) { + if (willIntercept && !isMcpApp() && fallbackToNative) { + warnNativeFallback("XHR", urlString); + } + this._nativeXhr = new NativeXMLHttpRequest(); + this._setupNativeXhrProxy(); + this._nativeXhr.open(method, urlString, async, user, password); + return; + } + + this._resetProxyState({ + method, + url: urlString, + async, + user, + password, + }); + + this._setReadyState(McpXMLHttpRequest.OPENED); + } + + private _resetProxyState({ + method, + url, + async, + user, + password, + }: { + method: string; + url: string; + async: boolean; + user?: string | null; + password?: string | null; + }): void { + this._method = method.toUpperCase(); + this._url = url; + this._async = async; + this._user = user ?? null; + this._password = password ?? null; + this._requestHeaders = {}; + this._responseHeaders = {}; + this._status = 0; + this._statusText = ""; + this._response = null; + this._responseText = ""; + this._responseURL = ""; + this._aborted = false; + this._sent = false; + this._abortController = new AbortController(); + this._timedOut = false; + if (this._timeoutId) { + clearTimeout(this._timeoutId); + this._timeoutId = null; + } + } + + setRequestHeader(name: string, value: string): void { + if (this._useNative && this._nativeXhr) { + this._nativeXhr.setRequestHeader(name, value); + return; + } + + if (this._readyState !== McpXMLHttpRequest.OPENED) { + throw new DOMException( + "Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED.", + "InvalidStateError", + ); + } + + if (this._sent) { + throw new DOMException( + "Failed to execute 'setRequestHeader' on 'XMLHttpRequest': send() has already been called.", + "InvalidStateError", + ); + } + + const normalizedName = name.toLowerCase(); + + if (FORBIDDEN_REQUEST_HEADERS.has(normalizedName)) { + console.warn(`Refused to set unsafe header "${name}"`); + return; + } + + if (this._requestHeaders[normalizedName]) { + this._requestHeaders[normalizedName] += ", " + value; + } else { + this._requestHeaders[normalizedName] = value; + } + } + + /** + * Gets a response header value. + */ + getResponseHeader(name: string): string | null { + if (this._useNative && this._nativeXhr) { + return this._nativeXhr.getResponseHeader(name); + } + + if ( + this._readyState < McpXMLHttpRequest.HEADERS_RECEIVED || + this._aborted + ) { + return null; + } + + return this._responseHeaders[name.toLowerCase()] ?? null; + } + + /** + * Gets all response headers as a string. + */ + getAllResponseHeaders(): string { + if (this._useNative && this._nativeXhr) { + return this._nativeXhr.getAllResponseHeaders(); + } + + if ( + this._readyState < McpXMLHttpRequest.HEADERS_RECEIVED || + this._aborted + ) { + return ""; + } + + return Object.entries(this._responseHeaders) + .map(([name, value]) => `${name}: ${value}`) + .join("\r\n"); + } + + /** + * Overrides the MIME type. Not supported in MCP mode. + */ + overrideMimeType(_mime: string): void { + if (this._useNative && this._nativeXhr) { + this._nativeXhr.overrideMimeType(_mime); + return; + } + } + + send(body?: Document | XMLHttpRequestBodyInit | null): void { + if (this._useNative && this._nativeXhr) { + this._nativeXhr.send(body); + return; + } + + if (this._readyState !== McpXMLHttpRequest.OPENED) { + throw new DOMException( + "Failed to execute 'send' on 'XMLHttpRequest': The object's state must be OPENED.", + "InvalidStateError", + ); + } + + if (this._sent) { + throw new DOMException( + "Failed to execute 'send' on 'XMLHttpRequest': send() has already been called.", + "InvalidStateError", + ); + } + + this._sent = true; + this._dispatchProgressEvent("loadstart", 0, 0, false); + + this._executeRequest(body).catch((error) => { + if (!this._aborted) { + this._handleError(error); + } + }); + } + + abort(): void { + if (this._useNative && this._nativeXhr) { + this._nativeXhr.abort(); + return; + } + + if (this._aborted) { + return; + } + + this._aborted = true; + this._abortController?.abort(); + if (this._timeoutId) { + clearTimeout(this._timeoutId); + this._timeoutId = null; + } + + if (this._sent && this._readyState !== McpXMLHttpRequest.DONE) { + this._sent = false; + this._setReadyState(McpXMLHttpRequest.DONE); + this._dispatchProgressEvent("abort", 0, 0, false); + this._dispatchProgressEvent("loadend", 0, 0, false); + } + + this._readyState = McpXMLHttpRequest.UNSENT; + } + + /** + * Executes the MCP request. + */ + private async _executeRequest( + body: Document | XMLHttpRequestBodyInit | null | undefined, + ): Promise { + let timeoutId: ReturnType | null = null; + try { + const { serializedBody, bodyType } = await this._serializeBody(body); + const request = this._buildMcpRequest(serializedBody, bodyType); + + const callOptions = this._abortController + ? { signal: this._abortController.signal } + : undefined; + + const timeoutMs = this.timeout > 0 ? this.timeout : options.timeoutMs; + if (timeoutMs && timeoutMs > 0) { + this._timedOut = false; + timeoutId = setTimeout(() => { + if (this._aborted) { + return; + } + this._timedOut = true; + this._abortController?.abort(); + }, timeoutMs); + this._timeoutId = timeoutId; + } + + const result = await app.callServerTool( + { name: toolName, arguments: request }, + callOptions, + ); + + if (this._aborted) { + return; + } + + this._handleResponse(result); + } catch (error) { + if (this._aborted) { + const errorName = + error instanceof Error ? error.name : "UnknownError"; + const errorMessage = + error instanceof Error ? error.message : String(error); + // Only log unexpected errors (non-AbortError) at warn level + // Expected AbortErrors are logged at debug level + if (errorName !== "AbortError") { + console.warn( + "[MCP XHR] Request aborted but encountered unexpected error:", + { + url: this._url, + method: this._method, + errorName, + errorMessage, + }, + ); + } else if (options.debug) { + console.debug("[MCP XHR] Request aborted:", { + url: this._url, + method: this._method, + }); + } + return; + } + this._handleError(error); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (this._timeoutId === timeoutId) { + this._timeoutId = null; + } + } + } + + private _buildMcpRequest( + serializedBody: unknown, + bodyType?: McpHttpBodyType, + ): McpHttpRequest { + const { toolUrl } = getRequestUrlInfo(this._url, allowAbsoluteUrls); + return buildMcpHttpRequestPayload({ + method: this._method, + url: toolUrl, + headers: + Object.keys(this._requestHeaders).length > 0 + ? this._requestHeaders + : undefined, + body: serializedBody, + bodyType, + credentials: this.withCredentials ? "include" : "same-origin", + timeoutMs: this.timeout > 0 ? this.timeout : options.timeoutMs, + }); + } + + /** + * Handles a successful response. + */ + private _handleResponse(result: CallToolResult): void { + if (result.isError) { + this._handleError(new Error(extractToolError(result))); + return; + } + + const response = extractHttpResponse(result, { + debug: options.debug, + }); + + this._status = response.status; + this._statusText = response.statusText ?? ""; + this._responseHeaders = {}; + + if (response.headers) { + for (const [name, value] of Object.entries(response.headers)) { + this._responseHeaders[name.toLowerCase()] = value; + } + } + + this._responseURL = response.url ?? this._url; + + this._decodeResponse(response); + + this._setReadyState(McpXMLHttpRequest.HEADERS_RECEIVED); + this._setReadyState(McpXMLHttpRequest.LOADING); + + const responseSize = this._responseText.length; + this._dispatchProgressEvent("progress", responseSize, responseSize, true); + + this._setReadyState(McpXMLHttpRequest.DONE); + this._dispatchProgressEvent("load", responseSize, responseSize, true); + this._dispatchProgressEvent("loadend", responseSize, responseSize, true); + } + + /** + * Handles an error. + */ + private _handleError(error: unknown): void { + this._lastError = error; + + if (options.debug) { + console.error("[MCP XHR] Request failed:", { + url: this._url, + method: this._method, + errorName: error instanceof Error ? error.name : "UnknownError", + errorMessage: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + } + + this._status = 0; + this._statusText = ""; + this._response = null; + this._responseText = ""; + + this._setReadyState(McpXMLHttpRequest.DONE); + + if ( + error instanceof Error && + error.name === "AbortError" && + this._timedOut + ) { + this._dispatchProgressEvent("timeout", 0, 0, false); + } else { + this._dispatchProgressEvent("error", 0, 0, false); + } + + this._dispatchProgressEvent("loadend", 0, 0, false); + } + + /** + * Serializes the request body. + */ + private async _serializeBody( + body: Document | XMLHttpRequestBodyInit | null | undefined, + ): Promise<{ serializedBody?: unknown; bodyType?: McpHttpBodyType }> { + if (body == null) { + return { bodyType: "none" }; + } + + if (this._method === "GET" || this._method === "HEAD") { + return { bodyType: "none" }; + } + + const contentType = this._requestHeaders["content-type"] ?? null; + const { body: serializedBody, bodyType } = await serializeBodyInit( + body, + contentType, + { allowDocument: true, debug: options.debug }, + ); + return { serializedBody, bodyType }; + } + + /** + * Decodes the response based on responseType. + */ + private _decodeResponse(response: McpHttpResponse): void { + const { body, bodyType } = response; + + let text = ""; + if (bodyType === "json") { + text = typeof body === "string" ? body : JSON.stringify(body); + } else if (bodyType === "text" || bodyType === "urlEncoded") { + text = String(body ?? ""); + } else if (bodyType === "base64" && typeof body === "string") { + text = atob(body); + } else if (body != null) { + text = String(body); + } + + this._responseText = text; + + switch (this.responseType) { + case "": + case "text": + this._response = text; + break; + case "json": + try { + this._response = JSON.parse(text); + } catch (error) { + console.warn( + "[MCP XHR] responseType is 'json' but response failed to parse:", + { + url: this._responseURL || this._url, + status: this._status, + error: error instanceof Error ? error.message : String(error), + textPreview: + text.length > 200 ? text.substring(0, 200) + "..." : text, + }, + ); + this._response = null; + } + break; + case "arraybuffer": + if (bodyType === "base64" && typeof body === "string") { + this._response = fromBase64(body).buffer; + } else { + this._response = new TextEncoder().encode(text).buffer; + } + break; + case "blob": + if (bodyType === "base64" && typeof body === "string") { + const bytes = fromBase64(body); + this._response = new Blob([bytes.slice().buffer]); + } else { + this._response = new Blob([text]); + } + break; + case "document": + this._response = null; + break; + default: + this._response = text; + } + } + + /** + * Sets the ready state and fires readystatechange event. + */ + private _setReadyState(state: number): void { + this._readyState = state; + const event = new Event("readystatechange"); + this.onreadystatechange?.call(this as unknown as XMLHttpRequest, event); + this.dispatchEvent(event); + } + + /** + * Dispatches a progress event. + */ + private _dispatchProgressEvent( + type: string, + loaded: number, + total: number, + lengthComputable: boolean, + ): void { + const event = new ProgressEvent(type, { + lengthComputable, + loaded, + total, + }); + + const handler = this[`on${type}` as keyof this]; + if (typeof handler === "function") { + (handler as (ev: ProgressEvent) => void).call( + this as unknown as XMLHttpRequest, + event, + ); + } + + this.dispatchEvent(event); + } + + /** + * Sets up proxying of native XHR events. + */ + private _setupNativeXhrProxy(): void { + if (!this._nativeXhr) return; + + const xhr = this._nativeXhr; + + const events = [ + "readystatechange", + "load", + "error", + "abort", + "timeout", + "loadstart", + "loadend", + "progress", + ]; + + for (const eventType of events) { + xhr.addEventListener(eventType, (event) => { + const handler = this[`on${eventType}` as keyof this]; + if (typeof handler === "function") { + (handler as (ev: Event) => void).call( + this as unknown as XMLHttpRequest, + event, + ); + } + this.dispatchEvent( + new (event.constructor as typeof Event)(event.type, event), + ); + }); + } + + const uploadEvents = [ + "loadstart", + "progress", + "load", + "error", + "abort", + "timeout", + "loadend", + ]; + for (const eventType of uploadEvents) { + xhr.upload.addEventListener(eventType, (event) => { + const handler = + this.upload[`on${eventType}` as keyof XMLHttpRequestUpload]; + if (typeof handler === "function") { + (handler as (ev: Event) => void).call(this.upload, event); + } + }); + } + } + } + + return McpXMLHttpRequest as unknown as typeof XMLHttpRequest; +} + +/** + * Minimal upload object for MCP XHR. + * Upload progress is not supported in MCP mode. + */ +class McpXMLHttpRequestUpload + extends EventTarget + implements XMLHttpRequestUpload +{ + onabort: ((this: XMLHttpRequestUpload, ev: ProgressEvent) => unknown) | null = + null; + onerror: ((this: XMLHttpRequestUpload, ev: ProgressEvent) => unknown) | null = + null; + onload: ((this: XMLHttpRequestUpload, ev: ProgressEvent) => unknown) | null = + null; + onloadend: + | ((this: XMLHttpRequestUpload, ev: ProgressEvent) => unknown) + | null = null; + onloadstart: + | ((this: XMLHttpRequestUpload, ev: ProgressEvent) => unknown) + | null = null; + onprogress: + | ((this: XMLHttpRequestUpload, ev: ProgressEvent) => unknown) + | null = null; + ontimeout: + | ((this: XMLHttpRequestUpload, ev: ProgressEvent) => unknown) + | null = null; +} diff --git a/src/react/useApp.tsx b/src/react/useApp.tsx index d6dcfbb7..dddb837d 100644 --- a/src/react/useApp.tsx +++ b/src/react/useApp.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from "react"; import { Implementation } from "@modelcontextprotocol/sdk/types.js"; -import { Client } from "@modelcontextprotocol/sdk/client"; import { App, McpUiAppCapabilities, PostMessageTransport } from "../app"; export * from "../app"; @@ -131,15 +130,27 @@ export function useApp({ async function connect() { try { - const transport = new PostMessageTransport( - window.parent, - window.parent, - ); const app = new App(appInfo, capabilities); // Register handlers BEFORE connecting onAppCreated?.(app); + // Standalone mode: when not in an iframe, provide the app without + // attempting connection. This enables testing/development outside + // an MCP host context - the app will have isConnected: false. + if (typeof window === "undefined" || window.self === window.top) { + if (mounted) { + setApp(app); + setIsConnected(false); + setError(null); + } + return; + } + + const transport = new PostMessageTransport( + window.parent, + window.parent, + ); await app.connect(transport); if (mounted) { diff --git a/src/spec.types.ts b/src/spec.types.ts index 6fc8a1f4..0c05b119 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -787,3 +787,214 @@ export interface McpUiClientCapabilities { */ mimeTypes?: string[]; } + +/** + * @description Body encoding type for http_request tool. + * + * - `"none"` - No body (explicit empty body) + * - `"json"` - JSON-serializable value, sent as `application/json` + * - `"text"` - Plain text string + * - `"formData"` - Array of {@link McpHttpFormField} entries + * - `"urlEncoded"` - URL-encoded string (key=value&key2=value2) + * - `"base64"` - Binary data as base64-encoded string + */ +export type McpHttpBodyType = + | "none" + | "json" + | "text" + | "formData" + | "urlEncoded" + | "base64"; + +/** + * @description Standard HTTP methods. + */ +export type McpHttpMethod = + | "GET" + | "POST" + | "PUT" + | "DELETE" + | "PATCH" + | "HEAD" + | "OPTIONS"; + +/** + * @description Text form field for multipart/form-data requests. + */ +export interface McpHttpFormFieldText { + /** @description Field name. */ + name: string; + /** @description Text value for this field. */ + value: string; +} + +/** + * @description Binary/file form field for multipart/form-data requests. + */ +export interface McpHttpFormFieldBinary { + /** @description Field name. */ + name: string; + /** @description Base64-encoded binary data. */ + data: string; + /** @description Original filename (for file uploads). */ + filename?: string; + /** @description MIME type of the file. */ + contentType?: string; +} + +/** + * @description Form field for formData body type. + * Either a text field with `value`, or a binary/file field with `data`. + */ +export type McpHttpFormField = McpHttpFormFieldText | McpHttpFormFieldBinary; + +/** + * @description HTTP request payload for http_request tool. + * Uses browser-compatible types where possible. + */ +export interface McpHttpRequest { + /** + * @description HTTP method. Defaults to "GET". + * Standard HTTP methods or custom strings. + */ + method?: string; + /** @description Request URL. */ + url: string; + /** @description Request headers as key-value pairs. */ + headers?: Record; + /** @description Request body. Type depends on bodyType. */ + body?: unknown; + /** @description Body encoding type. */ + bodyType?: McpHttpBodyType; + /** @description Redirect handling. Matches browser RequestInit.redirect semantics. */ + redirect?: "follow" | "error" | "manual"; + /** @description Cache mode. Matches browser RequestInit.cache semantics. */ + cache?: + | "default" + | "no-store" + | "reload" + | "no-cache" + | "force-cache" + | "only-if-cached"; + /** @description Credentials mode. Matches browser RequestInit.credentials semantics. */ + credentials?: "omit" | "same-origin" | "include"; + /** @description Request timeout in milliseconds. */ + timeoutMs?: number; + /** + * Index signature for MCP SDK compatibility. + * Allows additional properties for forward compatibility. + */ + [key: string]: unknown; +} + +/** + * @description HTTP response payload from http_request tool. + */ +export interface McpHttpResponse { + /** @description HTTP status code. */ + status: number; + /** @description HTTP status text (e.g., "OK", "Not Found"). */ + statusText?: string; + /** @description Response headers as key-value pairs. */ + headers?: Record; + /** @description Response body. Type depends on bodyType. */ + body?: unknown; + /** @description Body encoding type. */ + bodyType?: McpHttpBodyType; + /** @description Final URL after redirects. */ + url?: string; + /** @description True if the request was redirected. */ + redirected?: boolean; + /** @description True if status is 200-299. */ + ok?: boolean; + /** + * Index signature for MCP SDK compatibility. + * Allows additional properties for forward compatibility. + */ + [key: string]: unknown; +} + +/** + * @description Discriminated union for type-safe HTTP request body construction. + * Use these types when you want compile-time enforcement of body/bodyType correlation. + */ +export type McpHttpRequestBody = + | { bodyType?: "none" | undefined; body?: undefined } + | { bodyType: "json"; body: unknown } + | { bodyType: "text"; body: string } + | { bodyType: "base64"; body: string } + | { bodyType: "urlEncoded"; body: string } + | { bodyType: "formData"; body: McpHttpFormField[] }; + +/** + * @description Discriminated union for type-safe HTTP response body handling. + * Use these types when you want compile-time enforcement of body/bodyType correlation. + */ +export type McpHttpResponseBody = + | { bodyType?: "none" | undefined; body?: undefined } + | { bodyType: "json"; body: unknown } + | { bodyType: "text"; body: string } + | { bodyType: "base64"; body: string } + | { bodyType: "urlEncoded"; body: string } + | { bodyType: "formData"; body: McpHttpFormField[] }; + +/** + * @description Base request fields without body (for use with discriminated union). + */ +export interface McpHttpRequestBase { + /** @description HTTP method. Defaults to "GET". */ + method?: string; + /** @description Request URL. */ + url: string; + /** @description Request headers as key-value pairs. */ + headers?: Record; + /** @description Redirect handling. */ + redirect?: "follow" | "error" | "manual"; + /** @description Cache mode. */ + cache?: + | "default" + | "no-store" + | "reload" + | "no-cache" + | "force-cache" + | "only-if-cached"; + /** @description Credentials mode. */ + credentials?: "omit" | "same-origin" | "include"; + /** @description Request timeout in milliseconds. */ + timeoutMs?: number; +} + +/** + * @description Base response fields without body (for use with discriminated union). + */ +export interface McpHttpResponseBase { + /** @description HTTP status code. */ + status: number; + /** @description HTTP status text. */ + statusText?: string; + /** @description Response headers as key-value pairs. */ + headers?: Record; + /** @description Final URL after redirects. */ + url?: string; + /** @description True if the request was redirected. */ + redirected?: boolean; + /** @description True if status is 200-299. */ + ok?: boolean; +} + +/** + * @description Type-safe HTTP request with enforced body/bodyType correlation. + * Prefer this over McpHttpRequest when constructing requests programmatically. + */ +export type McpHttpRequestStrict = McpHttpRequestBase & McpHttpRequestBody; + +/** + * @description Type-safe HTTP response with enforced body/bodyType correlation. + * Prefer this over McpHttpResponse when handling responses programmatically. + */ +export type McpHttpResponseStrict = McpHttpResponseBase & McpHttpResponseBody; + +/** + * Default tool name for HTTP request proxying. + */ +export const HTTP_REQUEST_TOOL_NAME = "http_request" as const; diff --git a/src/types.ts b/src/types.ts index a4770fda..8841a90d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,6 +62,20 @@ export { type McpUiToolVisibility, type McpUiToolMeta, type McpUiClientCapabilities, + HTTP_REQUEST_TOOL_NAME, + type McpHttpBodyType, + type McpHttpMethod, + type McpHttpFormFieldText, + type McpHttpFormFieldBinary, + type McpHttpFormField, + type McpHttpRequest, + type McpHttpResponse, + type McpHttpRequestBody, + type McpHttpResponseBody, + type McpHttpRequestBase, + type McpHttpResponseBase, + type McpHttpRequestStrict, + type McpHttpResponseStrict, } from "./spec.types.js"; // Import types needed for protocol type unions (not re-exported, just used internally) @@ -123,6 +137,13 @@ export { McpUiRequestDisplayModeResultSchema, McpUiToolVisibilitySchema, McpUiToolMetaSchema, + McpHttpBodyTypeSchema, + McpHttpMethodSchema, + McpHttpFormFieldTextSchema, + McpHttpFormFieldBinarySchema, + McpHttpFormFieldSchema, + McpHttpRequestSchema, + McpHttpResponseSchema, } from "./generated/schema.js"; // Re-export SDK types used in protocol type unions diff --git a/tests/browser/fetch-wrapper.browser.test.ts b/tests/browser/fetch-wrapper.browser.test.ts new file mode 100644 index 00000000..2d5b758b --- /dev/null +++ b/tests/browser/fetch-wrapper.browser.test.ts @@ -0,0 +1,1022 @@ +import { describe, expect } from "vitest"; +import { http, HttpResponse } from "msw"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { App } from "../../src/app.ts"; +import { + createHttpRequestToolHandler, + initMcpFetch, + wrapCallToolHandlerWithFetchProxy, +} from "../../src/http-adapter/fetch-wrapper/fetch.ts"; +import { test } from "./test-extend"; + +function createAppStub(result: CallToolResult) { + const calls: Array<{ + params: { name: string; arguments?: Record }; + options?: { signal?: AbortSignal }; + }> = []; + const callServerTool = async ( + params: { name: string; arguments?: Record }, + options?: { signal?: AbortSignal }, + ) => { + calls.push({ params, options }); + return result; + }; + const getHostCapabilities = () => ({ serverTools: {} }); + return { + app: { callServerTool, getHostCapabilities } as unknown as App, + calls, + }; +} + +async function readRequest(request: Request): Promise<{ + url: string; + method: string; + headers: Record; + body: string; +}> { + return { + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + body: await request.text(), + }; +} + +describe("fetch-wrapper (browser)", () => { + test("intercepts fetch and calls http_request", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "application/json" }, + body: { ok: true }, + bodyType: "json", + }, + }; + + const { app, calls } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { installGlobal: false }); + + const response = await handle.fetch("/api/time", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ a: 1 }), + }); + + expect(calls).toHaveLength(1); + + const call = calls[0]?.params; + expect(call?.name).toBe("http_request"); + expect(call?.arguments?.url).toBe("/api/time"); + expect(call?.arguments?.method).toBe("POST"); + expect(call?.arguments?.bodyType).toBe("json"); + expect(call?.arguments?.body).toEqual({ a: 1 }); + + await expect(response.json()).resolves.toEqual({ ok: true }); + }); + + test("falls back to native fetch when not connected to MCP host", async ({ + worker, + }) => { + const calls: Array = []; + const app = { + callServerTool: async () => { + calls.push("called"); + return { content: [] } as CallToolResult; + }, + getHostCapabilities: () => undefined, + } as unknown as App; + + worker.use( + http.get("/public", () => { + return HttpResponse.text("native", { status: 200 }); + }), + ); + + const handle = initMcpFetch(app, { + installGlobal: false, + fallbackToNative: true, + }); + + const response = await handle.fetch("/public"); + + expect(calls).toHaveLength(0); + await expect(response.text()).resolves.toBe("native"); + }); + + test("passes Request.signal to callServerTool", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "application/json" }, + body: { ok: true }, + bodyType: "json", + }, + }; + + const controller = new AbortController(); + const calls: Array<{ options?: { signal?: AbortSignal } }> = []; + const app = { + callServerTool: async ( + _params: { name: string; arguments?: Record }, + options?: { signal?: AbortSignal }, + ) => { + calls.push({ options }); + return toolResult; + }, + getHostCapabilities: () => ({ serverTools: {} }), + } as unknown as App; + + const handle = initMcpFetch(app, { installGlobal: false }); + + const request = new Request("/api/signal", { signal: controller.signal }); + const response = await handle.fetch(request); + + expect(calls).toHaveLength(1); + expect(calls[0]?.options?.signal).toBeDefined(); + expect(calls[0]?.options?.signal?.aborted).toBe(false); + await expect(response.json()).resolves.toEqual({ ok: true }); + }); + + test("throws AbortError for pre-aborted Request.signal", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "application/json" }, + body: { ok: true }, + bodyType: "json", + }, + }; + + const { app, calls } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { installGlobal: false }); + + const controller = new AbortController(); + controller.abort(); + const request = new Request("/api/abort", { signal: controller.signal }); + + await expect(handle.fetch(request)).rejects.toMatchObject({ + name: "AbortError", + }); + expect(calls).toHaveLength(0); + }); + + test("respects interceptPaths", async ({ worker }) => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app, calls } = createAppStub(toolResult); + + worker.use( + http.get("/public", () => HttpResponse.text("native", { status: 200 })), + ); + + const handle = initMcpFetch(app, { + installGlobal: false, + interceptPaths: ["/api"], + }); + + const response = await handle.fetch("/public"); + + expect(calls).toHaveLength(0); + await expect(response.text()).resolves.toBe("native"); + }); + + test("bypasses interception when interceptEnabled returns false", async ({ + worker, + }) => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app, calls } = createAppStub(toolResult); + + worker.use( + http.get("/api/disabled", () => + HttpResponse.text("native", { status: 200 }), + ), + ); + + const handle = initMcpFetch(app, { + installGlobal: false, + interceptEnabled: () => false, + }); + + const response = await handle.fetch("/api/disabled"); + + expect(calls).toHaveLength(0); + await expect(response.text()).resolves.toBe("native"); + }); + + test("skips interception for absolute URLs when disallowed", async ({ + worker, + }) => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app, calls } = createAppStub(toolResult); + + worker.use( + http.get("https://example.com/api", () => + HttpResponse.text("native", { status: 200 }), + ), + ); + + const handle = initMcpFetch(app, { + installGlobal: false, + allowAbsoluteUrls: false, + }); + + const response = await handle.fetch("https://example.com/api"); + + expect(calls).toHaveLength(0); + await expect(response.text()).resolves.toBe("native"); + }); + + test("serializes text body correctly", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app, calls } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { installGlobal: false }); + + await handle.fetch("/api/text", { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: "Hello, World!", + }); + + const call = calls[0]?.params; + expect(call?.arguments?.bodyType).toBe("text"); + expect(call?.arguments?.body).toBe("Hello, World!"); + }); + + test("serializes URLSearchParams body as urlEncoded", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app, calls } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { installGlobal: false }); + + const params = new URLSearchParams(); + params.set("foo", "bar"); + params.set("baz", "qux"); + + await handle.fetch("/api/form", { + method: "POST", + body: params, + }); + + const call = calls[0]?.params; + expect(call?.arguments?.bodyType).toBe("urlEncoded"); + expect(call?.arguments?.body).toBe("foo=bar&baz=qux"); + }); + + test("serializes FormData body with fields", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app, calls } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { installGlobal: false }); + + const formData = new FormData(); + formData.append("name", "John"); + formData.append("age", "30"); + + await handle.fetch("/api/upload", { + method: "POST", + body: formData, + }); + + const call = calls[0]?.params; + expect(call?.arguments?.bodyType).toBe("formData"); + expect(call?.arguments?.body).toEqual([ + { name: "name", value: "John" }, + { name: "age", value: "30" }, + ]); + }); + + test("serializes Blob body as base64", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app, calls } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { installGlobal: false }); + + const bytes = new TextEncoder().encode("Hello"); + const blob = new Blob([bytes], { type: "application/octet-stream" }); + + await handle.fetch("/api/binary", { + method: "POST", + body: blob, + }); + + const call = calls[0]?.params; + expect(call?.arguments?.bodyType).toBe("base64"); + expect(call?.arguments?.body).toBe(btoa("Hello")); + }); + + test("serializes ArrayBuffer body as base64", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app, calls } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { installGlobal: false }); + + const bytes = new Uint8Array([1, 2, 3, 4, 5]); + + await handle.fetch("/api/binary", { + method: "POST", + body: bytes.buffer, + }); + + const call = calls[0]?.params; + expect(call?.arguments?.bodyType).toBe("base64"); + expect(call?.arguments?.body).toBe(btoa(String.fromCharCode(...bytes))); + }); + + test("includes query strings in tool url", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "application/json" }, + body: { ok: true }, + bodyType: "json", + }, + }; + const { app, calls } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { installGlobal: false }); + + await handle.fetch("/api/search?q=test&limit=1"); + + const call = calls[0]?.params; + expect(call?.arguments?.url).toBe("/api/search?q=test&limit=1"); + }); + + test("uses custom toolName option", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app, calls } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { + installGlobal: false, + toolName: "custom_request", + }); + + await handle.fetch("/api/custom"); + + const call = calls[0]?.params; + expect(call?.name).toBe("custom_request"); + }); + + test("respects custom shouldIntercept", async ({ worker }) => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app, calls } = createAppStub(toolResult); + + worker.use( + http.get("/api/nope", () => HttpResponse.text("native", { status: 200 })), + ); + + const handle = initMcpFetch(app, { + installGlobal: false, + shouldIntercept: () => false, + }); + + const response = await handle.fetch("/api/nope"); + + expect(calls).toHaveLength(0); + await expect(response.text()).resolves.toBe("native"); + }); + + test("throws AbortError when signal is already aborted", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { installGlobal: false }); + const controller = new AbortController(); + controller.abort(); + + await expect( + handle.fetch("/api/abort", { signal: controller.signal }), + ).rejects.toMatchObject({ name: "AbortError" }); + }); + + test("handles text response bodies", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "hello", + bodyType: "text", + }, + }; + const { app } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { installGlobal: false }); + const response = await handle.fetch("/api/text"); + + await expect(response.text()).resolves.toBe("hello"); + }); + + test("handles urlEncoded response bodies", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "a=1&b=2", + bodyType: "urlEncoded", + }, + }; + const { app } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { installGlobal: false }); + const response = await handle.fetch("/api/form"); + + await expect(response.text()).resolves.toBe("a=1&b=2"); + }); + + test("handles base64 response bodies", async () => { + const bytes = new Uint8Array([1, 2, 3]); + const base64 = btoa(String.fromCharCode(...bytes)); + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "application/octet-stream" }, + body: base64, + bodyType: "base64", + }, + }; + const { app } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { installGlobal: false }); + const response = await handle.fetch("/api/binary"); + + const buffer = await response.arrayBuffer(); + expect(new Uint8Array(buffer)).toEqual(bytes); + }); + + test("handles empty body responses", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 204, + headers: {}, + bodyType: "none", + }, + }; + const { app } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { installGlobal: false }); + const response = await handle.fetch("/api/empty"); + + expect(response.status).toBe(204); + await expect(response.text()).resolves.toBe(""); + }); + + test("replaces global fetch when installGlobal is true", async () => { + const originalFetch = globalThis.fetch; + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app } = createAppStub(toolResult); + + try { + const handle = initMcpFetch(app, { + installGlobal: true, + fetch: originalFetch, + }); + expect(globalThis.fetch).toBe(handle.fetch); + handle.restore(); + expect(globalThis.fetch).toBe(originalFetch); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("stop() pauses interception and isActive() returns false", async ({ + worker, + }) => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "application/json" }, + body: { ok: true }, + bodyType: "json", + }, + }; + const { app, calls } = createAppStub(toolResult); + + worker.use( + http.get("/api/test2", () => + HttpResponse.text("native", { status: 200 }), + ), + ); + + const handle = initMcpFetch(app, { + installGlobal: false, + }); + + expect(handle.isActive()).toBe(true); + + await handle.fetch("/api/test1"); + expect(calls).toHaveLength(1); + + handle.stop(); + expect(handle.isActive()).toBe(false); + + await handle.fetch("/api/test2"); + expect(calls).toHaveLength(1); + }); + + test("start() resumes interception after stop()", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "application/json" }, + body: { ok: true }, + bodyType: "json", + }, + }; + const { app, calls } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { + installGlobal: false, + }); + + handle.stop(); + expect(handle.isActive()).toBe(false); + + handle.start(); + expect(handle.isActive()).toBe(true); + + await handle.fetch("/api/test"); + expect(calls).toHaveLength(1); + }); +}); + +describe("fetch proxy handler (browser)", () => { + test("proxies requests, strips forbidden headers, and returns structured content", async ({ + worker, + }) => { + const requests: Array>> = []; + + worker.use( + http.post("https://example.com/api/test", async ({ request }) => { + requests.push(await readRequest(request)); + return HttpResponse.json({ ok: true }, { status: 200 }); + }), + ); + + const handler = createHttpRequestToolHandler({ + baseUrl: "https://example.com", + allowOrigins: ["https://example.com"], + allowPaths: ["/api"], + headers: { "x-server": "1" }, + }); + + const result = await handler({ + name: "http_request", + arguments: { + method: "POST", + url: "/api/test", + headers: { + authorization: "secret", + "x-client": "2", + }, + body: { foo: "bar" }, + bodyType: "json", + }, + }); + + expect(requests).toHaveLength(1); + const request = requests[0]; + expect(request.headers.authorization).toBeUndefined(); + expect(request.headers["x-server"]).toBe("1"); + expect(request.headers["x-client"]).toBe("2"); + expect(request.body).toBe(JSON.stringify({ foo: "bar" })); + + expect(result.structuredContent).toMatchObject({ + status: 200, + bodyType: "json", + body: { ok: true }, + }); + }); + + test("treats falsy JSON values as json", async ({ worker }) => { + worker.use( + http.get("https://example.com/api/test", () => + HttpResponse.text("false", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + + const handler = createHttpRequestToolHandler({ + baseUrl: "https://example.com", + allowOrigins: ["https://example.com"], + allowPaths: ["/api"], + }); + + const result = await handler({ + name: "http_request", + arguments: { url: "/api/test" }, + }); + + expect(result.structuredContent).toMatchObject({ + status: 200, + bodyType: "json", + body: false, + }); + }); + + test("rejects disallowed paths", async () => { + const handler = createHttpRequestToolHandler({ + baseUrl: "https://example.com", + allowOrigins: ["https://example.com"], + allowPaths: ["/api"], + }); + + await expect( + handler({ + name: "http_request", + arguments: { url: "/private" }, + }), + ).rejects.toThrow("Path not allowed"); + }); + + test("rejects oversized bodies", async () => { + const handler = createHttpRequestToolHandler({ + baseUrl: "https://example.com", + allowOrigins: ["https://example.com"], + allowPaths: ["/api"], + maxBodySize: 10, + }); + + await expect( + handler({ + name: "http_request", + arguments: { + url: "/api/test", + body: "x".repeat(32), + bodyType: "text", + }, + }), + ).rejects.toThrow("exceeds maximum allowed size"); + }); + + test("allows base64 object bodies within size limits", async ({ worker }) => { + worker.use( + http.get("https://example.com/api/test", () => + HttpResponse.text("ok", { status: 200 }), + ), + ); + + const handler = createHttpRequestToolHandler({ + baseUrl: "https://example.com", + allowOrigins: ["https://example.com"], + allowPaths: ["/api"], + maxBodySize: 10, + }); + + await handler({ + name: "http_request", + arguments: { + url: "/api/test", + bodyType: "base64", + body: { data: "a".repeat(8) }, + }, + }); + }); + + test("delegates non-http_request tools", async () => { + const calls: Array<{ name: string }> = []; + const baseHandler = async () => { + calls.push({ name: "other_tool" }); + return { content: [{ type: "text" as const, text: "ok" }] }; + }; + + const wrapped = wrapCallToolHandlerWithFetchProxy(baseHandler, { + baseUrl: "https://example.com", + allowOrigins: ["https://example.com"], + allowPaths: ["/api"], + }); + + await wrapped({ name: "other_tool", arguments: {} }, {}); + + expect(calls).toHaveLength(1); + }); + + test("rejects disallowed origins", async () => { + const handler = createHttpRequestToolHandler({ + allowOrigins: ["https://example.com"], + allowPaths: ["/"], + }); + + await expect( + handler({ + name: "http_request", + arguments: { + url: "https://evil.example.com/api", + }, + }), + ).rejects.toThrow("Origin not allowed"); + }); + + test("applies headers function and preserves allowed headers", async ({ + worker, + }) => { + const requests: Array>> = []; + + worker.use( + http.post("https://example.com/api/test", async ({ request }) => { + requests.push(await readRequest(request)); + return HttpResponse.text("ok", { status: 200 }); + }), + ); + + const handler = createHttpRequestToolHandler({ + baseUrl: "https://example.com", + allowOrigins: ["https://example.com"], + allowPaths: ["/api"], + headers: (request) => ({ + "x-dynamic": String(request.method ?? ""), + }), + }); + + await handler({ + name: "http_request", + arguments: { + method: "POST", + url: "/api/test", + headers: { "x-client": "1" }, + body: "ok", + bodyType: "text", + }, + }); + + expect(requests).toHaveLength(1); + const request = requests[0]; + expect(request.headers["x-dynamic"]).toBe("POST"); + expect(request.headers["x-client"]).toBe("1"); + }); + + test("merges HeadersInit entries from options", async ({ worker }) => { + const requests: Array>> = []; + + worker.use( + http.post("https://example.com/api/test", async ({ request }) => { + requests.push(await readRequest(request)); + return HttpResponse.text("ok", { status: 200 }); + }), + ); + + const serverHeaders = new Headers(); + serverHeaders.set("x-server", "1"); + + const handler = createHttpRequestToolHandler({ + baseUrl: "https://example.com", + allowOrigins: ["https://example.com"], + allowPaths: ["/api"], + headers: serverHeaders, + }); + + await handler({ + name: "http_request", + arguments: { + method: "POST", + url: "/api/test", + headers: { "x-client": "2" }, + body: "ok", + bodyType: "text", + }, + }); + + expect(requests).toHaveLength(1); + const request = requests[0]; + expect(request.headers["x-server"]).toBe("1"); + expect(request.headers["x-client"]).toBe("2"); + }); + + test("drops content-type headers for formData bodies", async ({ worker }) => { + const requests: Array>> = []; + + worker.use( + http.post("https://example.com/api/upload", async ({ request }) => { + requests.push(await readRequest(request)); + return HttpResponse.text("ok", { status: 200 }); + }), + ); + + const handler = createHttpRequestToolHandler({ + baseUrl: "https://example.com", + allowOrigins: ["https://example.com"], + allowPaths: ["/api"], + }); + + await handler({ + name: "http_request", + arguments: { + method: "POST", + url: "/api/upload", + headers: { + "content-type": "multipart/form-data; boundary=stale", + "content-length": "123", + }, + bodyType: "formData", + body: [{ name: "note", value: "hello" }], + }, + }); + + expect(requests).toHaveLength(1); + const request = requests[0]; + expect(request.headers["content-type"]).not.toBe( + "multipart/form-data; boundary=stale", + ); + expect(request.headers["content-length"]).not.toBe("123"); + }); + + test("strips all forbidden headers", async ({ worker }) => { + const requests: Array>> = []; + + worker.use( + http.get("https://example.com/api/headers", async ({ request }) => { + requests.push(await readRequest(request)); + return HttpResponse.text("ok", { status: 200 }); + }), + ); + + const handler = createHttpRequestToolHandler({ + baseUrl: "https://example.com", + allowOrigins: ["https://example.com"], + allowPaths: ["/api"], + }); + + await handler({ + name: "http_request", + arguments: { + method: "GET", + url: "/api/headers", + headers: { + cookie: "a=b", + "set-cookie": "a=b", + authorization: "secret", + "proxy-authorization": "secret", + host: "example.com", + origin: "https://example.com", + referer: "https://example.com", + }, + }, + }); + + expect(requests).toHaveLength(1); + const request = requests[0]; + const forbidden = [ + "cookie", + "set-cookie", + "authorization", + "proxy-authorization", + "host", + "origin", + "referer", + ]; + for (const name of forbidden) { + expect(request.headers[name]).toBeUndefined(); + } + }); + + test("passes credentials and cache options to fetch", async ({ worker }) => { + let capturedCredentials: RequestCredentials | undefined; + let capturedCache: RequestCache | undefined; + + worker.use( + http.get("https://example.com/api/cache", ({ request }) => { + capturedCredentials = request.credentials; + capturedCache = request.cache; + return HttpResponse.text("ok", { status: 200 }); + }), + ); + + const handler = createHttpRequestToolHandler({ + baseUrl: "https://example.com", + allowOrigins: ["https://example.com"], + allowPaths: ["/api"], + credentials: "include", + }); + + await handler({ + name: "http_request", + arguments: { + url: "/api/cache", + cache: "no-store", + }, + }); + + expect(capturedCredentials).toBe("include"); + expect(capturedCache).toBe("no-store"); + }); + + test("throws when tool returns isError", async () => { + const toolResult: CallToolResult = { + isError: true, + content: [{ type: "text", text: "boom" }], + }; + const { app } = createAppStub(toolResult); + + const handle = initMcpFetch(app, { installGlobal: false }); + + await expect(handle.fetch("/api/error")).rejects.toThrow("boom"); + }); +}); diff --git a/tests/browser/msw/browser.ts b/tests/browser/msw/browser.ts new file mode 100644 index 00000000..60706970 --- /dev/null +++ b/tests/browser/msw/browser.ts @@ -0,0 +1,3 @@ +import { setupWorker } from "msw/browser"; + +export const worker = setupWorker(); diff --git a/tests/browser/test-extend.ts b/tests/browser/test-extend.ts new file mode 100644 index 00000000..f2a4af74 --- /dev/null +++ b/tests/browser/test-extend.ts @@ -0,0 +1,24 @@ +import { test as testBase } from "vitest"; +import { worker } from "./msw/browser"; + +export const test = testBase.extend<{ worker: typeof worker }>({ + worker: [ + async ({}, use) => { + await worker.start({ + onUnhandledRequest: "error", + serviceWorker: { + url: "/mockServiceWorker.js", + options: { scope: "/" }, + }, + }); + + await use(worker); + + worker.resetHandlers(); + worker.stop(); + }, + { auto: true }, + ], +}); + +export { expect, describe, beforeEach, afterEach, vi } from "vitest"; diff --git a/tests/browser/xhr-wrapper.browser.test.ts b/tests/browser/xhr-wrapper.browser.test.ts new file mode 100644 index 00000000..a7a51174 --- /dev/null +++ b/tests/browser/xhr-wrapper.browser.test.ts @@ -0,0 +1,956 @@ +import { afterEach, describe, expect, vi } from "vitest"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { App } from "../../src/app.ts"; +import { initMcpXhr } from "../../src/http-adapter/xhr-wrapper/xhr.ts"; +import { test } from "./test-extend"; + +function createAppStub(result: CallToolResult) { + const callServerTool = vi.fn().mockResolvedValue(result); + const getHostCapabilities = vi.fn(() => ({ serverTools: {} })); + return { + app: { callServerTool, getHostCapabilities } as unknown as App, + callServerTool, + }; +} + +class FakeXMLHttpRequest extends EventTarget { + static instances: FakeXMLHttpRequest[] = []; + static lastRequest: { + method: string; + url: string; + async: boolean; + headers: Record; + body?: Document | XMLHttpRequestBodyInit | null; + } | null = null; + + static reset() { + FakeXMLHttpRequest.instances = []; + FakeXMLHttpRequest.lastRequest = null; + } + + responseType: XMLHttpRequestResponseType = ""; + response: unknown = "native"; + responseText = "native"; + responseURL = ""; + responseXML: Document | null = null; + status = 200; + statusText = "OK"; + readyState = 0; + timeout = 0; + withCredentials = false; + upload = new EventTarget() as XMLHttpRequestUpload; + + onreadystatechange: ((this: XMLHttpRequest, ev: Event) => unknown) | null = + null; + onload: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null = null; + onerror: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null = null; + onabort: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null = null; + ontimeout: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null = + null; + onloadstart: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null = + null; + onloadend: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null = + null; + onprogress: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null = + null; + + private headers: Record = {}; + + constructor() { + super(); + FakeXMLHttpRequest.instances.push(this); + } + + open( + method: string, + url: string, + async: boolean = true, + _user?: string | null, + _password?: string | null, + ): void { + this.readyState = 1; + FakeXMLHttpRequest.lastRequest = { + method, + url, + async, + headers: { ...this.headers }, + }; + } + + setRequestHeader(name: string, value: string): void { + this.headers[name.toLowerCase()] = value; + } + + send(body?: Document | XMLHttpRequestBodyInit | null): void { + if (!FakeXMLHttpRequest.lastRequest) { + FakeXMLHttpRequest.lastRequest = { + method: "GET", + url: "", + async: true, + headers: {}, + }; + } + FakeXMLHttpRequest.lastRequest = { + ...FakeXMLHttpRequest.lastRequest, + headers: { ...this.headers }, + body, + }; + this.dispatchEvent(new Event("load")); + this.dispatchEvent(new Event("loadend")); + } + + abort(): void { + this.dispatchEvent(new Event("abort")); + } + + getResponseHeader(_name: string): string | null { + return null; + } + + getAllResponseHeaders(): string { + return ""; + } + + overrideMimeType(_mime: string): void {} +} + +const NativeXMLHttpRequest = globalThis.XMLHttpRequest; + +afterEach(() => { + globalThis.XMLHttpRequest = NativeXMLHttpRequest; + FakeXMLHttpRequest.reset(); + vi.restoreAllMocks(); +}); + +describe("xhr-wrapper (browser)", () => { + test("intercepts XHR and calls http_request", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "application/json" }, + body: { ok: true }, + bodyType: "json", + }, + }; + const { app, callServerTool } = createAppStub(toolResult); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + xhr.responseType = "json"; + + const loaded = new Promise((resolve, reject) => { + xhr.onload = () => resolve(); + xhr.onerror = () => reject(new Error("XHR error")); + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + xhr.open("POST", "/api/time"); + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.setRequestHeader("Authorization", "secret"); + xhr.send(JSON.stringify({ foo: "bar" })); + + await loaded; + + expect(callServerTool).toHaveBeenCalledTimes(1); + const call = callServerTool.mock.calls[0]?.[0] as { + name: string; + arguments: Record; + }; + expect(call.name).toBe("http_request"); + expect(call.arguments.url).toBe("/api/time"); + expect(call.arguments.method).toBe("POST"); + expect(call.arguments.bodyType).toBe("json"); + expect(call.arguments.body).toEqual({ foo: "bar" }); + expect( + (call.arguments.headers as Record | undefined) + ?.authorization, + ).toBeUndefined(); + + expect(xhr.status).toBe(200); + expect(xhr.response).toEqual({ ok: true }); + expect(() => xhr.responseText).toThrow( + "Failed to read the 'responseText' property", + ); + expect(xhr.readyState).toBe(4); + + warnSpy.mockRestore(); + }); + + test("falls back to native XHR when not connected to MCP host", () => { + globalThis.XMLHttpRequest = + FakeXMLHttpRequest as unknown as typeof XMLHttpRequest; + + const app = { + callServerTool: vi.fn(), + getHostCapabilities: () => undefined, + } as unknown as App; + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + xhr.open("GET", "/public"); + xhr.send(); + + expect(app.callServerTool).not.toHaveBeenCalled(); + expect(FakeXMLHttpRequest.lastRequest?.url).toBe("/public"); + }); + + test("respects interceptPaths", () => { + globalThis.XMLHttpRequest = + FakeXMLHttpRequest as unknown as typeof XMLHttpRequest; + + const { app, callServerTool } = createAppStub({ + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }); + + const handle = initMcpXhr(app, { + installGlobal: false, + interceptPaths: ["/api"], + }); + const xhr = new handle.XMLHttpRequest(); + xhr.open("GET", "/public"); + xhr.send(); + + expect(callServerTool).not.toHaveBeenCalled(); + expect(FakeXMLHttpRequest.lastRequest?.url).toBe("/public"); + }); + + test("bypasses interception when interceptEnabled returns false", () => { + globalThis.XMLHttpRequest = + FakeXMLHttpRequest as unknown as typeof XMLHttpRequest; + + const { app, callServerTool } = createAppStub({ + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }); + + const handle = initMcpXhr(app, { + installGlobal: false, + interceptEnabled: () => false, + }); + const xhr = new handle.XMLHttpRequest(); + xhr.open("GET", "/api/skip"); + xhr.send(); + + expect(callServerTool).not.toHaveBeenCalled(); + expect(FakeXMLHttpRequest.lastRequest?.url).toBe("/api/skip"); + }); + + test("skips interception for absolute URLs when disallowed", () => { + globalThis.XMLHttpRequest = + FakeXMLHttpRequest as unknown as typeof XMLHttpRequest; + + const { app, callServerTool } = createAppStub({ + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }); + + const handle = initMcpXhr(app, { + installGlobal: false, + allowAbsoluteUrls: false, + }); + const xhr = new handle.XMLHttpRequest(); + xhr.open("GET", "https://example.com/api"); + xhr.send(); + + expect(callServerTool).not.toHaveBeenCalled(); + expect(FakeXMLHttpRequest.lastRequest?.url).toBe("https://example.com/api"); + }); + + test("decodes base64 responses for arraybuffer responseType", async () => { + const bytes = new Uint8Array([1, 2, 3]); + const base64 = btoa(String.fromCharCode(...bytes)); + + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "application/octet-stream" }, + body: base64, + bodyType: "base64", + }, + }; + const { app } = createAppStub(toolResult); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + xhr.responseType = "arraybuffer"; + + const loaded = new Promise((resolve, reject) => { + xhr.onload = () => resolve(); + xhr.onerror = () => reject(new Error("XHR error")); + }); + + xhr.open("GET", "/api/blob"); + xhr.send(); + + await loaded; + + const response = xhr.response as ArrayBuffer; + expect(new Uint8Array(response)).toEqual(bytes); + }); + + test("throws for synchronous intercepted requests", () => { + const { app } = createAppStub({ + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + + expect(() => xhr.open("GET", "/api", false)).toThrow( + "Synchronous XMLHttpRequest is not supported in MCP Apps", + ); + }); + + test("serializes text body correctly", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app, callServerTool } = createAppStub(toolResult); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + + const loaded = new Promise((resolve, reject) => { + xhr.onload = () => resolve(); + xhr.onerror = () => reject(new Error("XHR error")); + }); + + xhr.open("POST", "/api/text"); + xhr.setRequestHeader("Content-Type", "text/plain"); + xhr.send("Hello, World!"); + + await loaded; + + const call = callServerTool.mock.calls[0]?.[0] as { + name: string; + arguments: Record; + }; + expect(call.arguments.bodyType).toBe("text"); + expect(call.arguments.body).toBe("Hello, World!"); + }); + + test("serializes URLSearchParams body as urlEncoded", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app, callServerTool } = createAppStub(toolResult); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + + const loaded = new Promise((resolve, reject) => { + xhr.onload = () => resolve(); + xhr.onerror = () => reject(new Error("XHR error")); + }); + + const params = new URLSearchParams(); + params.set("foo", "bar"); + params.set("baz", "qux"); + + xhr.open("POST", "/api/form"); + xhr.send(params); + + await loaded; + + const call = callServerTool.mock.calls[0]?.[0] as { + name: string; + arguments: Record; + }; + expect(call.arguments.bodyType).toBe("urlEncoded"); + expect(call.arguments.body).toBe("foo=bar&baz=qux"); + }); + + test("serializes FormData body with fields", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app, callServerTool } = createAppStub(toolResult); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + + const loaded = new Promise((resolve, reject) => { + xhr.onload = () => resolve(); + xhr.onerror = () => reject(new Error("XHR error")); + }); + + const formData = new FormData(); + formData.append("name", "John"); + formData.append("age", "30"); + + xhr.open("POST", "/api/upload"); + xhr.send(formData); + + await loaded; + + const call = callServerTool.mock.calls[0]?.[0] as { + name: string; + arguments: Record; + }; + expect(call.arguments.bodyType).toBe("formData"); + expect(call.arguments.body).toEqual([ + { name: "name", value: "John" }, + { name: "age", value: "30" }, + ]); + }); + + test("handles text response with default responseType", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "hello", + bodyType: "text", + }, + }; + const { app } = createAppStub(toolResult); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + + const loaded = new Promise((resolve, reject) => { + xhr.onload = () => resolve(); + xhr.onerror = () => reject(new Error("XHR error")); + }); + + xhr.open("GET", "/api/text"); + xhr.send(); + + await loaded; + + expect(xhr.responseText).toBe("hello"); + expect(xhr.response).toBe("hello"); + }); + + test("handles blob responseType", async () => { + const bytes = new Uint8Array([4, 5, 6]); + const base64 = btoa(String.fromCharCode(...bytes)); + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "application/octet-stream" }, + body: base64, + bodyType: "base64", + }, + }; + const { app } = createAppStub(toolResult); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + xhr.responseType = "blob"; + + const loaded = new Promise((resolve, reject) => { + xhr.onload = () => resolve(); + xhr.onerror = () => reject(new Error("XHR error")); + }); + + xhr.open("GET", "/api/blob"); + xhr.send(); + + await loaded; + + const blob = xhr.response as Blob; + const buffer = await blob.arrayBuffer(); + expect(new Uint8Array(buffer)).toEqual(bytes); + }); + + test("returns null for document responseType", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app } = createAppStub(toolResult); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + xhr.responseType = "document"; + + const loaded = new Promise((resolve, reject) => { + xhr.onload = () => resolve(); + xhr.onerror = () => reject(new Error("XHR error")); + }); + + xhr.open("GET", "/api/doc"); + xhr.send(); + + await loaded; + + expect(xhr.response).toBeNull(); + }); + + test("fires readystatechange events in order", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app } = createAppStub(toolResult); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + + const states: number[] = []; + xhr.onreadystatechange = () => { + states.push(xhr.readyState); + }; + + const loaded = new Promise((resolve, reject) => { + xhr.onload = () => resolve(); + xhr.onerror = () => reject(new Error("XHR error")); + }); + + xhr.open("GET", "/api/state"); + xhr.send(); + + await loaded; + + expect(states).toEqual([1, 2, 3, 4]); + }); + + test("fires loadstart, progress, load, and loadend events", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "hello", + bodyType: "text", + }, + }; + const { app } = createAppStub(toolResult); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + + const events: string[] = []; + xhr.onloadstart = () => events.push("loadstart"); + xhr.onprogress = () => events.push("progress"); + xhr.onload = () => events.push("load"); + xhr.onloadend = () => events.push("loadend"); + + const loaded = new Promise((resolve, reject) => { + xhr.onload = () => { + events.push("load"); + resolve(); + }; + xhr.onerror = () => reject(new Error("XHR error")); + }); + + xhr.open("GET", "/api/events"); + xhr.send(); + + await loaded; + + expect(events).toEqual(["loadstart", "progress", "load", "loadend"]); + }); + + test("fires error when tool returns isError", async () => { + const toolResult: CallToolResult = { + isError: true, + content: [{ type: "text", text: "boom" }], + }; + const { app } = createAppStub(toolResult); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + + const errored = new Promise((resolve) => { + xhr.onerror = () => resolve(); + }); + + xhr.open("GET", "/api/error"); + xhr.send(); + + await errored; + + expect(xhr.status).toBe(0); + }); + + test("fires timeout when request exceeds timeout", async () => { + vi.useFakeTimers(); + try { + const callServerTool = vi.fn( + (_params: unknown, extra?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + extra?.signal?.addEventListener("abort", () => { + reject( + new DOMException("The operation was aborted.", "AbortError"), + ); + }); + }), + ); + const app = { + callServerTool, + getHostCapabilities: () => ({ serverTools: {} }), + } as unknown as App; + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + xhr.timeout = 10; + + const timedOut = new Promise((resolve) => { + xhr.ontimeout = () => resolve(); + }); + + xhr.open("GET", "/api/timeout"); + xhr.send(); + + await vi.advanceTimersByTimeAsync(20); + await timedOut; + } finally { + vi.useRealTimers(); + } + }); + + test("aborts requests and fires abort event", async () => { + const pending = new Promise(() => {}); + const callServerTool = vi.fn().mockReturnValue(pending); + const app = { + callServerTool, + getHostCapabilities: () => ({ serverTools: {} }), + } as unknown as App; + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + + const aborted = new Promise((resolve) => { + xhr.onabort = () => resolve(); + }); + + xhr.open("GET", "/api/abort"); + xhr.send(); + xhr.abort(); + + await aborted; + + expect(xhr.readyState).toBe(0); + }); + + test("throws when setRequestHeader is called before open", () => { + const { app } = createAppStub({ + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + + expect(() => xhr.setRequestHeader("X-Test", "1")).toThrow( + "The object's state must be OPENED", + ); + }); + + test("throws when setRequestHeader is called after send", async () => { + const { app } = createAppStub({ + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + + const loaded = new Promise((resolve, reject) => { + xhr.onload = () => resolve(); + xhr.onerror = () => reject(new Error("XHR error")); + }); + + xhr.open("GET", "/api/header"); + xhr.send(); + + expect(() => xhr.setRequestHeader("X-Test", "1")).toThrow( + "send() has already been called", + ); + + await loaded; + }); + + test("throws when send is called before open", () => { + const { app } = createAppStub({ + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + + expect(() => xhr.send()).toThrow("The object's state must be OPENED"); + }); + + test("throws when send is called twice", async () => { + const { app } = createAppStub({ + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + + const loaded = new Promise((resolve, reject) => { + xhr.onload = () => resolve(); + xhr.onerror = () => reject(new Error("XHR error")); + }); + + xhr.open("GET", "/api/double"); + xhr.send(); + + expect(() => xhr.send()).toThrow("send() has already been called"); + + await loaded; + }); + + test("uses custom toolName option", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app, callServerTool } = createAppStub(toolResult); + + const handle = initMcpXhr(app, { + installGlobal: false, + toolName: "custom_request", + }); + const xhr = new handle.XMLHttpRequest(); + + const loaded = new Promise((resolve, reject) => { + xhr.onload = () => resolve(); + xhr.onerror = () => reject(new Error("XHR error")); + }); + + xhr.open("GET", "/api/custom"); + xhr.send(); + + await loaded; + + const call = callServerTool.mock.calls[0]?.[0] as { + name: string; + arguments: Record; + }; + expect(call.name).toBe("custom_request"); + }); + + test("respects custom shouldIntercept", () => { + globalThis.XMLHttpRequest = + FakeXMLHttpRequest as unknown as typeof XMLHttpRequest; + + const { app, callServerTool } = createAppStub({ + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }); + + const handle = initMcpXhr(app, { + installGlobal: false, + shouldIntercept: () => false, + }); + const xhr = new handle.XMLHttpRequest(); + xhr.open("GET", "/api/skip"); + xhr.send(); + + expect(callServerTool).not.toHaveBeenCalled(); + expect(FakeXMLHttpRequest.lastRequest?.url).toBe("/api/skip"); + }); + + test("includes query strings in tool url", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app, callServerTool } = createAppStub(toolResult); + + const handle = initMcpXhr(app, { installGlobal: false }); + const xhr = new handle.XMLHttpRequest(); + + const loaded = new Promise((resolve, reject) => { + xhr.onload = () => resolve(); + xhr.onerror = () => reject(new Error("XHR error")); + }); + + xhr.open("GET", "/api/search?q=test&limit=1"); + xhr.send(); + + await loaded; + + const call = callServerTool.mock.calls[0]?.[0] as { + name: string; + arguments: Record; + }; + expect(call.arguments.url).toBe("/api/search?q=test&limit=1"); + }); + + test("replaces global XMLHttpRequest when installGlobal is true", () => { + const originalXhr = globalThis.XMLHttpRequest; + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "text/plain" }, + body: "ok", + bodyType: "text", + }, + }; + const { app } = createAppStub(toolResult); + + try { + const handle = initMcpXhr(app, { installGlobal: true }); + expect(globalThis.XMLHttpRequest).toBe(handle.XMLHttpRequest); + handle.restore(); + expect(globalThis.XMLHttpRequest).toBe(originalXhr); + } finally { + globalThis.XMLHttpRequest = originalXhr; + } + }); + + test("stop() pauses interception and isActive() returns false", () => { + globalThis.XMLHttpRequest = + FakeXMLHttpRequest as unknown as typeof XMLHttpRequest; + + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "application/json" }, + body: { ok: true }, + bodyType: "json", + }, + }; + const { app, callServerTool } = createAppStub(toolResult); + + const handle = initMcpXhr(app, { installGlobal: false }); + + expect(handle.isActive()).toBe(true); + + handle.stop(); + expect(handle.isActive()).toBe(false); + + const xhr = new handle.XMLHttpRequest(); + xhr.open("GET", "/api/test"); + xhr.send(); + + expect(callServerTool).not.toHaveBeenCalled(); + expect(FakeXMLHttpRequest.lastRequest?.url).toBe("/api/test"); + }); + + test("start() resumes interception after stop()", async () => { + const toolResult: CallToolResult = { + content: [], + structuredContent: { + status: 200, + headers: { "content-type": "application/json" }, + body: { ok: true }, + bodyType: "json", + }, + }; + const { app, callServerTool } = createAppStub(toolResult); + + const handle = initMcpXhr(app, { installGlobal: false }); + + handle.stop(); + expect(handle.isActive()).toBe(false); + + handle.start(); + expect(handle.isActive()).toBe(true); + + const xhr = new handle.XMLHttpRequest(); + xhr.responseType = "json"; + + const loaded = new Promise((resolve, reject) => { + xhr.onload = () => resolve(); + xhr.onerror = () => reject(new Error("XHR error")); + }); + + xhr.open("GET", "/api/test"); + xhr.send(); + + await loaded; + + expect(callServerTool).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/e2e/http-adapter.spec.ts b/tests/e2e/http-adapter.spec.ts new file mode 100644 index 00000000..ffca310c --- /dev/null +++ b/tests/e2e/http-adapter.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from "@playwright/test"; + +test("direct vs proxied fetch and XHR hit the same service worker", async ({ + page, +}) => { + await page.goto("/http-adapter-host.test.html"); + + await page.waitForFunction(() => (window as any).__hostReady === true, null, { + timeout: 20000, + }); + + const appFrameHandle = await page.waitForSelector("#app-frame"); + const appFrame = await appFrameHandle.contentFrame(); + if (!appFrame) { + throw new Error("App iframe did not load"); + } + const fetchPayload = { id: `fetch-${Date.now()}`, value: 42 }; + const xhrPayload = { id: `xhr-${Date.now()}`, value: 7 }; + + const directResult = await appFrame.evaluate( + (body) => (window as any).__runScenario("fetch", "direct", body), + fetchPayload, + ); + + await page.waitForFunction( + () => (window as any).__mswRequests?.length >= 1, + null, + { timeout: 10000 }, + ); + + const directLog = await page.evaluate(() => { + const logs = (window as any).__mswRequests as Array; + return logs[logs.length - 1]; + }); + + const proxyResult = await appFrame.evaluate( + (body) => (window as any).__runScenario("fetch", "proxy", body), + fetchPayload, + ); + + await page.waitForFunction( + () => (window as any).__mswRequests?.length >= 2, + null, + { timeout: 10000 }, + ); + + const proxyLog = await page.evaluate(() => { + const logs = (window as any).__mswRequests as Array; + return logs[logs.length - 1]; + }); + + expect(directResult.status).toBe(200); + expect(proxyResult.status).toBe(200); + expect(directResult.json).toEqual(proxyResult.json); + + expect(directLog.method).toBe("POST"); + expect(proxyLog.method).toBe("POST"); + expect(directLog.url).toBe("/api/echo"); + expect(proxyLog.url).toBe("/api/echo"); + expect(directLog.body).toBe(JSON.stringify(fetchPayload)); + expect(proxyLog.body).toBe(JSON.stringify(fetchPayload)); + expect(directLog.headers["x-test-id"]).toBe(fetchPayload.id); + expect(proxyLog.headers["x-test-id"]).toBe(fetchPayload.id); + + const directXhrResult = await appFrame.evaluate( + (body) => (window as any).__runScenario("xhr", "direct", body), + xhrPayload, + ); + + await page.waitForFunction( + () => (window as any).__mswRequests?.length >= 3, + null, + { timeout: 10000 }, + ); + + const directXhrLog = await page.evaluate(() => { + const logs = (window as any).__mswRequests as Array; + return logs[logs.length - 1]; + }); + + const proxyXhrResult = await appFrame.evaluate( + (body) => (window as any).__runScenario("xhr", "proxy", body), + xhrPayload, + ); + + await page.waitForFunction( + () => (window as any).__mswRequests?.length >= 4, + null, + { timeout: 10000 }, + ); + + const proxyXhrLog = await page.evaluate(() => { + const logs = (window as any).__mswRequests as Array; + return logs[logs.length - 1]; + }); + + expect(directXhrResult.status).toBe(200); + expect(proxyXhrResult.status).toBe(200); + expect(directXhrResult.json).toEqual(proxyXhrResult.json); + + expect(directXhrLog.method).toBe("POST"); + expect(proxyXhrLog.method).toBe("POST"); + expect(directXhrLog.url).toBe("/api/echo"); + expect(proxyXhrLog.url).toBe("/api/echo"); + expect(directXhrLog.body).toBe(JSON.stringify(xhrPayload)); + expect(proxyXhrLog.body).toBe(JSON.stringify(xhrPayload)); + expect(directXhrLog.headers["x-test-id"]).toBe(xhrPayload.id); + expect(proxyXhrLog.headers["x-test-id"]).toBe(xhrPayload.id); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..a9b2800f --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { playwright } from "@vitest/browser-playwright"; + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url))); + +export default defineConfig({ + root: rootDir, + resolve: { + alias: { + "@": resolve(rootDir, "src"), + }, + }, + server: { + host: "127.0.0.1", + port: 5173, + strictPort: false, + fs: { + allow: [rootDir], + }, + }, + test: { + root: rootDir, + dir: rootDir, + include: ["tests/browser/**/*.test.ts"], + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [ + { + browser: "chromium", + }, + ], + }, + }, +});