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..4c43da3d58 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. 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 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..42de67ec48 --- /dev/null +++ b/src/fetch/src/mcp_server_fetch/transport.py @@ -0,0 +1,89 @@ +"""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. + +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" +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) + + +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() + 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; + # `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..6992d59f07 --- /dev/null +++ b/src/fetch/tests/test_http.py @@ -0,0 +1,175 @@ +# 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 typing import Any +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, + LocalhostOriginMiddleware, + http_endpoint, + origin_allowed, +) + +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 + + +@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/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..1e0253a53b 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -402,6 +402,33 @@ 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 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 +{ + "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..3c10a13e5e --- /dev/null +++ b/src/filesystem/__tests__/http.test.ts @@ -0,0 +1,260 @@ +// 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 { 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"; +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 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); + 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..f4d6b0b79c --- /dev/null +++ b/src/filesystem/http.ts @@ -0,0 +1,119 @@ +// 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) => { + // 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; + } + 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..6e08b96271 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. 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 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..0786cb5fcb --- /dev/null +++ b/src/git/src/mcp_server_git/transport.py @@ -0,0 +1,89 @@ +"""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. + +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" +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) + + +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() + 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; + # `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..5c658d8e93 --- /dev/null +++ b/src/git/tests/test_http.py @@ -0,0 +1,166 @@ +# 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 typing import Any +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, + LocalhostOriginMiddleware, + http_endpoint, + origin_allowed, +) + +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 + + +@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 d519f14f3e..66fc3710a5 100644 --- a/src/memory/README.md +++ b/src/memory/README.md @@ -155,6 +155,33 @@ 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 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 +{ + "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..c1174ba3e1 --- /dev/null +++ b/src/memory/__tests__/http.test.ts @@ -0,0 +1,290 @@ +// 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 { connect as connectTcp } from "node:net"; +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.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); + }); + 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); + 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..a00d843139 --- /dev/null +++ b/src/memory/http.ts @@ -0,0 +1,127 @@ +// 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) => { + // 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; + } + 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..6479234ad3 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,15 @@ 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 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", + publishGraphUpdated?: () => void, ): McpServer { const knowledgeGraphManager = new KnowledgeGraphManager(memoryFilePath); @@ -980,7 +987,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 (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 +1475,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..fad54a0066 100644 --- a/src/sequentialthinking/README.md +++ b/src/sequentialthinking/README.md @@ -218,6 +218,33 @@ 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 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 +{ + "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..9148e687d8 --- /dev/null +++ b/src/sequentialthinking/__tests__/http.test.ts @@ -0,0 +1,268 @@ +// 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 { connect as connectTcp } from "node:net"; +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 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); + 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..81c81f782f --- /dev/null +++ b/src/sequentialthinking/http.ts @@ -0,0 +1,122 @@ +// 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) => { + // 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; + } + 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..877551eff9 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. 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 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..a1d7064e43 --- /dev/null +++ b/src/time/src/mcp_server_time/transport.py @@ -0,0 +1,89 @@ +"""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. + +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" +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) + + +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() + 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; + # `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..b23866dcac --- /dev/null +++ b/src/time/tests/test_http.py @@ -0,0 +1,186 @@ +# 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 typing import Any +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, + LocalhostOriginMiddleware, + http_endpoint, + origin_allowed, +) + +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 + + +@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"]