Skip to content

everything: bind the HTTP transports to loopback by default, with HOST to widen - #5452

Merged
cliffhall merged 1 commit into
v2/mainfrom
v2/feat/5371-everything-loopback-bind
Nov 5, 2026
Merged

cliffhall merged 1 commit into
v2/mainfrom
v2/feat/5371-everything-loopback-bind

Conversation

@cliffhall

Copy link
Copy Markdown
Member

Closes #5371

Description

The everything server's HTTP transports (Streamable HTTP and the deprecated HTTP+SSE) now bind the loopback interface, 127.0.0.1, by default instead of every interface. A new HOST environment variable widens the bind deliberately (HOST=0.0.0.0, or ::, or any address to bind).

  • transports/listen.ts: listenOrExit(app, port, host, message) now takes the host and passes it to listen. It also exports DEFAULT_HOST (127.0.0.1), bindHost() (HOST when set and not empty, else loopback) and hostAndPort() (formats host:port, bracketing an IPv6 literal). The port-in-use message now names the host too (Port 3001 is already in use on 127.0.0.1), and still matches the boot smoke's already in use retry check.
  • streamableHttp.ts and sse.ts: both resolve the host with bindHost(). Their listening lines now name the bound address: MCP Streamable HTTP Server listening on 127.0.0.1:3001 and Server is running on 127.0.0.1:3001 (previously … on port 3001).
  • Dockerfile: sets ENV HOST=0.0.0.0. Inside a container, loopback belongs to the container itself, so a published port could not reach a server bound there. docker run -p 127.0.0.1:3001:3001 still keeps it off the host's network.
  • scripts/smoke-servers.mjs: the boot smoke pins HOST=127.0.0.1 for the servers it launches, since that is the address its client connects to. Without the pin, a HOST exported in the developer's environment could move the bind. The freePort comment is updated: probing the wildcard address is still the strictest check.
  • Docs: the everything README documents HOST and the loopback default (with a PORT/HOST table) and says why the Docker image sets HOST. The local-dev and client-smoke skills no longer refer to the old listening on port wording.

Credit: this follows the reference prototype in #3303 by @TheodorNEngoy, which bound both HTTP transports to 127.0.0.1 with a HOST override. Its CORS half already shipped in #5241.

Server Details

  • Server: everything
  • Changes to: the Streamable HTTP and HTTP+SSE transports (bind address, listening line), a new HOST environment variable, the Dockerfile, and the README. stdio is unchanged.

Motivation and Context

