Skip to content

Conversation

@MiguelsPizza
Copy link

@MiguelsPizza MiguelsPizza commented Feb 3, 2026

MCP-APPS-HTTP-PROXY.mp4

Fetch That Works in MCP Apps

Track: Extensions (proposal)
Status: Draft
Reference implementation: src/http-adapter/
Demo: examples/hono-react-server/
Tests: tests/browser/*http-adapter*, tests/e2e/http-adapter.spec.ts

Abstract

This proposal standardizes an app-only http_request tool plus a client-side HTTP adapter that transparently routes fetch() and XMLHttpRequest calls through MCP tools/call. The result is a single, auditable HTTP transport primitive for MCP Apps that preserves MCP’s security model while dramatically improving developer experience, portability, and reuse of existing web code.

Motivation

1) MCP App development is diverging from normal web development

Today, building MCP Apps often means rewriting the web app layer so it can speak tools/call. That forces teams onto purpose-built frameworks or custom adapters just to reuse existing HTTP clients, SDKs, or libraries. If you’re not on a highly specialized stack, you end up writing and maintaining a parallel data layer.

The HTTP adapter restores framework-agnostic development: you can keep fetch, Axios, Hono hc, GraphQL clients, OpenAPI SDKs (whatever your stack already uses) without replatforming your UI just to run inside an MCP host.

2) UI-only tools overload MCP’s semantic tool model

MCP tools are semantic, model-facing interfaces that intentionally do not need to be versioned as the model calls them dynamically at runtime. But re-using MCP tools for UI interaction couples the MCP server to a programatic server client contract. UI-only interactions (button clicks, form submissions, hydration updates) force you to mint lots of tool wrappers that the model never sees and doesn’t need. That adds a semantic surface area you must now version and maintain, undermining the original “dynamic client” advantage of MCP.

The http_request tool collapses those UI-only calls into a single transport primitive, preserving MCP’s model-facing semantics while keeping UI traffic out of the tool namespace (except for the one tool that serves the UI resource itself).

Goals

  1. Preserve MCP’s trust boundaries and auditability.
  2. Reduce per-endpoint tool boilerplate for UI-only traffic.
  3. Enable dual-mode development and deployment with the same app code.
  4. Keep the adapter opt-in and backwards compatible.

Non-Goals

  1. Replacing model-facing tools or resource-based backends.
  2. Proxying streaming responses, WebSockets, or gRPC-Web (not supported by MCP transport today).
  3. Introducing a new MCP protocol method outside tools/call.

Quick Start (Server + App)

Server: one app-only http_request tool

import { McpHttpRequestSchema, McpHttpResponseSchema } from "@modelcontextprotocol/ext-apps";
import { createHttpRequestToolHandler } from "@modelcontextprotocol/ext-apps/fetch-wrapper";

const proxy = createHttpRequestToolHandler({
  baseUrl: "http://localhost:3000",
  allowPaths: ["/api/"],
});

server.registerTool(
  "http_request",
  {
    description: "Proxy HTTP requests from the app",
    inputSchema: McpHttpRequestSchema,
    outputSchema: McpHttpResponseSchema,
    _meta: { ui: { visibility: ["app"] } },
  },
  (args, extra) => proxy({ name: "http_request", arguments: args }, extra),
);

The MCP server can proxy upstream HTTP or handle routes directly (e.g., Hono). The tool contract is HTTP-shaped, but the handler can be any server logic.

App: fetch just works now

import { App } from "@modelcontextprotocol/ext-apps";
import { initMcpHttp } from "@modelcontextprotocol/ext-apps/http-adapter";

const app = new App({ name: "Shop", version: "1.0.0" }, {});
await app.connect();

initMcpHttp(app, { interceptPaths: ["/api/"] });

// Fetch just works now — no per-endpoint tools, no special call pattern.
const cart = await fetch("/api/cart").then((r) => r.json());
await fetch("/api/cart", {
  method: "POST",
  body: JSON.stringify({ itemId: "abc" }),
});

Architecture Overview

flowchart LR
  subgraph Host["MCP Host"]
    App["App iframe"]
    Adapter["HTTP Adapter (fetch/XHR wrapper)"]
  end

  subgraph Server["MCP Server"]
    Tool["http_request tool handler"]
    Routes["Local routes or upstream proxy"]
  end

  Backend["HTTP backend (optional)"]

  App --> Adapter
  Adapter -->|"tools/call"| Tool
  Tool --> Routes
  Routes --> Backend
  Backend --> Routes
  Routes --> Tool
  Tool -->|"structuredContent"| Adapter
  Adapter --> App
Loading

Current spec constraints (why direct HTTP is gated)

Per the MCP Apps spec (specification/2026-01-26/apps.mdx), hosts enforce a restrictive CSP by default. If ui.csp is omitted, the default includes connect-src 'none', so embedded apps cannot make network requests. Hosts may opt in to network access via ui.csp.connectDomains, but the iframe still runs in a separate origin and does not share backend credentials. In practice, authenticated app→backend traffic must flow through tools/call, which is already the audited, policy-enforced path in MCP.

Specification

Tool Contract (from src/spec.types.ts)

/**
 * @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 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<string, string>;
  /** @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<string, string>;
  /** @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;
}

/**
 * Default tool name for HTTP request proxying.
 */
