Skip to content

feat(browser-tools): add native Streamable HTTP MCP transport - #591

Open
keithah wants to merge 2 commits into
saffron-health:mainfrom
keithah:feat/browser-tools-streamable-http-mcp
Open

feat(browser-tools): add native Streamable HTTP MCP transport#591
keithah wants to merge 2 commits into
saffron-health:mainfrom
keithah:feat/browser-tools-streamable-http-mcp

Conversation

@keithah

@keithah keithah commented Sep 1, 2026

Copy link
Copy Markdown

Summary

The MCP binary only spoke stdio, so a host that needs a Streamable HTTP URL had to run a gateway process in front of it or write its own server around registerMcpBrowserTools. This adds the transport to the binary.

  • --transport streamable-http alongside the default stdio, with --host, --port, and --path.
  • One process serves many clients, replacing a gateway plus a stdio child per session.
  • Each MCP session gets its own server, provider, and toolkit, so clients never share browser state.
  • Sessions default to a throwaway browser profile, because concurrent clients cannot share one persistent Chromium profile.
  • Sessions are cleaned up on DELETE, transport close, and process shutdown.

stdio stays the default, so existing clients are unaffected.

npx -y libretto-browser-tools mcp --transport streamable-http --port 40103
{
  "mcpServers": {
    "libretto-browser-tools": { "url": "http://127.0.0.1:40103/mcp" }
  }
}

Notes for review

Three things worth a look, each with a test:

  • Unknown session IDs return 404. A POST carrying an unknown mcp-session-id and an initialize body must not fall through to session creation, or any caller could spawn a browser per bogus header.
  • Malformed JSON returns 400, not 500.
  • Request bodies are capped at 1 MiB. --host can bind beyond loopback, so an unbounded body is a memory risk.

The server binds to 127.0.0.1 by default and has no authentication; the docs say to put it behind a proxy before binding wider.

CI change

.github/workflows/cli-tests.yml — the test job's path filter only matched packages/libretto/*, so no packages/browser-tools change has been running the suite. The filter now covers browser-tools. This is why the new tests would otherwise be skipped on this PR.

Verification

  • pnpm --dir packages/browser-tools test --run — 110 passed, 12 skipped.
  • pnpm check:mirrors, pnpm --dir packages/browser-tools type-check, pnpm lint (oxlint --type-aware exit 0), and pnpm docs:build all pass.
  • Red-green checked: reverting the unknown-session fix or the profile-isolation fix fails exactly the tests that cover them.
  • Ran against a real MCP client (Hermes Agent) over HTTP: tool discovery, browser_open, browser_exec, and DELETE cleanup.

The MCP binary only spoke stdio, so hosts that need an HTTP URL had to run a
gateway process or write their own server around registerMcpBrowserTools.

Add --transport streamable-http alongside the default stdio transport, with
--host, --port, and --path. One process now serves many clients. Each MCP
session gets its own server, provider, and toolkit so clients never share
browser state, and sessions default to a throwaway browser profile because
concurrent clients cannot share one persistent Chromium profile.

Reject unknown session IDs so a bogus header cannot allocate a browser, return
400 for malformed JSON, and cap request bodies at 1 MiB.

Run the test job for browser-tools changes. The path filter only matched the
libretto package, so the browser-tools suite never ran in CI.
@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

@keithah is attempting to deploy a commit to the Libretto Team on Vercel.

A member of the Team first needs to authorize it.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-01T03:01:05.402319Z 41778c4 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 41778c4d47

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +255 to +257
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: randomUUID,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enable DNS-rebinding protection

When this unauthenticated server listens on loopback, a hostile page can rebind its own hostname to 127.0.0.1:40103, initialize an MCP session, and invoke the browser-control tools. StreamableHTTPServerTransport leaves Host and Origin checks off by default unless DNS-rebinding protection is enabled, so configure it with the allowed host values rather than relying on the loopback bind.

Useful? React with 👍 / 👎.

