From 311c0fdc953f38da08153fd2e8d134d03c8e168b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 11 Nov 2026 23:35:30 -0500 Subject: [PATCH 1/3] feat: serve Streamable HTTP from every reference server Every server already served stdio, and only everything served Streamable HTTP. Now filesystem, memory, sequential-thinking, fetch, git and time take --transport streamable-http and serve one /mcp endpoint for 2026-07-28 and 2025-era clients alike, on HOST:PORT (127.0.0.1:3001 by default). The TypeScript servers serve their existing factory through the SDK's createMcpHandler (stateless for 2025-era clients) behind a localhost Origin check; the Python servers run the SDK's Streamable HTTP app. Over HTTP, filesystem requires command-line directories (Roots cannot reach it), memory publishes graph changes on the handler's listen bus, and sequential-thinking keeps one history per process. The boot smoke now boots each server over Streamable HTTP as well. Closes #576 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .changeset/576-streamable-http.md | 7 + .claude/skills/local-dev/SKILL.md | 13 + .claude/skills/project-structure/SKILL.md | 7 +- .claude/skills/testing/SKILL.md | 2 +- AGENTS.md | 7 +- README.md | 4 +- docs/quality-gate.md | 2 +- package-lock.json | 3 + scripts/smoke-servers.mjs | 34 ++- scripts/smoke-servers.test.mjs | 18 +- src/fetch/README.md | 25 ++ src/fetch/src/mcp_server_fetch/__init__.py | 15 + src/fetch/src/mcp_server_fetch/server.py | 12 +- src/fetch/src/mcp_server_fetch/transport.py | 45 +++ src/fetch/tests/test_allowed_hosts.py | 1 + src/fetch/tests/test_entrypoints.py | 6 +- src/fetch/tests/test_http.py | 116 ++++++++ .../tests/test_readability_js_opt_out.py | 2 +- src/filesystem/README.md | 26 ++ .../__tests__/confine-roots.test.ts | 2 + src/filesystem/__tests__/http.test.ts | 242 ++++++++++++++++ .../__tests__/read-only-dirs.test.ts | 2 + src/filesystem/__tests__/read-only.test.ts | 5 +- src/filesystem/__tests__/stdio.test.ts | 2 +- src/filesystem/http.ts | 118 ++++++++ src/filesystem/index.ts | 143 ++++++++-- src/filesystem/package.json | 1 + src/git/README.md | 25 ++ src/git/src/mcp_server_git/__init__.py | 21 +- src/git/src/mcp_server_git/server.py | 11 +- src/git/src/mcp_server_git/transport.py | 45 +++ src/git/tests/test_entrypoints.py | 6 +- src/git/tests/test_http.py | 107 +++++++ src/memory/README.md | 26 ++ src/memory/__tests__/http.test.ts | 269 ++++++++++++++++++ src/memory/http.ts | 126 ++++++++ src/memory/index.ts | 89 +++++- src/memory/package.json | 1 + src/sequentialthinking/README.md | 26 ++ src/sequentialthinking/__tests__/http.test.ts | 249 ++++++++++++++++ src/sequentialthinking/http.ts | 121 ++++++++ src/sequentialthinking/index.ts | 92 +++++- src/sequentialthinking/package.json | 1 + src/time/README.md | 25 ++ src/time/src/mcp_server_time/__init__.py | 16 +- src/time/src/mcp_server_time/server.py | 11 +- src/time/src/mcp_server_time/transport.py | 45 +++ src/time/tests/test_entrypoints.py | 4 +- src/time/tests/test_http.py | 127 +++++++++ 49 files changed, 2200 insertions(+), 103 deletions(-) create mode 100644 .changeset/576-streamable-http.md create mode 100644 src/fetch/src/mcp_server_fetch/transport.py create mode 100644 src/fetch/tests/test_http.py create mode 100644 src/filesystem/__tests__/http.test.ts create mode 100644 src/filesystem/http.ts create mode 100644 src/git/src/mcp_server_git/transport.py create mode 100644 src/git/tests/test_http.py create mode 100644 src/memory/__tests__/http.test.ts create mode 100644 src/memory/http.ts create mode 100644 src/sequentialthinking/__tests__/http.test.ts create mode 100644 src/sequentialthinking/http.ts create mode 100644 src/time/src/mcp_server_time/transport.py create mode 100644 src/time/tests/test_http.py diff --git a/.changeset/576-streamable-http.md b/.changeset/576-streamable-http.md new file mode 100644 index 0000000000..9e6d847bb7 --- /dev/null +++ b/.changeset/576-streamable-http.md @@ -0,0 +1,7 @@ +--- +"@modelcontextprotocol/server-filesystem": minor +"@modelcontextprotocol/server-memory": minor +"@modelcontextprotocol/server-sequential-thinking": minor +--- + +Serve Streamable HTTP as well as stdio (#576). `--transport streamable-http` serves the server on one `/mcp` endpoint, for 2026-07-28 and 2025-era clients alike, at `HOST`:`PORT` (`127.0.0.1:3001` by default); stdio stays the default. Over HTTP the filesystem server requires its allowed directories on the command line, since it cannot ask a client for its Roots, and the sequential-thinking history belongs to the server process rather than to one connection. diff --git a/.claude/skills/local-dev/SKILL.md b/.claude/skills/local-dev/SKILL.md index 9935de473c..3872ca1446 100644 --- a/.claude/skills/local-dev/SKILL.md +++ b/.claude/skills/local-dev/SKILL.md @@ -79,6 +79,19 @@ streams: HTTP+SSE writes everything to stderr, while Streamable HTTP writes its request and session messages to stdout and its listening line (which names the bound `host:port`) and errors to stderr. +The other six servers serve Streamable HTTP too (#576), selected with a flag +rather than an argument, on the same `PORT`, `HOST` and `/mcp` path: + +```sh +node src/memory/dist/index.js --transport streamable-http +node src/filesystem/dist/index.js --transport streamable-http /tmp/scratch # directories required over HTTP +(cd src/time && uv run mcp-server-time --transport streamable-http) +``` + +Each one's endpoint is stateless for 2025-era clients (a fresh server per +request; GET and DELETE answer 405), so nothing the server asks of the client +mid-session (filesystem's Roots) reaches it over HTTP. + ## Run the local package the way a user would A user starts these servers with `npx` or `uvx`. Both accept a path in place of diff --git a/.claude/skills/project-structure/SKILL.md b/.claude/skills/project-structure/SKILL.md index c67e4e127f..78e9bf9a3a 100644 --- a/.claude/skills/project-structure/SKILL.md +++ b/.claude/skills/project-structure/SKILL.md @@ -29,9 +29,9 @@ includes the tests) and `vitest.config.ts`. | Server | Layout | Where its features are registered | | --- | --- | --- | | `everything` | `index.ts` picks a transport from `argv`; `server/` holds the factory; `tools/`, `resources/`, `prompts/` hold one file per feature plus an `index.ts`; `transports/` holds one file per transport; `version.ts` reads the package version; `docs/` is shipped | Each feature file exports a `register…` function, wired into its area's `index.ts` (`registerTools`, `registerResources`, `registerPrompts`), which `server/index.ts` calls from `createServer()` | -| `filesystem` | `index.ts` is the stdio entry point (argv → allowed directories, `main()`); `server.ts` exports `createServer(allowedDirectories)`; `lib.ts` holds the file operations and path validation, with the allow-list passed in rather than held globally; `path-utils.ts`, `path-validation.ts` and `roots-utils.ts` are focused helpers, and `pinned-directory.ts` runs the write operations inside their verified directory; `version.ts` reads the package version | Inline in `createServer()` in `server.ts`, with `server.registerTool(...)` | -| `memory` | `index.ts` holds the `KnowledgeGraphManager`, `createServer(memoryFilePath)` and the stdio `main()`; `version.ts` reads the package version | Inline in `createServer()`; the resource and its subscription handlers are exported `register…` functions in the same file | -| `sequentialthinking` | `index.ts` holds `createServer()` and the stdio `main()`; `lib.ts` holds `SequentialThinkingServer`, the logic; `version.ts` reads the package version | Inline in `createServer()` | +| `filesystem` | `index.ts` is the entry point (argv → transport and allowed directories, `main()`); `http.ts` serves the factory over Streamable HTTP; `server.ts` exports `createServer(allowedDirectories)`; `lib.ts` holds the file operations and path validation, with the allow-list passed in rather than held globally; `path-utils.ts`, `path-validation.ts` and `roots-utils.ts` are focused helpers, and `pinned-directory.ts` runs the write operations inside their verified directory; `version.ts` reads the package version | Inline in `createServer()` in `server.ts`, with `server.registerTool(...)` | +| `memory` | `index.ts` holds the `KnowledgeGraphManager`, `createServer(memoryFilePath)`, the stdio `main()` and `run()`, which picks the transport; `http.ts` serves the factory over Streamable HTTP; `version.ts` reads the package version | Inline in `createServer()`; the resource and its subscription handlers are exported `register…` functions in the same file | +| `sequentialthinking` | `index.ts` holds `createServer()`, the stdio `main()` and `run()`, which picks the transport; `http.ts` serves the factory over Streamable HTTP; `lib.ts` holds `SequentialThinkingServer`, the logic; `version.ts` reads the package version | Inline in `createServer()` | Three things about `everything` that the table cannot hold: @@ -69,6 +69,7 @@ src// ├── src/mcp_server_/ │ ├── __init__.py main(): the command-line flags, then asyncio.run(serve(...)) │ ├── __main__.py python -m mcp_server_ +│ ├── transport.py --transport: stdio, or Streamable HTTP on $HOST:$PORT │ └── server.py everything else └── tests/ pytest suite, driving serve() in-process ``` diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md index 1527dbce82..0cf090d7fe 100644 --- a/.claude/skills/testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -40,7 +40,7 @@ connection harness does (`__tests__/harness.ts` in `everything`, | Server | How its tests reach the code | | --- | --- | -| `everything`, `filesystem`, `memory`, `sequentialthinking` | An SDK `Client` connected to the server's `createServer()` over `InMemoryTransport`, in the vitest process, through the server's own harness, plus unit tests of helper modules such as `lib.ts`. `everything` drives its stdio, SSE and Streamable HTTP transports in-process too; the other three have one thin spawn smoke of the built `dist/index.js` | +| `everything`, `filesystem`, `memory`, `sequentialthinking` | An SDK `Client` connected to the server's `createServer()` over `InMemoryTransport`, in the vitest process, through the server's own harness, plus unit tests of helper modules such as `lib.ts`. `everything` drives its stdio, SSE and Streamable HTTP transports in-process too; the other three drive their `http.ts` in-process over a free loopback port, and have one thin spawn smoke of the built `dist/index.js` | | `fetch`, `git`, `time` | A `ClientSession` linked in-process to the real `serve()` over in-memory streams (below), plus direct calls on the pure functions in `server.py` | No TypeScript test uses an in-memory transport yet. diff --git a/AGENTS.md b/AGENTS.md index bad74767a6..1a40bbfd41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -688,8 +688,11 @@ look before retrying. 2026-07-28 request lives under a handle the server mints. Ask a client for input (elicitation, sampling, roots) by returning `inputRequired(...)`, which the SDK's legacy shim also serves to 2025-era clients. -- **Transports**: stdio is the default and every server supports it. - `everything` also serves Streamable HTTP and HTTP+SSE. **HTTP+SSE is +- **Transports**: stdio is the default and every server supports it. Every + server also serves Streamable HTTP on `/mcp` at `HOST`:`PORT` + (loopback:3001 by default), which `everything` selects with its + `streamableHttp` argument and the others with `--transport + streamable-http`; `everything` alone also serves HTTP+SSE. **HTTP+SSE is deprecated**: keep what exists working, and never make it the default or the recommended option in new code or docs. - **Tool annotations**: every tool you add or change sets `readOnlyHint`, diff --git a/README.md b/README.md index 9e612a110b..93219eea1b 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ An MCP server talks to its client over a **transport**. The protocol defines two | **stdio** | The client launches the server as a subprocess and talks to it over the process's stdin and stdout. The server opens no port. | A `command` and `args` in the client's configuration, as in the examples above | | **Streamable HTTP** | The server runs on its own as an HTTP service, and can serve many clients | A URL, such as `http://localhost:3001/mcp` | -**Every server in this repository serves stdio, and only [Everything](src/everything) also serves Streamable HTTP** (its README shows how to start it that way, and covers its deprecated HTTP+SSE transport too). So when you run `uvx mcp-server-git` or `npx -y @modelcontextprotocol/server-memory` in a terminal, it opens no port, prints at most a one-line startup notice, and then appears to hang. That is expected: it is waiting for a client to send it messages on stdin. You do not normally start a stdio server yourself; your MCP client starts it from its configuration and stops it when it is done. +**Every server in this repository serves stdio by default, and every one can also serve Streamable HTTP** on `http://127.0.0.1:3001/mcp` (set `PORT` and `HOST` to move it): [Everything](src/everything) with its `streamableHttp` argument (its README also covers its deprecated HTTP+SSE transport), and the others with `--transport streamable-http`, as each server's README shows. Started without one, a server uses stdio, so when you run `uvx mcp-server-git` or `npx -y @modelcontextprotocol/server-memory` in a terminal, it opens no port, prints at most a one-line startup notice, and then appears to hang. That is expected: it is waiting for a client to send it messages on stdin. You do not normally start a stdio server yourself; your MCP client starts it from its configuration and stops it when it is done. To try a server without setting up a client, use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). Given a stdio server's command, it launches the server for you and lets you browse and call its tools; given a URL, it connects to a running Streamable HTTP server: @@ -144,7 +144,7 @@ To try a server without setting up a client, use the [MCP Inspector](https://git npx @modelcontextprotocol/inspector uvx mcp-server-git # A Streamable HTTP server: start it first, then connect to its URL -npx @modelcontextprotocol/server-everything streamableHttp +npx @modelcontextprotocol/server-everything streamableHttp # or: uvx mcp-server-time --transport streamable-http npx @modelcontextprotocol/inspector --transport http --server-url http://localhost:3001/mcp ``` diff --git a/docs/quality-gate.md b/docs/quality-gate.md index eaa51a1ff9..9dcee6551c 100644 --- a/docs/quality-gate.md +++ b/docs/quality-gate.md @@ -105,7 +105,7 @@ Notes on the stages: offline. - **`smoke` launches what a user launches**: the built `dist/index.js` for a TypeScript server, the console script through `uv run --no-sync` for a Python - one. stdio for all seven; HTTP+SSE and Streamable HTTP as well for + one. stdio and Streamable HTTP for all seven; HTTP+SSE as well for `everything`. The servers are given nothing outside the machine to talk to and none of your files: `fetch` is pointed at a page the smoke serves on the loopback interface, and the HTTP transports listen on a free port rather diff --git a/package-lock.json b/package-lock.json index 9d5a1db14b..96fd00109e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5285,6 +5285,7 @@ "version": "2.3.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { + "@modelcontextprotocol/node": "^2.1.1", "@modelcontextprotocol/server": "^2.3.1", "diff": "^8.0.3", "minimatch": "^11.0.4", @@ -5312,6 +5313,7 @@ "version": "2.1.1", "license": "SEE LICENSE IN LICENSE", "dependencies": { + "@modelcontextprotocol/node": "^2.1.1", "@modelcontextprotocol/server": "^2.3.1", "zod": "^4.2.0" }, @@ -5335,6 +5337,7 @@ "version": "2.1.1", "license": "SEE LICENSE IN LICENSE", "dependencies": { + "@modelcontextprotocol/node": "^2.1.1", "@modelcontextprotocol/server": "^2.3.1", "chalk": "^5.3.0", "zod": "^4.2.0" diff --git a/scripts/smoke-servers.mjs b/scripts/smoke-servers.mjs index 94cbe47ba2..8212f9ee7c 100755 --- a/scripts/smoke-servers.mjs +++ b/scripts/smoke-servers.mjs @@ -8,8 +8,8 @@ * npm run smoke # the root entry point (all) * * For each server and transport it connects an MCP client, lists the tools, - * and calls one of them: stdio for all seven servers, plus HTTP+SSE and - * Streamable HTTP for `everything`, the only server that serves them. + * and calls one of them: stdio and Streamable HTTP for all seven servers + * (#576), plus HTTP+SSE for `everything`, the only server that serves it. * * Why it exists when every server has a test suite: the suites import the * source. Nothing in them runs the artifact a user actually launches, which is @@ -40,8 +40,9 @@ * 3001, so the smoke does not collide with a server the developer has running. * That port is not reserved between the probe and the server's own `listen`, * so a launch whose server reports the port taken is relaunched on a fresh - * one. Both HTTP transports report it (`listenOrExit` in `everything`'s - * `transports/listen.ts`). + * one. Every HTTP launch reports it: `listenOrExit` in `everything`'s + * `transports/listen.ts`, the other TypeScript servers' `http.ts` (through + * their fatal-error line), and uvicorn under the Python servers. * * One server failing does not stop the rest: the run reports every target's * verdict and exits non-zero if any failed, so one run shows the whole picture. @@ -122,7 +123,7 @@ export const SERVERS = [ { name: "filesystem", language: "ts", - transports: ["stdio"], + transports: ["stdio", "streamableHttp"], args: (ctx) => [ctx.dir], call: () => ({ name: "list_allowed_directories", arguments: {} }), expect: (ctx) => path.basename(ctx.dir), @@ -130,14 +131,14 @@ export const SERVERS = [ { name: "memory", language: "ts", - transports: ["stdio"], + transports: ["stdio", "streamableHttp"], env: (ctx) => ({ MEMORY_FILE_PATH: path.join(ctx.dir, "memory.jsonl") }), call: () => ({ name: "read_graph", arguments: {} }), }, { name: "sequentialthinking", language: "ts", - transports: ["stdio"], + transports: ["stdio", "streamableHttp"], call: () => ({ name: "sequentialthinking", arguments: { @@ -151,7 +152,7 @@ export const SERVERS = [ { name: "fetch", language: "py", - transports: ["stdio"], + transports: ["stdio", "streamableHttp"], // fetch refuses loopback addresses by default (#4838); the smoke page is // on loopback, so the guard is turned off for it. args: () => ["--allow-private-ips"], @@ -167,7 +168,7 @@ export const SERVERS = [ { name: "git", language: "py", - transports: ["stdio"], + transports: ["stdio", "streamableHttp"], args: (ctx) => ["--repository", ctx.dir], prepare: (ctx) => run("git", ["init", "--quiet", ctx.dir]), call: (ctx) => ({ name: "git_status", arguments: { repo_path: ctx.dir } }), @@ -175,14 +176,14 @@ export const SERVERS = [ { name: "time", language: "py", - transports: ["stdio"], + transports: ["stdio", "streamableHttp"], args: () => ["--local-timezone", "UTC"], call: () => ({ name: "get_current_time", arguments: { timezone: "UTC" } }), expect: "UTC", }, ]; -/** The argument that selects a transport; only `everything` takes one. */ +/** The path each HTTP transport is served on. */ const TRANSPORT_ENDPOINT = { sse: "/sse", streamableHttp: "/mcp" }; /** Run a setup command to completion; throw with its output if it fails. */ @@ -289,9 +290,14 @@ export function launchSpec( * @returns {string[]} */ export function serverArguments(server, transport, ctx) { - // `everything` picks its transport from its first argument; the others are - // stdio-only and take none. - const transportArg = server.transports.length > 1 ? [transport] : []; + // `everything` picks its transport from its first argument; the others + // default to stdio and take `--transport streamable-http` for HTTP (#576). + const transportArg = + server.name === "everything" + ? [transport] + : transport === "streamableHttp" + ? ["--transport", "streamable-http"] + : []; return [...transportArg, ...(server.args?.(ctx) ?? [])]; } diff --git a/scripts/smoke-servers.test.mjs b/scripts/smoke-servers.test.mjs index 22f002f4b5..f346b33109 100644 --- a/scripts/smoke-servers.test.mjs +++ b/scripts/smoke-servers.test.mjs @@ -45,12 +45,15 @@ test("findUnlistedServers names a server the table lacks, either language", (t) assert.deepEqual(findUnlistedServers(src), ["newpy", "newts"]); }); -test("stdio for all seven; SSE and Streamable HTTP for everything only", () => { +test("stdio and Streamable HTTP for all seven; SSE for everything only", () => { assert.equal(SERVERS.length, 7); for (const server of SERVERS) { - assert.ok(server.transports.includes("stdio"), server.name); if (server.name !== "everything") - assert.deepEqual(server.transports, ["stdio"], server.name); + assert.deepEqual( + server.transports, + ["stdio", "streamableHttp"], + server.name, + ); } assert.deepEqual(byName("everything").transports, [ "stdio", @@ -60,7 +63,7 @@ test("stdio for all seven; SSE and Streamable HTTP for everything only", () => { }); test("selectTargets: no names means every server and transport", () => { - assert.equal(selectTargets([]).length, 9); + assert.equal(selectTargets([]).length, 15); }); test("selectTargets: named servers only, in the table's order", () => { @@ -73,6 +76,7 @@ test("selectTargets: named servers only, in the table's order", () => { "everything/sse", "everything/streamableHttp", "time/stdio", + "time/streamableHttp", ], ); }); @@ -102,6 +106,12 @@ test("launchSpec: a TypeScript server runs its built bin, with the transport whe ); assert.equal(filesystem.command, bin("filesystem")); assert.deepEqual(filesystem.args, ["/tmp/x"]); + // The other servers take the HTTP transport as a flag (#576). + assert.deepEqual( + launchSpec(byName("filesystem"), "streamableHttp", ctx, "/repo", "linux") + .args, + ["--transport", "streamable-http", "/tmp/x"], + ); // Windows has no shebang: the file is handed to node. const win = launchSpec(byName("everything"), "sse", ctx, "/repo", "win32"); assert.equal(win.command, process.execPath); diff --git a/src/fetch/README.md b/src/fetch/README.md index 8bd0f48942..1ec12506b0 100644 --- a/src/fetch/README.md +++ b/src/fetch/README.md @@ -320,6 +320,31 @@ To always use the pure-Python extractor, add the argument `--no-readability-js` configuration. The server then never starts Node.js (or npm), whether or not it is installed, and never contacts the npm registry. +## Streamable HTTP + +The server talks over stdio by default. With `--transport streamable-http` it +runs on its own as an HTTP service instead, serving one MCP endpoint, `/mcp`, +to any number of clients of either protocol era: + +```sh +uvx mcp-server-fetch --transport streamable-http +``` + +It listens on `127.0.0.1:3001` unless `HOST` and `PORT` say otherwise (for +example `PORT=8080`); `HOST=0.0.0.0` exposes it to every machine that can reach +this one, so set it deliberately. Point a client at the URL, such as +`http://127.0.0.1:3001/mcp`, instead of giving it a command: + +```json +{ + "mcpServers": { + "fetch": { "type": "http", "url": "http://127.0.0.1:3001/mcp" } + } +} +``` + +Every other option works as it does over stdio. On loopback the SDK also checks each request's `Host` and `Origin` headers against loopback names, to guard against DNS rebinding. + ## Windows Configuration If you're experiencing timeout issues on Windows, you may need to set the `PYTHONIOENCODING` environment variable to ensure proper character encoding: diff --git a/src/fetch/src/mcp_server_fetch/__init__.py b/src/fetch/src/mcp_server_fetch/__init__.py index fa3a6eba88..02a41ddf3e 100644 --- a/src/fetch/src/mcp_server_fetch/__init__.py +++ b/src/fetch/src/mcp_server_fetch/__init__.py @@ -8,6 +8,7 @@ parse_timeout_ms, timeout_ms_from_env, ) +from .transport import TRANSPORTS, http_endpoint def main(): @@ -53,6 +54,13 @@ def main(): help="Extract HTML with the pure-Python extractor only, never starting Node.js " "or npm (by default Readability.js is used when Node.js is on PATH)", ) + parser.add_argument( + "--transport", + choices=TRANSPORTS, + default="stdio", + help="How clients reach the server (default: stdio). streamable-http " + "listens on $HOST:$PORT, 127.0.0.1:3001 by default", + ) args = parser.parse_args() # The server reads its timeout from the environment (see timeout.py), and @@ -63,6 +71,12 @@ def main(): timeout_ms_from_env() except ValueError as e: parser.error(str(e)) + if args.transport == "streamable-http": + # A bad HOST or PORT stops the server here, as a usage error. + try: + http_endpoint() + except ValueError as e: + parser.error(str(e)) asyncio.run( serve( args.user_agent, @@ -71,6 +85,7 @@ def main(): args.allow_private_ips, args.allowed_hosts, not args.no_readability_js, + args.transport, ) ) diff --git a/src/fetch/src/mcp_server_fetch/server.py b/src/fetch/src/mcp_server_fetch/server.py index c1bbbd3f91..0febcb2a86 100644 --- a/src/fetch/src/mcp_server_fetch/server.py +++ b/src/fetch/src/mcp_server_fetch/server.py @@ -55,6 +55,7 @@ timeout_ms_from_env, ) from .title import with_page_title +from .transport import run # The version this server reports in serverInfo, read from the installed # distribution's metadata (pyproject.toml) so it cannot drift from the @@ -1115,8 +1116,9 @@ async def serve( allow_private_ips: bool = False, allowed_hosts: Sequence[str] | None = None, use_readability_js: bool = True, + transport: str = "stdio", ) -> None: - """Run the fetch MCP server over stdio. + """Run the fetch MCP server over stdio, or over Streamable HTTP. Args: custom_user_agent: Optional custom User-Agent string to use for requests @@ -1131,6 +1133,7 @@ async def serve( use_readability_js: Extract HTML with Readability.js under Node.js when Node.js is on PATH (falling back to the pure-Python extractor when it cannot run); False always uses the pure-Python extractor + transport: ``stdio``, or ``streamable-http`` to listen on $HOST:$PORT """ server = create_server( custom_user_agent, @@ -1140,9 +1143,4 @@ async def serve( allowed_hosts, use_readability_js, ) - # Both eras (#4853): the SDK's dual-era loop lets the client's first - # request decide. `initialize` gets the 2025-11-25 handshake; - # `server/discover` or a request carrying a 2026-07-28 envelope gets the - # modern, sessionless era, with its required result fields (resultType, - # ttlMs, cacheScope) filled in by the SDK. - await server.run_stdio_async() + await run(server, transport) diff --git a/src/fetch/src/mcp_server_fetch/transport.py b/src/fetch/src/mcp_server_fetch/transport.py new file mode 100644 index 0000000000..a9db7f4d45 --- /dev/null +++ b/src/fetch/src/mcp_server_fetch/transport.py @@ -0,0 +1,45 @@ +"""How the fetch server is reached: stdio (the default) or Streamable HTTP (#576). + +`--transport streamable-http` serves the same server on one `/mcp` endpoint +through the SDK's Streamable HTTP app, which answers 2026-07-28 and 2025-era +clients alike. It listens on `HOST`:`PORT` from the environment, loopback:3001 +by default: the same variables, defaults and loopback-first posture as the +TypeScript servers, and on loopback the SDK also turns on its DNS-rebinding +protection (Host and Origin checks). +""" + +import os +from collections.abc import Mapping + +from mcp.server.mcpserver import MCPServer + +TRANSPORTS = ("stdio", "streamable-http") +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 3001 + + +def http_endpoint(env: Mapping[str, str] = os.environ) -> tuple[str, int]: + """The host and port to listen on: `HOST` and `PORT`, else the defaults.""" + host = env.get("HOST") or DEFAULT_HOST + raw = env.get("PORT") + if not raw: + return host, DEFAULT_PORT + if not raw.isascii() or not raw.isdigit() or int(raw) > 65535: + raise ValueError( + f"Invalid PORT {raw!r}: expected a whole number from 0 to 65535" + ) + return host, int(raw) + + +async def run(server: MCPServer, transport: str) -> None: + """Serve `server` on `transport` until it is stopped.""" + if transport == "streamable-http": + host, port = http_endpoint() + await server.run_streamable_http_async(host=host, port=port) + else: + # Both eras (#4853): the SDK's dual-era loop lets the client's first + # request decide. `initialize` gets the 2025-11-25 handshake; + # `server/discover` or a request carrying a 2026-07-28 envelope gets + # the modern, sessionless era, with its required result fields + # (resultType, ttlMs, cacheScope) filled in by the SDK. + await server.run_stdio_async() diff --git a/src/fetch/tests/test_allowed_hosts.py b/src/fetch/tests/test_allowed_hosts.py index 3d27fbe97e..220ae13174 100644 --- a/src/fetch/tests/test_allowed_hosts.py +++ b/src/fetch/tests/test_allowed_hosts.py @@ -174,6 +174,7 @@ def test_flag_is_normalized_and_passed_to_serve( False, ["example.com", "*.docs.example.org", "10.0.0.1"], True, + "stdio", ) diff --git a/src/fetch/tests/test_entrypoints.py b/src/fetch/tests/test_entrypoints.py index 6b4438b771..dac809a8ed 100644 --- a/src/fetch/tests/test_entrypoints.py +++ b/src/fetch/tests/test_entrypoints.py @@ -33,7 +33,7 @@ def run_main(monkeypatch: pytest.MonkeyPatch, *argv: str) -> AsyncMock: def test_main_defaults(monkeypatch: pytest.MonkeyPatch) -> None: serve = run_main(monkeypatch) - serve.assert_awaited_once_with(None, False, None, False, None, True) + serve.assert_awaited_once_with(None, False, None, False, None, True, "stdio") def test_main_passes_every_flag(monkeypatch: pytest.MonkeyPatch) -> None: @@ -47,7 +47,7 @@ def test_main_passes_every_flag(monkeypatch: pytest.MonkeyPatch) -> None: "--allow-private-ips", ) serve.assert_awaited_once_with( - "MyBot/1.0", True, "http://proxy.example.com:8080", True, None, True + "MyBot/1.0", True, "http://proxy.example.com:8080", True, None, True, "stdio" ) @@ -92,7 +92,7 @@ def test_package_init_run_as_main_calls_main(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(mcp_server_fetch.server, "serve", serve) monkeypatch.setattr(sys, "argv", ["mcp-server-fetch"]) runpy.run_module("mcp_server_fetch.__init__", run_name="__main__") - serve.assert_awaited_once_with(None, False, None, False, None, True) + serve.assert_awaited_once_with(None, False, None, False, None, True, "stdio") def console_script() -> Path: diff --git a/src/fetch/tests/test_http.py b/src/fetch/tests/test_http.py new file mode 100644 index 0000000000..846bf3fac3 --- /dev/null +++ b/src/fetch/tests/test_http.py @@ -0,0 +1,116 @@ +# The Streamable HTTP transport (#576): `serve(transport="streamable-http")` +# run in-process on a free loopback port and driven by the SDK `Client` over +# real HTTP in both protocol eras, plus `main()`'s `--transport` flag and +# `http_endpoint()`'s reading of HOST and PORT. + +import socket +import sys +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from functools import partial +from unittest.mock import AsyncMock, patch + +import anyio +import pytest +from mcp import Client + +import mcp_server_fetch +from mcp_server_fetch.server import serve +from mcp_server_fetch.transport import DEFAULT_HOST, DEFAULT_PORT, http_endpoint + +ERAS = [ + pytest.param("2026-07-28", "2026-07-28", id="2026-07-28"), + pytest.param("legacy", "2025-11-25", id="2025-11-25"), +] + + +def free_port() -> int: + """A loopback port nothing is listening on right now.""" + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +@asynccontextmanager +async def http_server(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[str]: + """Run `serve()` over Streamable HTTP and yield its URL once it answers.""" + port = free_port() + monkeypatch.setenv("HOST", "127.0.0.1") + monkeypatch.setenv("PORT", str(port)) + async with anyio.create_task_group() as tg: + tg.start_soon(partial(serve, transport="streamable-http")) + with anyio.fail_after(10): + while True: + try: + await (await anyio.connect_tcp("127.0.0.1", port)).aclose() + break + except OSError: + await anyio.sleep(0.05) + yield f"http://127.0.0.1:{port}/mcp" + tg.cancel_scope.cancel() + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_serves_each_era_over_streamable_http( + monkeypatch: pytest.MonkeyPatch, mode: str, negotiated: str +) -> None: + async with http_server(monkeypatch) as url, Client(url, mode=mode) as client: + assert client.session.protocol_version == negotiated + tools = await client.list_tools() + assert [tool.name for tool in tools.tools] == ["fetch"] + + +def test_http_endpoint_defaults_to_loopback() -> None: + assert http_endpoint({}) == (DEFAULT_HOST, DEFAULT_PORT) + + +def test_http_endpoint_reads_host_and_port() -> None: + assert http_endpoint({"HOST": "0.0.0.0", "PORT": "8080"}) == ("0.0.0.0", 8080) + + +@pytest.mark.parametrize("port", ["abc", "-1", "65536", "1.5", "٣"]) +def test_http_endpoint_refuses_a_bad_port(port: str) -> None: + with pytest.raises(ValueError, match="Invalid PORT"): + http_endpoint({"PORT": port}) + + +@pytest.mark.parametrize( + "argv,transport", + [ + ([], "stdio"), + (["--transport", "stdio"], "stdio"), + (["--transport", "streamable-http"], "streamable-http"), + ], +) +def test_main_passes_the_transport_to_serve( + monkeypatch: pytest.MonkeyPatch, argv: list[str], transport: str +) -> None: + monkeypatch.delenv("PORT", raising=False) + serve_mock = AsyncMock() + with ( + patch.object(mcp_server_fetch, "serve", serve_mock), + patch.object(sys, "argv", ["mcp-server-fetch", *argv]), + ): + mcp_server_fetch.main() + serve_mock.assert_awaited_once_with(None, False, None, False, None, True, transport) + + +@pytest.mark.parametrize( + "argv", [["--transport", "sse"], ["--transport", "streamable-http"]] +) +def test_main_refuses_an_unknown_transport_or_a_bad_port( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + argv: list[str], +) -> None: + monkeypatch.setenv("PORT", "not-a-port") + serve_mock = AsyncMock() + with ( + patch.object(mcp_server_fetch, "serve", serve_mock), + patch.object(sys, "argv", ["mcp-server-fetch", *argv]), + pytest.raises(SystemExit) as exit_info, + ): + mcp_server_fetch.main() + assert exit_info.value.code == 2 + serve_mock.assert_not_awaited() + assert "error:" in capsys.readouterr().err diff --git a/src/fetch/tests/test_readability_js_opt_out.py b/src/fetch/tests/test_readability_js_opt_out.py index 1a428d04c1..d621c6fee3 100644 --- a/src/fetch/tests/test_readability_js_opt_out.py +++ b/src/fetch/tests/test_readability_js_opt_out.py @@ -79,7 +79,7 @@ def test_flag_is_passed_to_serve(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(mcp_server_fetch, "serve", serve) monkeypatch.setattr(sys, "argv", ["mcp-server-fetch", "--no-readability-js"]) mcp_server_fetch.main() - serve.assert_awaited_once_with(None, False, None, False, None, False) + serve.assert_awaited_once_with(None, False, None, False, None, False, "stdio") def test_flag_is_in_help( diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 829cc9d03c..f41f64a8d5 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -402,6 +402,32 @@ The mapping for filesystem tools is: Node.js 24 or later, when the server runs through `npx` or from source. +## Streamable HTTP + +The server talks over stdio by default. With `--transport streamable-http` it +runs on its own as an HTTP service instead, serving one MCP endpoint, `/mcp`, +to any number of clients of either protocol era: + +```sh +npx -y @modelcontextprotocol/server-filesystem --transport streamable-http /Users/username/Desktop +``` + +It listens on `127.0.0.1:3001` unless `HOST` and `PORT` say otherwise (for +example `PORT=8080`); `HOST=0.0.0.0` exposes it to every machine that can reach +this one, so set it deliberately. A browser page can reach it only from a +localhost origin. Point a client at the URL, such as +`http://127.0.0.1:3001/mcp`, instead of giving it a command: + +```json +{ + "mcpServers": { + "filesystem": { "type": "http", "url": "http://127.0.0.1:3001/mcp" } + } +} +``` + +Over HTTP each request is served by a fresh server, and a 2025-era client gets no session (GET and DELETE on `/mcp` answer `405`), so the server cannot send it requests or notifications between its own requests. That is why **over HTTP the allowed directories must be given on the command line**: the server cannot ask a client for its [Roots](#method-2-mcp-roots-deprecated-2025-era-clients-only), on either protocol era, and it refuses to start without a directory. `--read-only`, `--read-only-dir` and the other options work as they do over stdio; `--confine-roots` has nothing to confine. + ## Usage with Claude Desktop Add this to your `claude_desktop_config.json`: diff --git a/src/filesystem/__tests__/confine-roots.test.ts b/src/filesystem/__tests__/confine-roots.test.ts index 09d9b40108..09f4748a5b 100644 --- a/src/filesystem/__tests__/confine-roots.test.ts +++ b/src/filesystem/__tests__/confine-roots.test.ts @@ -228,12 +228,14 @@ describe("the command line", () => { readOnly: false, confineRoots: true, readOnlyDirectories: [], + transport: "stdio", }); expect(parseArgs(["--", "--confine-roots"])).toEqual({ directories: ["--confine-roots"], readOnly: false, confineRoots: false, readOnlyDirectories: [], + transport: "stdio", }); }); diff --git a/src/filesystem/__tests__/http.test.ts b/src/filesystem/__tests__/http.test.ts new file mode 100644 index 0000000000..c00c7a80ac --- /dev/null +++ b/src/filesystem/__tests__/http.test.ts @@ -0,0 +1,242 @@ +// The Streamable HTTP transport (#576): main() started with +// `--transport streamable-http` on a free loopback port and driven by the SDK +// Client over real HTTP in both protocol eras, plus the pieces it is built +// from: `parseArgs()` reading `--transport`, `httpEndpoint()` reading HOST and +// PORT, and `startHttpServer()`'s own refusals. Over HTTP a client's Roots +// cannot reach the server, so the command-line directories are what both eras +// get, and main() refuses to start without one. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + Client, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; +import { + DEFAULT_HOST, + DEFAULT_PORT, + endpointUrl, + httpEndpoint, + startHttpServer, + type RunningHttpServer, +} from "../http.js"; +import { HTTP_NEEDS_DIRECTORIES_ERROR, main, parseArgs } from "../index.js"; +import { createServer } from "../server.js"; + +const ERAS = [ + ["2026-07-28", { mode: { pin: "2026-07-28" } }, "modern"], + ["2025-11-25", { mode: "legacy" }, "legacy"], +] as const; + +async function connect( + url: string, + versionNegotiation: (typeof ERAS)[number][1], +): Promise { + const client = new Client( + { name: "http-test", version: "0.0.0" }, + // A client that offers Roots: over HTTP the server never asks for them. + { versionNegotiation, capabilities: { roots: { listChanged: true } } }, + ); + await client.connect(new StreamableHTTPClientTransport(new URL(url))); + return client; +} + +describe("main() over Streamable HTTP", () => { + const saved = { HOST: process.env.HOST, PORT: process.env.PORT }; + let dir: string; + let running: RunningHttpServer | undefined; + + beforeEach(async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + dir = await realpath(await mkdtemp(path.join(tmpdir(), "fs-http-"))); + await writeFile(path.join(dir, "hello.txt"), "hello over http"); + process.env.HOST = "127.0.0.1"; + process.env.PORT = "0"; + }); + + afterEach(async () => { + await running?.close(); + running = undefined; + await rm(dir, { recursive: true, force: true }); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + vi.restoreAllMocks(); + }); + + /** main() on HTTP with `args`, kept for the afterEach to close. */ + async function start(...args: string[]): Promise { + running = (await main([ + "--transport", + "streamable-http", + ...args, + ])) as RunningHttpServer; + return running; + } + + it.each(ERAS)( + "serves a %s client within the command-line directories", + async (_, negotiation, era) => { + const client = await connect((await start(dir)).url, negotiation); + try { + expect(client.getProtocolEra()).toBe(era); + const result = await client.callTool({ + name: "read_text_file", + arguments: { path: path.join(dir, "hello.txt") }, + }); + expect(result.isError).toBeFalsy(); + expect(JSON.stringify(result.content)).toContain("hello over http"); + const outside = await client.callTool({ + name: "read_text_file", + arguments: { path: path.join(tmpdir(), "elsewhere.txt") }, + }); + expect(outside.isError).toBe(true); + } finally { + await client.close(); + } + }, + ); + + it("announces the URL, the options and the directories on stderr", async () => { + const url = (await start("--read-only", dir)).url; + expect(console.error).toHaveBeenCalledWith( + `Secure MCP Filesystem Server running on Streamable HTTP at ${url}`, + ); + expect(console.error).toHaveBeenCalledWith( + "Read-only mode: only tools that cannot change the filesystem are served", + ); + }); + + it("refuses to start without a usable directory", async () => { + await expect(main(["--transport=streamable-http"])).rejects.toThrow( + HTTP_NEEDS_DIRECTORIES_ERROR, + ); + }); +}); + +describe("startHttpServer", () => { + let running: RunningHttpServer | undefined; + + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(async () => { + await running?.close(); + running = undefined; + vi.restoreAllMocks(); + }); + + async function start(): Promise { + running = await startHttpServer(() => createServer([tmpdir()]), { + host: "127.0.0.1", + port: 0, + }); + return running; + } + + it("answers 404 off the MCP path and 403 to a foreign Origin", async () => { + const base = new URL((await start()).url); + expect((await fetch(new URL("/other", base))).status).toBe(404); + const foreign = await fetch(base, { + method: "POST", + headers: { + origin: "https://evil.example", + "content-type": "application/json", + }, + body: "{}", + }); + expect(foreign.status).toBe(403); + }); + + it("reports a request the SDK refuses through onerror", async () => { + // A 2026-07-28 header on a request with no 2026-07-28 envelope. + const response = await fetch((await start()).url, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-protocol-version": "2026-07-28", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {}, + }), + }); + expect(response.status).toBe(400); + expect(console.error).toHaveBeenCalledWith( + "Error serving MCP request:", + expect.any(Error), + ); + }); + + it("rejects when the port is already bound", async () => { + const port = Number(new URL((await start()).url).port); + await expect( + startHttpServer(() => createServer([tmpdir()]), { + host: "127.0.0.1", + port, + }), + ).rejects.toThrow(/EADDRINUSE/); + }); +}); + +describe("endpointUrl", () => { + it("brackets an IPv6 literal", () => { + expect(endpointUrl("127.0.0.1", 3001)).toBe("http://127.0.0.1:3001/mcp"); + expect(endpointUrl("::1", 3001)).toBe("http://[::1]:3001/mcp"); + }); +}); + +describe("httpEndpoint", () => { + it("defaults to loopback on the default port", () => { + expect(httpEndpoint({})).toEqual({ + host: DEFAULT_HOST, + port: DEFAULT_PORT, + }); + }); + + it("reads HOST and PORT", () => { + expect(httpEndpoint({ HOST: "0.0.0.0", PORT: "8080" })).toEqual({ + host: "0.0.0.0", + port: 8080, + }); + }); + + it.each(["abc", "-1", "65536", "1.5"])("refuses PORT=%s", (PORT) => { + expect(() => httpEndpoint({ PORT })).toThrow(/Invalid PORT/); + }); +}); + +describe("parseArgs with --transport", () => { + it.each([ + [["/a"], "stdio"], + [["--transport", "stdio", "/a"], "stdio"], + [["/a", "--transport", "streamable-http"], "streamable-http"], + [["--transport=streamable-http", "/a"], "streamable-http"], + ] as const)("reads %j as %s", (args, transport) => { + expect(parseArgs([...args])).toMatchObject({ + directories: ["/a"], + transport, + }); + }); + + it("takes --transport after -- as a directory", () => { + expect(parseArgs(["--", "--transport"])).toMatchObject({ + directories: ["--transport"], + transport: "stdio", + }); + }); + + it("throws on a missing or unknown transport", () => { + expect(() => parseArgs(["--transport"])).toThrow(/needs a value/); + expect(() => parseArgs(["--transport", "sse"])).toThrow( + /Unknown transport "sse"/, + ); + }); +}); diff --git a/src/filesystem/__tests__/read-only-dirs.test.ts b/src/filesystem/__tests__/read-only-dirs.test.ts index cacb96f657..c1f87b20df 100644 --- a/src/filesystem/__tests__/read-only-dirs.test.ts +++ b/src/filesystem/__tests__/read-only-dirs.test.ts @@ -472,6 +472,7 @@ describe("parseArgs with --read-only-dir", () => { readOnly: false, confineRoots: false, readOnlyDirectories: ["/b", "/c"], + transport: "stdio", }); }); @@ -487,6 +488,7 @@ describe("parseArgs with --read-only-dir", () => { readOnly: false, confineRoots: false, readOnlyDirectories: [], + transport: "stdio", }); }); diff --git a/src/filesystem/__tests__/read-only.test.ts b/src/filesystem/__tests__/read-only.test.ts index bcecf028be..73fa2707fc 100644 --- a/src/filesystem/__tests__/read-only.test.ts +++ b/src/filesystem/__tests__/read-only.test.ts @@ -143,6 +143,7 @@ describe("parseArgs", () => { readOnly: false, confineRoots: false, readOnlyDirectories: [], + transport: "stdio", }); }); @@ -157,6 +158,7 @@ describe("parseArgs", () => { readOnly: true, confineRoots: false, readOnlyDirectories: [], + transport: "stdio", }); } }); @@ -167,6 +169,7 @@ describe("parseArgs", () => { readOnly: true, confineRoots: false, readOnlyDirectories: [], + transport: "stdio", }); }); }); @@ -218,7 +221,7 @@ describe("main() with --read-only", () => { ); closers.push(() => handle.close()); expect(stderr).toHaveBeenCalledWith( - "Usage: mcp-server-filesystem [--read-only] [--confine-roots] [--read-only-dir ]... [allowed-directory] [additional-directories...]", + "Usage: mcp-server-filesystem [--transport stdio|streamable-http] [--read-only] [--confine-roots] [--read-only-dir ]... [allowed-directory] [additional-directories...]", ); }); }); diff --git a/src/filesystem/__tests__/stdio.test.ts b/src/filesystem/__tests__/stdio.test.ts index 42ea1ae7dc..74d6d11079 100644 --- a/src/filesystem/__tests__/stdio.test.ts +++ b/src/filesystem/__tests__/stdio.test.ts @@ -129,7 +129,7 @@ describe("startup", () => { it("prints usage, and waits for roots, when started with no directories", async () => { await session.start([]); expect(stderrText()).toContain( - "Usage: mcp-server-filesystem [--read-only] [--confine-roots] [--read-only-dir ]... [allowed-directory] [additional-directories...]", + "Usage: mcp-server-filesystem [--transport stdio|streamable-http] [--read-only] [--confine-roots] [--read-only-dir ]... [allowed-directory] [additional-directories...]", ); expect(stderrText()).toContain( "At least one directory must be provided by EITHER method for the server to operate.", diff --git a/src/filesystem/http.ts b/src/filesystem/http.ts new file mode 100644 index 0000000000..d074030a50 --- /dev/null +++ b/src/filesystem/http.ts @@ -0,0 +1,118 @@ +// Serves the filesystem server over Streamable HTTP (#576), the alternative to +// stdio that `--transport streamable-http` selects. +// +// One `/mcp` endpoint answers both protocol eras through the SDK's +// `createMcpHandler`: a 2026-07-28 request is served by a fresh server built +// for it, and a 2025-era request by the SDK's stateless fallback (also a fresh +// server per request, with no session, so GET and DELETE are answered 405). +// A stateless request has no channel back to the client, so the server cannot +// ask a 2025-era client for its Roots: over HTTP the allowed directories are +// the command-line ones on both eras, and index.ts refuses to start without +// them. +// +// The listener binds loopback unless `HOST` widens it, and a browser page may +// reach it only from a localhost Origin, the same posture as the everything +// server's HTTP transports (#5371). `startHttpServer()` resolves once the port +// is bound and rejects when it cannot be, so a launcher sees the failure. + +import { createServer as createNodeServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + createMcpHandler, + type McpHttpHandler, + type McpServerFactory, +} from "@modelcontextprotocol/server"; +import { + localhostOriginValidation, + toNodeHandler, +} from "@modelcontextprotocol/node"; + +/** The interface bound when `HOST` is unset: loopback only. */ +export const DEFAULT_HOST = "127.0.0.1"; +/** The port bound when `PORT` is unset. */ +export const DEFAULT_PORT = 3001; +/** The one path the endpoint answers on. */ +export const MCP_PATH = "/mcp"; + +/** Where to listen: `HOST` and `PORT` from the environment, else the defaults. */ +export function httpEndpoint(env: NodeJS.ProcessEnv = process.env): { + host: string; + port: number; +} { + const host = env.HOST || DEFAULT_HOST; + if (!env.PORT) return { host, port: DEFAULT_PORT }; + const port = Number(env.PORT); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error( + `Invalid PORT ${JSON.stringify(env.PORT)}: expected a whole number from 0 to 65535`, + ); + } + return { host, port }; +} + +/** The URL of the endpoint bound at `address`:`port`, an IPv6 literal in brackets. */ +export function endpointUrl(address: string, port: number): string { + const host = address.includes(":") ? `[${address}]` : address; + return `http://${host}:${port}${MCP_PATH}`; +} + +/** A running HTTP endpoint: the bound address, and how to stop it. */ +export interface RunningHttpServer { + httpServer: Server; + handler: McpHttpHandler; + /** The URL clients connect to, with the port actually bound. */ + url: string; + /** End in-flight MCP exchanges, then stop listening and drop open sockets. */ + close: () => Promise; +} + +/** + * Bind the endpoint on `host`:`port` (port 0 picks a free one) and serve the + * servers `factory` builds on it, one per request. Rejects when the port + * cannot be bound. + */ +export async function startHttpServer( + factory: McpServerFactory, + { host, port }: { host: string; port: number }, +): Promise { + const onerror = (error: Error) => + console.error("Error serving MCP request:", error); + const handler = createMcpHandler(factory, { onerror }); + const serve = toNodeHandler(handler, { onerror }); + const allowOrigin = localhostOriginValidation(); + + const httpServer = createNodeServer((req, res) => { + // A request a Node server hands its listener always carries a URL. + const path = new URL(req.url!, "http://localhost").pathname; + if (path !== MCP_PATH) { + res.writeHead(404, { "content-type": "text/plain" }).end("Not Found"); + return; + } + if (!allowOrigin(req, res)) return; + // Not awaited: the listener is synchronous. The adapter never rejects; it + // reports a failure through `onerror` and answers it with a 500 itself. + void serve(req, res); + }); + + await new Promise((resolve, reject) => { + httpServer.once("error", reject); + httpServer.listen({ host, port }, () => { + httpServer.off("error", reject); + resolve(); + }); + }); + + const bound = httpServer.address() as AddressInfo; + return { + httpServer, + handler, + url: endpointUrl(bound.address, bound.port), + close: async () => { + await handler.close(); + await new Promise((resolve) => { + httpServer.close(() => resolve()); + httpServer.closeAllConnections(); + }); + }, + }; +} diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index fa34660ed5..6334a43f34 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -1,10 +1,12 @@ #!/usr/bin/env node -// The stdio entry point of the filesystem server (the package's bin). It +// The entry point of the filesystem server (the package's bin). It // parses the command line (its options, such as --read-only or --read-only-dir, // and the allowed directories), resolves the allowed directories and serves // createServer() on stdio through the SDK's serveStdio entry, which answers // 2026-07-28 clients (server/discover, no handshake) and 2025-era clients -// (initialize, then Roots) from the same factory (#4852). Everything runs inside +// (initialize, then Roots) from the same factory (#4852), or, with +// --transport streamable-http, over Streamable HTTP (http.ts, #576), where +// Roots cannot reach the server and the directories must be given here. Everything runs inside // main(), and main() runs only when this file is the process entry point, so // importing the module (as the tests do) starts nothing (#4854). main() takes // the argv and the stdio streams as parameters so the tests can drive the real @@ -26,6 +28,11 @@ import { realpathWithRetry, } from "./path-utils.js"; import { createServer } from "./server.js"; +import { + httpEndpoint, + startHttpServer, + type RunningHttpServer, +} from "./http.js"; import { describeAllowedDirectories } from "./allowed-directories.js"; import { describeReadOnlyDirectories, @@ -114,6 +121,22 @@ export interface ParsedArgs { * listed twice. */ readOnlyDirectories: string[]; + /** `--transport `: how the server is reached (#576). */ + transport: TransportName; +} + +/** The transports `--transport` can name. */ +export const TRANSPORTS = ["stdio", "streamable-http"] as const; +export type TransportName = (typeof TRANSPORTS)[number]; + +/** `value` as a transport name; throws on any other value. */ +function transportName(value: string): TransportName { + if (!(TRANSPORTS as readonly string[]).includes(value)) { + throw new Error( + `Unknown transport ${JSON.stringify(value)}: expected stdio or streamable-http`, + ); + } + return value as TransportName; } /** @@ -122,7 +145,8 @@ export interface ParsedArgs { * option's can still be given after it. Any other argument is a directory, as * every argument was before options existed. `--read-only-dir` takes the next * argument as its directory, whatever it looks like, and throws when there is - * none. + * none. `--transport` takes the next argument (or `--transport=`) and + * throws when it is missing or names no transport. */ export function parseArgs(args: string[]): ParsedArgs { const parsed: ParsedArgs = { @@ -130,6 +154,7 @@ export function parseArgs(args: string[]): ParsedArgs { readOnly: false, confineRoots: false, readOnlyDirectories: [], + transport: "stdio", }; let options = true; for (let index = 0; index < args.length; index++) { @@ -146,6 +171,14 @@ export function parseArgs(args: string[]): ParsedArgs { throw new Error("--read-only-dir needs a directory after it"); } parsed.readOnlyDirectories.push(args[index]); + } else if (options && arg === "--transport") { + index++; + if (index >= args.length) { + throw new Error("--transport needs a value: stdio or streamable-http"); + } + parsed.transport = transportName(args[index]); + } else if (options && arg.startsWith("--transport=")) { + parsed.transport = transportName(arg.slice("--transport=".length)); } else { parsed.directories.push(arg); } @@ -153,22 +186,64 @@ export function parseArgs(args: string[]): ParsedArgs { return parsed; } +/** Why Streamable HTTP will not start without command-line directories. */ +export const HTTP_NEEDS_DIRECTORIES_ERROR = + "Streamable HTTP needs the allowed directories on the command line: over HTTP the server cannot ask a client for its Roots"; + /** - * Serve the server over stdio with the allowed directories in `args`. The - * first message the client sends picks the connection's protocol era, and the - * factory builds the server for it. A transport error, including one that - * stops the transport from starting, is reported on stderr. + * Serve the server with the allowed directories in `args`: over stdio by + * default, where the first message the client sends picks the connection's + * protocol era and the factory builds the server for it, or over Streamable + * HTTP on `HOST`:`PORT` with `--transport streamable-http`, which needs at + * least one usable directory. A transport error, including one that stops the + * transport from starting, is reported on stderr. */ export async function main( args: string[] = process.argv.slice(2), stdin: Readable = process.stdin, stdout: Writable = process.stdout, -): Promise { - const { directories, readOnly, confineRoots, readOnlyDirectories } = - parseArgs(args); +): Promise { + const { + directories, + readOnly, + confineRoots, + readOnlyDirectories, + transport, + } = parseArgs(args); + if (transport === "streamable-http") { + // Rejects, so the server does not start, on a read-only directory that + // cannot be resolved, as on stdio. + const allowedDirectories = await resolveAllowedDirectories([ + ...directories, + ...readOnlyDirectories, + ]); + if (allowedDirectories.length === 0) { + throw new Error(HTTP_NEEDS_DIRECTORIES_ERROR); + } + const resolvedReadOnlyDirectories = + await resolveReadOnlyDirectories(readOnlyDirectories); + const running = await startHttpServer( + ({ era }) => + createServer(allowedDirectories, { + era, + readOnly, + confineRoots, + readOnlyDirectories: resolvedReadOnlyDirectories, + }), + httpEndpoint(), + ); + console.error( + `Secure MCP Filesystem Server running on Streamable HTTP at ${running.url}`, + ); + announceOptions(readOnly, resolvedReadOnlyDirectories); + console.error( + describeAllowedDirectories(allowedDirectories, { from: "command-line" }), + ); + return running; + } if (directories.length === 0 && readOnlyDirectories.length === 0) { console.error( - "Usage: mcp-server-filesystem [--read-only] [--confine-roots] [--read-only-dir ]... [allowed-directory] [additional-directories...]", + "Usage: mcp-server-filesystem [--transport stdio|streamable-http] [--read-only] [--confine-roots] [--read-only-dir ]... [allowed-directory] [additional-directories...]", ); console.error("Note: Allowed directories can be provided via:"); console.error(" 1. Command-line arguments (shown above)"); @@ -213,14 +288,7 @@ export async function main( }, ); console.error("Secure MCP Filesystem Server running on stdio"); - if (readOnly) { - console.error( - "Read-only mode: only tools that cannot change the filesystem are served", - ); - } - if (resolvedReadOnlyDirectories.length > 0) { - console.error(describeReadOnlyDirectories(resolvedReadOnlyDirectories)); - } + announceOptions(readOnly, resolvedReadOnlyDirectories); if (confineRoots && allowedDirectories.length === 0) { console.error( "Warning: --confine-roots has no effect without command-line directories; a client's Roots will set the allowed directories", @@ -240,6 +308,21 @@ export async function main( return handle; } +/** Announce the options that narrow what the server may do, on stderr. */ +function announceOptions( + readOnly: boolean, + readOnlyDirectories: readonly string[], +): void { + if (readOnly) { + console.error( + "Read-only mode: only tools that cannot change the filesystem are served", + ); + } + if (readOnlyDirectories.length > 0) { + console.error(describeReadOnlyDirectories(readOnlyDirectories)); + } +} + /** * True when `entryPath` (process.argv[1]) is this module. Both sides are * realpath'd, because npm installs the bin as a symlink to dist/index.js. @@ -258,9 +341,23 @@ export function isEntryPoint( /* v8 ignore start -- runs only when node executes this file as the bin, never under vitest; the spawn smoke in __tests__/bin-smoke.test.ts covers it */ if (isEntryPoint(process.argv[1], import.meta.url)) { - main().catch((error) => { - console.error("Fatal error running server:", error); - process.exit(1); - }); + main().then( + (running) => { + // Streamable HTTP keeps the process up on its port; stop it cleanly. + if (!("handler" in running)) return; + const stop = () => { + running.close().then( + () => process.exit(0), + () => process.exit(1), + ); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + }, + (error) => { + console.error("Fatal error running server:", error); + process.exit(1); + }, + ); } /* v8 ignore stop */ diff --git a/src/filesystem/package.json b/src/filesystem/package.json index 8c5a6af782..56a4d744d1 100644 --- a/src/filesystem/package.json +++ b/src/filesystem/package.json @@ -34,6 +34,7 @@ "validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test" }, "dependencies": { + "@modelcontextprotocol/node": "^2.1.1", "@modelcontextprotocol/server": "^2.3.1", "diff": "^8.0.3", "minimatch": "^11.0.4", diff --git a/src/git/README.md b/src/git/README.md index fab7aa5527..0e996f4128 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -384,6 +384,31 @@ Add to your Zed settings.json: ``` +## Streamable HTTP + +The server talks over stdio by default. With `--transport streamable-http` it +runs on its own as an HTTP service instead, serving one MCP endpoint, `/mcp`, +to any number of clients of either protocol era: + +```sh +uvx mcp-server-git --transport streamable-http --repository path/to/git/repo +``` + +It listens on `127.0.0.1:3001` unless `HOST` and `PORT` say otherwise (for +example `PORT=8080`); `HOST=0.0.0.0` exposes it to every machine that can reach +this one, so set it deliberately. Point a client at the URL, such as +`http://127.0.0.1:3001/mcp`, instead of giving it a command: + +```json +{ + "mcpServers": { + "git": { "type": "http", "url": "http://127.0.0.1:3001/mcp" } + } +} +``` + +Every other option works as it does over stdio; `--repository` matters more here, since any client that can reach the port can use the server. On loopback the SDK also checks each request's `Host` and `Origin` headers against loopback names, to guard against DNS rebinding. + ## Debugging You can use the MCP inspector to debug the server. For uvx installations: diff --git a/src/git/src/mcp_server_git/__init__.py b/src/git/src/mcp_server_git/__init__.py index e799b99168..49ad5cb124 100644 --- a/src/git/src/mcp_server_git/__init__.py +++ b/src/git/src/mcp_server_git/__init__.py @@ -5,6 +5,7 @@ import click from .server import serve +from .transport import TRANSPORTS, http_endpoint @click.command() @@ -19,7 +20,17 @@ ), ) @click.option("-v", "--verbose", count=True) -def main(repository: Path | None, verbose: bool) -> None: +@click.option( + "--transport", + type=click.Choice(TRANSPORTS), + default="stdio", + show_default=True, + help=( + "How clients reach the server. streamable-http listens on" + " $HOST:$PORT, 127.0.0.1:3001 by default." + ), +) +def main(repository: Path | None, verbose: bool, transport: str) -> None: """MCP Git Server - Git functionality for MCP""" import asyncio @@ -30,7 +41,13 @@ def main(repository: Path | None, verbose: bool) -> None: logging_level = logging.DEBUG logging.basicConfig(level=logging_level, stream=sys.stderr) - asyncio.run(serve(repository)) + if transport == "streamable-http": + # A bad HOST or PORT stops the server here, as a usage error. + try: + http_endpoint() + except ValueError as e: + raise click.UsageError(str(e)) + asyncio.run(serve(repository, transport)) if ( diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index cd1d874ece..9cc02e1277 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -41,6 +41,8 @@ from mcp.types import INVALID_PARAMS, ToolAnnotations from pydantic import Field +from .transport import run + # The version this server reports in serverInfo, read from the installed # distribution's metadata (pyproject.toml) so it cannot drift from the # published version (#360). Without it the SDK reports its own `mcp` version. @@ -1489,7 +1491,7 @@ def remote( return server -async def serve(repository: Path | None) -> None: +async def serve(repository: Path | None, transport: str = "stdio") -> None: logger = logging.getLogger(__name__) if repository is not None: @@ -1510,9 +1512,4 @@ async def serve(repository: Path | None) -> None: repository = root logger.info(f"Using repository at {repository}") - # Both eras (#4853): the SDK's dual-era loop lets the client's first - # request decide. `initialize` gets the 2025-11-25 handshake; - # `server/discover` or a request carrying a 2026-07-28 envelope gets the - # modern, sessionless era, with its required result fields (resultType, - # ttlMs, cacheScope) filled in by the SDK. - await create_server(repository).run_stdio_async() + await run(create_server(repository), transport) diff --git a/src/git/src/mcp_server_git/transport.py b/src/git/src/mcp_server_git/transport.py new file mode 100644 index 0000000000..673ca6e4d0 --- /dev/null +++ b/src/git/src/mcp_server_git/transport.py @@ -0,0 +1,45 @@ +"""How the git server is reached: stdio (the default) or Streamable HTTP (#576). + +`--transport streamable-http` serves the same server on one `/mcp` endpoint +through the SDK's Streamable HTTP app, which answers 2026-07-28 and 2025-era +clients alike. It listens on `HOST`:`PORT` from the environment, loopback:3001 +by default: the same variables, defaults and loopback-first posture as the +TypeScript servers, and on loopback the SDK also turns on its DNS-rebinding +protection (Host and Origin checks). +""" + +import os +from collections.abc import Mapping + +from mcp.server.mcpserver import MCPServer + +TRANSPORTS = ("stdio", "streamable-http") +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 3001 + + +def http_endpoint(env: Mapping[str, str] = os.environ) -> tuple[str, int]: + """The host and port to listen on: `HOST` and `PORT`, else the defaults.""" + host = env.get("HOST") or DEFAULT_HOST + raw = env.get("PORT") + if not raw: + return host, DEFAULT_PORT + if not raw.isascii() or not raw.isdigit() or int(raw) > 65535: + raise ValueError( + f"Invalid PORT {raw!r}: expected a whole number from 0 to 65535" + ) + return host, int(raw) + + +async def run(server: MCPServer, transport: str) -> None: + """Serve `server` on `transport` until it is stopped.""" + if transport == "streamable-http": + host, port = http_endpoint() + await server.run_streamable_http_async(host=host, port=port) + else: + # Both eras (#4853): the SDK's dual-era loop lets the client's first + # request decide. `initialize` gets the 2025-11-25 handshake; + # `server/discover` or a request carrying a 2026-07-28 envelope gets + # the modern, sessionless era, with its required result fields + # (resultType, ttlMs, cacheScope) filled in by the SDK. + await server.run_stdio_async() diff --git a/src/git/tests/test_entrypoints.py b/src/git/tests/test_entrypoints.py index 8346ad96cb..f4c5dcdfe7 100644 --- a/src/git/tests/test_entrypoints.py +++ b/src/git/tests/test_entrypoints.py @@ -122,7 +122,7 @@ def test_main_sets_log_level_and_serves_repository( ): served: list[Path | None] = [] - async def fake_serve(repository: Path | None) -> None: + async def fake_serve(repository: Path | None, transport: str = "stdio") -> None: served.append(repository) with ( @@ -139,7 +139,7 @@ async def fake_serve(repository: Path | None) -> None: def test_main_short_repository_flag(tmp_path: Path): served: list[Path | None] = [] - async def fake_serve(repository: Path | None) -> None: + async def fake_serve(repository: Path | None, transport: str = "stdio") -> None: served.append(repository) with ( @@ -154,7 +154,7 @@ async def fake_serve(repository: Path | None) -> None: def test_main_without_repository_serves_none(): served: list[Path | None] = [] - async def fake_serve(repository: Path | None) -> None: + async def fake_serve(repository: Path | None, transport: str = "stdio") -> None: served.append(repository) with ( diff --git a/src/git/tests/test_http.py b/src/git/tests/test_http.py new file mode 100644 index 0000000000..0917f5db9e --- /dev/null +++ b/src/git/tests/test_http.py @@ -0,0 +1,107 @@ +# The Streamable HTTP transport (#576): `serve(transport="streamable-http")` +# run in-process on a free loopback port and driven by the SDK `Client` over +# real HTTP in both protocol eras, plus `main()`'s `--transport` flag and +# `http_endpoint()`'s reading of HOST and PORT. + +import socket +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, patch + +import anyio +import pytest +from click.testing import CliRunner +from mcp import Client + +import mcp_server_git +from mcp_server_git.server import serve +from mcp_server_git.transport import DEFAULT_HOST, DEFAULT_PORT, http_endpoint + +ERAS = [ + pytest.param("2026-07-28", "2026-07-28", id="2026-07-28"), + pytest.param("legacy", "2025-11-25", id="2025-11-25"), +] + + +def free_port() -> int: + """A loopback port nothing is listening on right now.""" + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +@asynccontextmanager +async def http_server(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[str]: + """Run `serve()` over Streamable HTTP and yield its URL once it answers.""" + port = free_port() + monkeypatch.setenv("HOST", "127.0.0.1") + monkeypatch.setenv("PORT", str(port)) + async with anyio.create_task_group() as tg: + tg.start_soon(serve, None, "streamable-http") + with anyio.fail_after(10): + while True: + try: + await (await anyio.connect_tcp("127.0.0.1", port)).aclose() + break + except OSError: + await anyio.sleep(0.05) + yield f"http://127.0.0.1:{port}/mcp" + tg.cancel_scope.cancel() + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_serves_each_era_over_streamable_http( + monkeypatch: pytest.MonkeyPatch, mode: str, negotiated: str +) -> None: + async with http_server(monkeypatch) as url, Client(url, mode=mode) as client: + assert client.session.protocol_version == negotiated + tools = await client.list_tools() + assert "git_status" in [tool.name for tool in tools.tools] + + +def test_http_endpoint_defaults_to_loopback() -> None: + assert http_endpoint({}) == (DEFAULT_HOST, DEFAULT_PORT) + + +def test_http_endpoint_reads_host_and_port() -> None: + assert http_endpoint({"HOST": "0.0.0.0", "PORT": "8080"}) == ("0.0.0.0", 8080) + + +@pytest.mark.parametrize("port", ["abc", "-1", "65536", "1.5", "٣"]) +def test_http_endpoint_refuses_a_bad_port(port: str) -> None: + with pytest.raises(ValueError, match="Invalid PORT"): + http_endpoint({"PORT": port}) + + +@pytest.mark.parametrize( + "argv,transport", + [ + ([], "stdio"), + (["--transport", "stdio"], "stdio"), + (["--transport", "streamable-http"], "streamable-http"), + ], +) +def test_main_passes_the_transport_to_serve( + monkeypatch: pytest.MonkeyPatch, argv: list[str], transport: str +) -> None: + monkeypatch.delenv("PORT", raising=False) + serve_mock = AsyncMock() + with patch.object(mcp_server_git, "serve", serve_mock): + result = CliRunner().invoke(mcp_server_git.main, argv) + assert result.exit_code == 0, result.output + serve_mock.assert_awaited_once_with(None, transport) + + +@pytest.mark.parametrize( + "argv", [["--transport", "sse"], ["--transport", "streamable-http"]] +) +def test_main_refuses_an_unknown_transport_or_a_bad_port( + monkeypatch: pytest.MonkeyPatch, argv: list[str] +) -> None: + monkeypatch.setenv("PORT", "not-a-port") + serve_mock = AsyncMock() + with patch.object(mcp_server_git, "serve", serve_mock): + result = CliRunner().invoke(mcp_server_git.main, argv) + assert result.exit_code == 2 + serve_mock.assert_not_awaited() + assert "Error" in result.output diff --git a/src/memory/README.md b/src/memory/README.md index d519f14f3e..c58121a8fc 100644 --- a/src/memory/README.md +++ b/src/memory/README.md @@ -155,6 +155,32 @@ The server serves both protocol eras on stdio from the same process. The client' Node.js 24 or later, when the server runs through `npx` or from source. +# Streamable HTTP + +The server talks over stdio by default. With `--transport streamable-http` it +runs on its own as an HTTP service instead, serving one MCP endpoint, `/mcp`, +to any number of clients of either protocol era: + +```sh +npx -y @modelcontextprotocol/server-memory --transport streamable-http +``` + +It listens on `127.0.0.1:3001` unless `HOST` and `PORT` say otherwise (for +example `PORT=8080`); `HOST=0.0.0.0` exposes it to every machine that can reach +this one, so set it deliberately. A browser page can reach it only from a +localhost origin. Point a client at the URL, such as +`http://127.0.0.1:3001/mcp`, instead of giving it a command: + +```json +{ + "mcpServers": { + "memory": { "type": "http", "url": "http://127.0.0.1:3001/mcp" } + } +} +``` + +Over HTTP each request is served by a fresh server, and a 2025-era client gets no session (GET and DELETE on `/mcp` answer `405`), so the server cannot send it requests or notifications between its own requests. The graph lives in its file, so nothing is lost between requests. A 2026-07-28 client that names `memory://knowledge-graph` in its `subscriptions/listen` filter is told of every change any client makes; `resources/subscribe` from a 2025-era client lasts only for its own request, so over HTTP it hears of none. + # Usage with Claude Desktop ### Setup diff --git a/src/memory/__tests__/http.test.ts b/src/memory/__tests__/http.test.ts new file mode 100644 index 0000000000..e812235382 --- /dev/null +++ b/src/memory/__tests__/http.test.ts @@ -0,0 +1,269 @@ +// The Streamable HTTP transport (#576): `http.ts` bound on a free loopback +// port and driven by the SDK Client over real HTTP in both protocol eras, plus +// `run()`'s transport selection and `httpEndpoint()`'s reading of HOST and +// PORT. Over HTTP every request gets a fresh server, so the graph is asserted +// across requests, and a 2026-07-28 listen stream across clients. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + Client, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; +import { + DEFAULT_HOST, + DEFAULT_PORT, + endpointUrl, + httpEndpoint, + startHttpServer, + type RunningHttpServer, +} from "../http.js"; +import { parseTransport, RESOURCE_URI, run } from "../index.js"; +import { call, makeTempGraph } from "./helpers.js"; + +const alice = { name: "Alice", entityType: "person", observations: [] }; + +const ERAS = [ + ["2026-07-28", { mode: { pin: "2026-07-28" } }, "modern"], + ["2025-11-25", { mode: "legacy" }, "legacy"], +] as const; + +async function connect( + url: string, + versionNegotiation: (typeof ERAS)[number][1], +): Promise { + const client = new Client( + { name: "http-test", version: "0.0.0" }, + { versionNegotiation }, + ); + await client.connect(new StreamableHTTPClientTransport(new URL(url))); + return client; +} + +describe("Streamable HTTP", () => { + let running: RunningHttpServer | undefined; + let graph: Awaited>; + + beforeEach(async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + graph = await makeTempGraph(); + }); + + afterEach(async () => { + await running?.close(); + running = undefined; + await graph.cleanup(); + vi.restoreAllMocks(); + }); + + /** Bind the endpoint on a free loopback port over the test's graph. */ + async function start(): Promise { + running = await startHttpServer(graph.filePath, { + host: "127.0.0.1", + port: 0, + }); + return running; + } + + it.each(ERAS)( + "serves a %s client and keeps the graph across its requests", + async (_, negotiation, era) => { + const client = await connect((await start()).url, negotiation); + try { + expect(client.getProtocolEra()).toBe(era); + const { tools } = await client.listTools(); + expect(tools).toHaveLength(9); + await call(client, "create_entities", { entities: [alice] }); + const read = await call(client, "open_nodes", { names: ["Alice"] }); + expect(read.structuredContent).toMatchObject({ + entities: [{ name: "Alice" }], + }); + } finally { + await client.close(); + } + }, + ); + + it("sends a 2026-07-28 listener the updates another client's write makes", async () => { + const url = (await start()).url; + const listener = await connect(url, ERAS[0][1]); + const writer = await connect(url, ERAS[0][1]); + try { + const updated = new Promise((resolve) => { + listener.setNotificationHandler( + "notifications/resources/updated", + (notification) => resolve(notification.params.uri), + ); + }); + const subscription = await listener.listen({ + resourceSubscriptions: [RESOURCE_URI], + }); + await call(writer, "create_entities", { entities: [alice] }); + expect(await updated).toBe(RESOURCE_URI); + await subscription.close(); + } finally { + await listener.close(); + await writer.close(); + } + }); + + it("answers 404 off the MCP path and 403 to a foreign Origin", async () => { + const base = new URL((await start()).url); + expect((await fetch(new URL("/other", base))).status).toBe(404); + const foreign = await fetch(base, { + method: "POST", + headers: { + origin: "https://evil.example", + "content-type": "application/json", + }, + body: "{}", + }); + expect(foreign.status).toBe(403); + }); + + it("reports a request the SDK refuses through onerror", async () => { + // A 2026-07-28 header on a request with no 2026-07-28 envelope. + const response = await fetch((await start()).url, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-protocol-version": "2026-07-28", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {}, + }), + }); + expect(response.status).toBe(400); + expect(console.error).toHaveBeenCalledWith( + "Error serving MCP request:", + expect.any(Error), + ); + }); + + it("rejects when the port is already bound", async () => { + const port = Number(new URL((await start()).url).port); + await expect( + startHttpServer(graph.filePath, { host: "127.0.0.1", port }), + ).rejects.toThrow(/EADDRINUSE/); + }); +}); + +describe("endpointUrl", () => { + it("brackets an IPv6 literal", () => { + expect(endpointUrl("127.0.0.1", 3001)).toBe("http://127.0.0.1:3001/mcp"); + expect(endpointUrl("::1", 3001)).toBe("http://[::1]:3001/mcp"); + }); +}); + +describe("httpEndpoint", () => { + it("defaults to loopback on the default port", () => { + expect(httpEndpoint({})).toEqual({ + host: DEFAULT_HOST, + port: DEFAULT_PORT, + }); + }); + + it("reads HOST and PORT", () => { + expect(httpEndpoint({ HOST: "0.0.0.0", PORT: "8080" })).toEqual({ + host: "0.0.0.0", + port: 8080, + }); + }); + + it.each(["abc", "-1", "65536", "1.5"])("refuses PORT=%s", (PORT) => { + expect(() => httpEndpoint({ PORT })).toThrow(/Invalid PORT/); + }); +}); + +describe("parseTransport", () => { + it.each([ + [[], "stdio"], + [["--transport", "stdio"], "stdio"], + [["--transport", "streamable-http"], "streamable-http"], + [["--transport=streamable-http"], "streamable-http"], + ] as const)("reads %j as %s", (args, expected) => { + expect(parseTransport([...args])).toBe(expected); + }); + + it("throws on a missing or unknown transport", () => { + expect(() => parseTransport(["--transport"])).toThrow(/needs a value/); + expect(() => parseTransport(["--transport", "sse"])).toThrow( + /Unknown transport "sse"/, + ); + }); +}); + +describe("run", () => { + const saved = { + HOST: process.env.HOST, + PORT: process.env.PORT, + MEMORY_FILE_PATH: process.env.MEMORY_FILE_PATH, + }; + let graph: Awaited>; + const signals = ["SIGINT", "SIGTERM"] as const; + let before: Record<(typeof signals)[number], unknown[]>; + + /** The signal listeners `run()` added since the test began. */ + function added(signal: (typeof signals)[number]) { + return process + .listeners(signal) + .filter((listener) => !before[signal].includes(listener)); + } + + beforeEach(async () => { + graph = await makeTempGraph(); + process.env.MEMORY_FILE_PATH = graph.filePath; + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); + before = { + SIGINT: process.listeners("SIGINT"), + SIGTERM: process.listeners("SIGTERM"), + }; + process.env.HOST = "127.0.0.1"; + process.env.PORT = "0"; + }); + + afterEach(async () => { + await graph.cleanup(); + for (const signal of signals) { + for (const listener of added(signal)) process.off(signal, listener); + } + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + vi.restoreAllMocks(); + }); + + it("starts Streamable HTTP on HOST:PORT and stops it on SIGINT", async () => { + await run(["--transport", "streamable-http"]); + expect(added("SIGINT")).toHaveLength(1); + expect(added("SIGTERM")).toHaveLength(1); + const announced = vi + .mocked(console.error) + .mock.calls.flat() + .map(String) + .find((line) => line.includes("running on Streamable HTTP")); + const url = announced!.match(/http:\/\/\S+/)![0]; + // A 2025-era session operation, which the stateless endpoint refuses. + expect((await fetch(url)).status).toBe(405); + + added("SIGINT")[0]("SIGINT"); + await vi.waitFor(() => expect(process.exit).toHaveBeenCalledWith(0)); + await expect(fetch(url)).rejects.toThrow(); + }); + + it("reports a shutdown that fails and exits non-zero", async () => { + const running = await run(["--transport=streamable-http"]); + const close = running.close; + const failure = new Error("stuck"); + running.close = vi.fn().mockRejectedValue(failure); + added("SIGTERM")[0]("SIGTERM"); + await vi.waitFor(() => expect(process.exit).toHaveBeenCalledWith(1)); + expect(console.error).toHaveBeenCalledWith("Error shutting down:", failure); + await close(); + }); +}); diff --git a/src/memory/http.ts b/src/memory/http.ts new file mode 100644 index 0000000000..abe4c26056 --- /dev/null +++ b/src/memory/http.ts @@ -0,0 +1,126 @@ +// Serves the memory server over Streamable HTTP (#576), the alternative to +// stdio that `--transport streamable-http` selects. +// +// One `/mcp` endpoint answers both protocol eras through the SDK's +// `createMcpHandler`: a 2026-07-28 request is served by a fresh server built +// for it, and a 2025-era request by the SDK's stateless fallback (also a fresh +// server per request, with no session, so GET and DELETE are answered 405). +// The graph lives in its file, so a fresh server per request loses nothing; +// the file lock orders concurrent mutations as it does between processes. +// A 2026-07-28 client hears of graph changes on its subscriptions/listen +// stream: each server publishes them on the handler's bus rather than on its +// own request. A 2025-era client has no session, so resources/subscribe +// cannot outlive its request and HTTP sends it no updates. +// +// The listener binds loopback unless `HOST` widens it, and a browser page may +// reach it only from a localhost Origin, the same posture as the everything +// server's HTTP transports (#5371). `startHttpServer()` resolves once the port +// is bound and rejects when it cannot be, so a launcher sees the failure. + +import { createServer as createNodeServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + createMcpHandler, + type McpHttpHandler, +} from "@modelcontextprotocol/server"; +import { + localhostOriginValidation, + toNodeHandler, +} from "@modelcontextprotocol/node"; +import { createServer, RESOURCE_URI } from "./index.js"; + +/** The interface bound when `HOST` is unset: loopback only. */ +export const DEFAULT_HOST = "127.0.0.1"; +/** The port bound when `PORT` is unset. */ +export const DEFAULT_PORT = 3001; +/** The one path the endpoint answers on. */ +export const MCP_PATH = "/mcp"; + +/** Where to listen: `HOST` and `PORT` from the environment, else the defaults. */ +export function httpEndpoint(env: NodeJS.ProcessEnv = process.env): { + host: string; + port: number; +} { + const host = env.HOST || DEFAULT_HOST; + if (!env.PORT) return { host, port: DEFAULT_PORT }; + const port = Number(env.PORT); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error( + `Invalid PORT ${JSON.stringify(env.PORT)}: expected a whole number from 0 to 65535`, + ); + } + return { host, port }; +} + +/** The URL of the endpoint bound at `address`:`port`, an IPv6 literal in brackets. */ +export function endpointUrl(address: string, port: number): string { + const host = address.includes(":") ? `[${address}]` : address; + return `http://${host}:${port}${MCP_PATH}`; +} + +/** A running HTTP endpoint: the bound address, and how to stop it. */ +export interface RunningHttpServer { + httpServer: Server; + handler: McpHttpHandler; + /** The URL clients connect to, with the port actually bound. */ + url: string; + /** End in-flight MCP exchanges, then stop listening and drop open sockets. */ + close: () => Promise; +} + +/** + * Bind the endpoint on `host`:`port` (port 0 picks a free one) and serve the + * memory server over the graph at `memoryFilePath` on it. Rejects when the + * port cannot be bound. + */ +export async function startHttpServer( + memoryFilePath: string, + { host, port }: { host: string; port: number }, +): Promise { + const onerror = (error: Error) => + console.error("Error serving MCP request:", error); + const handler: McpHttpHandler = createMcpHandler( + ({ era }) => + createServer(memoryFilePath, era, () => + handler.notify.resourceUpdated(RESOURCE_URI), + ), + { onerror }, + ); + const serve = toNodeHandler(handler, { onerror }); + const allowOrigin = localhostOriginValidation(); + + const httpServer = createNodeServer((req, res) => { + // A request a Node server hands its listener always carries a URL. + const path = new URL(req.url!, "http://localhost").pathname; + if (path !== MCP_PATH) { + res.writeHead(404, { "content-type": "text/plain" }).end("Not Found"); + return; + } + if (!allowOrigin(req, res)) return; + // Not awaited: the listener is synchronous. The adapter never rejects; it + // reports a failure through `onerror` and answers it with a 500 itself. + void serve(req, res); + }); + + await new Promise((resolve, reject) => { + httpServer.once("error", reject); + httpServer.listen({ host, port }, () => { + httpServer.off("error", reject); + resolve(); + }); + }); + + const bound = httpServer.address() as AddressInfo; + return { + httpServer, + handler, + url: endpointUrl(bound.address, bound.port), + close: async () => { + await handler.close(); + await new Promise((resolve) => { + httpServer.close(() => resolve()); + httpServer.closeAllConnections(); + }); + }, + }; +} diff --git a/src/memory/index.ts b/src/memory/index.ts index e57bf12ed3..6328db1542 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -6,8 +6,10 @@ // tests can run it in-process; main() serves it on stdio through the SDK's // serveStdio entry, which answers 2026-07-28 clients (server/discover, change // notifications on subscriptions/listen) and 2025-era clients (initialize, -// resources/subscribe) from the same factory (#4852). main() runs only when -// this file is the process entry point, so importing it starts nothing. +// resources/subscribe) from the same factory (#4852). run() reads --transport +// and starts stdio (the default) or Streamable HTTP (http.ts, #576). It runs +// only when this file is the process entry point, so importing it starts +// nothing. import { serveStdio, type StdioServerHandle, @@ -900,7 +902,7 @@ const RelationSchema = z.object({ relationType: z.string().describe("The type of the relation"), }); -const RESOURCE_URI = "memory://knowledge-graph"; +export const RESOURCE_URI = "memory://knowledge-graph"; export const SEARCH_QUERY_MAX_LENGTH = 2048; @@ -957,10 +959,14 @@ const ReadEntitySchema = EntitySchema.extend({ // result on stdio; tests connect it to an in-memory transport, so each call // returns an independent server with its own subscriber set. `era` is the // protocol era the instance will serve, which decides how graph changes are -// announced (see notifyGraphUpdated). +// announced (see notifyGraphUpdated). `publishGraphUpdated`, when given, +// announces a 2026-07-28 change instead of the instance itself: over HTTP each +// request gets a fresh server that holds no listen stream, so http.ts hands in +// the handler's subscription bus, which every open stream listens on. export function createServer( memoryFilePath: string, era: "legacy" | "modern" = "legacy", + publishGraphUpdated?: () => void, ): McpServer { const knowledgeGraphManager = new KnowledgeGraphManager(memoryFilePath); @@ -980,7 +986,9 @@ export function createServer( // the notification only to a listen stream that named the URI, so it is // always handed over. function notifyGraphUpdated() { - if (era === "modern" || resourceSubscribers.has(RESOURCE_URI)) { + if (era === "modern" && publishGraphUpdated) { + publishGraphUpdated(); + } else if (era === "modern" || resourceSubscribers.has(RESOURCE_URI)) { // Fire-and-forget: the tool result must not wait on (or fail because of) // the notification, so a delivery failure is logged rather than thrown. server.server @@ -1466,9 +1474,76 @@ export function isMainModule( } } +/** The transports `--transport` can name. */ +export const TRANSPORTS = ["stdio", "streamable-http"] as const; +export type TransportName = (typeof TRANSPORTS)[number]; + +/** + * The transport the command line asks for: `--transport ` or + * `--transport=`, else stdio. Throws on a missing or unknown name. + * Other arguments are ignored, as they always have been. + */ +export function parseTransport(args: string[]): TransportName { + let value: string | undefined; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === "--transport") { + value = args[++index]; + if (value === undefined) { + throw new Error("--transport needs a value: stdio or streamable-http"); + } + } else if (arg.startsWith("--transport=")) { + value = arg.slice("--transport=".length); + } + } + if (value === undefined) return "stdio"; + if (!(TRANSPORTS as readonly string[]).includes(value)) { + throw new Error( + `Unknown transport ${JSON.stringify(value)}: expected stdio or streamable-http`, + ); + } + return value as TransportName; +} + +// Start the server on the transport args names, and hand back how to stop +// it. Streamable HTTP listens on HOST:PORT (loopback:3001 by default) and also +// stops, exiting the process, on SIGINT or SIGTERM. +export async function run( + args: string[] = process.argv.slice(2), +): Promise<{ close: () => Promise }> { + if (parseTransport(args) === "stdio") { + return main(); + } + const memoryFilePath = await ensureMemoryFilePath(); + const locationWarning = memoryFileLocationWarning(memoryFilePath); + if (locationWarning) { + console.error(locationWarning); + } + const { httpEndpoint, startHttpServer } = await import("./http.js"); + const running = await startHttpServer(memoryFilePath, httpEndpoint()); + console.error( + `Knowledge Graph MCP Server running on Streamable HTTP at ${running.url}`, + ); + const stop = () => { + running.close().then( + () => process.exit(0), + (error: unknown) => { + console.error("Error shutting down:", error); + process.exit(1); + }, + ); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + return running; +} + if (isMainModule()) { - main().catch((error) => { - console.error("Fatal error in main():", error); + run().catch((error: unknown) => { + console.error( + "Fatal error running server:", + error instanceof Error ? error.message : error, + ); process.exit(1); }); } diff --git a/src/memory/package.json b/src/memory/package.json index 3ae2cab2f3..3a6c17701a 100644 --- a/src/memory/package.json +++ b/src/memory/package.json @@ -34,6 +34,7 @@ "validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test" }, "dependencies": { + "@modelcontextprotocol/node": "^2.1.1", "@modelcontextprotocol/server": "^2.3.1", "zod": "^4.2.0" }, diff --git a/src/sequentialthinking/README.md b/src/sequentialthinking/README.md index 81ae0ae26a..9434d9713e 100644 --- a/src/sequentialthinking/README.md +++ b/src/sequentialthinking/README.md @@ -218,6 +218,32 @@ Run the following: codex mcp add sequential-thinking npx -y @modelcontextprotocol/server-sequential-thinking ``` +## Streamable HTTP + +The server talks over stdio by default. With `--transport streamable-http` it +runs on its own as an HTTP service instead, serving one MCP endpoint, `/mcp`, +to any number of clients of either protocol era: + +```sh +npx -y @modelcontextprotocol/server-sequential-thinking --transport streamable-http +``` + +It listens on `127.0.0.1:3001` unless `HOST` and `PORT` say otherwise (for +example `PORT=8080`); `HOST=0.0.0.0` exposes it to every machine that can reach +this one, so set it deliberately. A browser page can reach it only from a +localhost origin. Point a client at the URL, such as +`http://127.0.0.1:3001/mcp`, instead of giving it a command: + +```json +{ + "mcpServers": { + "sequentialthinking": { "type": "http", "url": "http://127.0.0.1:3001/mcp" } + } +} +``` + +Over HTTP each request is served by a fresh server, and a 2025-era client gets no session (GET and DELETE on `/mcp` answer `405`), so the server cannot send it requests or notifications between its own requests. So the thought history and branches, which over stdio belong to one connection, **belong to the server process over HTTP and are shared by every client it serves**: run one process per client when histories must stay apart. + ## Building Docker: diff --git a/src/sequentialthinking/__tests__/http.test.ts b/src/sequentialthinking/__tests__/http.test.ts new file mode 100644 index 0000000000..1bbd11b381 --- /dev/null +++ b/src/sequentialthinking/__tests__/http.test.ts @@ -0,0 +1,249 @@ +// The Streamable HTTP transport (#576): `http.ts` bound on a free loopback +// port and driven by the SDK Client over real HTTP in both protocol eras, plus +// `run()`'s transport selection and `httpEndpoint()`'s reading of HOST and +// PORT. Over HTTP every request gets a fresh server, so the shared thought +// history is asserted across calls and across clients. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + Client, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; +import { + DEFAULT_HOST, + DEFAULT_PORT, + endpointUrl, + httpEndpoint, + startHttpServer, + type RunningHttpServer, +} from "../http.js"; +import { parseTransport, run } from "../index.js"; +import { TOOL_NAME, parseOk, thought, type WireResult } from "./helpers.js"; + +const ERAS = [ + ["2026-07-28", { mode: { pin: "2026-07-28" } }, "modern"], + ["2025-11-25", { mode: "legacy" }, "legacy"], +] as const; + +async function connect( + url: string, + versionNegotiation: (typeof ERAS)[number][1], +): Promise { + const client = new Client( + { name: "http-test", version: "0.0.0" }, + { versionNegotiation }, + ); + await client.connect(new StreamableHTTPClientTransport(new URL(url))); + return client; +} + +describe("Streamable HTTP", () => { + let running: RunningHttpServer | undefined; + + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + process.env.DISABLE_THOUGHT_LOGGING = "true"; + }); + + afterEach(async () => { + await running?.close(); + running = undefined; + delete process.env.DISABLE_THOUGHT_LOGGING; + vi.restoreAllMocks(); + }); + + it.each(ERAS)( + "serves a %s client and keeps one history across its requests", + async (_, negotiation, era) => { + running = await startHttpServer({ host: "127.0.0.1", port: 0 }); + const client = await connect(running.url, negotiation); + try { + expect(client.getProtocolEra()).toBe(era); + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toEqual([TOOL_NAME]); + for (const n of [1, 2]) { + const result = (await client.callTool({ + name: TOOL_NAME, + arguments: thought({ thoughtNumber: n }), + })) as WireResult; + expect(parseOk(result).thoughtHistoryLength).toBe(n); + } + } finally { + await client.close(); + } + }, + ); + + it("shares the history between clients of the same process", async () => { + running = await startHttpServer({ host: "127.0.0.1", port: 0 }); + const first = await connect(running.url, ERAS[0][1]); + const second = await connect(running.url, ERAS[1][1]); + try { + await first.callTool({ name: TOOL_NAME, arguments: thought() }); + const result = (await second.callTool({ + name: TOOL_NAME, + arguments: thought(), + })) as WireResult; + expect(parseOk(result).thoughtHistoryLength).toBe(2); + } finally { + await first.close(); + await second.close(); + } + }); + + it("answers 404 off the MCP path and 403 to a foreign Origin", async () => { + running = await startHttpServer({ host: "127.0.0.1", port: 0 }); + const base = new URL(running.url); + expect((await fetch(new URL("/other", base))).status).toBe(404); + const foreign = await fetch(base, { + method: "POST", + headers: { + origin: "https://evil.example", + "content-type": "application/json", + }, + body: "{}", + }); + expect(foreign.status).toBe(403); + }); + + it("reports a request the SDK refuses through onerror", async () => { + running = await startHttpServer({ host: "127.0.0.1", port: 0 }); + // A 2026-07-28 header on a request with no 2026-07-28 envelope. + const response = await fetch(running.url, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-protocol-version": "2026-07-28", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {}, + }), + }); + expect(response.status).toBe(400); + expect(console.error).toHaveBeenCalledWith( + "Error serving MCP request:", + expect.any(Error), + ); + }); + + it("rejects when the port is already bound", async () => { + running = await startHttpServer({ host: "127.0.0.1", port: 0 }); + const port = Number(new URL(running.url).port); + await expect(startHttpServer({ host: "127.0.0.1", port })).rejects.toThrow( + /EADDRINUSE/, + ); + }); +}); + +describe("endpointUrl", () => { + it("brackets an IPv6 literal", () => { + expect(endpointUrl("127.0.0.1", 3001)).toBe("http://127.0.0.1:3001/mcp"); + expect(endpointUrl("::1", 3001)).toBe("http://[::1]:3001/mcp"); + }); +}); + +describe("httpEndpoint", () => { + it("defaults to loopback on the default port", () => { + expect(httpEndpoint({})).toEqual({ + host: DEFAULT_HOST, + port: DEFAULT_PORT, + }); + }); + + it("reads HOST and PORT", () => { + expect(httpEndpoint({ HOST: "0.0.0.0", PORT: "8080" })).toEqual({ + host: "0.0.0.0", + port: 8080, + }); + }); + + it.each(["abc", "-1", "65536", "1.5"])("refuses PORT=%s", (PORT) => { + expect(() => httpEndpoint({ PORT })).toThrow(/Invalid PORT/); + }); +}); + +describe("parseTransport", () => { + it.each([ + [[], "stdio"], + [["--transport", "stdio"], "stdio"], + [["--transport", "streamable-http"], "streamable-http"], + [["--transport=streamable-http"], "streamable-http"], + ] as const)("reads %j as %s", (args, expected) => { + expect(parseTransport([...args])).toBe(expected); + }); + + it("throws on a missing or unknown transport", () => { + expect(() => parseTransport(["--transport"])).toThrow(/needs a value/); + expect(() => parseTransport(["--transport", "sse"])).toThrow( + /Unknown transport "sse"/, + ); + }); +}); + +describe("run", () => { + const saved = { HOST: process.env.HOST, PORT: process.env.PORT }; + const signals = ["SIGINT", "SIGTERM"] as const; + let before: Record<(typeof signals)[number], unknown[]>; + + /** The signal listeners `run()` added since the test began. */ + function added(signal: (typeof signals)[number]) { + return process + .listeners(signal) + .filter((listener) => !before[signal].includes(listener)); + } + + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); + before = { + SIGINT: process.listeners("SIGINT"), + SIGTERM: process.listeners("SIGTERM"), + }; + process.env.HOST = "127.0.0.1"; + process.env.PORT = "0"; + }); + + afterEach(() => { + for (const signal of signals) { + for (const listener of added(signal)) process.off(signal, listener); + } + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + vi.restoreAllMocks(); + }); + + it("starts Streamable HTTP on HOST:PORT and stops it on SIGINT", async () => { + await run(["--transport", "streamable-http"]); + expect(added("SIGINT")).toHaveLength(1); + expect(added("SIGTERM")).toHaveLength(1); + const announced = vi + .mocked(console.error) + .mock.calls.flat() + .map(String) + .find((line) => line.includes("running on Streamable HTTP")); + const url = announced!.match(/http:\/\/\S+/)![0]; + // A 2025-era session operation, which the stateless endpoint refuses. + expect((await fetch(url)).status).toBe(405); + + added("SIGINT")[0]("SIGINT"); + await vi.waitFor(() => expect(process.exit).toHaveBeenCalledWith(0)); + await expect(fetch(url)).rejects.toThrow(); + }); + + it("reports a shutdown that fails and exits non-zero", async () => { + const running = await run(["--transport=streamable-http"]); + const close = running.close; + const failure = new Error("stuck"); + running.close = vi.fn().mockRejectedValue(failure); + added("SIGTERM")[0]("SIGTERM"); + await vi.waitFor(() => expect(process.exit).toHaveBeenCalledWith(1)); + expect(console.error).toHaveBeenCalledWith("Error shutting down:", failure); + await close(); + }); +}); diff --git a/src/sequentialthinking/http.ts b/src/sequentialthinking/http.ts new file mode 100644 index 0000000000..d89feaa453 --- /dev/null +++ b/src/sequentialthinking/http.ts @@ -0,0 +1,121 @@ +// Serves the sequential-thinking server over Streamable HTTP (#576), the +// alternative to stdio that `--transport streamable-http` selects. +// +// One `/mcp` endpoint answers both protocol eras through the SDK's +// `createMcpHandler`: a 2026-07-28 request is served by a fresh server built +// for it, and a 2025-era request by the SDK's stateless fallback (also a fresh +// server per request, with no session, so GET and DELETE are answered 405). +// Because no server outlives its request, the thought history is held by one +// `SequentialThinkingServer` shared by every request this process serves: +// over HTTP the history belongs to the process, not to a connection. +// +// The listener binds loopback unless `HOST` widens it, and a browser page may +// reach it only from a localhost Origin, the same posture as the everything +// server's HTTP transports (#5371). `startHttpServer()` resolves once the port +// is bound and rejects when it cannot be, so a launcher sees the failure. + +import { createServer as createNodeServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + createMcpHandler, + type McpHttpHandler, +} from "@modelcontextprotocol/server"; +import { + localhostOriginValidation, + toNodeHandler, +} from "@modelcontextprotocol/node"; +import { createServer } from "./index.js"; +import { SequentialThinkingServer } from "./lib.js"; + +/** The interface bound when `HOST` is unset: loopback only. */ +export const DEFAULT_HOST = "127.0.0.1"; +/** The port bound when `PORT` is unset. */ +export const DEFAULT_PORT = 3001; +/** The one path the endpoint answers on. */ +export const MCP_PATH = "/mcp"; + +/** Where to listen: `HOST` and `PORT` from the environment, else the defaults. */ +export function httpEndpoint(env: NodeJS.ProcessEnv = process.env): { + host: string; + port: number; +} { + const host = env.HOST || DEFAULT_HOST; + if (!env.PORT) return { host, port: DEFAULT_PORT }; + const port = Number(env.PORT); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error( + `Invalid PORT ${JSON.stringify(env.PORT)}: expected a whole number from 0 to 65535`, + ); + } + return { host, port }; +} + +/** The URL of the endpoint bound at `address`:`port`, an IPv6 literal in brackets. */ +export function endpointUrl(address: string, port: number): string { + const host = address.includes(":") ? `[${address}]` : address; + return `http://${host}:${port}${MCP_PATH}`; +} + +/** A running HTTP endpoint: the bound address, and how to stop it. */ +export interface RunningHttpServer { + httpServer: Server; + handler: McpHttpHandler; + /** The URL clients connect to, with the port actually bound. */ + url: string; + /** End in-flight MCP exchanges, then stop listening and drop open sockets. */ + close: () => Promise; +} + +/** + * Bind the endpoint on `host`:`port` (port 0 picks a free one) and serve the + * sequential-thinking server on it. Rejects when the port cannot be bound. + */ +export async function startHttpServer({ + host, + port, +}: { + host: string; + port: number; +}): Promise { + const thinking = new SequentialThinkingServer(); + const onerror = (error: Error) => + console.error("Error serving MCP request:", error); + const handler = createMcpHandler(() => createServer(thinking), { onerror }); + const serve = toNodeHandler(handler, { onerror }); + const allowOrigin = localhostOriginValidation(); + + const httpServer = createNodeServer((req, res) => { + // A request a Node server hands its listener always carries a URL. + const path = new URL(req.url!, "http://localhost").pathname; + if (path !== MCP_PATH) { + res.writeHead(404, { "content-type": "text/plain" }).end("Not Found"); + return; + } + if (!allowOrigin(req, res)) return; + // Not awaited: the listener is synchronous. The adapter never rejects; it + // reports a failure through `onerror` and answers it with a 500 itself. + void serve(req, res); + }); + + await new Promise((resolve, reject) => { + httpServer.once("error", reject); + httpServer.listen({ host, port }, () => { + httpServer.off("error", reject); + resolve(); + }); + }); + + const bound = httpServer.address() as AddressInfo; + return { + httpServer, + handler, + url: endpointUrl(bound.address, bound.port), + close: async () => { + await handler.close(); + await new Promise((resolve) => { + httpServer.close(() => resolve()); + httpServer.closeAllConnections(); + }); + }, + }; +} diff --git a/src/sequentialthinking/index.ts b/src/sequentialthinking/index.ts index be0aaa2eb2..f8cd5ab2df 100644 --- a/src/sequentialthinking/index.ts +++ b/src/sequentialthinking/index.ts @@ -5,9 +5,10 @@ // SDK Client over an in-memory transport (#4854); `main()` serves it through // the SDK's `serveStdio` entry, which answers both 2026-07-28 clients // (`server/discover`, no handshake) and 2025-era clients (`initialize`) from -// the same factory (#4852); and stdio is only attached when this file is run -// as the binary, so importing it from a test never touches the runner's -// stdin/stdout. +// the same factory (#4852); `run()` reads `--transport` and starts stdio (the +// default) or Streamable HTTP (`http.ts`, #576); and a transport is only +// attached when this file is run as the binary, so importing it from a test +// never touches the runner's stdin/stdout. import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -37,19 +38,21 @@ const coercedBoolean = z }); /** - * Build the sequential-thinking server with its one tool registered. Each call - * returns an independent server with its own thought history and branches. - * `serveStdio` calls it once per connection, so the history lives exactly as - * long as the client's connection, on either protocol era. + * Build the sequential-thinking server with its one tool registered, recording + * thoughts in `thinkingServer`. By default each call gets a history and + * branches of its own: `serveStdio` calls it once per connection, so the + * history lives exactly as long as the client's connection, on either + * protocol era. Over HTTP every request gets a fresh server, so `http.ts` + * passes one history shared by all of them. */ -export function createServer(): McpServer { +export function createServer( + thinkingServer: SequentialThinkingServer = new SequentialThinkingServer(), +): McpServer { const server = new McpServer({ name: "sequential-thinking-server", version: SERVER_VERSION, }); - const thinkingServer = new SequentialThinkingServer(); - server.registerTool( "sequentialthinking", { @@ -189,8 +192,75 @@ export function isEntryPoint( } } +/** The transports `--transport` can name. */ +export const TRANSPORTS = ["stdio", "streamable-http"] as const; +export type TransportName = (typeof TRANSPORTS)[number]; + +/** + * The transport the command line asks for: `--transport ` or + * `--transport=`, else stdio. Throws on a missing or unknown name. + * Other arguments are ignored, as they always have been. + */ +export function parseTransport(args: string[]): TransportName { + let value: string | undefined; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === "--transport") { + value = args[++index]; + if (value === undefined) { + throw new Error("--transport needs a value: stdio or streamable-http"); + } + } else if (arg.startsWith("--transport=")) { + value = arg.slice("--transport=".length); + } + } + if (value === undefined) return "stdio"; + if (!(TRANSPORTS as readonly string[]).includes(value)) { + throw new Error( + `Unknown transport ${JSON.stringify(value)}: expected stdio or streamable-http`, + ); + } + return value as TransportName; +} + +/** + * Start the server on the transport `args` names, and hand back how to stop + * it. Streamable HTTP listens on `HOST`:`PORT` (loopback:3001 by default) and + * also stops, exiting the process, on SIGINT or SIGTERM. + */ +export async function run( + args: string[] = process.argv.slice(2), +): Promise<{ close: () => Promise }> { + if (parseTransport(args) === "stdio") { + return main(); + } + const { httpEndpoint, startHttpServer } = await import("./http.js"); + const running = await startHttpServer(httpEndpoint()); + console.error( + `Sequential Thinking MCP Server running on Streamable HTTP at ${running.url}`, + ); + const stop = () => { + running.close().then( + () => process.exit(0), + (error: unknown) => { + console.error("Error shutting down:", error); + process.exit(1); + }, + ); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + return running; +} + /* v8 ignore start -- runs only when this file is the process entry point (the spawned bin), which stdio-smoke.test.ts exercises; v8 cannot see into that child */ if (isEntryPoint(import.meta.url, process.argv[1])) { - main(); + run().catch((error: unknown) => { + console.error( + "Fatal error running server:", + error instanceof Error ? error.message : error, + ); + process.exit(1); + }); } /* v8 ignore stop */ diff --git a/src/sequentialthinking/package.json b/src/sequentialthinking/package.json index 18b019f929..71e91c2133 100644 --- a/src/sequentialthinking/package.json +++ b/src/sequentialthinking/package.json @@ -34,6 +34,7 @@ "validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test" }, "dependencies": { + "@modelcontextprotocol/node": "^2.1.1", "@modelcontextprotocol/server": "^2.3.1", "chalk": "^5.3.0", "zod": "^4.2.0" diff --git a/src/time/README.md b/src/time/README.md index 3c75a7687d..21dc256b2f 100644 --- a/src/time/README.md +++ b/src/time/README.md @@ -232,6 +232,31 @@ Example: The Docker image cannot see the host's timezone, so it reads the `LOCAL_TIMEZONE` environment variable when the container starts, and uses `UTC` when it is unset or empty. Pass it with `-e`: `"-e", "LOCAL_TIMEZONE"` forwards the client's own value, as in the Docker configuration above, and `"-e", "LOCAL_TIMEZONE=America/New_York"` sets one. A `--local-timezone` given after the image name takes precedence. +## Streamable HTTP + +The server talks over stdio by default. With `--transport streamable-http` it +runs on its own as an HTTP service instead, serving one MCP endpoint, `/mcp`, +to any number of clients of either protocol era: + +```sh +uvx mcp-server-time --transport streamable-http +``` + +It listens on `127.0.0.1:3001` unless `HOST` and `PORT` say otherwise (for +example `PORT=8080`); `HOST=0.0.0.0` exposes it to every machine that can reach +this one, so set it deliberately. Point a client at the URL, such as +`http://127.0.0.1:3001/mcp`, instead of giving it a command: + +```json +{ + "mcpServers": { + "time": { "type": "http", "url": "http://127.0.0.1:3001/mcp" } + } +} +``` + +Every other option works as it does over stdio. On loopback the SDK also checks each request's `Host` and `Origin` headers against loopback names, to guard against DNS rebinding. + ## Example Interactions 1. Get current time: diff --git a/src/time/src/mcp_server_time/__init__.py b/src/time/src/mcp_server_time/__init__.py index cce7ccc9ad..70d6ff6551 100644 --- a/src/time/src/mcp_server_time/__init__.py +++ b/src/time/src/mcp_server_time/__init__.py @@ -1,4 +1,5 @@ from .server import serve +from .transport import TRANSPORTS, http_endpoint def main(): @@ -10,9 +11,22 @@ def main(): description="give a model the ability to handle time queries and timezone conversions" ) parser.add_argument("--local-timezone", type=str, help="Override local timezone") + parser.add_argument( + "--transport", + choices=TRANSPORTS, + default="stdio", + help="How clients reach the server (default: stdio). streamable-http " + "listens on $HOST:$PORT, 127.0.0.1:3001 by default", + ) args = parser.parse_args() - asyncio.run(serve(args.local_timezone)) + if args.transport == "streamable-http": + # A bad HOST or PORT stops the server here, as a usage error. + try: + http_endpoint() + except ValueError as e: + parser.error(str(e)) + asyncio.run(serve(args.local_timezone, args.transport)) if __name__ == "__main__": diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index 7e5bc22d64..a7c1271483 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -30,6 +30,8 @@ from pydantic import BaseModel, Field from tzlocal import get_localzone_name # ← returns "Europe/Paris", etc. +from .transport import run + # The version this server reports in serverInfo, read from the installed # distribution's metadata (pyproject.toml) so it cannot drift from the # published version (#360). Without it the SDK reports its own `mcp` version. @@ -385,7 +387,7 @@ def get_time_ago( return server -async def serve(local_timezone: str | None = None) -> None: +async def serve(local_timezone: str | None = None, transport: str = "stdio") -> None: if local_timezone: # Fail before the transport opens, with one line naming the bad value # rather than a traceback. zoneinfo raises a different type per kind of @@ -399,9 +401,4 @@ async def serve(local_timezone: str | None = None) -> None: "not a known IANA timezone name" ) server = create_server(str(get_local_tz(local_timezone))) - # Both eras (#4853): the SDK's dual-era loop lets the client's first - # request decide. `initialize` gets the 2025-11-25 handshake; - # `server/discover` or a request carrying a 2026-07-28 envelope gets the - # modern, sessionless era, with its required result fields (resultType, - # ttlMs, cacheScope) filled in by the SDK. - await server.run_stdio_async() + await run(server, transport) diff --git a/src/time/src/mcp_server_time/transport.py b/src/time/src/mcp_server_time/transport.py new file mode 100644 index 0000000000..778a20190b --- /dev/null +++ b/src/time/src/mcp_server_time/transport.py @@ -0,0 +1,45 @@ +"""How the time server is reached: stdio (the default) or Streamable HTTP (#576). + +`--transport streamable-http` serves the same server on one `/mcp` endpoint +through the SDK's Streamable HTTP app, which answers 2026-07-28 and 2025-era +clients alike. It listens on `HOST`:`PORT` from the environment, loopback:3001 +by default: the same variables, defaults and loopback-first posture as the +TypeScript servers, and on loopback the SDK also turns on its DNS-rebinding +protection (Host and Origin checks). +""" + +import os +from collections.abc import Mapping + +from mcp.server.mcpserver import MCPServer + +TRANSPORTS = ("stdio", "streamable-http") +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 3001 + + +def http_endpoint(env: Mapping[str, str] = os.environ) -> tuple[str, int]: + """The host and port to listen on: `HOST` and `PORT`, else the defaults.""" + host = env.get("HOST") or DEFAULT_HOST + raw = env.get("PORT") + if not raw: + return host, DEFAULT_PORT + if not raw.isascii() or not raw.isdigit() or int(raw) > 65535: + raise ValueError( + f"Invalid PORT {raw!r}: expected a whole number from 0 to 65535" + ) + return host, int(raw) + + +async def run(server: MCPServer, transport: str) -> None: + """Serve `server` on `transport` until it is stopped.""" + if transport == "streamable-http": + host, port = http_endpoint() + await server.run_streamable_http_async(host=host, port=port) + else: + # Both eras (#4853): the SDK's dual-era loop lets the client's first + # request decide. `initialize` gets the 2025-11-25 handshake; + # `server/discover` or a request carrying a 2026-07-28 envelope gets + # the modern, sessionless era, with its required result fields + # (resultType, ttlMs, cacheScope) filled in by the SDK. + await server.run_stdio_async() diff --git a/src/time/tests/test_entrypoints.py b/src/time/tests/test_entrypoints.py index abf7d7799a..a16157c7a7 100644 --- a/src/time/tests/test_entrypoints.py +++ b/src/time/tests/test_entrypoints.py @@ -41,7 +41,7 @@ def test_main_passes_local_timezone_to_serve( patch.object(sys, "argv", ["mcp-server-time", *argv]), ): mcp_server_time.main() - serve.assert_awaited_once_with(expected_local_timezone) + serve.assert_awaited_once_with(expected_local_timezone, "stdio") def test_main_help_describes_the_server(capsys: pytest.CaptureFixture[str]) -> None: @@ -93,7 +93,7 @@ def test_init_module_run_as_main_calls_main() -> None: patch.object(sys, "argv", ["mcp-server-time", "--local-timezone", "UTC"]), ): runpy.run_module("mcp_server_time.__init__", run_name="__main__") - serve.assert_awaited_once_with("UTC") + serve.assert_awaited_once_with("UTC", "stdio") @pytest.fixture diff --git a/src/time/tests/test_http.py b/src/time/tests/test_http.py new file mode 100644 index 0000000000..0dc001e0eb --- /dev/null +++ b/src/time/tests/test_http.py @@ -0,0 +1,127 @@ +# The Streamable HTTP transport (#576): `serve(transport="streamable-http")` +# run in-process on a free loopback port and driven by the SDK `Client` over +# real HTTP in both protocol eras, plus `main()`'s `--transport` flag and +# `http_endpoint()`'s reading of HOST and PORT. + +import socket +import sys +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, patch + +import anyio +import pytest +from mcp import Client +from mcp.types import CallToolResult, TextContent + +import mcp_server_time +from mcp_server_time.server import serve +from mcp_server_time.transport import DEFAULT_HOST, DEFAULT_PORT, http_endpoint + +ERAS = [ + pytest.param("2026-07-28", "2026-07-28", id="2026-07-28"), + pytest.param("legacy", "2025-11-25", id="2025-11-25"), +] + + +def free_port() -> int: + """A loopback port nothing is listening on right now.""" + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +@asynccontextmanager +async def http_server(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[str]: + """Run `serve()` over Streamable HTTP and yield its URL once it answers.""" + port = free_port() + monkeypatch.setenv("HOST", "127.0.0.1") + monkeypatch.setenv("PORT", str(port)) + async with anyio.create_task_group() as tg: + tg.start_soon(serve, "UTC", "streamable-http") + with anyio.fail_after(10): + while True: + try: + await (await anyio.connect_tcp("127.0.0.1", port)).aclose() + break + except OSError: + await anyio.sleep(0.05) + yield f"http://127.0.0.1:{port}/mcp" + tg.cancel_scope.cancel() + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_serves_each_era_over_streamable_http( + monkeypatch: pytest.MonkeyPatch, mode: str, negotiated: str +) -> None: + async with http_server(monkeypatch) as url, Client(url, mode=mode) as client: + assert client.session.protocol_version == negotiated + result = await client.call_tool( + "convert_time", + { + "source_timezone": "UTC", + "time": "12:00", + "target_timezone": "Asia/Tokyo", + }, + ) + assert isinstance(result, CallToolResult) + assert not result.is_error + block = result.content[0] + assert isinstance(block, TextContent) + assert "Asia/Tokyo" in block.text + + +def test_http_endpoint_defaults_to_loopback() -> None: + assert http_endpoint({}) == (DEFAULT_HOST, DEFAULT_PORT) + + +def test_http_endpoint_reads_host_and_port() -> None: + assert http_endpoint({"HOST": "0.0.0.0", "PORT": "8080"}) == ("0.0.0.0", 8080) + + +@pytest.mark.parametrize("port", ["abc", "-1", "65536", "1.5", "٣"]) +def test_http_endpoint_refuses_a_bad_port(port: str) -> None: + with pytest.raises(ValueError, match="Invalid PORT"): + http_endpoint({"PORT": port}) + + +@pytest.mark.parametrize( + "argv,transport", + [ + ([], "stdio"), + (["--transport", "stdio"], "stdio"), + (["--transport", "streamable-http"], "streamable-http"), + ], +) +def test_main_passes_the_transport_to_serve( + monkeypatch: pytest.MonkeyPatch, argv: list[str], transport: str +) -> None: + monkeypatch.delenv("PORT", raising=False) + serve_mock = AsyncMock() + with ( + patch.object(mcp_server_time, "serve", serve_mock), + patch.object(sys, "argv", ["mcp-server-time", *argv]), + ): + mcp_server_time.main() + serve_mock.assert_awaited_once_with(None, transport) + + +@pytest.mark.parametrize( + "argv", [["--transport", "sse"], ["--transport", "streamable-http"]] +) +def test_main_refuses_an_unknown_transport_or_a_bad_port( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + argv: list[str], +) -> None: + monkeypatch.setenv("PORT", "not-a-port") + serve_mock = AsyncMock() + with ( + patch.object(mcp_server_time, "serve", serve_mock), + patch.object(sys, "argv", ["mcp-server-time", *argv]), + pytest.raises(SystemExit) as exit_info, + ): + mcp_server_time.main() + assert exit_info.value.code == 2 + serve_mock.assert_not_awaited() + assert "error:" in capsys.readouterr().err From 17d9f9e43677948c5785f8a92f832eebc90015dd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 12 Nov 2026 00:01:32 -0500 Subject: [PATCH 2/3] fix: survive a malformed request target; publish legacy-era graph writes A target such as `//[` made `new URL()` throw inside the Node request listener, outside any handler, ending the process. The HTTP endpoints now compare the path without parsing the target, so it is answered 404. Over HTTP, memory now publishes every graph change on the handler's bus whatever era wrote it, so a 2026-07-28 listener hears of a 2025-era client's writes too. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/filesystem/__tests__/http.test.ts | 18 ++++++ src/filesystem/http.ts | 5 +- src/memory/__tests__/http.test.ts | 61 +++++++++++++------ src/memory/http.ts | 5 +- src/memory/index.ts | 9 +-- src/sequentialthinking/__tests__/http.test.ts | 19 ++++++ src/sequentialthinking/http.ts | 5 +- 7 files changed, 92 insertions(+), 30 deletions(-) diff --git a/src/filesystem/__tests__/http.test.ts b/src/filesystem/__tests__/http.test.ts index c00c7a80ac..3c10a13e5e 100644 --- a/src/filesystem/__tests__/http.test.ts +++ b/src/filesystem/__tests__/http.test.ts @@ -7,6 +7,7 @@ // get, and main() refuses to start without one. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { connect as connectTcp } from "node:net"; import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -138,6 +139,23 @@ describe("startHttpServer", () => { return running; } + it("answers a malformed request target 404 and keeps serving", async () => { + const url = new URL((await start()).url); + const reply = await new Promise((resolve, reject) => { + const socket = connectTcp(Number(url.port), url.hostname, () => + socket.write("GET //[ HTTP/1.1\r\nHost: localhost\r\n\r\n"), + ); + socket.setEncoding("utf8"); + socket.once("data", (chunk: string) => { + socket.destroy(); + resolve(chunk); + }); + socket.once("error", reject); + }); + expect(reply).toMatch(/^HTTP\/1\.1 404/); + expect((await fetch(new URL("/other", url))).status).toBe(404); + }); + it("answers 404 off the MCP path and 403 to a foreign Origin", async () => { const base = new URL((await start()).url); expect((await fetch(new URL("/other", base))).status).toBe(404); diff --git a/src/filesystem/http.ts b/src/filesystem/http.ts index d074030a50..f4d6b0b79c 100644 --- a/src/filesystem/http.ts +++ b/src/filesystem/http.ts @@ -82,8 +82,9 @@ export async function startHttpServer( const allowOrigin = localhostOriginValidation(); const httpServer = createNodeServer((req, res) => { - // A request a Node server hands its listener always carries a URL. - const path = new URL(req.url!, "http://localhost").pathname; + // The path, without parsing the target: a malformed one (`//[`) would + // make URL parsing throw here, outside any handler, and end the process. + const path = String(req.url).split("?")[0]; if (path !== MCP_PATH) { res.writeHead(404, { "content-type": "text/plain" }).end("Not Found"); return; diff --git a/src/memory/__tests__/http.test.ts b/src/memory/__tests__/http.test.ts index e812235382..c1174ba3e1 100644 --- a/src/memory/__tests__/http.test.ts +++ b/src/memory/__tests__/http.test.ts @@ -5,6 +5,7 @@ // across requests, and a 2026-07-28 listen stream across clients. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { connect as connectTcp } from "node:net"; import { Client, StreamableHTTPClientTransport, @@ -83,27 +84,47 @@ describe("Streamable HTTP", () => { }, ); - it("sends a 2026-07-28 listener the updates another client's write makes", async () => { - const url = (await start()).url; - const listener = await connect(url, ERAS[0][1]); - const writer = await connect(url, ERAS[0][1]); - try { - const updated = new Promise((resolve) => { - listener.setNotificationHandler( - "notifications/resources/updated", - (notification) => resolve(notification.params.uri), - ); - }); - const subscription = await listener.listen({ - resourceSubscriptions: [RESOURCE_URI], + it.each(ERAS)( + "sends a 2026-07-28 listener the updates a %s writer makes", + async (_, negotiation) => { + const url = (await start()).url; + const listener = await connect(url, ERAS[0][1]); + const writer = await connect(url, negotiation); + try { + const updated = new Promise((resolve) => { + listener.setNotificationHandler( + "notifications/resources/updated", + (notification) => resolve(notification.params.uri), + ); + }); + const subscription = await listener.listen({ + resourceSubscriptions: [RESOURCE_URI], + }); + await call(writer, "create_entities", { entities: [alice] }); + expect(await updated).toBe(RESOURCE_URI); + await subscription.close(); + } finally { + await listener.close(); + await writer.close(); + } + }, + ); + + it("answers a malformed request target 404 and keeps serving", async () => { + const url = new URL((await start()).url); + const reply = await new Promise((resolve, reject) => { + const socket = connectTcp(Number(url.port), url.hostname, () => + socket.write("GET //[ HTTP/1.1\r\nHost: localhost\r\n\r\n"), + ); + socket.setEncoding("utf8"); + socket.once("data", (chunk: string) => { + socket.destroy(); + resolve(chunk); }); - await call(writer, "create_entities", { entities: [alice] }); - expect(await updated).toBe(RESOURCE_URI); - await subscription.close(); - } finally { - await listener.close(); - await writer.close(); - } + socket.once("error", reject); + }); + expect(reply).toMatch(/^HTTP\/1\.1 404/); + expect((await fetch(new URL("/other", url))).status).toBe(404); }); it("answers 404 off the MCP path and 403 to a foreign Origin", async () => { diff --git a/src/memory/http.ts b/src/memory/http.ts index abe4c26056..a00d843139 100644 --- a/src/memory/http.ts +++ b/src/memory/http.ts @@ -90,8 +90,9 @@ export async function startHttpServer( const allowOrigin = localhostOriginValidation(); const httpServer = createNodeServer((req, res) => { - // A request a Node server hands its listener always carries a URL. - const path = new URL(req.url!, "http://localhost").pathname; + // The path, without parsing the target: a malformed one (`//[`) would + // make URL parsing throw here, outside any handler, and end the process. + const path = String(req.url).split("?")[0]; if (path !== MCP_PATH) { res.writeHead(404, { "content-type": "text/plain" }).end("Not Found"); return; diff --git a/src/memory/index.ts b/src/memory/index.ts index 6328db1542..6479234ad3 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -960,9 +960,10 @@ const ReadEntitySchema = EntitySchema.extend({ // returns an independent server with its own subscriber set. `era` is the // protocol era the instance will serve, which decides how graph changes are // announced (see notifyGraphUpdated). `publishGraphUpdated`, when given, -// announces a 2026-07-28 change instead of the instance itself: over HTTP each -// request gets a fresh server that holds no listen stream, so http.ts hands in -// the handler's subscription bus, which every open stream listens on. +// announces every change instead of the instance itself, whatever era wrote +// it: over HTTP each request gets a fresh server that holds no listen stream +// and no subscriber, so http.ts hands in the handler's subscription bus, which +// every open 2026-07-28 stream listens on. export function createServer( memoryFilePath: string, era: "legacy" | "modern" = "legacy", @@ -986,7 +987,7 @@ export function createServer( // the notification only to a listen stream that named the URI, so it is // always handed over. function notifyGraphUpdated() { - if (era === "modern" && publishGraphUpdated) { + if (publishGraphUpdated) { publishGraphUpdated(); } else if (era === "modern" || resourceSubscribers.has(RESOURCE_URI)) { // Fire-and-forget: the tool result must not wait on (or fail because of) diff --git a/src/sequentialthinking/__tests__/http.test.ts b/src/sequentialthinking/__tests__/http.test.ts index 1bbd11b381..9148e687d8 100644 --- a/src/sequentialthinking/__tests__/http.test.ts +++ b/src/sequentialthinking/__tests__/http.test.ts @@ -5,6 +5,7 @@ // history is asserted across calls and across clients. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { connect as connectTcp } from "node:net"; import { Client, StreamableHTTPClientTransport, @@ -91,6 +92,24 @@ describe("Streamable HTTP", () => { } }); + it("answers a malformed request target 404 and keeps serving", async () => { + running = await startHttpServer({ host: "127.0.0.1", port: 0 }); + const url = new URL(running.url); + const reply = await new Promise((resolve, reject) => { + const socket = connectTcp(Number(url.port), url.hostname, () => + socket.write("GET //[ HTTP/1.1\r\nHost: localhost\r\n\r\n"), + ); + socket.setEncoding("utf8"); + socket.once("data", (chunk: string) => { + socket.destroy(); + resolve(chunk); + }); + socket.once("error", reject); + }); + expect(reply).toMatch(/^HTTP\/1\.1 404/); + expect((await fetch(new URL("/other", url))).status).toBe(404); + }); + it("answers 404 off the MCP path and 403 to a foreign Origin", async () => { running = await startHttpServer({ host: "127.0.0.1", port: 0 }); const base = new URL(running.url); diff --git a/src/sequentialthinking/http.ts b/src/sequentialthinking/http.ts index d89feaa453..81c81f782f 100644 --- a/src/sequentialthinking/http.ts +++ b/src/sequentialthinking/http.ts @@ -85,8 +85,9 @@ export async function startHttpServer({ const allowOrigin = localhostOriginValidation(); const httpServer = createNodeServer((req, res) => { - // A request a Node server hands its listener always carries a URL. - const path = new URL(req.url!, "http://localhost").pathname; + // The path, without parsing the target: a malformed one (`//[`) would + // make URL parsing throw here, outside any handler, and end the process. + const path = String(req.url).split("?")[0]; if (path !== MCP_PATH) { res.writeHead(404, { "content-type": "text/plain" }).end("Not Found"); return; From 97d89b92e3fb74a070cc38caf4e4b8062e1bb741 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 12 Nov 2026 00:13:09 -0500 Subject: [PATCH 3/3] fix: refuse foreign Origins on the Python servers whatever HOST is The SDK's DNS-rebinding protection checks Origin only when it binds loopback, so with HOST widened a page on any origin could reach the git, fetch and time tools. Each transport.py now wraps the SDK's app in a localhost-Origin middleware, as the TypeScript servers do with localhostOriginValidation, and runs it under uvicorn itself. The READMEs now say what each endpoint refuses, and that the TypeScript ones send no CORS headers. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/fetch/README.md | 2 +- src/fetch/src/mcp_server_fetch/transport.py | 50 ++++++++++++++++- src/fetch/tests/test_http.py | 61 ++++++++++++++++++++- src/filesystem/README.md | 5 +- src/git/README.md | 2 +- src/git/src/mcp_server_git/transport.py | 50 ++++++++++++++++- src/git/tests/test_http.py | 61 ++++++++++++++++++++- src/memory/README.md | 5 +- src/sequentialthinking/README.md | 5 +- src/time/README.md | 2 +- src/time/src/mcp_server_time/transport.py | 50 ++++++++++++++++- src/time/tests/test_http.py | 61 ++++++++++++++++++++- 12 files changed, 333 insertions(+), 21 deletions(-) diff --git a/src/fetch/README.md b/src/fetch/README.md index 1ec12506b0..4c43da3d58 100644 --- a/src/fetch/README.md +++ b/src/fetch/README.md @@ -343,7 +343,7 @@ this one, so set it deliberately. Point a client at the URL, such as } ``` -Every other option works as it does over stdio. On loopback the SDK also checks each request's `Host` and `Origin` headers against loopback names, to guard against DNS rebinding. +Every other option works as it does over stdio. A browser page from any origin but localhost is refused with `403`, whatever `HOST` is, to guard against DNS rebinding; on loopback the SDK also checks each request's `Host` header. ## Windows Configuration diff --git a/src/fetch/src/mcp_server_fetch/transport.py b/src/fetch/src/mcp_server_fetch/transport.py index a9db7f4d45..42de67ec48 100644 --- a/src/fetch/src/mcp_server_fetch/transport.py +++ b/src/fetch/src/mcp_server_fetch/transport.py @@ -4,14 +4,25 @@ through the SDK's Streamable HTTP app, which answers 2026-07-28 and 2025-era clients alike. It listens on `HOST`:`PORT` from the environment, loopback:3001 by default: the same variables, defaults and loopback-first posture as the -TypeScript servers, and on loopback the SDK also turns on its DNS-rebinding -protection (Host and Origin checks). +TypeScript servers. + +A browser page may reach it only from a localhost Origin, whatever `HOST` is, +as with the TypeScript servers' `localhostOriginValidation`. The SDK's own +DNS-rebinding protection checks Origin only when it binds loopback, and its +Host check cannot name the addresses of an interface `HOST` widens to, so the +Origin check is this module's middleware; on loopback the SDK's Host and +Origin checks still run behind it. """ import os from collections.abc import Mapping +from urllib.parse import urlsplit +import uvicorn from mcp.server.mcpserver import MCPServer +from starlette.datastructures import Headers +from starlette.responses import PlainTextResponse +from starlette.types import ASGIApp, Receive, Scope, Send TRANSPORTS = ("stdio", "streamable-http") DEFAULT_HOST = "127.0.0.1" @@ -31,11 +42,44 @@ def http_endpoint(env: Mapping[str, str] = os.environ) -> tuple[str, int]: return host, int(raw) +LOCALHOST_NAMES = ("localhost", "127.0.0.1", "::1") + + +def origin_allowed(origin: str | None) -> bool: + """Whether a request with this Origin may proceed: none (a non-browser + client), or one whose host is localhost, 127.0.0.1 or [::1].""" + if origin is None: + return True + try: + hostname = urlsplit(origin).hostname + except ValueError: + return False + return hostname in LOCALHOST_NAMES + + +class LocalhostOriginMiddleware: + """Answer an HTTP request from any other Origin with 403 before `app` sees it.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http" and not origin_allowed( + Headers(scope=scope).get("origin") + ): + response = PlainTextResponse("Invalid Origin header", status_code=403) + await response(scope, receive, send) + return + await self.app(scope, receive, send) + + async def run(server: MCPServer, transport: str) -> None: """Serve `server` on `transport` until it is stopped.""" if transport == "streamable-http": host, port = http_endpoint() - await server.run_streamable_http_async(host=host, port=port) + app = LocalhostOriginMiddleware(server.streamable_http_app(host=host)) + config = uvicorn.Config(app, host=host, port=port) + await uvicorn.Server(config).serve() else: # Both eras (#4853): the SDK's dual-era loop lets the client's first # request decide. `initialize` gets the 2025-11-25 handshake; diff --git a/src/fetch/tests/test_http.py b/src/fetch/tests/test_http.py index 846bf3fac3..6992d59f07 100644 --- a/src/fetch/tests/test_http.py +++ b/src/fetch/tests/test_http.py @@ -8,6 +8,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from functools import partial +from typing import Any from unittest.mock import AsyncMock, patch import anyio @@ -16,7 +17,13 @@ import mcp_server_fetch from mcp_server_fetch.server import serve -from mcp_server_fetch.transport import DEFAULT_HOST, DEFAULT_PORT, http_endpoint +from mcp_server_fetch.transport import ( + DEFAULT_HOST, + DEFAULT_PORT, + LocalhostOriginMiddleware, + http_endpoint, + origin_allowed, +) ERAS = [ pytest.param("2026-07-28", "2026-07-28", id="2026-07-28"), @@ -114,3 +121,55 @@ def test_main_refuses_an_unknown_transport_or_a_bad_port( assert exit_info.value.code == 2 serve_mock.assert_not_awaited() assert "error:" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "origin,allowed", + [ + (None, True), + ("http://localhost:6274", True), + ("http://127.0.0.1", True), + ("http://[::1]:3000", True), + ("https://evil.example", False), + ("http://localhost.evil.example", False), + ("http://[::1", False), + ("null", False), + ], +) +def test_origin_allowed(origin: str | None, allowed: bool) -> None: + assert origin_allowed(origin) is allowed + + +async def test_middleware_refuses_a_foreign_origin_whatever_the_host() -> None: + """The check does not depend on the bind address, so it holds under HOST=0.0.0.0.""" + reached: list[str] = [] + + async def app(scope: Any, receive: Any, send: Any) -> None: + reached.append(scope["type"]) + + sent: list[Any] = [] + + async def send(message: Any) -> None: + sent.append(message) + + async def receive() -> Any: + return {"type": "http.request", "body": b"", "more_body": False} + + middleware = LocalhostOriginMiddleware(app) + foreign = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [ + (b"host", b"192.168.1.20:3001"), + (b"origin", b"https://evil.example"), + ], + } + await middleware(foreign, receive, send) + assert reached == [] + assert sent[0]["status"] == 403 + + local = {**foreign, "headers": [(b"origin", b"http://localhost:6274")]} + await middleware(local, receive, send) + await middleware({"type": "lifespan"}, receive, send) + assert reached == ["http", "lifespan"] diff --git a/src/filesystem/README.md b/src/filesystem/README.md index f41f64a8d5..1e0253a53b 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -414,8 +414,9 @@ npx -y @modelcontextprotocol/server-filesystem --transport streamable-http /User It listens on `127.0.0.1:3001` unless `HOST` and `PORT` say otherwise (for example `PORT=8080`); `HOST=0.0.0.0` exposes it to every machine that can reach -this one, so set it deliberately. A browser page can reach it only from a -localhost origin. Point a client at the URL, such as +this one, so set it deliberately. A request from a browser page on any +origin but localhost is refused with `403`, and the endpoint sends no CORS +headers, so it serves clients that are not browser pages. Point a client at the URL, such as `http://127.0.0.1:3001/mcp`, instead of giving it a command: ```json diff --git a/src/git/README.md b/src/git/README.md index 0e996f4128..6e08b96271 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -407,7 +407,7 @@ this one, so set it deliberately. Point a client at the URL, such as } ``` -Every other option works as it does over stdio; `--repository` matters more here, since any client that can reach the port can use the server. On loopback the SDK also checks each request's `Host` and `Origin` headers against loopback names, to guard against DNS rebinding. +Every other option works as it does over stdio; `--repository` matters more here, since any client that can reach the port can use the server. A browser page from any origin but localhost is refused with `403`, whatever `HOST` is, to guard against DNS rebinding; on loopback the SDK also checks each request's `Host` header. ## Debugging diff --git a/src/git/src/mcp_server_git/transport.py b/src/git/src/mcp_server_git/transport.py index 673ca6e4d0..0786cb5fcb 100644 --- a/src/git/src/mcp_server_git/transport.py +++ b/src/git/src/mcp_server_git/transport.py @@ -4,14 +4,25 @@ through the SDK's Streamable HTTP app, which answers 2026-07-28 and 2025-era clients alike. It listens on `HOST`:`PORT` from the environment, loopback:3001 by default: the same variables, defaults and loopback-first posture as the -TypeScript servers, and on loopback the SDK also turns on its DNS-rebinding -protection (Host and Origin checks). +TypeScript servers. + +A browser page may reach it only from a localhost Origin, whatever `HOST` is, +as with the TypeScript servers' `localhostOriginValidation`. The SDK's own +DNS-rebinding protection checks Origin only when it binds loopback, and its +Host check cannot name the addresses of an interface `HOST` widens to, so the +Origin check is this module's middleware; on loopback the SDK's Host and +Origin checks still run behind it. """ import os from collections.abc import Mapping +from urllib.parse import urlsplit +import uvicorn from mcp.server.mcpserver import MCPServer +from starlette.datastructures import Headers +from starlette.responses import PlainTextResponse +from starlette.types import ASGIApp, Receive, Scope, Send TRANSPORTS = ("stdio", "streamable-http") DEFAULT_HOST = "127.0.0.1" @@ -31,11 +42,44 @@ def http_endpoint(env: Mapping[str, str] = os.environ) -> tuple[str, int]: return host, int(raw) +LOCALHOST_NAMES = ("localhost", "127.0.0.1", "::1") + + +def origin_allowed(origin: str | None) -> bool: + """Whether a request with this Origin may proceed: none (a non-browser + client), or one whose host is localhost, 127.0.0.1 or [::1].""" + if origin is None: + return True + try: + hostname = urlsplit(origin).hostname + except ValueError: + return False + return hostname in LOCALHOST_NAMES + + +class LocalhostOriginMiddleware: + """Answer an HTTP request from any other Origin with 403 before `app` sees it.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http" and not origin_allowed( + Headers(scope=scope).get("origin") + ): + response = PlainTextResponse("Invalid Origin header", status_code=403) + await response(scope, receive, send) + return + await self.app(scope, receive, send) + + async def run(server: MCPServer, transport: str) -> None: """Serve `server` on `transport` until it is stopped.""" if transport == "streamable-http": host, port = http_endpoint() - await server.run_streamable_http_async(host=host, port=port) + app = LocalhostOriginMiddleware(server.streamable_http_app(host=host)) + config = uvicorn.Config(app, host=host, port=port) + await uvicorn.Server(config).serve() else: # Both eras (#4853): the SDK's dual-era loop lets the client's first # request decide. `initialize` gets the 2025-11-25 handshake; diff --git a/src/git/tests/test_http.py b/src/git/tests/test_http.py index 0917f5db9e..5c658d8e93 100644 --- a/src/git/tests/test_http.py +++ b/src/git/tests/test_http.py @@ -6,6 +6,7 @@ import socket from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from typing import Any from unittest.mock import AsyncMock, patch import anyio @@ -15,7 +16,13 @@ import mcp_server_git from mcp_server_git.server import serve -from mcp_server_git.transport import DEFAULT_HOST, DEFAULT_PORT, http_endpoint +from mcp_server_git.transport import ( + DEFAULT_HOST, + DEFAULT_PORT, + LocalhostOriginMiddleware, + http_endpoint, + origin_allowed, +) ERAS = [ pytest.param("2026-07-28", "2026-07-28", id="2026-07-28"), @@ -105,3 +112,55 @@ def test_main_refuses_an_unknown_transport_or_a_bad_port( assert result.exit_code == 2 serve_mock.assert_not_awaited() assert "Error" in result.output + + +@pytest.mark.parametrize( + "origin,allowed", + [ + (None, True), + ("http://localhost:6274", True), + ("http://127.0.0.1", True), + ("http://[::1]:3000", True), + ("https://evil.example", False), + ("http://localhost.evil.example", False), + ("http://[::1", False), + ("null", False), + ], +) +def test_origin_allowed(origin: str | None, allowed: bool) -> None: + assert origin_allowed(origin) is allowed + + +async def test_middleware_refuses_a_foreign_origin_whatever_the_host() -> None: + """The check does not depend on the bind address, so it holds under HOST=0.0.0.0.""" + reached: list[str] = [] + + async def app(scope: Any, receive: Any, send: Any) -> None: + reached.append(scope["type"]) + + sent: list[Any] = [] + + async def send(message: Any) -> None: + sent.append(message) + + async def receive() -> Any: + return {"type": "http.request", "body": b"", "more_body": False} + + middleware = LocalhostOriginMiddleware(app) + foreign = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [ + (b"host", b"192.168.1.20:3001"), + (b"origin", b"https://evil.example"), + ], + } + await middleware(foreign, receive, send) + assert reached == [] + assert sent[0]["status"] == 403 + + local = {**foreign, "headers": [(b"origin", b"http://localhost:6274")]} + await middleware(local, receive, send) + await middleware({"type": "lifespan"}, receive, send) + assert reached == ["http", "lifespan"] diff --git a/src/memory/README.md b/src/memory/README.md index c58121a8fc..66fc3710a5 100644 --- a/src/memory/README.md +++ b/src/memory/README.md @@ -167,8 +167,9 @@ npx -y @modelcontextprotocol/server-memory --transport streamable-http It listens on `127.0.0.1:3001` unless `HOST` and `PORT` say otherwise (for example `PORT=8080`); `HOST=0.0.0.0` exposes it to every machine that can reach -this one, so set it deliberately. A browser page can reach it only from a -localhost origin. Point a client at the URL, such as +this one, so set it deliberately. A request from a browser page on any +origin but localhost is refused with `403`, and the endpoint sends no CORS +headers, so it serves clients that are not browser pages. Point a client at the URL, such as `http://127.0.0.1:3001/mcp`, instead of giving it a command: ```json diff --git a/src/sequentialthinking/README.md b/src/sequentialthinking/README.md index 9434d9713e..fad54a0066 100644 --- a/src/sequentialthinking/README.md +++ b/src/sequentialthinking/README.md @@ -230,8 +230,9 @@ npx -y @modelcontextprotocol/server-sequential-thinking --transport streamable-h It listens on `127.0.0.1:3001` unless `HOST` and `PORT` say otherwise (for example `PORT=8080`); `HOST=0.0.0.0` exposes it to every machine that can reach -this one, so set it deliberately. A browser page can reach it only from a -localhost origin. Point a client at the URL, such as +this one, so set it deliberately. A request from a browser page on any +origin but localhost is refused with `403`, and the endpoint sends no CORS +headers, so it serves clients that are not browser pages. Point a client at the URL, such as `http://127.0.0.1:3001/mcp`, instead of giving it a command: ```json diff --git a/src/time/README.md b/src/time/README.md index 21dc256b2f..877551eff9 100644 --- a/src/time/README.md +++ b/src/time/README.md @@ -255,7 +255,7 @@ this one, so set it deliberately. Point a client at the URL, such as } ``` -Every other option works as it does over stdio. On loopback the SDK also checks each request's `Host` and `Origin` headers against loopback names, to guard against DNS rebinding. +Every other option works as it does over stdio. A browser page from any origin but localhost is refused with `403`, whatever `HOST` is, to guard against DNS rebinding; on loopback the SDK also checks each request's `Host` header. ## Example Interactions diff --git a/src/time/src/mcp_server_time/transport.py b/src/time/src/mcp_server_time/transport.py index 778a20190b..a1d7064e43 100644 --- a/src/time/src/mcp_server_time/transport.py +++ b/src/time/src/mcp_server_time/transport.py @@ -4,14 +4,25 @@ through the SDK's Streamable HTTP app, which answers 2026-07-28 and 2025-era clients alike. It listens on `HOST`:`PORT` from the environment, loopback:3001 by default: the same variables, defaults and loopback-first posture as the -TypeScript servers, and on loopback the SDK also turns on its DNS-rebinding -protection (Host and Origin checks). +TypeScript servers. + +A browser page may reach it only from a localhost Origin, whatever `HOST` is, +as with the TypeScript servers' `localhostOriginValidation`. The SDK's own +DNS-rebinding protection checks Origin only when it binds loopback, and its +Host check cannot name the addresses of an interface `HOST` widens to, so the +Origin check is this module's middleware; on loopback the SDK's Host and +Origin checks still run behind it. """ import os from collections.abc import Mapping +from urllib.parse import urlsplit +import uvicorn from mcp.server.mcpserver import MCPServer +from starlette.datastructures import Headers +from starlette.responses import PlainTextResponse +from starlette.types import ASGIApp, Receive, Scope, Send TRANSPORTS = ("stdio", "streamable-http") DEFAULT_HOST = "127.0.0.1" @@ -31,11 +42,44 @@ def http_endpoint(env: Mapping[str, str] = os.environ) -> tuple[str, int]: return host, int(raw) +LOCALHOST_NAMES = ("localhost", "127.0.0.1", "::1") + + +def origin_allowed(origin: str | None) -> bool: + """Whether a request with this Origin may proceed: none (a non-browser + client), or one whose host is localhost, 127.0.0.1 or [::1].""" + if origin is None: + return True + try: + hostname = urlsplit(origin).hostname + except ValueError: + return False + return hostname in LOCALHOST_NAMES + + +class LocalhostOriginMiddleware: + """Answer an HTTP request from any other Origin with 403 before `app` sees it.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http" and not origin_allowed( + Headers(scope=scope).get("origin") + ): + response = PlainTextResponse("Invalid Origin header", status_code=403) + await response(scope, receive, send) + return + await self.app(scope, receive, send) + + async def run(server: MCPServer, transport: str) -> None: """Serve `server` on `transport` until it is stopped.""" if transport == "streamable-http": host, port = http_endpoint() - await server.run_streamable_http_async(host=host, port=port) + app = LocalhostOriginMiddleware(server.streamable_http_app(host=host)) + config = uvicorn.Config(app, host=host, port=port) + await uvicorn.Server(config).serve() else: # Both eras (#4853): the SDK's dual-era loop lets the client's first # request decide. `initialize` gets the 2025-11-25 handshake; diff --git a/src/time/tests/test_http.py b/src/time/tests/test_http.py index 0dc001e0eb..b23866dcac 100644 --- a/src/time/tests/test_http.py +++ b/src/time/tests/test_http.py @@ -7,6 +7,7 @@ import sys from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from typing import Any from unittest.mock import AsyncMock, patch import anyio @@ -16,7 +17,13 @@ import mcp_server_time from mcp_server_time.server import serve -from mcp_server_time.transport import DEFAULT_HOST, DEFAULT_PORT, http_endpoint +from mcp_server_time.transport import ( + DEFAULT_HOST, + DEFAULT_PORT, + LocalhostOriginMiddleware, + http_endpoint, + origin_allowed, +) ERAS = [ pytest.param("2026-07-28", "2026-07-28", id="2026-07-28"), @@ -125,3 +132,55 @@ def test_main_refuses_an_unknown_transport_or_a_bad_port( assert exit_info.value.code == 2 serve_mock.assert_not_awaited() assert "error:" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "origin,allowed", + [ + (None, True), + ("http://localhost:6274", True), + ("http://127.0.0.1", True), + ("http://[::1]:3000", True), + ("https://evil.example", False), + ("http://localhost.evil.example", False), + ("http://[::1", False), + ("null", False), + ], +) +def test_origin_allowed(origin: str | None, allowed: bool) -> None: + assert origin_allowed(origin) is allowed + + +async def test_middleware_refuses_a_foreign_origin_whatever_the_host() -> None: + """The check does not depend on the bind address, so it holds under HOST=0.0.0.0.""" + reached: list[str] = [] + + async def app(scope: Any, receive: Any, send: Any) -> None: + reached.append(scope["type"]) + + sent: list[Any] = [] + + async def send(message: Any) -> None: + sent.append(message) + + async def receive() -> Any: + return {"type": "http.request", "body": b"", "more_body": False} + + middleware = LocalhostOriginMiddleware(app) + foreign = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [ + (b"host", b"192.168.1.20:3001"), + (b"origin", b"https://evil.example"), + ], + } + await middleware(foreign, receive, send) + assert reached == [] + assert sent[0]["status"] == 403 + + local = {**foreign, "headers": [(b"origin", b"http://localhost:6274")]} + await middleware(local, receive, send) + await middleware({"type": "lifespan"}, receive, send) + assert reached == ["http", "lifespan"]