export const HTTP_REQUEST_TOOL_NAME = "http_request" as const;

Visibility: _meta.ui.visibility: ["app"]

Server Handler (reference implementation)

createHttpRequestToolHandler(options) enforces:

  1. allowPaths prefix allowlist.
  2. Optional allowOrigins for absolute URLs.
  3. forbiddenHeaders stripping for sensitive headers.
  4. baseUrl pinning for relative requests.
  5. maxBodySize enforcement.
  6. Optional credentials, timeoutMs, and custom fetch implementation.

The handler maps request body types to HTTP body encodings and normalizes responses back into McpHttpResponse.

Client Adapter (fetch/XHR)

initMcpHttp(app, options) installs wrappers for:

  1. fetch() via initMcpFetch.
  2. XMLHttpRequest via initMcpXhr.

Interception policy (default):

  1. Only intercept when running in an MCP host (app.getHostCapabilities()?.serverTools).
  2. Only intercept same-origin paths unless allowAbsoluteUrls is enabled.
  3. Only intercept configured interceptPaths prefixes.
  4. When interception is disabled or the host is unavailable, fall back to native transport if fallbackToNative: true.

Request Lifecycle

sequenceDiagram
  participant App as App iframe
  participant Adapter as HTTP Adapter
  participant Host as MCP Host
  participant Server as MCP Server
  participant Backend as HTTP Backend

  App->>Adapter: fetch("/api/items", { method: "POST", body: ... })
  Adapter->>Adapter: shouldIntercept? isMcpApp? path allowed?
  Adapter->>Host: tools/call http_request
  Host->>Server: tools/call http_request
  Server->>Server: validate allowPaths/headers/body size
  Server->>Backend: fetch(baseUrl + url, { method, headers, body })
  Backend-->>Server: HTTP response
  Server-->>Host: CallToolResult (structuredContent)
  Host-->>Adapter: CallToolResult
  Adapter-->>App: Response(status, headers, body)
Loading

Developer Experience and Portability

Why HTTP alignment matters

  1. Drop-in porting: Existing web apps can keep their fetch() or client library code.
  2. Shared code paths: UI and model-facing tools can call the same functions without environment forks.
  3. Local dev workflows: Apps can run against a local backend with native HTTP, hot reload, and DevTools network inspection.
  4. Library compatibility: Works with HTTP clients like Axios (XHR), Hono’s hc, GraphQL clients, OpenAPI SDKs, and form upload flows.

Dual-Mode Example

flowchart TB
  Dev["Standalone dev (same app code)"] -->|"fetch() direct"| Backend["HTTP backend"]
  Host["MCP host iframe (same app code)"] -->|"fetch() via adapter"| Tool["tools/call http_request"]
  Tool --> Backend
Loading

The app code remains identical in both modes. The adapter chooses the transport.

Rationale

Why a single generic tool?

Per-endpoint tools are ideal for model-facing actions. For app-only traffic, they introduce boilerplate without adding meaning. A single http_request tool preserves MCP auditability while removing the per-endpoint tool layer from UI code.

Why intercept fetch() and XHR?

Intercepting fetch() and XMLHttpRequest preserves the web platform surface area and allows existing HTTP libraries to work without modification. The adapter is opt-in and path-scoped, so it does not change global network behavior unless explicitly configured.

Why not host-level HTTP proxying?

Host-level proxies bypass MCP’s JSON-RPC transport. The HTTP adapter keeps every request within tools/call, preserving audit logging, rate limiting, and policy enforcement.

Security Considerations

  1. Model isolation: visibility: ["app"] keeps the tool out of the model’s tool list.
  2. Path allowlist: allowPaths blocks arbitrary URL access.
  3. Origin pinning: baseUrl ensures the backend destination is server-controlled.
  4. Header filtering: forbiddenHeaders prevents cookie and auth exfiltration.
  5. Body size limits: Prevents large payload abuse.

