Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/576-streamable-http.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions .claude/skills/local-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions .claude/skills/project-structure/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -69,6 +69,7 @@ src/<server>/
├── src/mcp_server_<name>/
│ ├── __init__.py main(): the command-line flags, then asyncio.run(serve(...))
│ ├── __main__.py python -m mcp_server_<name>
│ ├── transport.py --transport: stdio, or Streamable HTTP on $HOST:$PORT
│ └── server.py everything else
└── tests/ pytest suite, driving serve() in-process
```
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion docs/quality-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 20 additions & 14 deletions scripts/smoke-servers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -122,22 +123,22 @@ 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),
},
{
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: {
Expand All @@ -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"],
Expand All @@ -167,22 +168,22 @@ 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 } }),
},
{
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. */
Expand Down Expand Up @@ -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) ?? [])];
}

Expand Down
18 changes: 14 additions & 4 deletions scripts/smoke-servers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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", () => {
Expand All @@ -73,6 +76,7 @@ test("selectTargets: named servers only, in the table's order", () => {
"everything/sse",
"everything/streamableHttp",
"time/stdio",
"time/streamableHttp",
],
);
});
Expand Down Expand Up @@ -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);
Expand Down
25 changes: 25 additions & 0 deletions src/fetch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions src/fetch/src/mcp_server_fetch/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
parse_timeout_ms,
timeout_ms_from_env,
)
from .transport import TRANSPORTS, http_endpoint


def main():
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -71,6 +85,7 @@ def main():
args.allow_private_ips,
args.allowed_hosts,
not args.no_readability_js,
args.transport,
)
)

Expand Down
Loading
Loading