With no host, app.listen(port) binds every interface. A developer who starts everything over HTTP on a laptop then exposes its tools to every host on the local network. The localhost-only Origin check (#5241) stops a foreign browser page, but a non-browser client on the LAN sends no Origin and gets through. See #5371.

How Has This Been Tested?

Automated (vitest, real sockets): listen.test.ts checks that the default bind is 127.0.0.1 (read back from server.address()), that a given host is bound (0.0.0.0), that a port held on loopback produces the host-naming port-in-use message and exit 1, and that an address on no interface (192.0.2.1, RFC 5737) fails with EADDRNOTAVAIL and exit 1 without printing the listening line. It also covers bindHost (unset, empty, set, and read from process.env) and hostAndPort (IPv4, name, bracketed IPv6). sse.test.ts and streamable-http.test.ts check that each launcher passes 127.0.0.1 by default and HOST when set, with the matching listening line. Per-file coverage for everything stays at or above 90 on all four dimensions. npm run local:gate exits 0, including the boot smoke of everything over stdio, SSE and Streamable HTTP.

Inspector CLI (@modelcontextprotocol/inspector@2.14.0 --cli <url> --protocol-era <era> --method tools/call --tool-name echo --tool-arg message=hi-<era> --format json). 192.168.2.23 is this machine's LAN address, standing in for another host on the network.

Before: the published @modelcontextprotocol/server-everything@2.2.1, which has v2/main's listen.ts. PORT=3941 … streamableHttp:

MCP Streamable HTTP Server listening on port 3941
lsof: node *:3941
[legacy] http://127.0.0.1:3941/mcp    -> exit 0: {"result":{"content":[{"type":"text","text":"Echo: hi-legacy"}]}}
[modern] http://127.0.0.1:3941/mcp    -> exit 0: {"result":{"_meta":{"io.modelcontextprotocol/serverInfo":…},"content":[…"Echo: hi-modern"…]}}
[legacy] http://192.168.2.23:3941/mcp -> exit 0: {"result":{"content":[{"type":"text","text":"Echo: hi-legacy"}]}}
[modern] http://192.168.2.23:3941/mcp -> exit 0: {"result":{"_meta":{"io.modelcontextprotocol/serverInfo":…}…}}

After: this branch, HOST unset, PORT=3942 node src/everything/dist/index.js streamableHttp:

MCP Streamable HTTP Server listening on 127.0.0.1:3942
lsof: node 127.0.0.1:3942
[legacy] http://127.0.0.1:3942/mcp    -> exit 0: {"result":{"content":[{"type":"text","text":"Echo: hi-legacy"}]}}
[modern] http://127.0.0.1:3942/mcp    -> exit 0: {"result":{"_meta":{"io.modelcontextprotocol/serverInfo":…}…}}
[legacy] http://localhost:3942/mcp    -> exit 0: {"result":{"content":[{"type":"text","text":"Echo: hi-legacy"}]}}
[modern] http://localhost:3942/mcp    -> exit 0: {"result":{"_meta":{"io.modelcontextprotocol/serverInfo":…}…}}
[legacy] http://192.168.2.23:3942/mcp -> exit 4: {"error":{"code":"unreachable","message":"fetch failed","cause":"connect ECONNREFUSED 192.168.2.23:3942"}}
[modern] http://192.168.2.23:3942/mcp -> exit 4: {"error":{"code":"unreachable","message":"Version negotiation probe failed: fetch failed","cause":"fetch failed: connect ECONNREFUSED 192.168.2.23:3942"}}

After: HOST=0.0.0.0. The LAN address is reachable again, deliberately:

MCP Streamable HTTP Server listening on 0.0.0.0:3943
lsof: node *:3943
[legacy] http://192.168.2.23:3943/mcp -> exit 0: {"result":{"content":[{"type":"text","text":"Echo: hi-legacy"}]}}
[modern] http://192.168.2.23:3943/mcp -> exit 0: {"result":{"_meta":{"io.modelcontextprotocol/serverInfo":…}…}}
(127.0.0.1 and localhost: exit 0 in both eras)

HTTP+SSE (2025 era only), this branch:

HOST unset:   Server is running on 127.0.0.1:3944   lsof: 127.0.0.1:3944
  [legacy] http://localhost:3944/sse    -> exit 0: {"result":{"content":[{"type":"text","text":"Echo: hi-legacy"}]}}
  [legacy] http://192.168.2.23:3944/sse -> exit 4: {"error":{"code":"unreachable","message":"SSE error: TypeError: fetch failed: connect ECONNREFUSED 192.168.2.23:3944"}}
HOST=0.0.0.0: Server is running on 0.0.0.0:3945     lsof: *:3945
  [legacy] http://192.168.2.23:3945/sse -> exit 0: {"result":{"content":[{"type":"text","text":"Echo: hi-legacy"}]}}

HOST=::1: MCP Streamable HTTP Server listening on [::1]:3946, lsof: [::1]:3946. http://localhost:3946/mcp succeeds in both eras, and 127.0.0.1 and the LAN address are refused.

LLM client: Claude Code 2.1.318 (claude -p --strict-mcp-config --mcp-config <file>, with {"type":"http","url":…}), against this branch with HOST unset (PORT=3951). Prompt: "Use the everything server's echo tool to echo the message 'bound to loopback', and tell me exactly what it returned. If the server or tool is unavailable, say so plainly."

  • url: http://localhost:3951/mcp: "The everything server's echo tool worked, and it returned exactly this: Echo: bound to loopback"
  • url: http://192.168.2.23:3951/mcp: "I couldn't run the echo, because the everything MCP server is unavailable. It failed to connect at session start with ECONNREFUSED…"

Era coverage: this Claude Code version connected on 2026-07-28 (the server created no 2025-era session, only session-less POSTs). No 2025-era LLM client was available on this machine, so the 2025-11-25 era is covered by the Inspector CLI runs above (--protocol-era legacy, over both Streamable HTTP and SSE). The change is the bind address, below the protocol, so it does not depend on the era.

Breaking Changes

Only for anyone who reached an HTTP-transport everything server from another host. They now need HOST=0.0.0.0 (or the address to bind). Local clients, stdio, and the Docker image (which sets HOST=0.0.0.0 itself) are unaffected. Released as a minor bump, since HOST is a new option.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Protocol Documentation (Streamable HTTP, Security & Endpoint, item 2: "When running locally, servers SHOULD bind only to localhost (127.0.0.1) rather than all network interfaces (0.0.0.0)"; the same text is in every earlier revision's transports page)
  • My changes follow MCP security best practices
  • I have updated the server's README accordingly
  • I have added a changeset (npm run changeset) if this changes what a TypeScript server publishes (.changeset/everything-loopback-bind-5371.md, minor)
  • I have tested this with an LLM client
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling (an unbindable HOST exits 1 through listenOrExit's existing error path, without printing the listening line)
  • I have documented all environment variables and configuration options

Additional context

HOST=localhost binds whichever address localhost resolves to first, which can be ::1 alone. The README's examples use 127.0.0.1 and 0.0.0.0 for that reason. The default listening line prints the configured host, not a resolved one.

🤖 Generated with Claude Code

…th HOST to widen

The Streamable HTTP and HTTP+SSE transports called app.listen(port) with
no host, so they bound every interface and a non-browser client on the
local network (which sends no Origin) could reach the server's tools.
listenOrExit now takes the host to bind; both transports resolve it from
HOST, defaulting to 127.0.0.1, and the listening line and the port-in-use
message name the bound address.

The container image sets HOST=0.0.0.0, since loopback inside a container
is unreachable from a published port. The boot smoke pins HOST to the
loopback address it connects to. The README, the Dockerfile and the
local-dev and client-smoke skills document HOST and the new listening
line.

Based on the prototype in #3303 by TheodorNEngoy.

Closes #5371

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
@changeset-bot

changeset-bot Bot commented Nov 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 41a758c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@modelcontextprotocol/server-everything Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cliffhall cliffhall added the v2 label Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

everything: MCP interface diff

Base: 281b1671ed30 (Merge pull request #5437 from modelcontextprotocol/changeset-release/v2/main)
Head: the build in this checkout, at deabaeed3e06 (Merge 41a758c into 281b167)

✅ No interface changes detected.

Tools Prompts Resources Resource templates
Base 22 4 8 5
Head 22 4 8 5

Updated by this run for 41a758c288398b7aa2b38f6ddaad43dd60e6498d.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The default binding change breaks remote access for existing non-Docker deployments without HOST configured, so the minor changeset must be changed to major under the release policy.

1 open issue
What changed in this PR

This PR addresses #5371 by making the everything server’s HTTP transports loopback-only by default, reducing unintended network exposure while allowing explicit access through HOST.

Changes:

  • Defaults both HTTP transports to 127.0.0.1 and includes the host in startup messages.
  • Preserves Docker port access and pins boot-smoke connections to loopback.
  • Adds bind-address tests and updates documentation and release metadata.
File Description
src/​everything/​transports/​streamableHttp.ts Applies the configured host to Streamable HTTP.
src/​everything/​transports/​sse.ts Applies the configured host to HTTP+SSE.
src/​everything/​transports/​listen.ts Centralizes host selection, binding, and address formatting.
src/​everything/​README.md Documents HOST, loopback defaults, and Docker behavior.
src/​everything/​Dockerfile Sets HOST=0.0.0.0 for published-port access.
src/​everything/​__tests__/​streamable-http.test.ts Checks default and overridden launcher hosts.
src/​everything/​__tests__/​sse.test.ts Checks default and overridden SSE hosts.
src/​everything/​__tests__/​listen.test.ts Tests real socket binds, failures, and host helpers.
scripts/​smoke-servers.mjs Pins HTTP smoke-test servers to loopback.
.claude/​skills/​local-dev/​SKILL.md Updates bind configuration and logging guidance.
.claude/​skills/​client-smoke/​SKILL.md Updates the expected listening-line example.
.changeset/​everything-loopback-bind-5371.md Records the release impact and migration option.

Review effort: Balanced

@@ -0,0 +1,5 @@
---
"@modelcontextprotocol/server-everything": minor

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Declined, keeping minor. This is a release-policy call the maintainers already made for this issue: a new HOST option is a minor change. It also matches how this repo has shipped comparable security-default tightening. #5241 made both HTTP transports refuse any non-localhost Origin with a 403, which likewise stopped some remote callers, and it went out as a patch ("Harden request handling in the HTTP transports", 2.0.1). AGENTS.md's examples of a major change are interface breaks (a tool removed or renamed, a schema change that breaks clients, a protocol or Node floor bump). This change touches none of those: no tool, schema or protocol changes, and every local client and the Docker image keep working unchanged.

The changeset and the PR's Breaking Changes section both say what the default change means for anyone who reached the server from another host, and how to restore it (HOST=0.0.0.0). If a maintainer wants this released as major instead, that is a one-word change to the changeset.

@cliffhall

Copy link
Copy Markdown
Member Author

Copilot round 1: one finding, declined.

  • Breaking host default requires a major changeset (.changeset/everything-loopback-bind-5371.md): declined, the changeset stays minor. The bump level is the maintainers' decision for this issue (a new HOST option is minor). It also follows precedent: fix(everything): harden HTTP transport request handling #5241's comparable tightening, refusing non-localhost Origins, shipped as a patch. No tool, schema or protocol change is involved, and the effect on remote callers is documented in the changeset and under Breaking Changes, with HOST=0.0.0.0 as the fix. The reply is in the thread.

Nothing was pushed, and every finding in the round was declined, so the loop stops here (pr-flow exit: out of scope only).

@cliffhall
cliffhall merged commit c704929 into v2/main Nov 5, 2026
45 checks passed
@cliffhall
cliffhall deleted the v2/feat/5371-everything-loopback-bind branch November 5, 2026 04:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

everything: bind the HTTP transports to loopback by default, with HOST to widen

2 participants