Comment on lines +166 to +170
options.authProfile === false || this.defaultAuthProfile === false
? undefined
: (options.authProfile ??
(provider.supportsAuthProfiles ? "default" : undefined));
(this.defaultAuthProfile ??
(provider.supportsAuthProfiles ? "default" : undefined)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor explicit auth profiles over the HTTP default

When the HTTP toolkit sets defaultAuthProfile: false, this condition resolves every request to undefined, including browser_open({ authProfile: "work" }). As a result, HTTP clients cannot use the named persistent profile that the new docs promise; apply the false default only when the caller omits authProfile, while explicit strings must take precedence.

Useful? React with 👍 / 👎.

req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
const pathname = new URL(req.url ?? "/", `http://${options.host}`).pathname;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Format IPv6 bind addresses as valid URL hosts

When the CLI receives a valid IPv6 bind address such as --host ::1, http.listen accepts it, but new URL(..., "http://::1") throws because IPv6 literals need brackets in URLs. Since this statement is also outside the request try, the first request rejects without a response; the advertised URL later has the same malformed form. Keep the raw address for listen and use a bracketed form when building URLs.

Useful? React with 👍 / 👎.

Comment on lines +265 to +267
await transport.handleRequest(req, res, body);
// The transport assigns the ID while handling initialize.
if (transport.sessionId) sessions.set(transport.sessionId, session);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Expire abandoned HTTP sessions

When a client initializes a session, opens a browser, and then crashes or loses its network without sending DELETE, the stateful transport must keep working across HTTP connection loss and therefore does not call onclose. This map retains the transport, toolkit, and browser indefinitely, so repeated abandoned clients can exhaust local processes or leave paid cloud sessions running; track request activity and close sessions after an idle limit.

Useful? React with 👍 / 👎.

Comment on lines +181 to +184
async function closeSession(session: McpHttpSession): Promise<void> {
await session.transport.close().catch(() => undefined);
await session.server.close().catch(() => undefined);
await session.toolkit.dispose().catch(() => undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle cleanup errors returned as values

When a provider fails to close a browser session, toolkit.dispose() resolves to a BrowserCleanupError value rather than rejecting, so this .catch does not handle it. The server then reports a successful close, and signal shutdown can exit while the local or cloud browser remains open; inspect the resolved value and surface or log the recovery step.

AGENTS.md reference: AGENTS.md:L44-L44

Useful? React with 👍 / 👎.

Comment on lines +289 to +292
if (listening) {
return new Error(
`Could not listen on ${options.host}:${options.port} (${errorMessage(listening)}). Pass a free --port, or stop the process already using it.`,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Give host failures a matching recovery step

When --host cannot resolve or names an address that is not assigned to this machine, listen returns errors such as ENOTFOUND or EADDRNOTAVAIL, but the message only tells the caller to change or free the port. Neither step fixes the failure, so include guidance to correct --host or use a local bind address based on the error code.

AGENTS.md reference: AGENTS.md:L69-L71

Useful? React with 👍 / 👎.

Comment on lines +304 to +306
closed ??= (async () => {
httpServer.closeAllConnections();
await new Promise<void>((resolve) => httpServer.close(() => resolve()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop accepting connections before forcing them closed

When a connection reaches the listener between closeAllConnections() and close(), the first call cannot destroy it and the second call waits for it to end. A new SSE request or slow body can therefore leave started.close() pending and prevent the SIGINT/SIGTERM handler from exiting; initiate httpServer.close() first, then force-close connections so no new socket can enter the set.

Useful? React with 👍 / 👎.

Enable DNS-rebinding protection. The server has no authentication, so a
hostile page could rebind its own name to the listener and drive the browser
tools; it now rejects Host headers other than the bound address.

Expire idle HTTP sessions. The transport keeps a session alive across
connection loss, so a client that crashed without sending DELETE held its
browser open forever.

Let an explicit authProfile override the HTTP default. The false default
resolved every request to no profile, so HTTP clients could not reach a named
persistent profile.

Bracket IPv6 addresses when building URLs, so --host ::1 no longer throws on
the first request and advertises a valid URL.

Report cleanup failures from dispose, which resolves them as a value rather
than rejecting, and point host bind failures at --host instead of --port.

Close the listener before forcing connections shut, so a socket arriving
mid-shutdown cannot keep the process alive.
@keithah

keithah commented Sep 1, 2026

Copy link
Copy Markdown
Author

Thanks — all seven findings were reproducible, and 18306071 fixes them. Each has a regression test that I checked fails without the fix.

P1 — DNS rebinding. Confirmed the SDK leaves Host and Origin checks off by default. Now constructs the transport with enableDnsRebindingProtection: true and allowedHosts built from the bound address (plus localhost for a loopback bind, and the resolved port so --port 0 works). A forged Host now gets 403. The test uses node:http rather than fetch, because fetch silently drops a forged Host header and made an earlier version of this test pass against unprotected code.

P1 — abandoned sessions. Confirmed: onclose does not fire on connection loss, so a crashed client held its transport, toolkit, and browser forever. Sessions now record lastActiveAt and an unref'd sweeper closes anything idle for ten minutes. The timings are injectable so the test runs in milliseconds instead of waiting.

P2 — explicit authProfile ignored. Correct, and it contradicted the docs in this PR. defaultAuthProfile === false short-circuited before the caller's value was read, so browser_open({ authProfile: "work" }) resolved to no profile. The default now applies only when the caller omits authProfile.

P2 — IPv6 bind addresses. Reproduced: new URL("/mcp", "http://::1") throws, so the first request failed without a response and the advertised URL was malformed. A urlHost() helper brackets IPv6 literals for URLs while listen still gets the raw address.

P2 — cleanup errors returned as values. Right — dispose() resolves to BrowserCleanupError instead of rejecting, so the .catch never saw it and shutdown could report success with a browser still open. Cleanup now inspects the resolved value and reports it through browserCleanupErrorMessage.

P2 — host failures pointed at --port. Fixed: EADDRNOTAVAIL and ENOTFOUND now tell the caller to correct --host, other errors still point at --port.

P2 — shutdown ordering. Fixed: close() is initiated before closeAllConnections(), so a socket arriving mid-shutdown cannot keep the handler pending.

Docs updated for the Host check and the idle timeout.

Verification: 115 passed, 12 skipped; type-check, oxlint --type-aware, check:mirrors, and docs:build all pass.

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