Backward Compatibility

  1. The adapter is opt-in and does not change existing tool behavior.
  2. app.callServerTool remains unchanged.
  3. Hosts require no changes; http_request is standard tools/call.

Reference Implementation Notes

  1. src/http-adapter/fetch-wrapper/fetch.ts implements fetch interception, payload serialization, and response reconstruction.
  2. src/http-adapter/xhr-wrapper/xhr.ts mirrors XHR semantics and response types.
  3. src/http-adapter/shared/body.ts handles body type conversion and response extraction.
  4. examples/hono-react-server/ demonstrates dual-mode operation against a real HTTP backend.
  5. tests/browser/*http-adapter* and tests/e2e/http-adapter.spec.ts validate direct vs proxied parity.

Future Work

  1. Streaming responses once MCP adds streaming/notification primitives suitable for incremental delivery.
  2. Optional scope or policy metadata for structured authorization decisions on http_request.
  3. Host UX patterns for error surfaces and re-auth flows.

Testing

  • Not run (not requested).
  • npm run test:browser
  • npm run test:e2e

Introduce a standard http_request tool contract and the client/server adapter that makes
HTTP a first-class, auditable transport for MCP Apps.

Core additions:
- Spec/types + generated schemas for McpHttpRequest/Response + body types.
- Fetch and XHR wrappers that proxy matching requests through tools/call and
  reconstruct a Response/XHR surface on the way back.
- Shared codecs for body serialization (json/text/formData/urlEncoded/base64),
  response parsing, and request normalization.
- Server-side handler that enforces allowPaths/allowOrigins, strips forbidden
  headers, pins baseUrl, applies body size limits, and maps HTTP responses into
  structuredContent.
- initMcpHttp entrypoint to wire fetch and XHR together with a single handle.

This keeps auth and policy on the server side while letting UI code stay HTTP-
native and portable across MCP and standalone environments.
Wire the adapter into the SDK build/exports and add end-to-end test coverage
that exercises the exact host/app/server proxy path.

Packaging + integration:
- Export /http-adapter, /fetch-wrapper, and /xhr-wrapper bundles and build them
  in the SDK pipeline.
- Add workspace linking so example packages resolve freshly built dist output.
- Improve useApp so standalone UIs can initialize without a parent host.

Test strategy (real browser + full flow):
- Vitest browser harness with MSW service worker to capture real network
  requests and assert parity between direct and proxied calls.
- Browser suites for fetch and XHR adapters covering body types, headers,
  status mapping, and error surfaces.
- Basic-host app/host fixtures plus Playwright E2E that run a full loop:
  iframe → AppBridge → tools/call → http_request handler → HTTP response.

Also expands gitignore for caches and browser screenshot output.
Show the adapter in practice with both a lightweight walkthrough and a
full-stack reference app.

- basic-server-vanillajs now uses http_request to serve /api/* in-process and
  demonstrates fetch/XHR interception without requiring a separate backend.
- hono-react-server adds a complete dual-mode example (Hono backend + React UI)
  that runs against native HTTP in standalone dev and switches to MCP proxying
  in-host.
- README calls out the new reference example for discovery.
@MiguelsPizza MiguelsPizza changed the title HTTP adapter: app-only http_request proxy and dual-mode examples Fetch That Works in MCP Apps Feb 3, 2026
@MiguelsPizza MiguelsPizza marked this pull request as draft February 3, 2026 20:18
MiguelsPizza and others added 5 commits February 3, 2026 13:53
- Rename http-adapter HTML files to .test.html convention
- Update hono-react-server with improved MCP app UI
- Add getBackendUrl helper to App class
- Update E2E test to use new file naming

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Documentation:
- Fix misleading @throws JSDoc in initMcpFetch
- Combine split JSDoc comments (/** desc */ /** @internal */)
- Add @internal tags to internal helper functions
- Document maxBodySize <= 0 disables enforcement
- Improve warnNativeFallback with actionable guidance

Types:
- Extract McpHttpHandleBase interface for common lifecycle methods
- McpFetchHandle, McpXhrHandle, McpHttpHandle now extend base
- Reduces duplication and improves discoverability

Error context:
- Add value preview to JSON parse warnings
- Add stack trace to XHR debug error logging
- Fix isInIframe() for cross-origin iframes and SSR

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove link-workspace-root.mjs (no longer needed)
- Fix formatting in hono-react mcp-app.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add schema.js bundle to dist/src/generated/ so TypeScript can resolve
  types through declaration files with NodeNext module resolution
- Re-export McpHttpRequestSchema and McpHttpResponseSchema from fetch-wrapper
  entry point for server-side tool registration
- Update hono-react-server example to import schemas from fetch-wrapper

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant