Skip to content

fix(mcp-middlewares): import the MCP SSE transport lazily so eventsource stays out of the module graph - #2307

Draft
jpr5 wants to merge 2 commits into
mainfrom
fix/lazy-mcp-sse-transport-import
Draft

fix(mcp-middlewares): import the MCP SSE transport lazily so eventsource stays out of the module graph#2307
jpr5 wants to merge 2 commits into
mainfrom
fix/lazy-mcp-sse-transport-import

Conversation

@jpr5

@jpr5 jpr5 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What

Make the MCP SSE client transport a lazy await import() in both
@ag-ui/mcp-apps-middleware and @ag-ui/mcp-middleware, so that
eventsource no longer enters the module graph just because the middleware
was imported.

Why

@modelcontextprotocol/sdk/client/sse.js pulls in eventsource. Both
middlewares imported it statically at the top level, unconditionally — so every
consumer paid for it, including the (typical) ones that only configure
type: "http" servers.

Under Bun this is fatal rather than merely wasteful: eventsource's bun
export condition points at its ESM build, and Bun resolves the bun condition
ahead of require. The SDK's CJS require("eventsource") therefore receives an
async ESM module and throws at import time. Because it depends on load order,
the failure is intermittent.

In both middlewares the SSE construction already sits behind a
config.type === "sse" check inside private async methods, so deferring the
import is contained:

  • mcp-apps-middleware: buildMCPTransport is module-private; it becomes
    async and its three call sites (executeMCPRequest, executeToolCall,
    fetchToolsFromServer) gain an await. All three were already private async.
  • mcp-middleware: connect() is already private async; the import moves
    into the sse branch with no signature change. Transport is brought in as a
    type-only import, which is erased at compile time and never enters the
    runtime graph.

No as any, no @ts-ignore, no implicit any.

The dynamic import survives the packages' own tsdown build (format: ["cjs","esm"],
minify: true) as a real import() rather than being rewritten to require:

$ grep -o 'import(`[^`]*`)' middlewares/*/dist/index.js
middlewares/mcp-middleware/dist/index.js:import(`@modelcontextprotocol/sdk/client/sse.js`)
middlewares/mcp-apps-middleware/dist/index.js:import(`@modelcontextprotocol/sdk/client/sse.js`)

Red / green proof

The load-bearing claim is "eventsource no longer enters the module graph when
the middleware is imported."
The probe loads one target per fresh Bun
process
(so require.cache cannot leak between targets) and reports whether
any eventsource package file — excluding eventsource-parser — landed in the
graph. Resolution is anchored at the middleware package directory via
createRequire, so bare specifiers hit the real pnpm node_modules and never
Bun's auto-install cache.

Both a positive control (import the SDK's client/sse.js directly — MUST be
true) and a negative control (import the SDK's client/streamableHttp.js
MUST be false) run on every invocation, so the probe cannot pass vacuously.

Probe driver (probe.sh), unchanged between the two runs:

BUN=/path/to/bun-1.3.14/bin/bun            # Bun 1.3.14
A=middlewares/mcp-apps-middleware
B=middlewares/mcp-middleware
$BUN probe-one.cjs "$A" "@modelcontextprotocol/sdk/client/sse.js"            # positive control
$BUN probe-one.cjs "$A" "@modelcontextprotocol/sdk/client/streamableHttp.js" # negative control
$BUN probe-one.cjs "$A" "$A/dist/index.js"                                   # subject A
$BUN probe-one.cjs "$B" "$B/dist/index.js"                                   # subject B

probe-one.cjs:

const path = require("path");
const { createRequire } = require("module");
const fromDir = path.resolve(process.argv[2]);
const target = process.argv[3];
const req = createRequire(path.join(fromDir, "__probe__.cjs"));
let loadError = null;
try { req(target); } catch (e) { loadError = e.stack.split("\n").slice(0, 4).join(" | "); }
const isEventSource = (k) =>
  /(^|[\\/])eventsource([@\\/])/.test(k) && !/eventsource-parser/.test(k);
const keys = Object.keys(require.cache);
const hits = keys.filter(isEventSource);
console.log(JSON.stringify({ target, totalModules: keys.length,
  eventsourceInGraph: hits.length > 0, eventsourcePaths: hits, loadError }));

RED — at origin/main (0880dff), before the change

bun 1.3.14
--- POSITIVE CONTROL: MCP SDK client/sse.js directly (MUST be true) ---
{"target":"@modelcontextprotocol/sdk/client/sse.js","totalModules":20,"eventsourceInGraph":true,"eventsourcePaths":["node_modules/.pnpm/eventsource@3.0.7/node_modules/eventsource/dist/index.js"],"loadError":null}
--- NEGATIVE CONTROL: MCP SDK client/streamableHttp.js (MUST be false) ---
{"target":"@modelcontextprotocol/sdk/client/streamableHttp.js","totalModules":20,"eventsourceInGraph":false,"eventsourcePaths":[],"loadError":null}
--- SUBJECT A: @ag-ui/mcp-apps-middleware dist/index.js (CJS) ---
{"target":"middlewares/mcp-apps-middleware/dist/index.js","totalModules":347,"eventsourceInGraph":true,"eventsourcePaths":["node_modules/.pnpm/eventsource@3.0.7/node_modules/eventsource/dist/index.js"],"loadError":null}
--- SUBJECT B: @ag-ui/mcp-middleware dist/index.js (CJS) ---
{"target":"middlewares/mcp-middleware/dist/index.js","totalModules":493,"eventsourceInGraph":true,"eventsourcePaths":["node_modules/.pnpm/eventsource@3.0.7/node_modules/eventsource/dist/index.js"],"loadError":null}

GREEN — this branch, same command, probe unchanged

bun 1.3.14
--- POSITIVE CONTROL: MCP SDK client/sse.js directly (MUST be true) ---
{"target":"@modelcontextprotocol/sdk/client/sse.js","totalModules":20,"eventsourceInGraph":true,"eventsourcePaths":["node_modules/.pnpm/eventsource@3.0.7/node_modules/eventsource/dist/index.js"],"loadError":null}
--- NEGATIVE CONTROL: MCP SDK client/streamableHttp.js (MUST be false) ---
{"target":"@modelcontextprotocol/sdk/client/streamableHttp.js","totalModules":20,"eventsourceInGraph":false,"eventsourcePaths":[],"loadError":null}
--- SUBJECT A: @ag-ui/mcp-apps-middleware dist/index.js (CJS) ---
{"target":"middlewares/mcp-apps-middleware/dist/index.js","totalModules":344,"eventsourceInGraph":false,"eventsourcePaths":[],"loadError":null}
--- SUBJECT B: @ag-ui/mcp-middleware dist/index.js (CJS) ---
{"target":"middlewares/mcp-middleware/dist/index.js","totalModules":490,"eventsourceInGraph":false,"eventsourcePaths":[],"loadError":null}

Both controls are identical across the two runs; both subjects flip
eventsourceInGraph from true to false. Module counts drop 347 → 344 and
493 → 490 (the three eventsource / eventsource-parser / sse.js modules).

Test suites

Both existing suites cover the SSE branch (mcp-apps-middleware.test.ts mocks
@modelcontextprotocol/sdk/client/sse.js and asserts the SSE transport URL and
header wiring). They pass before and after — which proves the change is
non-regressive, not that it works; the probe above is the evidence.

Suite cwd Result
pnpm test (vitest 4.0.18) middlewares/mcp-apps-middleware 68 passed / 68, 1 file
pnpm test (vitest 4.0.18) middlewares/mcp-middleware 27 passed / 27, 1 file

Also run from the repo root:

  • pnpm --filter @ag-ui/mcp-apps-middleware --filter @ag-ui/mcp-middleware -r build — both packages build clean (cjs + esm + dts).
  • pnpm --filter @ag-ui/mcp-middleware typecheck (tsc --noEmit) — clean.
  • npx prettier --write on both changed files — clean.

Pre-existing failure, not introduced here: pnpm --filter @ag-ui/mcp-apps-middleware typecheck
fails with src/index.ts: error TS2307: Cannot find module 'crypto' or its corresponding type declarations. Verified on unmodified origin/main (same
error, one line lower). Untouched by this PR.

Follow-up (not fixed here): the published exports field

Published @ag-ui/mcp-apps-middleware@0.0.3 has no exports field, which is
why main wins for CJS consumers and the whole CJS chain gets walked. I traced
it, and the release tooling did not drop it — the field did not exist yet:

  • npm publish times: 0.0.1 2025-12-05, 0.0.2 2026-01-13, 0.0.3 2026-01-15.
  • middlewares/mcp-apps-middleware/package.json first appears at 945ef738
    (2026-01-22) already carrying "version": "0.0.3", with main + module and
    no exports.
  • exports (and exports: true in tsdown.config.ts) were added later, in
    41ea3ff8 — PR feat(middleware): add @ag-ui/mcp-middleware #1818, feat(middleware): add @ag-ui/mcp-middleware,
    2026-06-01 — i.e. months after 0.0.3 was cut.

Would the next release repeat it? No. Packing the current source confirms the
field ships:

$ pnpm pack   # middlewares/mcp-apps-middleware
version 0.0.2
exports {".": {"require": "./dist/index.js", "import": "./dist/index.mjs"}, "./package.json": "./package.json"}

files: ["dist/**"] does not strip package.json fields, and tsdown's
exports: true regenerates the field at build time (it only reorders the
require/import keys, which are mutually exclusive conditions — cosmetic).
So the next @ag-ui/mcp-apps-middleware release will carry exports and CJS
consumers will stop being force-routed through main. No change needed; flagged
here only so it is not re-diagnosed.

Scope note

This does not retire the downstream eventsource patch. Consumers are still
on the published 0.0.3, which has the static import baked into dist/. The
patch can only be dropped after a new release of both middlewares and a
consumer bump to it.

jpr5 added 2 commits August 3, 2026 12:11
The static `@modelcontextprotocol/sdk/client/sse.js` import pulled
`eventsource` into the module graph on every import of the middleware,
even for consumers that only ever configure `type: "http"` servers.

Under Bun that is fatal, not merely wasteful: `eventsource`'s `bun`
export condition points at its ESM build and Bun resolves `bun` before
`require`, so the SDK's CJS `require("eventsource")` gets an async ESM
module back and throws at load time. Because it depends on load order it
surfaces intermittently.

`buildMCPTransport` is module-private and all three call sites are
already inside `private async` methods, so making it async and deferring
the import to the `sse` branch is contained. The dynamic import survives
the tsdown cjs+esm minified build as a real `import()`.
Same defect as @ag-ui/mcp-apps-middleware: the static
`@modelcontextprotocol/sdk/client/sse.js` import dragged `eventsource`
into the module graph for every consumer, which throws at load time
under Bun (its `bun` export condition resolves to ESM, so the SDK's CJS
`require` receives an async module).

`connect()` is already `private async`, so the import moves into the
`sse` branch with no signature change. `Transport` is imported as a
type-only import, which is erased and never enters the runtime graph.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1785784335 published to TestPyPI.

Warning: These packages are built from contributor code that may not yet have been vetted for correctness or security. Install at your own risk and do not use in production.

Install with uv

Add the TestPyPI index to your pyproject.toml:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = true

Then install the packages you need:

# Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1785784335' --index testpypi

# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1785784335' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1785784335' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1785784335' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1785784335' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1785784335' --index testpypi

Install with pip

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  ag-ui-protocol==0.0.0.dev1785784335

Use --extra-index-url https://pypi.org/simple/ so pip can resolve
transitive dependencies (pydantic, fastapi, etc.) from real PyPI.


Commit: 96453b7

@pkg-pr-new

pkg-pr-new Bot commented Aug 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

@ag-ui/a2a-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a-middleware@2307

@ag-ui/a2ui-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-middleware@2307

@ag-ui/event-throttle-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/event-throttle-middleware@2307

@ag-ui/mcp-apps-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-apps-middleware@2307

@ag-ui/mcp-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-middleware@2307

@ag-ui/a2a

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a@2307

@ag-ui/adk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/adk@2307

@ag-ui/ag2

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/ag2@2307

@ag-ui/agno

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/agno@2307

@ag-ui/aws-strands

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/aws-strands@2307

@ag-ui/claude-agent-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-agent-sdk@2307

@ag-ui/claude-managed-agents

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-managed-agents@2307

@ag-ui/crewai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/crewai@2307

@ag-ui/langchain

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langchain@2307

@ag-ui/langgraph

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langgraph@2307

@ag-ui/llamaindex

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/llamaindex@2307

@ag-ui/mastra

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mastra@2307

@ag-ui/pydantic-ai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/pydantic-ai@2307

@ag-ui/vercel-ai-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/vercel-ai-sdk@2307

@ag-ui/watsonx

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/watsonx@2307

@ag-ui/a2ui-toolkit

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-toolkit@2307

create-ag-ui-app

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/create-ag-ui-app@2307

@ag-ui/client

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/client@2307

@ag-ui/core

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/core@2307

@ag-ui/encoder

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/encoder@2307

@ag-ui/proto

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/proto@2307

commit: 68b7c2a

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