From 8ad79e43838f3cdeb68e11b5a77c5a5dac02b92a Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 15 Jun 2026 17:26:49 -0700 Subject: [PATCH 01/15] docs: add stateless HTTP transport mode design spec Design for an opt-in stateless HTTP transport (MISTMCP_STATELESS / --stateless) so an already-connected MCP client survives a server restart. Port of the private-fork feature, adapted to this repo (no URL mode / webui / Mongo). Centralizes write+write_delete build-time visibility and adds a request-scoped elicitation bypass for the stateless DANGER-ZONE path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-15-stateless-http-mode-design.md | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-15-stateless-http-mode-design.md diff --git a/docs/superpowers/specs/2026-06-15-stateless-http-mode-design.md b/docs/superpowers/specs/2026-06-15-stateless-http-mode-design.md new file mode 100644 index 0000000..5afa8cd --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-stateless-http-mode-design.md @@ -0,0 +1,402 @@ +# Stateless HTTP transport mode — design + +- **Date:** 2026-06-15 +- **Status:** Approved (design) +- **Author:** Thomas Munzer (port assisted by Claude) +- **Repo:** `tmunzer/mistmcp` (public) + +## 1. Summary + +Add an **opt-in** "stateless HTTP" transport. When enabled (`MISTMCP_STATELESS=true` +or `--stateless`) together with `--transport http`, the server is served via +`mcp_server.http_app(stateless_http=True)` behind `uvicorn`, so the MCP SDK builds a +**fresh transport per request** with no server-side session to go stale. The practical +benefit: an already-connected MCP client **survives a server restart** without +reconnecting (no session id to invalidate). + +This is a **port** of a feature already shipped on a private fork +(`tmunzer-AIDE/mistmcp-private` PR #15). The port keeps only the parts that fit this +repo; fork-only machinery (URL elicitation mode, web UI, MongoDB, retention sweeper, +push notifier) is dropped because none of it exists here. + +### Design principles + +1. **Opt-in and additive.** Default off ⇒ runtime behavior is unchanged. +2. **Behavior-neutral centralization.** Moving write-tool visibility to build time must + not change what any *stateful* session ultimately sees. +3. **No in-band elicitation in stateless.** Stateless has no live session and no + server→client channel, so the `ctx.elicit()` handshake cannot work. The design + refuses exactly the one config that would require it, and otherwise fails closed. + +## 2. Background / current behavior + +- `ServerConfig` (`src/mistmcp/config.py`) holds `transport_mode` (`"stdio"`/`"http"`), + `enable_write_tools`, `disable_elicitation`, `response_format`, `debug`, `log_file`. + There is **no** `elicitation_mode`, no URL mode, no web UI, no MongoDB. +- `__main__.py` provides `start()`, `load_env_var()` (returns an 8-tuple), and `main()` + (argparse). HTTP is served today via `mcp_server.run(transport="http", host, port)`. +- `server.py` builds a module-level singleton `mcp = FastMCP(...)` and, at module scope, + runs `mcp.add_transform(Visibility(False, tags={"write"}, components={"tool"}))`. + `create_mcp_server(config)` loads tools and returns that singleton. +- `ElicitationMiddleware` (`elicitation_middleware.py`) has **only** `on_initialize`. It + resolves write-tool visibility **per session**: it always ends in an + `if/elif/else` that explicitly enables/disables `write` and `write_delete`, and it + records `disable_elicitation` session state via `ctx.set_state(...)`. +- `config_elicitation_handler` (`elicitation_processor.py`) reads + `await ctx.get_state("disable_elicitation")`; if `True` it auto-accepts, otherwise it + calls `ctx.elicit(...)` (requires a live session). + +### Tool / tag inventory (verified) + +Each of the three write-ish tools carries exactly one tag: + +| Tag | Tool | Build-time visibility today | How a mutation is gated today | +|---|---|---|---| +| `write` | `mist_update_configuration_objects` | **hidden** (server.py:200) | shown per session by middleware when write enabled | +| `write_delete` | `mist_change_configuration_objects` (incl. DELETE) | **visible** | hidden per session by middleware unless `?experimental=true`; mutation always elicits | +| `utilities_upgrade` | `mist_upgrades` | **visible, never touched** | mutating actions elicit only — **no `enable_write_tools` check** | +| `utilities` | `mist_utilities` | visible (`utilities` tag) | mutating utilities hard-gated by `enable_write_tools` (`utilities.py:838`) **and** then elicit | + +The asymmetry between `mist_upgrades` (elicitation-only) and `mist_utilities` +(`enable_write_tools` + elicitation) is intentional existing behavior and is **preserved**. + +### Why this is the crux of the port + +In stateless mode `on_initialize` **does not fire** (fresh transport per request), so the +middleware's per-session visibility resolution never runs. Therefore **build-time +visibility is final** in stateless mode. Today only `write` is hidden at build time, which +means in naive stateless: + +- `write_delete` (the DELETE tool) would be **visible** in a read-only session, and +- worse, in the DANGER-ZONE config (see §5.4) `on_call_tool` sets `disable_elicitation`, + so a visible `write_delete` would **auto-accept** — escalating beyond stateful + DANGER-ZONE behavior, where `write_delete` is explicitly hidden. + +Hence folding `write_delete` into the build-time resolver is **required**, not optional. + +## 3. Tech / version compatibility (verified in this repo) + +- `fastmcp 3.4.2` — `FastMCP.http_app(..., stateless_http: bool | None = None, ...)` is + supported; `http_app(stateless_http=True)` builds a `StarletteWithLifespan` app. +- `uvicorn 0.49.0` — `ws="websockets-sansio"` is a valid choice + (`auto|none|websockets|websockets-sansio|wsproto`). +- `Context.set_state(key, value, *, serializable: bool = True)` — `serializable=False` + (request-scoped state) is supported. +- `mcp 1.27.2`. No `pymongo`, no web UI, no URL mode. +- Tests: `pytest`, `asyncio_mode=auto`, coverage floor 30%. CI runs on Python + 3.10–3.13. No Mongo-absent CI gate is needed (there is no Mongo here). + +## 4. Scope + +### In scope + +- `stateless` config field + `validate_stateless_config()` gate. +- `MISTMCP_STATELESS` env + `--stateless` CLI flag, threaded through + `load_env_var()`/`main()`/`start()`. +- **New:** `MISTMCP_DISABLE_ELICITATION` env path (so env-only deployments can reach the + DANGER-ZONE combo that stateless writes require — see §5.2). +- Stateless launch path (`_run_stateless_http`) using `http_app(stateless_http=True)` + + `uvicorn.run(...)`. Non-stateless paths unchanged. +- Build-time write-tool visibility centralization for `write` + `write_delete` into + `create_mcp_server()`. +- `ElicitationMiddleware.on_call_tool` to set request-scoped `disable_elicitation` in the + stateless DANGER-ZONE path. +- Observability log; README + `.env.example` docs. +- Tests. + +### Out of scope (dropped fork-only machinery) + +- URL elicitation mode, `_ensure_started_safe`, MongoDB, retention sweeper, push + notifier and notifier-skip logic — none exist in this repo. +- Gating `mist_upgrades`/`mist_utilities` visibility (they are mixed read/write tools; + hiding them would remove valid read/list behavior). Their mutation gating is unchanged. + +## 5. Detailed design + +### 5.1 Config + validation (`config.py`) + +Add to `ServerConfig.__init__`: + +```python +stateless: bool = False, +... +self.stateless = stateless +``` + +Add a dedicated exception and a pure validator: + +```python +class ConfigurationError(Exception): + """Raised when the server configuration is invalid and startup must be refused.""" + + +def validate_stateless_config(config: ServerConfig) -> None: + """Refuse stateless when it collides with in-band elicitation. + + Stateless HTTP has no live session and no server->client channel, so the + ctx.elicit() handshake cannot work. The only config that *needs* that handshake + is: write tools enabled over HTTP without disable_elicitation. Everything else + (read-only HTTP, write + disable_elicitation, stdio) is stateless-safe. + """ + if not config.stateless: + return + if ( + config.transport_mode == "http" + and config.enable_write_tools + and not config.disable_elicitation + ): + raise ConfigurationError( + "Stateless HTTP mode is incompatible with in-band elicitation " + "(write tools enabled without disable_elicitation), which needs a live " + "session. Add --disable-elicitation / MISTMCP_DISABLE_ELICITATION=true, " + "drop --enable-write-tools, or unset --stateless / MISTMCP_STATELESS." + ) +``` + +(No `elicitation_mode` term — simplified gate, as this repo has no URL mode.) + +### 5.2 Env / CLI threading (`__main__.py`) + +**`load_env_var`** — add a `stateless` parameter and a `MISTMCP_STATELESS` parse, and +add the new `MISTMCP_DISABLE_ELICITATION` parse (mirroring the existing +`MISTMCP_ENABLE_WRITE_TOOLS` idiom so the CLI flag is the default and the env can +override). Return becomes a **9-tuple** (adds `stateless`). + +```python +env_stateless = os.getenv("MISTMCP_STATELESS", str(stateless)) +stateless = env_stateless.lower() in ("true", "1", "yes") + +env_disable_elicitation = os.getenv("MISTMCP_DISABLE_ELICITATION", str(disable_elicitation)) +disable_elicitation = env_disable_elicitation.lower() in ("true", "1", "yes") +``` + +**`main()`** — add the CLI flag and thread `args.stateless` into `load_env_var`/`start`: + +```python +parser.add_argument( + "--stateless", + action="store_true", + help="Serve HTTP statelessly (fresh transport per request) so the MCP client " + "survives a server restart. HTTP only; incompatible with in-band elicitation. " + "Loses server->client push.", +) +``` + +`main()` converts a refused config into a **non-zero exit** (see §5.7 fatality): + +```python +try: + start(transport_mode, mcp_host, mcp_port, debug, enable_write_tools, + disable_elicitation, response_format, log_file, stateless) +except ConfigurationError as exc: + logger.error("Invalid configuration: %s", exc) + raise SystemExit(2) +``` + +**`start()`** — add a `stateless: bool = False` parameter; set `config.stateless`; add the +transport guard; validate **before** the broad `try`; log the active mode: + +```python +config.stateless = stateless + +# stateless only applies to http +if config.stateless and transport_mode != "http": + logger.warning( + "MISTMCP_STATELESS / --stateless is set but transport is %s; stateless " + "applies only to http — ignoring.", transport_mode) + config.stateless = False + +# Refuse incompatible config BEFORE the broad try below, so it cannot be swallowed. +validate_stateless_config(config) + +if config.stateless: # implies http + logger.info( + "Stateless HTTP mode active: fresh transport per request, so an " + "already-connected MCP client survives a server restart. Server->client " + "push (notifications/elicitation) is disabled; in-band elicitation is " + "unavailable, so destructive utility/upgrade actions require " + "disable_elicitation (DANGER ZONE) or are refused.") +``` + +### 5.3 Launch path (`__main__.py`) + +Keep stdio and non-stateless HTTP **byte-for-byte**; add only the stateless branch: + +```python +if transport_mode == "http": + if config.stateless: + _run_stateless_http(mcp_server, mcp_host, mcp_port) + else: + mcp_server.run(transport="http", host=mcp_host, port=mcp_port) # unchanged +else: + mcp_server.run() # unchanged +``` + +```python +def _run_stateless_http(mcp_server, host: str, port: int) -> None: + """Serve via http_app(stateless_http=True): the SDK builds a fresh transport per + request, so there is no session id to go stale on a server restart. We pass no + event_store; in stateless mode the SDK's per-request transport uses + event_store=None regardless (the resumable GET stream is dropped).""" + import uvicorn + + app = mcp_server.http_app(stateless_http=True) + uvicorn.run(app, host=host, port=port, lifespan="on", + timeout_graceful_shutdown=2, ws="websockets-sansio") +``` + +`http_app()` and `run(transport="http")` use the same default mount path, so the +client-facing URL path is unchanged between stateless and non-stateless. + +### 5.4 Build-time write visibility (`server.py`) + +Remove the module-scope transform (current line 200) and resolve visibility from config +inside `create_mcp_server()`: + +```python +_PROTECTED_WRITE_TAGS = {"write", "write_delete"} + + +def _write_visible_tags(config: ServerConfig) -> set[str]: + """Protected write tags that should be visible at build time for this config. + Authoritative in stateless mode; a behavior-neutral floor in stateful mode, where + ElicitationMiddleware.on_initialize re-resolves write/write_delete per session.""" + if config.enable_write_tools and config.disable_elicitation: + return {"write"} # DANGER ZONE: update only, never write_delete + return set() # read-only / elicitation-capable: hide both + + +def _configure_write_visibility(mcp_server: FastMCP, config: ServerConfig) -> None: + """Install a deterministic hide-all-then-show-visible sequence for the protected + write tags. FastMCP Visibility marks are later-wins, so the EFFECTIVE visibility + equals this call's resolution even when called repeatedly on the reused module + singleton. (The transform list itself grows by 1-2 entries per call; create_mcp_server + runs once per process, so this is bounded. There is no per-transform removal API for + Visibility, and effective-visibility idempotency is sufficient for correctness.)""" + visible = _write_visible_tags(config) + mcp_server.add_transform( + Visibility(False, tags=_PROTECTED_WRITE_TAGS, components={"tool"})) + if visible: + mcp_server.add_transform( + Visibility(True, tags=visible, components={"tool"})) +``` + +Call it at the end of `create_mcp_server`: + +```python +def create_mcp_server(config: ServerConfig) -> FastMCP: + enabled_tools = _load_tools(config) + _configure_write_visibility(mcp, config) + logger.debug("MCP Server ready with %d tools", len(enabled_tools)) + return mcp +``` + +**Why behavior-neutral in stateful mode:** `on_initialize` always re-resolves both +`write` and `write_delete` per session (every branch ends in the explicit +enable/disable), and per-session `enable_components`/`disable_components` override the +server-level transform (this is exactly how `write` already works today). `utilities` and +`utilities_upgrade` are never touched by the resolver, so they remain always-visible. + +### 5.5 Request-scoped elicitation state (`elicitation_middleware.py`) + +Add an `on_call_tool` handler so the DANGER-ZONE auto-accept works in stateless, where +`on_initialize` never set the session state: + +```python +async def on_call_tool(self, context, call_next): + ctx = context.fastmcp_context + if ( + config.stateless + and config.transport_mode == "http" + and config.enable_write_tools + and config.disable_elicitation + and ctx is not None + ): + # on_initialize does not fire in stateless. Set the flag request-scoped so + # config_elicitation_handler auto-accepts for this call only, without leaking + # into the session store (per-request sessions in stateless are discarded). + await ctx.set_state("disable_elicitation", True, serializable=False) + return await call_next(context) +``` + +Gated on `config.stateless` so the stateful code path is literally unchanged (stateful +DANGER ZONE already sets the flag in `on_initialize`). + +### 5.6 Observability + +The INFO log in §5.2 is emitted once at startup when stateless+http is active. No +per-request logging is added. + +### 5.7 Fatality of config errors + +`validate_stateless_config()` is called in `start()` **before** the broad +`try/except Exception` that wraps `create_mcp_server`/`run`, so a `ConfigurationError` +propagates out of `start()` rather than being logged-and-swallowed. `main()` catches +`ConfigurationError`, logs a clear message, and exits with status `2`. An invalid server +config therefore **never** results in a silent successful return. + +## 6. Visibility & behavior matrix + +Tools: **W** = `mist_update_configuration_objects` (`write`), +**WD** = `mist_change_configuration_objects` (`write_delete`), +**UP** = `mist_upgrades` (`utilities_upgrade`), +**UT** = `mist_utilities` (`utilities`). "elicit" = prompts the client; "auto" = +auto-accept; "fail-closed" = mutation refused with a clean `ToolError`. + +| Scenario | W | WD | UP | UT | Mutation behavior | +|---|---|---|---|---|---| +| **Stateful normal** (read-only) | hidden | hidden | visible | visible | UT mutating hard-blocked (`enable_write_tools=False`); UP mutating elicits (fails if client lacks elicitation) | +| **Stateful elicitation-capable** (write, client supports elicit) | visible | hidden | visible | visible | W/UP/UT mutating **elicit** (user prompted) | +| **Stateful DANGER** (write + disable_elicitation) | visible | hidden | visible | visible | W/UP/UT mutating **auto** (session state set in on_initialize) | +| **Stateful experimental** (`?experimental=true`) | hidden | visible | visible | visible | WD/UP/UT mutating **auto** | +| **Stateless read-only** (no write; gate passes) | hidden | hidden | visible | visible | UT mutating hard-blocked; UP mutating **fail-closed** (no session ⇒ elicit raises ⇒ ToolError); WD not listed | +| **Stateless DANGER** (write + disable_elicitation; gate passes) | visible | hidden | visible | visible | W/UP/UT mutating **auto** (request-scoped state set in on_call_tool); WD hidden ⇒ no delete | + +Note: **stateless + http + write + NOT disable_elicitation** is **refused at startup** +(§5.1) and so has no row. + +Each stateless row's W/WD column matches its stateful counterpart's *final* (post- +initialize) state — that is the behavior-neutrality the centralization preserves. + +## 7. Accepted trade-offs + +- **No server→client push in stateless.** `stateless_http=True` drops the GET route, so + notifications and in-band elicitation are unavailable. Accepted. +- **Destructive actions in stateless read-only fail closed.** `mist_upgrades` mutating + actions return a clean `ToolError` (elicitation unavailable) rather than executing; + `mist_utilities` mutating actions are hard-blocked by `enable_write_tools`. This is the + safe default. +- **Write in stateless requires DANGER ZONE.** The only way to perform writes in stateless + is `enable_write_tools=True` + `disable_elicitation=True` (auto-accept). This is explicit + and logged loudly. + +## 8. Testing strategy + +`pytest`, `asyncio_mode=auto`. New/extended tests (all pure — no network, no Mongo): + +- **Config / validation** (`test_config*.py`): `validate_stateless_config` refusal matrix — + refuse only `stateless+http+enable_write_tools+not disable_elicitation`; pass for stdio, + read-only, and write+disable_elicitation. `stateless` defaults to `False`. +- **Env / CLI / start** (`test_main.py`): `MISTMCP_STATELESS` and + `MISTMCP_DISABLE_ELICITATION` truthy/falsy parsing; `--stateless` flag; `load_env_var` + 9-tuple; `start()` sets `config.stateless`; stdio downgrade warning; `start()` raises + `ConfigurationError` (not swallowed) on the refused combo; `main()` exits non-zero. +- **Visibility** (`test_server.py`): `_write_visible_tags` matrix (read-only ⇒ `∅`; + DANGER ⇒ `{write}`); `_configure_write_visibility` hides `{write, write_delete}` and + shows the resolved set; **effective-visibility idempotency** — call twice with different + configs on the singleton and assert the last config wins; confirm `utilities`/ + `utilities_upgrade` are untouched. +- **Launch** (`test_main.py` or new): mock `http_app` + `uvicorn.run`; assert `start()` + selects `_run_stateless_http` for http+stateless and `mcp_server.run(...)` for + http+non-stateless; assert `http_app(stateless_http=True)` is called with **no** + `event_store` kwarg; assert uvicorn args `(host, port, lifespan="on", + timeout_graceful_shutdown=2, ws="websockets-sansio")`. +- **Middleware** (`test_elicitation_middleware.py`): `on_call_tool` sets request-scoped + (`serializable=False`) `disable_elicitation` only in stateless DANGER ZONE; no-op + otherwise; existing `on_initialize` tests stay green under the new build-time floor. + +## 9. Process + +Spec → implementation plan (`writing-plans`) → subagent-driven TDD with two-stage review → +PR to `tmunzer/mistmcp` `main`. Default-off behavior must remain unchanged; verify with the +existing suite plus the new tests. From fc01b1cecd9b4c6400b0aaed324973d3b1f7a205 Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 15 Jun 2026 17:35:09 -0700 Subject: [PATCH 02/15] =?UTF-8?q?docs:=20address=20spec=20review=20?= =?UTF-8?q?=E2=80=94=20deterministic=20elicitation=20guard=20+=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [P2] Add §5.8 fail-closed guard in config_elicitation_handler: raise ElicitationUnavailableError when stateless+http and state is not set, instead of relying on ctx.elicit() behavior. All 3 call sites already convert it to ToolError. Matrix/trade-offs/tests updated. - [P3] Soften "on_initialize does not fire" -> "cannot be relied on to set state for later tool calls" (SDK starts the per-request session initialized). - [P3] Drop .env.example (absent in this repo); README env tables are canonical. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-15-stateless-http-mode-design.md | 85 ++++++++++++++++--- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/specs/2026-06-15-stateless-http-mode-design.md b/docs/superpowers/specs/2026-06-15-stateless-http-mode-design.md index 5afa8cd..c015ae0 100644 --- a/docs/superpowers/specs/2026-06-15-stateless-http-mode-design.md +++ b/docs/superpowers/specs/2026-06-15-stateless-http-mode-design.md @@ -62,10 +62,12 @@ The asymmetry between `mist_upgrades` (elicitation-only) and `mist_utilities` ### Why this is the crux of the port -In stateless mode `on_initialize` **does not fire** (fresh transport per request), so the -middleware's per-session visibility resolution never runs. Therefore **build-time -visibility is final** in stateless mode. Today only `write` is hidden at build time, which -means in naive stateless: +In stateless mode each request is served by a **fresh transport that the SDK starts +already-initialized**, so `on_initialize` **cannot be relied on to set state for later tool +calls** — any state it records during one request does not carry to a subsequent +tool-call request, and the middleware's per-session visibility resolution does not run for +those calls. Therefore **build-time visibility is final** in stateless mode. Today only +`write` is hidden at build time, which means in naive stateless: - `write_delete` (the DELETE tool) would be **visible** in a read-only session, and - worse, in the DANGER-ZONE config (see §5.4) `on_call_tool` sets `disable_elicitation`, @@ -101,7 +103,10 @@ Hence folding `write_delete` into the build-time resolver is **required**, not o `create_mcp_server()`. - `ElicitationMiddleware.on_call_tool` to set request-scoped `disable_elicitation` in the stateless DANGER-ZONE path. -- Observability log; README + `.env.example` docs. +- A deterministic fail-closed guard in `config_elicitation_handler` so mutating actions + that reach elicitation in stateless cannot hang or behave undefined (see §5.8). +- Observability log; README docs (this repo has no `.env.example`; the README env-var + table is the canonical place — see §5.6). - Tests. ### Out of scope (dropped fork-only machinery) @@ -300,7 +305,7 @@ server-level transform (this is exactly how `write` already works today). `utili ### 5.5 Request-scoped elicitation state (`elicitation_middleware.py`) Add an `on_call_tool` handler so the DANGER-ZONE auto-accept works in stateless, where -`on_initialize` never set the session state: +state set in `on_initialize` does not carry to the tool-call request: ```python async def on_call_tool(self, context, call_next): @@ -312,9 +317,9 @@ async def on_call_tool(self, context, call_next): and config.disable_elicitation and ctx is not None ): - # on_initialize does not fire in stateless. Set the flag request-scoped so - # config_elicitation_handler auto-accepts for this call only, without leaking - # into the session store (per-request sessions in stateless are discarded). + # In stateless, on_initialize state does not carry to this call. Set the flag + # request-scoped so config_elicitation_handler auto-accepts for this call only, + # without leaking into the session store (per-request sessions are discarded). await ctx.set_state("disable_elicitation", True, serializable=False) return await call_next(context) ``` @@ -322,11 +327,56 @@ async def on_call_tool(self, context, call_next): Gated on `config.stateless` so the stateful code path is literally unchanged (stateful DANGER ZONE already sets the flag in `on_initialize`). -### 5.6 Observability +### 5.6 Observability and documentation The INFO log in §5.2 is emitted once at startup when stateless+http is active. No per-request logging is added. +This repo has **no `.env.example`**; the README environment-variable tables (currently +README.md lines ~84–106) are the canonical reference. The README change adds rows for +`MISTMCP_STATELESS` and `MISTMCP_DISABLE_ELICITATION` and a short "Stateless HTTP mode" +subsection covering the restart-survival benefit and the no-push / writes-require-DANGER-ZONE +trade-offs. No new `.env.example` artifact is introduced. + +### 5.8 Deterministic fail-closed elicitation guard (`elicitation_processor.py`) + +The handler must not depend on `ctx.elicit()`'s undefined behavior in stateless (it could +block awaiting a client response that can never be correlated). Add an explicit guard +**after** the state check so any mutating action that reaches elicitation in stateless +fails fast and deterministically: + +```python +from mistmcp.config import config + + +class ElicitationUnavailableError(RuntimeError): + """Raised when elicitation is required but cannot be performed (stateless HTTP has no + server->client channel). The tool wrappers convert this into a clean ToolError.""" + + +async def config_elicitation_handler(message, ctx: Context): + if await ctx.get_state("disable_elicitation") is True: + return ElicitResult(action="accept") + + if config.stateless and config.transport_mode == "http": + # No live session / server->client channel in stateless: in-band elicitation + # cannot complete. Fail closed deterministically instead of calling ctx.elicit(). + raise ElicitationUnavailableError( + "In-band elicitation is unavailable in stateless HTTP mode; this action " + "requires disable_elicitation (DANGER ZONE) or a stateful transport." + ) + + result = await ctx.elicit(message, response_type=None) + ... # unchanged +``` + +All three elicitation call sites already wrap `config_elicitation_handler` in +`try/except Exception` and re-raise as `ToolError` (`upgrades.py` `_confirm_upgrade_write_action`, +`utilities.py` `_confirm_disruptive_utility`, `change_configuration_objects.py`), so the raised +`ElicitationUnavailableError` surfaces to the client as a clean tool error. This guard is +behavior-neutral in stateful mode (`config.stateless` is `False`) and never reached in the +stateless DANGER-ZONE path (the state check returns "accept" first). + ### 5.7 Fatality of config errors `validate_stateless_config()` is called in `start()` **before** the broad @@ -349,7 +399,7 @@ auto-accept; "fail-closed" = mutation refused with a clean `ToolError`. | **Stateful elicitation-capable** (write, client supports elicit) | visible | hidden | visible | visible | W/UP/UT mutating **elicit** (user prompted) | | **Stateful DANGER** (write + disable_elicitation) | visible | hidden | visible | visible | W/UP/UT mutating **auto** (session state set in on_initialize) | | **Stateful experimental** (`?experimental=true`) | hidden | visible | visible | visible | WD/UP/UT mutating **auto** | -| **Stateless read-only** (no write; gate passes) | hidden | hidden | visible | visible | UT mutating hard-blocked; UP mutating **fail-closed** (no session ⇒ elicit raises ⇒ ToolError); WD not listed | +| **Stateless read-only** (no write; gate passes) | hidden | hidden | visible | visible | UT mutating hard-blocked (`enable_write_tools=False`); UP mutating **fail-closed** via the §5.8 guard (ElicitationUnavailableError ⇒ ToolError, deterministic — never calls `ctx.elicit()`); WD not listed | | **Stateless DANGER** (write + disable_elicitation; gate passes) | visible | hidden | visible | visible | W/UP/UT mutating **auto** (request-scoped state set in on_call_tool); WD hidden ⇒ no delete | Note: **stateless + http + write + NOT disable_elicitation** is **refused at startup** @@ -363,9 +413,10 @@ initialize) state — that is the behavior-neutrality the centralization preserv - **No server→client push in stateless.** `stateless_http=True` drops the GET route, so notifications and in-band elicitation are unavailable. Accepted. - **Destructive actions in stateless read-only fail closed.** `mist_upgrades` mutating - actions return a clean `ToolError` (elicitation unavailable) rather than executing; - `mist_utilities` mutating actions are hard-blocked by `enable_write_tools`. This is the - safe default. + actions hit the §5.8 guard and return a clean `ToolError` (elicitation unavailable) + deterministically, rather than relying on `ctx.elicit()` behavior; `mist_utilities` + mutating actions are hard-blocked earlier by `enable_write_tools`. This is the safe + default. - **Write in stateless requires DANGER ZONE.** The only way to perform writes in stateless is `enable_write_tools=True` + `disable_elicitation=True` (auto-accept). This is explicit and logged loudly. @@ -394,6 +445,12 @@ initialize) state — that is the behavior-neutrality the centralization preserv - **Middleware** (`test_elicitation_middleware.py`): `on_call_tool` sets request-scoped (`serializable=False`) `disable_elicitation` only in stateless DANGER ZONE; no-op otherwise; existing `on_initialize` tests stay green under the new build-time floor. +- **Elicitation guard** (`test_elicitation_processor.py` or similar): `config_elicitation_handler` + returns `accept` when `disable_elicitation` state is `True`; raises + `ElicitationUnavailableError` when `config.stateless and config.transport_mode == "http"` + and state is not set (asserting `ctx.elicit` is **not** called); calls `ctx.elicit` normally + in stateful mode. Optionally assert a wrapper (e.g. `_confirm_upgrade_write_action`) converts + the raised error into a `ToolError`. ## 9. Process From b8b44b28e1895c1f635ea5fce16125ca86f9922a Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 15 Jun 2026 17:41:10 -0700 Subject: [PATCH 03/15] =?UTF-8?q?docs:=20spec=20cleanups=20=E2=80=94=20sec?= =?UTF-8?q?tion=20order,=20tool=20count,=20experimental=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renumber: fail-closed guard 5.7, fatality 5.8 (match physical order); fix refs - "three write-ish tools" -> "four tools in the write/mutation surface" - Experimental matrix row labeled "write + ?experimental=true" (branch needs enable_write_tools) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-15-stateless-http-mode-design.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-06-15-stateless-http-mode-design.md b/docs/superpowers/specs/2026-06-15-stateless-http-mode-design.md index c015ae0..c1762ec 100644 --- a/docs/superpowers/specs/2026-06-15-stateless-http-mode-design.md +++ b/docs/superpowers/specs/2026-06-15-stateless-http-mode-design.md @@ -48,7 +48,7 @@ push notifier) is dropped because none of it exists here. ### Tool / tag inventory (verified) -Each of the three write-ish tools carries exactly one tag: +Each of the four tools in the write/mutation surface carries exactly one tag: | Tag | Tool | Build-time visibility today | How a mutation is gated today | |---|---|---|---| @@ -104,7 +104,7 @@ Hence folding `write_delete` into the build-time resolver is **required**, not o - `ElicitationMiddleware.on_call_tool` to set request-scoped `disable_elicitation` in the stateless DANGER-ZONE path. - A deterministic fail-closed guard in `config_elicitation_handler` so mutating actions - that reach elicitation in stateless cannot hang or behave undefined (see §5.8). + that reach elicitation in stateless cannot hang or behave undefined (see §5.7). - Observability log; README docs (this repo has no `.env.example`; the README env-var table is the canonical place — see §5.6). - Tests. @@ -187,7 +187,7 @@ parser.add_argument( ) ``` -`main()` converts a refused config into a **non-zero exit** (see §5.7 fatality): +`main()` converts a refused config into a **non-zero exit** (see §5.8 fatality): ```python try: @@ -338,7 +338,7 @@ README.md lines ~84–106) are the canonical reference. The README change adds r subsection covering the restart-survival benefit and the no-push / writes-require-DANGER-ZONE trade-offs. No new `.env.example` artifact is introduced. -### 5.8 Deterministic fail-closed elicitation guard (`elicitation_processor.py`) +### 5.7 Deterministic fail-closed elicitation guard (`elicitation_processor.py`) The handler must not depend on `ctx.elicit()`'s undefined behavior in stateless (it could block awaiting a client response that can never be correlated). Add an explicit guard @@ -377,7 +377,7 @@ All three elicitation call sites already wrap `config_elicitation_handler` in behavior-neutral in stateful mode (`config.stateless` is `False`) and never reached in the stateless DANGER-ZONE path (the state check returns "accept" first). -### 5.7 Fatality of config errors +### 5.8 Fatality of config errors `validate_stateless_config()` is called in `start()` **before** the broad `try/except Exception` that wraps `create_mcp_server`/`run`, so a `ConfigurationError` @@ -398,8 +398,8 @@ auto-accept; "fail-closed" = mutation refused with a clean `ToolError`. | **Stateful normal** (read-only) | hidden | hidden | visible | visible | UT mutating hard-blocked (`enable_write_tools=False`); UP mutating elicits (fails if client lacks elicitation) | | **Stateful elicitation-capable** (write, client supports elicit) | visible | hidden | visible | visible | W/UP/UT mutating **elicit** (user prompted) | | **Stateful DANGER** (write + disable_elicitation) | visible | hidden | visible | visible | W/UP/UT mutating **auto** (session state set in on_initialize) | -| **Stateful experimental** (`?experimental=true`) | hidden | visible | visible | visible | WD/UP/UT mutating **auto** | -| **Stateless read-only** (no write; gate passes) | hidden | hidden | visible | visible | UT mutating hard-blocked (`enable_write_tools=False`); UP mutating **fail-closed** via the §5.8 guard (ElicitationUnavailableError ⇒ ToolError, deterministic — never calls `ctx.elicit()`); WD not listed | +| **Stateful experimental** (write + `?experimental=true`) | hidden | visible | visible | visible | WD/UP/UT mutating **auto** | +| **Stateless read-only** (no write; gate passes) | hidden | hidden | visible | visible | UT mutating hard-blocked (`enable_write_tools=False`); UP mutating **fail-closed** via the §5.7 guard (ElicitationUnavailableError ⇒ ToolError, deterministic — never calls `ctx.elicit()`); WD not listed | | **Stateless DANGER** (write + disable_elicitation; gate passes) | visible | hidden | visible | visible | W/UP/UT mutating **auto** (request-scoped state set in on_call_tool); WD hidden ⇒ no delete | Note: **stateless + http + write + NOT disable_elicitation** is **refused at startup** @@ -413,7 +413,7 @@ initialize) state — that is the behavior-neutrality the centralization preserv - **No server→client push in stateless.** `stateless_http=True` drops the GET route, so notifications and in-band elicitation are unavailable. Accepted. - **Destructive actions in stateless read-only fail closed.** `mist_upgrades` mutating - actions hit the §5.8 guard and return a clean `ToolError` (elicitation unavailable) + actions hit the §5.7 guard and return a clean `ToolError` (elicitation unavailable) deterministically, rather than relying on `ctx.elicit()` behavior; `mist_utilities` mutating actions are hard-blocked earlier by `enable_write_tools`. This is the safe default. From ed268e3c6f26bcec7a45c666cd0c48f7a97fe6ea Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 15 Jun 2026 18:19:50 -0700 Subject: [PATCH 04/15] docs: add stateless HTTP mode implementation plan 7-task TDD plan (config+validation, elicitation guard, build-time visibility, on_call_tool, start()+launch, env+main wiring, README). Task order keeps the full suite green at every step. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-15-stateless-http-mode.md | 1149 +++++++++++++++++ 1 file changed, 1149 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-stateless-http-mode.md diff --git a/docs/superpowers/plans/2026-06-15-stateless-http-mode.md b/docs/superpowers/plans/2026-06-15-stateless-http-mode.md new file mode 100644 index 0000000..c09c23d --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-stateless-http-mode.md @@ -0,0 +1,1149 @@ +# Stateless HTTP Transport Mode Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an opt-in stateless HTTP transport (`MISTMCP_STATELESS` / `--stateless`) so an already-connected MCP client survives a server restart, with default-off behavior byte-for-byte unchanged. + +**Architecture:** When `stateless` + `transport=http`, serve via `mcp_server.http_app(stateless_http=True)` under `uvicorn` (fresh transport per request, no session). Because `on_initialize` state does not carry across stateless requests, write-tool visibility is resolved at build time in `create_mcp_server()`, the DANGER-ZONE elicitation bypass is set request-scoped in `on_call_tool`, and `config_elicitation_handler` fails closed in stateless. A startup gate refuses the one combo that needs in-band elicitation. + +**Tech Stack:** Python 3.10–3.13, fastmcp 3.4.2, mcp 1.27.2, uvicorn 0.49.0, pytest (`asyncio_mode=auto`). + +**Reference spec:** `docs/superpowers/specs/2026-06-15-stateless-http-mode-design.md` + +--- + +## Conventions for every task + +- **Run a single test** (avoids the repo-wide `--cov-fail-under=30` gate firing on a partial run): + `uv run python -m pytest :::: -v --no-cov` +- **Run a whole new/edited test file:** `uv run python -m pytest -v --no-cov` +- **Final full suite** (Task 7 / end): `uv run python -m pytest` (coverage gate applies). +- **Lint after code changes:** `uv run ruff format ` then `uv run ruff check `. +- Every commit message ends with the trailer: + `Co-Authored-By: Claude Opus 4.8 (1M context) ` +- The global `config` singleton (`mistmcp.config.config`) leaks across tests. New tests either + use a **fresh `ServerConfig(...)`**, `monkeypatch.setattr(config, ...)` (auto-reverts), or + explicitly reset the field they set. Follow the pattern shown in each task. +- **Task order matters.** `start()` gains its `stateless` param (Task 5) *before* the coupled + `load_env_var`/`main()` rewrite (Task 6); each intermediate state keeps the full suite green. + +## File map + +| File | Change | +|---|---| +| `src/mistmcp/config.py` | Add `stateless` field; add `ConfigurationError`; add `validate_stateless_config()` | +| `src/mistmcp/elicitation_processor.py` | Add `ElicitationUnavailableError`; import `config`; add stateless fail-closed guard | +| `src/mistmcp/server.py` | Remove module-level write transform; add `_PROTECTED_WRITE_TAGS`, `_write_visible_tags()`, `_configure_write_visibility()`; call it in `create_mcp_server()` | +| `src/mistmcp/elicitation_middleware.py` | Add `on_call_tool` (request-scoped DANGER-ZONE bypass) | +| `src/mistmcp/__main__.py` | `start()` threading/guard/validate/log + launch branch + `_run_stateless_http()`; `load_env_var` env parse + 9-tuple; `main()` CLI flag + 9-tuple + `ConfigurationError`→exit(2) | +| `README.md` | `--stateless` option; `MISTMCP_STATELESS` / `MISTMCP_DISABLE_ELICITATION` rows; "Stateless HTTP mode" subsection | +| `tests/test_config.py` | New `TestStatelessConfig`, `TestValidateStatelessConfig` | +| `tests/test_elicitation_processor.py` | **New file** — guard tests | +| `tests/test_server.py` | New `TestWriteVisibleTags`, `TestConfigureWriteVisibility` | +| `tests/test_elicitation_middleware.py` | Extend `FakeFastMCPContext`; new `on_call_tool` tests | +| `tests/test_main.py` | New stateless start/launch tests (Task 5); main CLI/exit tests + fix 3 assertions (Task 6) | +| `tests/test_env_loading.py` | New stateless/disable-elicitation parse tests; fix 5 unpackings to 9-tuple (Task 6) | + +--- + +## Task 1: Config field, `ConfigurationError`, and `validate_stateless_config` + +**Files:** +- Modify: `src/mistmcp/config.py` +- Test: `tests/test_config.py` + +- [ ] **Step 1: Write the failing tests** + +Replace the import line at the top of `tests/test_config.py` with: + +```python +import pytest + +from mistmcp.config import ( + ConfigurationError, + ServerConfig, + validate_stateless_config, +) +``` + +Append (after the existing `TestServerConfig` class): + +```python +class TestStatelessConfig: + """Test the stateless config field""" + + def test_stateless_defaults_false(self) -> None: + assert ServerConfig().stateless is False + + def test_stateless_can_be_set(self) -> None: + assert ServerConfig(stateless=True).stateless is True + + +class TestValidateStatelessConfig: + """Test validate_stateless_config refusal matrix""" + + def test_noop_when_not_stateless(self) -> None: + # Otherwise-refused combo, but stateless=False -> never raises + cfg = ServerConfig( + transport_mode="http", enable_write_tools=True, + disable_elicitation=False, stateless=False) + validate_stateless_config(cfg) # must not raise + + def test_refuses_http_write_without_disable(self) -> None: + cfg = ServerConfig( + transport_mode="http", enable_write_tools=True, + disable_elicitation=False, stateless=True) + with pytest.raises(ConfigurationError): + validate_stateless_config(cfg) + + def test_allows_http_write_with_disable(self) -> None: + cfg = ServerConfig( + transport_mode="http", enable_write_tools=True, + disable_elicitation=True, stateless=True) + validate_stateless_config(cfg) # must not raise + + def test_allows_http_readonly(self) -> None: + cfg = ServerConfig( + transport_mode="http", enable_write_tools=False, + disable_elicitation=False, stateless=True) + validate_stateless_config(cfg) # must not raise + + def test_allows_stdio_even_with_write(self) -> None: + cfg = ServerConfig( + transport_mode="stdio", enable_write_tools=True, + disable_elicitation=False, stateless=True) + validate_stateless_config(cfg) # must not raise (combo needs http) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run python -m pytest tests/test_config.py -v --no-cov` +Expected: FAIL — `ImportError: cannot import name 'ConfigurationError'`. + +- [ ] **Step 3: Implement in `src/mistmcp/config.py`** + +Add the `stateless` parameter/attribute to `ServerConfig.__init__` (insert `stateless` after `log_file`): + +```python + def __init__( + self, + transport_mode: str = "stdio", + debug: bool = False, + enable_write_tools: bool = False, + disable_elicitation: bool = False, + response_format: str = "json", + log_file: str | None = None, + stateless: bool = False, + ) -> None: + self.transport_mode: str = transport_mode + self.mist_apitoken: str = "" + self.mist_host: str = "" + self.debug = debug + self.enable_write_tools = enable_write_tools + self.disable_elicitation = disable_elicitation + self.response_format = response_format + self.log_file: str | None = log_file + self.stateless = stateless +``` + +Add, above the `config = ServerConfig()` singleton line: + +```python +class ConfigurationError(Exception): + """Raised when the server configuration is invalid and startup must be refused.""" + + +def validate_stateless_config(config: "ServerConfig") -> None: + """Refuse stateless when it collides with in-band elicitation. + + Stateless HTTP has no live session and no server->client channel, so the + ctx.elicit() handshake cannot work. The only config that needs that handshake is + write tools enabled over HTTP without disable_elicitation. Everything else + (read-only HTTP, write + disable_elicitation, stdio) is stateless-safe. + """ + if not config.stateless: + return + if ( + config.transport_mode == "http" + and config.enable_write_tools + and not config.disable_elicitation + ): + raise ConfigurationError( + "Stateless HTTP mode is incompatible with in-band elicitation " + "(write tools enabled without disable_elicitation), which needs a live " + "session. Add --disable-elicitation / MISTMCP_DISABLE_ELICITATION=true, " + "drop --enable-write-tools, or unset --stateless / MISTMCP_STATELESS." + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run python -m pytest tests/test_config.py -v --no-cov` +Expected: PASS. + +- [ ] **Step 5: Lint and commit** + +```bash +uv run ruff format src/mistmcp/config.py tests/test_config.py +uv run ruff check src/mistmcp/config.py tests/test_config.py +git add src/mistmcp/config.py tests/test_config.py +git commit -m "$(printf 'feat: add stateless config field and validate_stateless_config\n\nCo-Authored-By: Claude Opus 4.8 (1M context) ')" +``` + +--- + +## Task 2: Fail-closed elicitation guard + +**Files:** +- Modify: `src/mistmcp/elicitation_processor.py` +- Test: `tests/test_elicitation_processor.py` (new) + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_elicitation_processor.py`: + +```python +"""Tests for the elicitation handler's stateless fail-closed guard""" + +import pytest + +from mistmcp.config import config +from mistmcp.elicitation_processor import ( + ElicitationUnavailableError, + config_elicitation_handler, +) + + +class FakeCtx: + def __init__(self, state=None, elicit_exc=None) -> None: + self._state = state or {} + self._elicit_exc = elicit_exc + self.elicit_calls: list = [] + + async def get_state(self, key): + return self._state.get(key) + + async def elicit(self, message, response_type=None): + self.elicit_calls.append((message, response_type)) + if self._elicit_exc is not None: + raise self._elicit_exc + return None + + +async def test_auto_accepts_when_state_true(monkeypatch) -> None: + monkeypatch.setattr(config, "stateless", True) + monkeypatch.setattr(config, "transport_mode", "http") + ctx = FakeCtx(state={"disable_elicitation": True}) + + result = await config_elicitation_handler("msg", ctx) + + assert result.action == "accept" + assert ctx.elicit_calls == [] # state check returns before the guard + + +async def test_raises_unavailable_in_stateless_http(monkeypatch) -> None: + monkeypatch.setattr(config, "stateless", True) + monkeypatch.setattr(config, "transport_mode", "http") + ctx = FakeCtx(state={}) # disable_elicitation not set + + with pytest.raises(ElicitationUnavailableError): + await config_elicitation_handler("msg", ctx) + + assert ctx.elicit_calls == [] # guard fired BEFORE ctx.elicit + + +async def test_calls_elicit_in_stateful(monkeypatch) -> None: + monkeypatch.setattr(config, "stateless", False) + monkeypatch.setattr(config, "transport_mode", "http") + sentinel = RuntimeError("elicit-reached") + ctx = FakeCtx(state={}, elicit_exc=sentinel) + + # In stateful mode the guard must NOT fire; ctx.elicit is reached (and here + # raises our sentinel, proving the handler proceeded past the guard). + with pytest.raises(RuntimeError, match="elicit-reached"): + await config_elicitation_handler("msg", ctx) + + assert len(ctx.elicit_calls) == 1 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run python -m pytest tests/test_elicitation_processor.py -v --no-cov` +Expected: FAIL — `ImportError: cannot import name 'ElicitationUnavailableError'`. + +- [ ] **Step 3: Implement in `src/mistmcp/elicitation_processor.py`** + +Add the config import alongside the existing logger import near the top: + +```python +from mistmcp.config import config +from mistmcp.logger import logger +``` + +Add the exception class above `config_elicitation_handler`: + +```python +class ElicitationUnavailableError(RuntimeError): + """Raised when elicitation is required but cannot be performed (stateless HTTP has + no server->client channel). The tool wrappers convert this into a clean ToolError.""" +``` + +Insert the guard in `config_elicitation_handler`, immediately AFTER the `get_state` auto-accept block and BEFORE the `ctx.elicit` call: + +```python +async def config_elicitation_handler(message, ctx: Context): + + if await ctx.get_state("disable_elicitation") is True: + logger.debug( + "Elicitation middleware: elicitation is disabled for this client, automatically accepting without prompting" + ) + return ElicitResult(action="accept") + + if config.stateless and config.transport_mode == "http": + # No live session / server->client channel in stateless: in-band elicitation + # cannot complete. Fail closed deterministically instead of calling ctx.elicit(). + raise ElicitationUnavailableError( + "In-band elicitation is unavailable in stateless HTTP mode; this action " + "requires disable_elicitation (DANGER ZONE) or a stateful transport." + ) + + logger.debug( + "Elicitation middleware: prompting user with message: %s", + message, + ) + result = await ctx.elicit(message, response_type=None) + ... # rest of the function unchanged +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run python -m pytest tests/test_elicitation_processor.py tests/test_elicitation_middleware.py -v --no-cov` +Expected: PASS (new guard tests AND the existing `test_stdio_disable_elicitation_sets_state_and_skips_prompt`, which reaches the auto-accept return before the guard). + +- [ ] **Step 5: Lint and commit** + +```bash +uv run ruff format src/mistmcp/elicitation_processor.py tests/test_elicitation_processor.py +uv run ruff check src/mistmcp/elicitation_processor.py tests/test_elicitation_processor.py +git add src/mistmcp/elicitation_processor.py tests/test_elicitation_processor.py +git commit -m "$(printf 'feat: fail closed in config_elicitation_handler under stateless http\n\nCo-Authored-By: Claude Opus 4.8 (1M context) ')" +``` + +--- + +## Task 3: Build-time write-tool visibility centralization + +**Files:** +- Modify: `src/mistmcp/server.py` +- Test: `tests/test_server.py` + +- [ ] **Step 1: Write the failing tests** + +Replace the imports at the top of `tests/test_server.py` with: + +```python +from unittest.mock import patch + +from fastmcp import FastMCP + +from mistmcp.config import ServerConfig +from mistmcp.server import ( + _PROTECTED_WRITE_TAGS, + _configure_write_visibility, + _write_visible_tags, + create_mcp_server, + mcp, +) +``` + +Append these classes to `tests/test_server.py`: + +```python +def _build_fresh_mcp() -> FastMCP: + m = FastMCP(name="test_visibility") + + @m.tool(name="w_tool", tags={"write"}) + def w_tool() -> str: + return "w" + + @m.tool(name="wd_tool", tags={"write_delete"}) + def wd_tool() -> str: + return "wd" + + @m.tool(name="up_tool", tags={"utilities_upgrade"}) + def up_tool() -> str: + return "up" + + @m.tool(name="read_tool", tags={"info"}) + def read_tool() -> str: + return "r" + + return m + + +async def _visible_names(m: FastMCP) -> set[str]: + tools = await m.list_tools() # public path applies Visibility transforms + return {t.name for t in tools} + + +class TestWriteVisibleTags: + def test_protected_tags_are_write_and_write_delete(self) -> None: + assert _PROTECTED_WRITE_TAGS == {"write", "write_delete"} + + def test_readonly_hides_all(self) -> None: + cfg = ServerConfig(enable_write_tools=False, disable_elicitation=False) + assert _write_visible_tags(cfg) == set() + + def test_danger_zone_shows_write_only(self) -> None: + cfg = ServerConfig(enable_write_tools=True, disable_elicitation=True) + assert _write_visible_tags(cfg) == {"write"} + + def test_write_without_disable_shows_nothing_at_build(self) -> None: + cfg = ServerConfig(enable_write_tools=True, disable_elicitation=False) + assert _write_visible_tags(cfg) == set() + + +class TestConfigureWriteVisibility: + async def test_readonly_hides_write_and_write_delete(self) -> None: + m = _build_fresh_mcp() + _configure_write_visibility(m, ServerConfig(enable_write_tools=False)) + visible = await _visible_names(m) + assert "w_tool" not in visible + assert "wd_tool" not in visible + assert "up_tool" in visible # utilities_upgrade untouched + assert "read_tool" in visible + + async def test_danger_zone_shows_write_hides_write_delete(self) -> None: + m = _build_fresh_mcp() + _configure_write_visibility( + m, ServerConfig(enable_write_tools=True, disable_elicitation=True)) + visible = await _visible_names(m) + assert "w_tool" in visible + assert "wd_tool" not in visible + assert "up_tool" in visible + + async def test_idempotent_last_config_wins(self) -> None: + m = _build_fresh_mcp() + _configure_write_visibility( + m, ServerConfig(enable_write_tools=True, disable_elicitation=True)) + _configure_write_visibility(m, ServerConfig(enable_write_tools=False)) + visible = await _visible_names(m) + assert "w_tool" not in visible + assert "wd_tool" not in visible + + async def test_idempotent_last_config_wins_reverse(self) -> None: + m = _build_fresh_mcp() + _configure_write_visibility(m, ServerConfig(enable_write_tools=False)) + _configure_write_visibility( + m, ServerConfig(enable_write_tools=True, disable_elicitation=True)) + visible = await _visible_names(m) + assert "w_tool" in visible + assert "wd_tool" not in visible +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run python -m pytest tests/test_server.py -v --no-cov` +Expected: FAIL — `ImportError: cannot import name '_PROTECTED_WRITE_TAGS'`. + +- [ ] **Step 3: Implement in `src/mistmcp/server.py`** + +Delete the module-level transform and its comment (currently lines 197–200): + +```python +# Write tools are disabled by default and enabled per-session by +# ElicitationMiddleware during initialization when the client declares +# elicitation support or explicitly sends X-Disable-Elicitation: true. +mcp.add_transform(Visibility(False, tags={"write"}, components={"tool"})) +``` + +Add the resolver functions above `def create_mcp_server`: + +```python +_PROTECTED_WRITE_TAGS = {"write", "write_delete"} + + +def _write_visible_tags(config: ServerConfig) -> set[str]: + """Protected write tags that should be visible at build time for this config. + + Authoritative in stateless mode; a behavior-neutral floor in stateful mode, where + ElicitationMiddleware.on_initialize re-resolves write/write_delete per session. + """ + if config.enable_write_tools and config.disable_elicitation: + return {"write"} # DANGER ZONE: update only, never write_delete + return set() # read-only / elicitation-capable: hide both at build time + + +def _configure_write_visibility(mcp_server: FastMCP, config: ServerConfig) -> None: + """Install a deterministic hide-all-then-show-visible transform sequence for the + protected write tags. FastMCP Visibility marks are later-wins, so the EFFECTIVE + visibility equals this call's resolution even when called repeatedly on the reused + module singleton (the transform list grows by 1-2 entries per call; create_mcp_server + runs once per process).""" + visible = _write_visible_tags(config) + mcp_server.add_transform( + Visibility(False, tags=_PROTECTED_WRITE_TAGS, components={"tool"})) + if visible: + mcp_server.add_transform( + Visibility(True, tags=visible, components={"tool"})) +``` + +Call it inside `create_mcp_server` (after `_load_tools`, before the debug log): + +```python +def create_mcp_server(config: ServerConfig) -> FastMCP: + """Configure and return the MCP server with all tools loaded.""" + enabled_tools = _load_tools(config) + + _configure_write_visibility(mcp, config) + + logger.debug("MCP Server ready with %d tools", len(enabled_tools)) + + return mcp +``` + +(`Visibility` is already imported at `server.py:16`; `ServerConfig` at `server.py:18`.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run python -m pytest tests/test_server.py -v --no-cov` +Expected: PASS (new visibility tests AND the existing `TestMcpInstance`/`TestCreateMcpServer` tests). + +- [ ] **Step 5: Lint and commit** + +```bash +uv run ruff format src/mistmcp/server.py tests/test_server.py +uv run ruff check src/mistmcp/server.py tests/test_server.py +git add src/mistmcp/server.py tests/test_server.py +git commit -m "$(printf 'feat: resolve write-tool visibility at build time in create_mcp_server\n\nCo-Authored-By: Claude Opus 4.8 (1M context) ')" +``` + +--- + +## Task 4: `on_call_tool` request-scoped DANGER-ZONE bypass + +**Files:** +- Modify: `src/mistmcp/elicitation_middleware.py` +- Test: `tests/test_elicitation_middleware.py` + +- [ ] **Step 1: Extend the fake and write the failing tests** + +In `tests/test_elicitation_middleware.py`, replace the `FakeFastMCPContext.__init__` and `set_state` so the fake accepts the `serializable` keyword and records calls (the rest of the class is unchanged): + +```python +class FakeFastMCPContext: + def __init__(self) -> None: + self.state: dict[str, bool] = {} + self.enabled_calls: list[dict[str, set[str]]] = [] + self.disabled_calls: list[dict[str, set[str]]] = [] + self.elicit_calls: list[tuple[str, None]] = [] + self.set_state_calls: list[tuple[str, bool, bool]] = [] + + async def set_state(self, key: str, value: bool, *, serializable: bool = True) -> None: + self.state[key] = value + self.set_state_calls.append((key, value, serializable)) +``` + +Append these tests to the same file: + +```python +async def test_on_call_tool_sets_request_scoped_state_in_stateless_danger( + monkeypatch, +) -> None: + monkeypatch.setattr(config, "stateless", True) + monkeypatch.setattr(config, "transport_mode", "http") + monkeypatch.setattr(config, "enable_write_tools", True) + monkeypatch.setattr(config, "disable_elicitation", True) + + fastmcp_context = FakeFastMCPContext() + context = FakeMiddlewareContext(fastmcp_context) + middleware = ElicitationMiddleware() + + async def call_next(_context): + return "tool-result" + + result = await middleware.on_call_tool(context, call_next) + + assert result == "tool-result" + assert fastmcp_context.state.get("disable_elicitation") is True + # request-scoped: serializable must be False + assert fastmcp_context.set_state_calls == [("disable_elicitation", True, False)] + + +async def test_on_call_tool_noop_when_not_stateless(monkeypatch) -> None: + monkeypatch.setattr(config, "stateless", False) + monkeypatch.setattr(config, "transport_mode", "http") + monkeypatch.setattr(config, "enable_write_tools", True) + monkeypatch.setattr(config, "disable_elicitation", True) + + fastmcp_context = FakeFastMCPContext() + context = FakeMiddlewareContext(fastmcp_context) + middleware = ElicitationMiddleware() + + async def call_next(_context): + return "tool-result" + + result = await middleware.on_call_tool(context, call_next) + + assert result == "tool-result" + assert fastmcp_context.set_state_calls == [] + assert "disable_elicitation" not in fastmcp_context.state +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run python -m pytest tests/test_elicitation_middleware.py -v --no-cov` +Expected: FAIL — `AttributeError: 'ElicitationMiddleware' object has no attribute 'on_call_tool'`. + +- [ ] **Step 3: Implement in `src/mistmcp/elicitation_middleware.py`** + +Add a new method to the `ElicitationMiddleware` class (after `on_initialize`): + +```python + async def on_call_tool(self, context, call_next): + """In stateless HTTP, on_initialize state does not carry to this tool call. + Set the DANGER-ZONE auto-accept flag request-scoped so config_elicitation_handler + accepts for this call only (no leak into the session store). Gated on + config.stateless so the stateful path is literally unchanged.""" + ctx = context.fastmcp_context + if ( + config.stateless + and config.transport_mode == "http" + and config.enable_write_tools + and config.disable_elicitation + and ctx is not None + ): + await ctx.set_state("disable_elicitation", True, serializable=False) + return await call_next(context) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run python -m pytest tests/test_elicitation_middleware.py -v --no-cov` +Expected: PASS (new `on_call_tool` tests AND the existing `on_initialize` test, unaffected by the backward-compatible `set_state` signature). + +- [ ] **Step 5: Lint and commit** + +```bash +uv run ruff format src/mistmcp/elicitation_middleware.py tests/test_elicitation_middleware.py +uv run ruff check src/mistmcp/elicitation_middleware.py tests/test_elicitation_middleware.py +git add src/mistmcp/elicitation_middleware.py tests/test_elicitation_middleware.py +git commit -m "$(printf 'feat: add on_call_tool request-scoped elicitation bypass for stateless danger zone\n\nCo-Authored-By: Claude Opus 4.8 (1M context) ')" +``` + +--- + +## Task 5: `start()` threading + stateless launch path + +**Files:** +- Modify: `src/mistmcp/__main__.py` +- Test: `tests/test_main.py` + +> Why before Task 6: `start()` must accept `stateless` before `main()` can pass it. `main()` and `load_env_var` are left unchanged here, so the full suite stays green (`main()` still calls `start()` with 8 positional args ⇒ `stateless` defaults to `False`). + +- [ ] **Step 1: Write the failing tests** + +Replace the imports at the top of `tests/test_main.py` with: + +```python +from unittest.mock import Mock, patch + +import pytest + +from mistmcp.__main__ import _run_stateless_http, main, start +from mistmcp.config import ConfigurationError, config +``` + +Append this class to `tests/test_main.py`: + +```python +class TestStatelessStart: + """Test stateless threading and launch path in start()""" + + @patch("mistmcp.__main__._run_stateless_http") + @patch("mistmcp.__main__.create_mcp_server") + def test_http_stateless_uses_stateless_launch( + self, mock_create, mock_run_stateless + ) -> None: + mock_server = Mock() + mock_create.return_value = mock_server + + start("http", "127.0.0.1", 8000, enable_write_tools=False, + disable_elicitation=False, stateless=True) + + mock_run_stateless.assert_called_once_with(mock_server, "127.0.0.1", 8000) + mock_server.run.assert_not_called() + config.stateless = False # reset global + + @patch("mistmcp.__main__._run_stateless_http") + @patch("mistmcp.__main__.create_mcp_server") + def test_http_non_stateless_uses_run( + self, mock_create, mock_run_stateless + ) -> None: + mock_server = Mock() + mock_create.return_value = mock_server + + start("http", "127.0.0.1", 8000, stateless=False) + + mock_run_stateless.assert_not_called() + mock_server.run.assert_called_once_with( + transport="http", host="127.0.0.1", port=8000) + + @patch("mistmcp.__main__.create_mcp_server") + def test_stateless_stdio_downgrades_with_warning( + self, mock_create, capsys + ) -> None: + mock_server = Mock() + mock_create.return_value = mock_server + + start("stdio", "127.0.0.1", 8000, stateless=True) + + captured = capsys.readouterr() + assert "stateless applies only to http" in captured.err + mock_server.run.assert_called_once_with() + config.stateless = False # reset global + + def test_start_does_not_swallow_config_error(self) -> None: + with pytest.raises(ConfigurationError): + start("http", "127.0.0.1", 8000, enable_write_tools=True, + disable_elicitation=False, stateless=True) + config.stateless = False # reset global + + @patch("uvicorn.run") + def test_run_stateless_http_builds_stateless_app(self, mock_uvicorn_run) -> None: + mock_server = Mock() + app = mock_server.http_app.return_value + + _run_stateless_http(mock_server, "0.0.0.0", 9000) + + # no event_store kwarg — only stateless_http=True + mock_server.http_app.assert_called_once_with(stateless_http=True) + mock_uvicorn_run.assert_called_once_with( + app, host="0.0.0.0", port=9000, lifespan="on", + timeout_graceful_shutdown=2, ws="websockets-sansio") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run python -m pytest tests/test_main.py::TestStatelessStart -v --no-cov` +Expected: FAIL — `ImportError: cannot import name '_run_stateless_http'`. + +- [ ] **Step 3: Implement in `src/mistmcp/__main__.py`** + +Update the config import at the top of the file (do NOT add `ConfigurationError` yet — it is unused until Task 6 and ruff will flag it): + +```python +from mistmcp.config import config, validate_stateless_config +``` + +Add the launch helper directly above `def start(`: + +```python +def _run_stateless_http(mcp_server, host: str, port: int) -> None: + """Serve via http_app(stateless_http=True): the SDK builds a fresh transport per + request, so there is no session id to go stale on a server restart. We pass no + event_store; in stateless mode the SDK's per-request transport uses event_store=None + regardless (the resumable GET stream is dropped).""" + import uvicorn + + app = mcp_server.http_app(stateless_http=True) + uvicorn.run( + app, + host=host, + port=port, + lifespan="on", + timeout_graceful_shutdown=2, + ws="websockets-sansio", + ) +``` + +Replace `start()` with this full version (adds the `stateless` param, downgrade guard, validation, INFO log, and launch branch): + +```python +def start( + transport_mode: str, + mcp_host: str, + mcp_port: int, + debug: bool = False, + enable_write_tools: bool = False, + disable_elicitation: bool = False, + response_format: str = "json", + log_file: str | None = None, + stateless: bool = False, +) -> None: + # Update global config + config.transport_mode = transport_mode + config.debug = debug + config.enable_write_tools = enable_write_tools + config.disable_elicitation = disable_elicitation + config.response_format = response_format + config.log_file = log_file + config.stateless = stateless + + setup_logging(debug=debug, log_file=log_file) + + # stateless only applies to http + if config.stateless and transport_mode != "http": + logger.warning( + "MISTMCP_STATELESS / --stateless is set but transport is %s; stateless " + "applies only to http — ignoring.", + transport_mode, + ) + config.stateless = False + + # Refuse incompatible config BEFORE the broad try below, so it cannot be swallowed. + validate_stateless_config(config) + + logger.info("Starting Mist MCP Server — transport: %s", transport_mode) + logger.debug(" MIST_HOST: %s", config.mist_host) + logger.debug(" RESPONSE_FORMAT: %s", config.response_format) + logger.debug(" ENABLE_WRITE_TOOLS: %s", config.enable_write_tools) + logger.debug(" DISABLE_ELICITATION: %s", config.disable_elicitation) + if transport_mode == "http": + logger.debug(" MCP_HOST: %s", mcp_host) + logger.debug(" MCP_PORT: %s", mcp_port) + if config.stateless: + logger.info( + "Stateless HTTP mode active: fresh transport per request, so an " + "already-connected MCP client survives a server restart. Server->client " + "push (notifications/elicitation) is disabled; in-band elicitation is " + "unavailable, so destructive utility/upgrade actions require " + "disable_elicitation (DANGER ZONE) or are refused." + ) + + try: + mcp_server = create_mcp_server(config) + + if transport_mode == "http": + if config.stateless: + _run_stateless_http(mcp_server, mcp_host, mcp_port) + else: + mcp_server.run(transport="http", host=mcp_host, port=mcp_port) + else: + mcp_server.run() + + except KeyboardInterrupt: + logger.info("Mist MCP Server stopped by user") + + except Exception as e: + logger.error("Mist MCP Error: %s", e) + if debug: + import traceback + + traceback.print_exc() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run python -m pytest tests/test_main.py -v --no-cov` +Expected: PASS — `TestStatelessStart` passes; existing `TestStart` and `TestMain` tests still pass (`main()` unchanged ⇒ still calls `start()` with 8 positional args; `stateless` defaults `False`; validate is a no-op). + +- [ ] **Step 5: Lint and commit** + +```bash +uv run ruff format src/mistmcp/__main__.py tests/test_main.py +uv run ruff check src/mistmcp/__main__.py tests/test_main.py +git add src/mistmcp/__main__.py tests/test_main.py +git commit -m "$(printf 'feat: thread stateless into start() and add stateless http launch path\n\nCo-Authored-By: Claude Opus 4.8 (1M context) ')" +``` + +--- + +## Task 6: `load_env_var` env parsing + `main()` CLI flag and fatal exit + +These changes are coupled (the 9-tuple, the `--stateless` flag, and `main()`'s 9-unpack/pass-through must land together) and are done as one task. + +**Files:** +- Modify: `src/mistmcp/__main__.py` +- Test: `tests/test_env_loading.py`, `tests/test_main.py` + +- [ ] **Step 1: Write the failing tests AND fix existing assertions/unpackings** + +**In `tests/test_env_loading.py`** — fix the 5 existing `load_env_var` unpackings to a 9-tuple (each gains one trailing `_`): + +- `test_load_env_var_stdio_mode` (~line 91): `..., response_format, _ = load_env_var(` → `..., response_format, _, _ = load_env_var(` +- `test_load_env_var_http_mode` (~line 114): `..., response_format, _ = load_env_var(` → `..., response_format, _, _ = load_env_var(` +- `test_load_env_var_debug_variations` (~line 145): `_, _, _, debug, _, _, _, _ = load_env_var(` → `_, _, _, debug, _, _, _, _, _ = load_env_var(` +- `test_load_env_var_port_parsing` (~line 167): `_, _, mcp_port, _, _, _, _, _ = load_env_var(` → `_, _, mcp_port, _, _, _, _, _, _ = load_env_var(` +- `test_load_env_var_host_and_port_from_env` (~line 181): `_, mcp_host, mcp_port, _, _, _, _, _ = load_env_var(` → `_, mcp_host, mcp_port, _, _, _, _, _, _ = load_env_var(` + +Then append to the `TestLoadEnvVar` class: + +```python + def test_load_env_var_returns_9_tuple(self) -> None: + base_env = {"MIST_APITOKEN": "t", "MIST_HOST": "h"} + with patch.dict(os.environ, base_env, clear=False): + result = load_env_var( + "stdio", None, None, False, False, False, None, None, False) + assert len(result) == 9 + + def test_load_env_var_stateless_parsing(self) -> None: + test_cases = [ + ("true", True), ("TRUE", True), ("1", True), ("yes", True), + ("false", False), ("0", False), ("", False), + ] + base_env = {"MIST_APITOKEN": "t", "MIST_HOST": "h"} + for value, expected in test_cases: + env = {**base_env, "MISTMCP_STATELESS": value} + with patch.dict(os.environ, env, clear=False): + result = load_env_var( + "http", None, None, False, False, False, None, None, False) + assert result[8] == expected, f"Failed for MISTMCP_STATELESS='{value}'" + + def test_load_env_var_disable_elicitation_parsing(self) -> None: + base_env = {"MIST_APITOKEN": "t", "MIST_HOST": "h"} + env = {**base_env, "MISTMCP_DISABLE_ELICITATION": "true"} + with patch.dict(os.environ, env, clear=False): + result = load_env_var( + "stdio", None, None, False, False, False, None, None, False) + assert result[5] is True # disable_elicitation +``` + +**In `tests/test_main.py`** — update the 3 existing `TestMain` assertions to include the trailing `stateless` argument (`False`): + +- `test_main_default_args`: `...("stdio", "127.0.0.1", 8000, False, False, False, "json", None)` → `...("stdio", "127.0.0.1", 8000, False, False, False, "json", None, False)` +- `test_main_with_debug`: `...("stdio", "127.0.0.1", 8000, True, False, False, "json", None)` → `...("stdio", "127.0.0.1", 8000, True, False, False, "json", None, False)` +- `test_main_custom_host_and_port`: `...("http", "0.0.0.0", 9000, False, False, False, "json", None)` → `...("http", "0.0.0.0", 9000, False, False, False, "json", None, False)` + +Then append to the `TestMain` class: + +```python + @patch("mistmcp.__main__.start") + def test_main_stateless_flag(self, mock_start) -> None: + with patch("sys.argv", ["mistmcp", "--transport", "http", "--stateless"]): + main() + mock_start.assert_called_once_with( + "http", "127.0.0.1", 8000, False, False, False, "json", None, True) + + @patch("mistmcp.__main__.start", side_effect=ConfigurationError("bad combo")) + def test_main_exits_2_on_config_error(self, mock_start) -> None: + with patch( + "sys.argv", + ["mistmcp", "--transport", "http", "--stateless", "--enable-write-tools"], + ): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 2 +``` + +(`ConfigurationError` and `pytest` are already imported in `tests/test_main.py` from Task 5.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run python -m pytest tests/test_env_loading.py tests/test_main.py -v --no-cov` +Expected: FAIL — env tests pass 9 args / read `result[8]` (`TypeError`/`IndexError`); `test_main_stateless_flag` hits argparse `SystemExit(2)` on the unknown `--stateless`; updated assertions fail (`main()` still calls `start()` with 8 args). + +- [ ] **Step 3: Implement in `src/mistmcp/__main__.py`** + +Update the config import to add `ConfigurationError`: + +```python +from mistmcp.config import ConfigurationError, config, validate_stateless_config +``` + +Change the `load_env_var` signature (add `stateless` last, update return annotation): + +```python +def load_env_var( + transport_mode: str | None, + mcp_host: str | None, + mcp_port: int | None, + debug: bool, + enable_write_tools: bool, + disable_elicitation: bool, + response_format: str | None, + log_file: str | None, + stateless: bool = False, +) -> tuple[str, str, int, bool, bool, bool, str, str | None, bool]: +``` + +In the `load_env_var` body, immediately after the existing `enable_write_tools` parse block, add the two new parses: + +```python + env_enable_write_tools = os.getenv( + "MISTMCP_ENABLE_WRITE_TOOLS", str(enable_write_tools) + ) + enable_write_tools = env_enable_write_tools.lower() in ("true", "1", "yes") + + env_disable_elicitation = os.getenv( + "MISTMCP_DISABLE_ELICITATION", str(disable_elicitation) + ) + disable_elicitation = env_disable_elicitation.lower() in ("true", "1", "yes") + + env_stateless = os.getenv("MISTMCP_STATELESS", str(stateless)) + stateless = env_stateless.lower() in ("true", "1", "yes") +``` + +Change the `load_env_var` return to include `stateless`: + +```python + return ( + transport_mode, + mcp_host, + mcp_port, + debug, + enable_write_tools, + disable_elicitation, + response_format, + log_file, + stateless, + ) +``` + +In `main()`, add the CLI argument (after the `--disable-elicitation` argument): + +```python + parser.add_argument( + "--stateless", + action="store_true", + help="Serve HTTP statelessly (fresh transport per request) so the MCP client " + "survives a server restart. HTTP only; incompatible with in-band elicitation. " + "Loses server->client push (notifications/elicitation).", + ) +``` + +In `main()`, update the `load_env_var` unpacking + call to 9 elements and pass `args.stateless`: + +```python + ( + transport_mode, + mcp_host, + mcp_port, + debug, + enable_write_tools, + disable_elicitation, + response_format, + log_file, + stateless, + ) = load_env_var( + args.transport, + args.host, + args.port, + args.debug, + args.enable_write_tools, + args.disable_elicitation, + args.response_format, + args.log_file, + args.stateless, + ) +``` + +In `main()`, replace the `start(...)` call with a guarded version that exits non-zero on a refused config: + +```python + try: + start( + transport_mode, + mcp_host, + mcp_port, + debug, + enable_write_tools, + disable_elicitation, + response_format, + log_file, + stateless, + ) + except ConfigurationError as exc: + logger.error("Invalid configuration: %s", exc) + raise SystemExit(2) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run python -m pytest tests/test_env_loading.py tests/test_main.py -v --no-cov` +Expected: PASS (all env-loading + all `TestStart`/`TestStatelessStart`/`TestMain` tests). + +- [ ] **Step 5: Lint and commit** + +```bash +uv run ruff format src/mistmcp/__main__.py tests/test_env_loading.py tests/test_main.py +uv run ruff check src/mistmcp/__main__.py tests/test_env_loading.py tests/test_main.py +git add src/mistmcp/__main__.py tests/test_env_loading.py tests/test_main.py +git commit -m "$(printf 'feat: add --stateless flag, env parsing, and fatal exit on invalid config\n\nCo-Authored-By: Claude Opus 4.8 (1M context) ')" +``` + +--- + +## Task 7: README documentation + full-suite verification + +**Files:** +- Modify: `README.md` +- Verify: whole test suite + +- [ ] **Step 1: Add the `--stateless` CLI option** + +In `README.md`, in the `OPTIONS:` block (after the `--disable-elicitation` line, ~line 65), add: + +``` + --stateless Only when transport==http, serve statelessly so clients survive a server restart (no server->client push) +``` + +- [ ] **Step 2: Add env-var rows** + +In the **HTTP Mode** table (after the `MISTMCP_ENABLE_WRITE_TOOLS` row, ~line 104), add: + +``` +| MISTMCP_DISABLE_ELICITATION | No | DANGER ZONE! true/false (default: false) | +| MISTMCP_STATELESS | No | true/false (default: false) — survive server restart, no server->client push | +``` + +In the **STDIO Mode** table (after its `MISTMCP_ENABLE_WRITE_TOOLS` row, ~line 94), add: + +``` +| MISTMCP_DISABLE_ELICITATION | No | DANGER ZONE! true/false (default: false) | +``` + +- [ ] **Step 3: Add a "Stateless HTTP mode" subsection** + +After the HTTP Mode `> **Note:**` line (~line 106), add (the `===STATELESS-FENCE===` markers below stand in for triple backticks — replace each with ``` when inserting): + +``` +### Stateless HTTP mode + +Set `MISTMCP_STATELESS=true` (or `--stateless`) with `--transport http` to serve each +request on a fresh transport. There is no server session id to go stale, so an +already-connected MCP client survives a server restart without reconnecting. + +Trade-offs: + +- **No server→client push.** Notifications and in-band elicitation are disabled. +- **Writes require the DANGER ZONE.** Because elicitation can't prompt, write tools are + only available with `--enable-write-tools` **and** `--disable-elicitation` (or + `MISTMCP_DISABLE_ELICITATION=true`), which auto-accepts. Starting stateless + http + + `--enable-write-tools` without `--disable-elicitation` is refused at startup. +- **Read-only stays safe.** Without write tools, destructive upgrade/utility actions + fail closed with a clear error. + +Example: + +===STATELESS-FENCE===bash +uv run mistmcp --transport http --stateless # read-only, restart-safe +uv run mistmcp --transport http --stateless \ + --enable-write-tools --disable-elicitation # writes (DANGER ZONE) +===STATELESS-FENCE=== +``` + +- [ ] **Step 4: Verify the full suite passes with coverage** + +Run: `uv run python -m pytest` +Expected: PASS — all tests green, coverage ≥ 30% (the repo gate). If coverage dips below 30%, a test was likely skipped; re-check Tasks 1–6. + +- [ ] **Step 5: Lint everything touched and commit** + +```bash +uv run ruff format src/mistmcp tests +uv run ruff check src/mistmcp tests +git add README.md +git commit -m "$(printf 'docs: document stateless HTTP mode and new env vars\n\nCo-Authored-By: Claude Opus 4.8 (1M context) ')" +``` + +--- + +## Final verification checklist + +- [ ] `uv run python -m pytest` — full suite green, coverage ≥ 30%. +- [ ] `uv run ruff check src tests` — clean. +- [ ] `uv run ruff format --check src tests` — clean. +- [ ] Default-off behavior unchanged: `git diff main -- src/mistmcp` shows the only runtime change for `stateless=False` is the (behavior-neutral) build-time visibility move and the new, gated `on_call_tool`/guard branches. +- [ ] Manual smoke (optional): `uv run mistmcp --transport http --stateless` starts and logs the stateless INFO line; `uv run mistmcp --transport http --stateless --enable-write-tools` exits non-zero with the refusal message. +``` From 9441bf9c18671a37a1206917ecb605635d77a2fe Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 15 Jun 2026 18:22:07 -0700 Subject: [PATCH 05/15] feat: add stateless config field and validate_stateless_config Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mistmcp/config.py | 29 ++++++++++++++++++ tests/test_config.py | 69 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/mistmcp/config.py b/src/mistmcp/config.py index 6638bf6..cc5c8a1 100644 --- a/src/mistmcp/config.py +++ b/src/mistmcp/config.py @@ -22,6 +22,7 @@ def __init__( disable_elicitation: bool = False, response_format: str = "json", log_file: str | None = None, + stateless: bool = False, ) -> None: self.transport_mode: str = transport_mode self.mist_apitoken: str = "" @@ -31,6 +32,34 @@ def __init__( self.disable_elicitation = disable_elicitation self.response_format = response_format self.log_file: str | None = log_file + self.stateless = stateless + + +class ConfigurationError(Exception): + """Raised when the server configuration is invalid and startup must be refused.""" + + +def validate_stateless_config(config: "ServerConfig") -> None: + """Refuse stateless when it collides with in-band elicitation. + + Stateless HTTP has no live session and no server->client channel, so the + ctx.elicit() handshake cannot work. The only config that needs that handshake is + write tools enabled over HTTP without disable_elicitation. Everything else + (read-only HTTP, write + disable_elicitation, stdio) is stateless-safe. + """ + if not config.stateless: + return + if ( + config.transport_mode == "http" + and config.enable_write_tools + and not config.disable_elicitation + ): + raise ConfigurationError( + "Stateless HTTP mode is incompatible with in-band elicitation " + "(write tools enabled without disable_elicitation), which needs a live " + "session. Add --disable-elicitation / MISTMCP_DISABLE_ELICITATION=true, " + "drop --enable-write-tools, or unset --stateless / MISTMCP_STATELESS." + ) # Global config instance diff --git a/tests/test_config.py b/tests/test_config.py index 7025545..d5ab4b5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,12 @@ """Tests for mistmcp configuration module""" -from mistmcp.config import ServerConfig +import pytest + +from mistmcp.config import ( + ConfigurationError, + ServerConfig, + validate_stateless_config, +) class TestServerConfig: @@ -32,3 +38,64 @@ def test_config_attributes_can_be_set(self) -> None: assert config.mist_apitoken == "test-token" assert config.mist_host == "api.mist.com" + + +class TestStatelessConfig: + """Test the stateless config field""" + + def test_stateless_defaults_false(self) -> None: + assert ServerConfig().stateless is False + + def test_stateless_can_be_set(self) -> None: + assert ServerConfig(stateless=True).stateless is True + + +class TestValidateStatelessConfig: + """Test validate_stateless_config refusal matrix""" + + def test_noop_when_not_stateless(self) -> None: + # Otherwise-refused combo, but stateless=False -> never raises + cfg = ServerConfig( + transport_mode="http", + enable_write_tools=True, + disable_elicitation=False, + stateless=False, + ) + validate_stateless_config(cfg) # must not raise + + def test_refuses_http_write_without_disable(self) -> None: + cfg = ServerConfig( + transport_mode="http", + enable_write_tools=True, + disable_elicitation=False, + stateless=True, + ) + with pytest.raises(ConfigurationError): + validate_stateless_config(cfg) + + def test_allows_http_write_with_disable(self) -> None: + cfg = ServerConfig( + transport_mode="http", + enable_write_tools=True, + disable_elicitation=True, + stateless=True, + ) + validate_stateless_config(cfg) # must not raise + + def test_allows_http_readonly(self) -> None: + cfg = ServerConfig( + transport_mode="http", + enable_write_tools=False, + disable_elicitation=False, + stateless=True, + ) + validate_stateless_config(cfg) # must not raise + + def test_allows_stdio_even_with_write(self) -> None: + cfg = ServerConfig( + transport_mode="stdio", + enable_write_tools=True, + disable_elicitation=False, + stateless=True, + ) + validate_stateless_config(cfg) # must not raise (combo needs http) From 39f7f151d71069f2a72df436572b113ec181edb0 Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 15 Jun 2026 18:26:48 -0700 Subject: [PATCH 06/15] feat: fail closed in config_elicitation_handler under stateless http Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mistmcp/elicitation_processor.py | 14 +++++++ tests/test_elicitation_processor.py | 61 ++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/test_elicitation_processor.py diff --git a/src/mistmcp/elicitation_processor.py b/src/mistmcp/elicitation_processor.py index b68828c..4d4a64f 100644 --- a/src/mistmcp/elicitation_processor.py +++ b/src/mistmcp/elicitation_processor.py @@ -6,9 +6,15 @@ DeclinedElicitation, ) +from mistmcp.config import config from mistmcp.logger import logger +class ElicitationUnavailableError(RuntimeError): + """Raised when elicitation is required but cannot be performed (stateless HTTP has + no server->client channel). The tool wrappers convert this into a clean ToolError.""" + + async def config_elicitation_handler(message, ctx: Context): if await ctx.get_state("disable_elicitation") is True: @@ -17,6 +23,14 @@ async def config_elicitation_handler(message, ctx: Context): ) return ElicitResult(action="accept") + if config.stateless and config.transport_mode == "http": + # No live session / server->client channel in stateless: in-band elicitation + # cannot complete. Fail closed deterministically instead of calling ctx.elicit(). + raise ElicitationUnavailableError( + "In-band elicitation is unavailable in stateless HTTP mode; this action " + "requires disable_elicitation (DANGER ZONE) or a stateful transport." + ) + logger.debug( "Elicitation middleware: prompting user with message: %s", message, diff --git a/tests/test_elicitation_processor.py b/tests/test_elicitation_processor.py new file mode 100644 index 0000000..be465a9 --- /dev/null +++ b/tests/test_elicitation_processor.py @@ -0,0 +1,61 @@ +"""Tests for the elicitation handler's stateless fail-closed guard""" + +import pytest + +from mistmcp.config import config +from mistmcp.elicitation_processor import ( + ElicitationUnavailableError, + config_elicitation_handler, +) + + +class FakeCtx: + def __init__(self, state=None, elicit_exc=None) -> None: + self._state = state or {} + self._elicit_exc = elicit_exc + self.elicit_calls: list = [] + + async def get_state(self, key): + return self._state.get(key) + + async def elicit(self, message, response_type=None): + self.elicit_calls.append((message, response_type)) + if self._elicit_exc is not None: + raise self._elicit_exc + return None + + +async def test_auto_accepts_when_state_true(monkeypatch) -> None: + monkeypatch.setattr(config, "stateless", True) + monkeypatch.setattr(config, "transport_mode", "http") + ctx = FakeCtx(state={"disable_elicitation": True}) + + result = await config_elicitation_handler("msg", ctx) + + assert result.action == "accept" + assert ctx.elicit_calls == [] # state check returns before the guard + + +async def test_raises_unavailable_in_stateless_http(monkeypatch) -> None: + monkeypatch.setattr(config, "stateless", True) + monkeypatch.setattr(config, "transport_mode", "http") + ctx = FakeCtx(state={}) # disable_elicitation not set + + with pytest.raises(ElicitationUnavailableError): + await config_elicitation_handler("msg", ctx) + + assert ctx.elicit_calls == [] # guard fired BEFORE ctx.elicit + + +async def test_calls_elicit_in_stateful(monkeypatch) -> None: + monkeypatch.setattr(config, "stateless", False) + monkeypatch.setattr(config, "transport_mode", "http") + sentinel = RuntimeError("elicit-reached") + ctx = FakeCtx(state={}, elicit_exc=sentinel) + + # In stateful mode the guard must NOT fire; ctx.elicit is reached (and here + # raises our sentinel, proving the handler proceeded past the guard). + with pytest.raises(RuntimeError, match="elicit-reached"): + await config_elicitation_handler("msg", ctx) + + assert len(ctx.elicit_calls) == 1 From ab7d536df8cedb49fdd8fe578ea498b99566f8fb Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 15 Jun 2026 18:33:41 -0700 Subject: [PATCH 07/15] docs: clarify why stateless elicitation guard is broader than the startup gate Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mistmcp/elicitation_processor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/mistmcp/elicitation_processor.py b/src/mistmcp/elicitation_processor.py index 4d4a64f..f4f82e4 100644 --- a/src/mistmcp/elicitation_processor.py +++ b/src/mistmcp/elicitation_processor.py @@ -26,6 +26,9 @@ async def config_elicitation_handler(message, ctx: Context): if config.stateless and config.transport_mode == "http": # No live session / server->client channel in stateless: in-band elicitation # cannot complete. Fail closed deterministically instead of calling ctx.elicit(). + # Intentionally broader than validate_stateless_config (which also checks + # enable_write_tools): mist_upgrades reaches this handler without an + # enable_write_tools gate, so read-only stateless must fail closed here too. raise ElicitationUnavailableError( "In-band elicitation is unavailable in stateless HTTP mode; this action " "requires disable_elicitation (DANGER ZONE) or a stateful transport." From 2820ca0502883e981e333b20c7397c9756444848 Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 15 Jun 2026 18:35:28 -0700 Subject: [PATCH 08/15] feat: resolve write-tool visibility at build time in create_mcp_server Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mistmcp/server.py | 35 +++++++++++++--- tests/test_server.py | 93 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 122 insertions(+), 6 deletions(-) diff --git a/src/mistmcp/server.py b/src/mistmcp/server.py index 7eb5dd2..e401462 100644 --- a/src/mistmcp/server.py +++ b/src/mistmcp/server.py @@ -194,11 +194,6 @@ middleware=[NullStripMiddleware(), ElicitationMiddleware()], ) -# Write tools are disabled by default and enabled per-session by -# ElicitationMiddleware during initialization when the client declares -# elicitation support or explicitly sends X-Disable-Elicitation: true. -mcp.add_transform(Visibility(False, tags={"write"}, components={"tool"})) - def _load_tools(config: ServerConfig) -> list[str]: """Load all available tools into the MCP server""" @@ -225,10 +220,40 @@ def _load_tools(config: ServerConfig) -> list[str]: return loaded_tools +_PROTECTED_WRITE_TAGS = {"write", "write_delete"} + + +def _write_visible_tags(config: ServerConfig) -> set[str]: + """Protected write tags that should be visible at build time for this config. + + Authoritative in stateless mode; a behavior-neutral floor in stateful mode, where + ElicitationMiddleware.on_initialize re-resolves write/write_delete per session. + """ + if config.enable_write_tools and config.disable_elicitation: + return {"write"} # DANGER ZONE: update only, never write_delete + return set() # read-only / elicitation-capable: hide both at build time + + +def _configure_write_visibility(mcp_server: FastMCP, config: ServerConfig) -> None: + """Install a deterministic hide-all-then-show-visible transform sequence for the + protected write tags. FastMCP Visibility marks are later-wins, so the EFFECTIVE + visibility equals this call's resolution even when called repeatedly on the reused + module singleton (the transform list grows by 1-2 entries per call; create_mcp_server + runs once per process).""" + visible = _write_visible_tags(config) + mcp_server.add_transform( + Visibility(False, tags=_PROTECTED_WRITE_TAGS, components={"tool"}) + ) + if visible: + mcp_server.add_transform(Visibility(True, tags=visible, components={"tool"})) + + def create_mcp_server(config: ServerConfig) -> FastMCP: """Configure and return the MCP server with all tools loaded.""" enabled_tools = _load_tools(config) + _configure_write_visibility(mcp, config) + logger.debug("MCP Server ready with %d tools", len(enabled_tools)) return mcp diff --git a/tests/test_server.py b/tests/test_server.py index 350f0f9..6fe2f4b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -5,7 +5,13 @@ from fastmcp import FastMCP from mistmcp.config import ServerConfig -from mistmcp.server import create_mcp_server, mcp +from mistmcp.server import ( + _PROTECTED_WRITE_TAGS, + _configure_write_visibility, + _write_visible_tags, + create_mcp_server, + mcp, +) class TestMcpInstance: @@ -61,3 +67,88 @@ def test_create_mcp_server_without_debug(self, mock_load_tools, caplog) -> None: create_mcp_server(config) assert "MCP Server ready" not in caplog.text + + +def _build_fresh_mcp() -> FastMCP: + m = FastMCP(name="test_visibility") + + @m.tool(name="w_tool", tags={"write"}) + def w_tool() -> str: + return "w" + + @m.tool(name="wd_tool", tags={"write_delete"}) + def wd_tool() -> str: + return "wd" + + @m.tool(name="up_tool", tags={"utilities_upgrade"}) + def up_tool() -> str: + return "up" + + @m.tool(name="read_tool", tags={"info"}) + def read_tool() -> str: + return "r" + + return m + + +async def _visible_names(m: FastMCP) -> set[str]: + tools = await m.list_tools() # public path applies Visibility transforms + return {t.name for t in tools} + + +class TestWriteVisibleTags: + def test_protected_tags_are_write_and_write_delete(self) -> None: + assert _PROTECTED_WRITE_TAGS == {"write", "write_delete"} + + def test_readonly_hides_all(self) -> None: + cfg = ServerConfig(enable_write_tools=False, disable_elicitation=False) + assert _write_visible_tags(cfg) == set() + + def test_danger_zone_shows_write_only(self) -> None: + cfg = ServerConfig(enable_write_tools=True, disable_elicitation=True) + assert _write_visible_tags(cfg) == {"write"} + + def test_write_without_disable_shows_nothing_at_build(self) -> None: + cfg = ServerConfig(enable_write_tools=True, disable_elicitation=False) + assert _write_visible_tags(cfg) == set() + + +class TestConfigureWriteVisibility: + async def test_readonly_hides_write_and_write_delete(self) -> None: + m = _build_fresh_mcp() + _configure_write_visibility(m, ServerConfig(enable_write_tools=False)) + visible = await _visible_names(m) + assert "w_tool" not in visible + assert "wd_tool" not in visible + assert "up_tool" in visible # utilities_upgrade untouched + assert "read_tool" in visible + + async def test_danger_zone_shows_write_hides_write_delete(self) -> None: + m = _build_fresh_mcp() + _configure_write_visibility( + m, ServerConfig(enable_write_tools=True, disable_elicitation=True) + ) + visible = await _visible_names(m) + assert "w_tool" in visible + assert "wd_tool" not in visible + assert "up_tool" in visible + + async def test_idempotent_last_config_wins(self) -> None: + m = _build_fresh_mcp() + _configure_write_visibility( + m, ServerConfig(enable_write_tools=True, disable_elicitation=True) + ) + _configure_write_visibility(m, ServerConfig(enable_write_tools=False)) + visible = await _visible_names(m) + assert "w_tool" not in visible + assert "wd_tool" not in visible + + async def test_idempotent_last_config_wins_reverse(self) -> None: + m = _build_fresh_mcp() + _configure_write_visibility(m, ServerConfig(enable_write_tools=False)) + _configure_write_visibility( + m, ServerConfig(enable_write_tools=True, disable_elicitation=True) + ) + visible = await _visible_names(m) + assert "w_tool" in visible + assert "wd_tool" not in visible From f5dd825246e4b83db3c9be1d9da65003a93f0816 Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 15 Jun 2026 18:41:49 -0700 Subject: [PATCH 09/15] feat: add on_call_tool request-scoped elicitation bypass for stateless danger zone Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mistmcp/elicitation_middleware.py | 20 ++++++++++- tests/test_elicitation_middleware.py | 52 +++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/mistmcp/elicitation_middleware.py b/src/mistmcp/elicitation_middleware.py index a68eb76..47b6670 100644 --- a/src/mistmcp/elicitation_middleware.py +++ b/src/mistmcp/elicitation_middleware.py @@ -24,7 +24,9 @@ class ElicitationMiddleware(Middleware): (via MCP capabilities) or has explicitly opted out via the X-Disable-Elicitation HTTP header (HTTP transport) or the --disable-elicitation flag (stdio transport). - Write tools are disabled by default (via the server-level Visibility transform). + Write tools are hidden at build time by _configure_write_visibility() in + create_mcp_server; in stateful mode this middleware re-resolves their per-session + visibility below. If either condition is detected, they are enabled for this session only. """ @@ -139,3 +141,19 @@ async def on_initialize( ) return result + + async def on_call_tool(self, context, call_next): + """In stateless HTTP, on_initialize state does not carry to this tool call. + Set the DANGER-ZONE auto-accept flag request-scoped so config_elicitation_handler + accepts for this call only (no leak into the session store). Gated on + config.stateless so the stateful path is literally unchanged.""" + ctx = context.fastmcp_context + if ( + config.stateless + and config.transport_mode == "http" + and config.enable_write_tools + and config.disable_elicitation + and ctx is not None + ): + await ctx.set_state("disable_elicitation", True, serializable=False) + return await call_next(context) diff --git a/tests/test_elicitation_middleware.py b/tests/test_elicitation_middleware.py index 5e46c65..337f236 100644 --- a/tests/test_elicitation_middleware.py +++ b/tests/test_elicitation_middleware.py @@ -11,9 +11,13 @@ def __init__(self) -> None: self.enabled_calls: list[dict[str, set[str]]] = [] self.disabled_calls: list[dict[str, set[str]]] = [] self.elicit_calls: list[tuple[str, None]] = [] + self.set_state_calls: list[tuple[str, bool, bool]] = [] - async def set_state(self, key: str, value: bool) -> None: + async def set_state( + self, key: str, value: bool, *, serializable: bool = True + ) -> None: self.state[key] = value + self.set_state_calls.append((key, value, serializable)) async def get_state(self, key: str) -> bool | None: return self.state.get(key) @@ -27,7 +31,8 @@ async def disable_components(self, **kwargs) -> None: async def elicit(self, message: str, response_type=None): self.elicit_calls.append((message, response_type)) raise AssertionError( - "ctx.elicit should not be called when elicitation is disabled") + "ctx.elicit should not be called when elicitation is disabled" + ) class FakeMiddlewareContext: @@ -69,3 +74,46 @@ async def call_next(_context): assert elicitation_result.action == "accept" assert fastmcp_context.elicit_calls == [] + + +async def test_on_call_tool_sets_request_scoped_state_in_stateless_danger( + monkeypatch, +) -> None: + monkeypatch.setattr(config, "stateless", True) + monkeypatch.setattr(config, "transport_mode", "http") + monkeypatch.setattr(config, "enable_write_tools", True) + monkeypatch.setattr(config, "disable_elicitation", True) + + fastmcp_context = FakeFastMCPContext() + context = FakeMiddlewareContext(fastmcp_context) + middleware = ElicitationMiddleware() + + async def call_next(_context): + return "tool-result" + + result = await middleware.on_call_tool(context, call_next) + + assert result == "tool-result" + assert fastmcp_context.state.get("disable_elicitation") is True + # request-scoped: serializable must be False + assert fastmcp_context.set_state_calls == [("disable_elicitation", True, False)] + + +async def test_on_call_tool_noop_when_not_stateless(monkeypatch) -> None: + monkeypatch.setattr(config, "stateless", False) + monkeypatch.setattr(config, "transport_mode", "http") + monkeypatch.setattr(config, "enable_write_tools", True) + monkeypatch.setattr(config, "disable_elicitation", True) + + fastmcp_context = FakeFastMCPContext() + context = FakeMiddlewareContext(fastmcp_context) + middleware = ElicitationMiddleware() + + async def call_next(_context): + return "tool-result" + + result = await middleware.on_call_tool(context, call_next) + + assert result == "tool-result" + assert fastmcp_context.set_state_calls == [] + assert "disable_elicitation" not in fastmcp_context.state From 14a27e83676acaa06ab67543ac9e9817113eeb1c Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 15 Jun 2026 18:44:50 -0700 Subject: [PATCH 10/15] docs: cross-reference the two danger-zone disable_elicitation paths Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mistmcp/elicitation_middleware.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/mistmcp/elicitation_middleware.py b/src/mistmcp/elicitation_middleware.py index 47b6670..b04ca57 100644 --- a/src/mistmcp/elicitation_middleware.py +++ b/src/mistmcp/elicitation_middleware.py @@ -25,8 +25,8 @@ class ElicitationMiddleware(Middleware): HTTP header (HTTP transport) or the --disable-elicitation flag (stdio transport). Write tools are hidden at build time by _configure_write_visibility() in - create_mcp_server; in stateful mode this middleware re-resolves their per-session - visibility below. + create_mcp_server; in stateful mode on_initialize re-resolves per-session + visibility. If either condition is detected, they are enabled for this session only. """ @@ -51,6 +51,9 @@ async def on_initialize( if config.enable_write_tools and config.disable_elicitation: enable_write_tools = True if ctx is not None: + # session-scoped (serializable defaults True). on_call_tool sets the + # same flag request-scoped for stateless, where on_initialize state + # does not carry to the tool call — keep both paths in sync. await ctx.set_state("disable_elicitation", True) logger.warning( "Elicitation middleware: WARNING - both enable_write_tools and disable_elicitation config flags are set. This is not recommended as it will enable write tools without elicitation safeguards. Proceed with caution!" From 7f718e40006e20c9916aeafaf15b596cf6748c1d Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 15 Jun 2026 18:47:05 -0700 Subject: [PATCH 11/15] feat: thread stateless into start() and add stateless http launch path Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mistmcp/__main__.py | 60 ++++++++++---- tests/test_main.py | 172 +++++++++++++++++++++++++++++++++++----- 2 files changed, 197 insertions(+), 35 deletions(-) diff --git a/src/mistmcp/__main__.py b/src/mistmcp/__main__.py index 6e384b0..b0c79a1 100644 --- a/src/mistmcp/__main__.py +++ b/src/mistmcp/__main__.py @@ -16,11 +16,29 @@ from dotenv import load_dotenv -from mistmcp.config import config +from mistmcp.config import config, validate_stateless_config from mistmcp.logger import logger, setup_logging from mistmcp.server import create_mcp_server +def _run_stateless_http(mcp_server, host: str, port: int) -> None: + """Serve via http_app(stateless_http=True): the SDK builds a fresh transport per + request, so there is no session id to go stale on a server restart. We pass no + event_store; in stateless mode the SDK's per-request transport uses event_store=None + regardless (the resumable GET stream is dropped).""" + import uvicorn + + app = mcp_server.http_app(stateless_http=True) + uvicorn.run( + app, + host=host, + port=port, + lifespan="on", + timeout_graceful_shutdown=2, + ws="websockets-sansio", + ) + + def start( transport_mode: str, mcp_host: str, @@ -30,20 +48,8 @@ def start( disable_elicitation: bool = False, response_format: str = "json", log_file: str | None = None, + stateless: bool = False, ) -> None: - """ - Main entry point for the Mist MCP Server - - Args: - transport_mode: Transport mode to use ("stdio" or "http") - mcp_host: Host to bind HTTP server to - mcp_port: Port for HTTP server - debug: Enable debug output - enable_write_tools: Enable write tools. By default, only read tools are enabled for safety. This flag enabled the full set of tools including those that can modify configuration (secured with elicitation). Use with caution! - disable_elicitation: DANGER ZONE!!! Disable elicitation for write tools. This will allow any AI App to modify configuration objects without confirmation. Use only for testing with non-malicious AI Apps or if you have other safeguards in place. Do NOT use this in production or with untrusted AI Apps! - response_format: Response format for HTTP transport ("json" or "string") - log_file: Optional path to write logs to a file - """ # Update global config config.transport_mode = transport_mode config.debug = debug @@ -51,9 +57,22 @@ def start( config.disable_elicitation = disable_elicitation config.response_format = response_format config.log_file = log_file + config.stateless = stateless setup_logging(debug=debug, log_file=log_file) + # stateless only applies to http + if config.stateless and transport_mode != "http": + logger.warning( + "MISTMCP_STATELESS / --stateless is set but transport is %s; stateless " + "applies only to http — ignoring.", + transport_mode, + ) + config.stateless = False + + # Refuse incompatible config BEFORE the broad try below, so it cannot be swallowed. + validate_stateless_config(config) + logger.info("Starting Mist MCP Server — transport: %s", transport_mode) logger.debug(" MIST_HOST: %s", config.mist_host) logger.debug(" RESPONSE_FORMAT: %s", config.response_format) @@ -62,12 +81,23 @@ def start( if transport_mode == "http": logger.debug(" MCP_HOST: %s", mcp_host) logger.debug(" MCP_PORT: %s", mcp_port) + if config.stateless: + logger.info( + "Stateless HTTP mode active: fresh transport per request, so an " + "already-connected MCP client survives a server restart. Server->client " + "push (notifications/elicitation) is disabled; in-band elicitation is " + "unavailable, so destructive utility/upgrade actions require " + "disable_elicitation (DANGER ZONE) or are refused." + ) try: mcp_server = create_mcp_server(config) if transport_mode == "http": - mcp_server.run(transport="http", host=mcp_host, port=mcp_port) + if config.stateless: + _run_stateless_http(mcp_server, mcp_host, mcp_port) + else: + mcp_server.run(transport="http", host=mcp_host, port=mcp_port) else: mcp_server.run() diff --git a/tests/test_main.py b/tests/test_main.py index aafed6c..3ed1a38 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -4,7 +4,8 @@ import pytest -from mistmcp.__main__ import main, start +from mistmcp.__main__ import _run_stateless_http, main, start +from mistmcp.config import ConfigurationError, config class TestStart: @@ -16,8 +17,15 @@ def test_start_http(self, mock_create_server) -> None: mock_server = Mock() mock_create_server.return_value = mock_server - start("http", "127.0.0.1", 8000, debug=True, enable_write_tools=False, - disable_elicitation=False, response_format="json") + start( + "http", + "127.0.0.1", + 8000, + debug=True, + enable_write_tools=False, + disable_elicitation=False, + response_format="json", + ) mock_create_server.assert_called_once() config_arg = mock_create_server.call_args[0][0] @@ -34,8 +42,15 @@ def test_start_http_with_custom_host(self, mock_create_server) -> None: mock_server = Mock() mock_create_server.return_value = mock_server - start("http", "0.0.0.0", 8000, debug=True, enable_write_tools=False, - disable_elicitation=False, response_format="json") + start( + "http", + "0.0.0.0", + 8000, + debug=True, + enable_write_tools=False, + disable_elicitation=False, + response_format="json", + ) mock_create_server.assert_called_once() config_arg = mock_create_server.call_args[0][0] @@ -52,8 +67,15 @@ def test_start_stdio(self, mock_create_server) -> None: mock_server = Mock() mock_create_server.return_value = mock_server - start("stdio", "127.0.0.1", 8000, debug=False, enable_write_tools=False, - disable_elicitation=False, response_format="json") + start( + "stdio", + "127.0.0.1", + 8000, + debug=False, + enable_write_tools=False, + disable_elicitation=False, + response_format="json", + ) mock_create_server.assert_called_once() mock_server.run.assert_called_once_with() @@ -65,8 +87,15 @@ def test_start_keyboard_interrupt(self, mock_create_server, capsys) -> None: mock_server.run.side_effect = KeyboardInterrupt() mock_create_server.return_value = mock_server - start("stdio", "127.0.0.1", 8000, debug=False, enable_write_tools=False, - disable_elicitation=False, response_format="json") + start( + "stdio", + "127.0.0.1", + 8000, + debug=False, + enable_write_tools=False, + disable_elicitation=False, + response_format="json", + ) captured = capsys.readouterr() assert "stopped by user" in captured.err @@ -76,8 +105,15 @@ def test_start_exception_without_debug(self, mock_create_server, capsys) -> None """Test handling of exceptions without debug mode""" mock_create_server.side_effect = Exception("Test error") - start("stdio", "127.0.0.1", 8000, debug=False, enable_write_tools=False, - disable_elicitation=False, response_format="json") + start( + "stdio", + "127.0.0.1", + 8000, + debug=False, + enable_write_tools=False, + disable_elicitation=False, + response_format="json", + ) captured = capsys.readouterr() assert "Mist MCP Error: Test error" in captured.err @@ -90,8 +126,15 @@ def test_start_exception_with_debug( """Test handling of exceptions with debug mode""" mock_create_server.side_effect = Exception("Test error") - start("stdio", "127.0.0.1", 8000, debug=True, enable_write_tools=False, - disable_elicitation=False, response_format="json") + start( + "stdio", + "127.0.0.1", + 8000, + debug=True, + enable_write_tools=False, + disable_elicitation=False, + response_format="json", + ) captured = capsys.readouterr() assert "Mist MCP Error: Test error" in captured.err @@ -103,8 +146,15 @@ def test_start_debug_output(self, mock_create_server, capsys) -> None: mock_server = Mock() mock_create_server.return_value = mock_server - start("http", "127.0.0.1", 8000, debug=True, enable_write_tools=False, - disable_elicitation=False, response_format="json") + start( + "http", + "127.0.0.1", + 8000, + debug=True, + enable_write_tools=False, + disable_elicitation=False, + response_format="json", + ) captured = capsys.readouterr() assert "Starting Mist MCP Server" in captured.err @@ -121,7 +171,8 @@ def test_main_default_args(self, mock_start) -> None: main() mock_start.assert_called_once_with( - "stdio", "127.0.0.1", 8000, False, False, False, "json", None) + "stdio", "127.0.0.1", 8000, False, False, False, "json", None + ) @patch("mistmcp.__main__.start") def test_main_with_debug(self, mock_start) -> None: @@ -130,7 +181,8 @@ def test_main_with_debug(self, mock_start) -> None: main() mock_start.assert_called_once_with( - "stdio", "127.0.0.1", 8000, True, False, False, "json", None) + "stdio", "127.0.0.1", 8000, True, False, False, "json", None + ) def test_main_help_exits(self) -> None: """Test that --help exits appropriately""" @@ -153,10 +205,90 @@ def test_main_custom_host_and_port(self, mock_start) -> None: """Test main with custom host and port""" with patch( "sys.argv", - ["mistmcp", "--transport", "http", - "--host", "0.0.0.0", "--port", "9000"], + ["mistmcp", "--transport", "http", "--host", "0.0.0.0", "--port", "9000"], ): main() mock_start.assert_called_once_with( - "http", "0.0.0.0", 9000, False, False, False, "json", None) + "http", "0.0.0.0", 9000, False, False, False, "json", None + ) + + +class TestStatelessStart: + """Test stateless threading and launch path in start()""" + + @patch("mistmcp.__main__._run_stateless_http") + @patch("mistmcp.__main__.create_mcp_server") + def test_http_stateless_uses_stateless_launch( + self, mock_create, mock_run_stateless + ) -> None: + mock_server = Mock() + mock_create.return_value = mock_server + + start( + "http", + "127.0.0.1", + 8000, + enable_write_tools=False, + disable_elicitation=False, + stateless=True, + ) + + mock_run_stateless.assert_called_once_with(mock_server, "127.0.0.1", 8000) + mock_server.run.assert_not_called() + config.stateless = False # reset global + + @patch("mistmcp.__main__._run_stateless_http") + @patch("mistmcp.__main__.create_mcp_server") + def test_http_non_stateless_uses_run(self, mock_create, mock_run_stateless) -> None: + mock_server = Mock() + mock_create.return_value = mock_server + + start("http", "127.0.0.1", 8000, stateless=False) + + mock_run_stateless.assert_not_called() + mock_server.run.assert_called_once_with( + transport="http", host="127.0.0.1", port=8000 + ) + + @patch("mistmcp.__main__.create_mcp_server") + def test_stateless_stdio_downgrades_with_warning(self, mock_create, capsys) -> None: + mock_server = Mock() + mock_create.return_value = mock_server + + start("stdio", "127.0.0.1", 8000, stateless=True) + + captured = capsys.readouterr() + assert "stateless applies only to http" in captured.err + mock_server.run.assert_called_once_with() + config.stateless = False # reset global + + def test_start_does_not_swallow_config_error(self) -> None: + with pytest.raises(ConfigurationError): + start( + "http", + "127.0.0.1", + 8000, + enable_write_tools=True, + disable_elicitation=False, + stateless=True, + ) + config.stateless = False # reset global + + @patch("uvicorn.run") + def test_run_stateless_http_builds_stateless_app(self, mock_uvicorn_run) -> None: + mock_server = Mock() + app = mock_server.http_app.return_value + + _run_stateless_http(mock_server, "0.0.0.0", 9000) + + # no event_store kwarg — only stateless_http=True + mock_server.http_app.assert_called_once_with(stateless_http=True) + mock_uvicorn_run.assert_called_once_with( + app, + host="0.0.0.0", + port=9000, + lifespan="on", + timeout_graceful_shutdown=2, + ws="websockets-sansio", + ) From 3ec86b0f9c3d97c9a4a3d32590c97388654794f5 Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 15 Jun 2026 18:50:34 -0700 Subject: [PATCH 12/15] refactor: autouse stateless reset fixture + restore start() docstring Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mistmcp/__main__.py | 15 +++++++++++++++ tests/test_main.py | 10 +++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/mistmcp/__main__.py b/src/mistmcp/__main__.py index b0c79a1..b036645 100644 --- a/src/mistmcp/__main__.py +++ b/src/mistmcp/__main__.py @@ -50,6 +50,21 @@ def start( log_file: str | None = None, stateless: bool = False, ) -> None: + """Configure the global config and run the Mist MCP Server. + + Args: + transport_mode: "stdio" or "http". + mcp_host / mcp_port: HTTP bind address (http transport only). + debug: enable debug logging. + enable_write_tools: expose write tools (gated by elicitation unless disabled). + disable_elicitation: DANGER ZONE — auto-accept write actions without prompting. + response_format: "json" or "string" (http transport only). + log_file: optional path to also write logs to. + stateless: http only — serve statelessly (fresh transport per request) so a + connected client survives a server restart. Downgraded to False on non-http + transport; refused at startup if it would require in-band elicitation + (see validate_stateless_config). + """ # Update global config config.transport_mode = transport_mode config.debug = debug diff --git a/tests/test_main.py b/tests/test_main.py index 3ed1a38..948a538 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -217,6 +217,13 @@ def test_main_custom_host_and_port(self, mock_start) -> None: class TestStatelessStart: """Test stateless threading and launch path in start()""" + @pytest.fixture(autouse=True) + def _reset_stateless(self): + # start() mutates the global config singleton; restore after each test + # so visibility/launch state never leaks across tests. + yield + config.stateless = False + @patch("mistmcp.__main__._run_stateless_http") @patch("mistmcp.__main__.create_mcp_server") def test_http_stateless_uses_stateless_launch( @@ -236,7 +243,6 @@ def test_http_stateless_uses_stateless_launch( mock_run_stateless.assert_called_once_with(mock_server, "127.0.0.1", 8000) mock_server.run.assert_not_called() - config.stateless = False # reset global @patch("mistmcp.__main__._run_stateless_http") @patch("mistmcp.__main__.create_mcp_server") @@ -261,7 +267,6 @@ def test_stateless_stdio_downgrades_with_warning(self, mock_create, capsys) -> N captured = capsys.readouterr() assert "stateless applies only to http" in captured.err mock_server.run.assert_called_once_with() - config.stateless = False # reset global def test_start_does_not_swallow_config_error(self) -> None: with pytest.raises(ConfigurationError): @@ -273,7 +278,6 @@ def test_start_does_not_swallow_config_error(self) -> None: disable_elicitation=False, stateless=True, ) - config.stateless = False # reset global @patch("uvicorn.run") def test_run_stateless_http_builds_stateless_app(self, mock_uvicorn_run) -> None: From d5bdd314aa660f1eed35a02147b016a05642f4c4 Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 15 Jun 2026 18:53:30 -0700 Subject: [PATCH 13/15] feat: add --stateless flag, env parsing, and fatal exit on invalid config Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mistmcp/__main__.py | 48 ++++++++++++++++++------ tests/test_env_loading.py | 79 +++++++++++++++++++++++++++++++++------ tests/test_main.py | 24 ++++++++++-- 3 files changed, 124 insertions(+), 27 deletions(-) diff --git a/src/mistmcp/__main__.py b/src/mistmcp/__main__.py index b036645..271e0c6 100644 --- a/src/mistmcp/__main__.py +++ b/src/mistmcp/__main__.py @@ -16,7 +16,7 @@ from dotenv import load_dotenv -from mistmcp.config import config, validate_stateless_config +from mistmcp.config import ConfigurationError, config, validate_stateless_config from mistmcp.logger import logger, setup_logging from mistmcp.server import create_mcp_server @@ -157,7 +157,8 @@ def load_env_var( disable_elicitation: bool, response_format: str | None, log_file: str | None, -) -> tuple[str, str, int, bool, bool, bool, str, str | None]: + stateless: bool = False, +) -> tuple[str, str, int, bool, bool, bool, str, str | None, bool]: """Load configuration from environment variables""" if transport_mode is None: @@ -182,6 +183,14 @@ def load_env_var( ) enable_write_tools = env_enable_write_tools.lower() in ("true", "1", "yes") + env_disable_elicitation = os.getenv( + "MISTMCP_DISABLE_ELICITATION", str(disable_elicitation) + ) + disable_elicitation = env_disable_elicitation.lower() in ("true", "1", "yes") + + env_stateless = os.getenv("MISTMCP_STATELESS", str(stateless)) + stateless = env_stateless.lower() in ("true", "1", "yes") + if response_format is None: response_format = "json" @@ -201,6 +210,7 @@ def load_env_var( disable_elicitation, response_format, log_file, + stateless, ) @@ -245,6 +255,13 @@ def main() -> None: action="store_true", help="DANGER ZONE!!! Disable elicitation for write tools. This will allow any AI App to modify configuration objects without confirmation. Use only for testing with non-malicious AI Apps or if you have other safeguards in place. Do NOT use this in production or with untrusted AI Apps!", ) + parser.add_argument( + "--stateless", + action="store_true", + help="Serve HTTP statelessly (fresh transport per request) so the MCP client " + "survives a server restart. HTTP only; incompatible with in-band elicitation. " + "Loses server->client push (notifications/elicitation).", + ) parser.add_argument( "-r", "--response_format", @@ -270,6 +287,7 @@ def main() -> None: disable_elicitation, response_format, log_file, + stateless, ) = load_env_var( args.transport, args.host, @@ -279,18 +297,24 @@ def main() -> None: args.disable_elicitation, args.response_format, args.log_file, + args.stateless, ) - start( - transport_mode, - mcp_host, - mcp_port, - debug, - enable_write_tools, - disable_elicitation, - response_format, - log_file, - ) + try: + start( + transport_mode, + mcp_host, + mcp_port, + debug, + enable_write_tools, + disable_elicitation, + response_format, + log_file, + stateless, + ) + except ConfigurationError as exc: + logger.error("Invalid configuration: %s", exc) + raise SystemExit(2) if __name__ == "__main__": diff --git a/tests/test_env_loading.py b/tests/test_env_loading.py index 8fa89f5..db020db 100644 --- a/tests/test_env_loading.py +++ b/tests/test_env_loading.py @@ -88,9 +88,17 @@ def test_load_env_var_stdio_mode(self) -> None: } with patch.dict(os.environ, test_env, clear=False): - transport_mode, mcp_host, mcp_port, debug, enable_write_tools, disable_elicitation, response_format, _ = load_env_var( - "stdio", None, None, True, False, False, None, None - ) + ( + transport_mode, + mcp_host, + mcp_port, + debug, + enable_write_tools, + disable_elicitation, + response_format, + _, + _, + ) = load_env_var("stdio", None, None, True, False, False, None, None) assert config.mist_apitoken == "test-api-token" assert config.mist_host == "api.mist.com" @@ -111,9 +119,17 @@ def test_load_env_var_http_mode(self) -> None: } with patch.dict(os.environ, test_env, clear=False): - transport_mode, mcp_host, mcp_port, debug, enable_write_tools, disable_elicitation, response_format, _ = load_env_var( - "http", None, None, False, False, False, None, None - ) + ( + transport_mode, + mcp_host, + mcp_port, + debug, + enable_write_tools, + disable_elicitation, + response_format, + _, + _, + ) = load_env_var("http", None, None, False, False, False, None, None) assert transport_mode == "http" assert debug is False @@ -142,8 +158,9 @@ def test_load_env_var_debug_variations(self) -> None: test_env = {**base_env, "MISTMCP_DEBUG": debug_value} with patch.dict(os.environ, test_env, clear=False): - _, _, _, debug, _, _, _, _ = load_env_var( - "stdio", None, None, False, False, False, None, None) + _, _, _, debug, _, _, _, _, _ = load_env_var( + "stdio", None, None, False, False, False, None, None + ) assert debug == expected, f"Failed for debug_value='{debug_value}'" def test_load_env_var_port_parsing(self) -> None: @@ -164,8 +181,9 @@ def test_load_env_var_port_parsing(self) -> None: test_env = {**base_env, "MISTMCP_PORT": port_value} with patch.dict(os.environ, test_env, clear=False): - _, _, mcp_port, _, _, _, _, _ = load_env_var( - "stdio", None, None, False, False, False, None, None) + _, _, mcp_port, _, _, _, _, _, _ = load_env_var( + "stdio", None, None, False, False, False, None, None + ) assert mcp_port == expected, f"Failed for port='{port_value}'" def test_load_env_var_host_and_port_from_env(self) -> None: @@ -178,8 +196,45 @@ def test_load_env_var_host_and_port_from_env(self) -> None: } with patch.dict(os.environ, test_env, clear=False): - _, mcp_host, mcp_port, _, _, _, _, _ = load_env_var( - "stdio", None, None, False, False, False, None, None) + _, mcp_host, mcp_port, _, _, _, _, _, _ = load_env_var( + "stdio", None, None, False, False, False, None, None + ) assert mcp_host == "0.0.0.0" assert mcp_port == 9000 + + def test_load_env_var_returns_9_tuple(self) -> None: + base_env = {"MIST_APITOKEN": "t", "MIST_HOST": "h"} + with patch.dict(os.environ, base_env, clear=False): + result = load_env_var( + "stdio", None, None, False, False, False, None, None, False + ) + assert len(result) == 9 + + def test_load_env_var_stateless_parsing(self) -> None: + test_cases = [ + ("true", True), + ("TRUE", True), + ("1", True), + ("yes", True), + ("false", False), + ("0", False), + ("", False), + ] + base_env = {"MIST_APITOKEN": "t", "MIST_HOST": "h"} + for value, expected in test_cases: + env = {**base_env, "MISTMCP_STATELESS": value} + with patch.dict(os.environ, env, clear=False): + result = load_env_var( + "http", None, None, False, False, False, None, None, False + ) + assert result[8] == expected, f"Failed for MISTMCP_STATELESS='{value}'" + + def test_load_env_var_disable_elicitation_parsing(self) -> None: + base_env = {"MIST_APITOKEN": "t", "MIST_HOST": "h"} + env = {**base_env, "MISTMCP_DISABLE_ELICITATION": "true"} + with patch.dict(os.environ, env, clear=False): + result = load_env_var( + "stdio", None, None, False, False, False, None, None, False + ) + assert result[5] is True # disable_elicitation diff --git a/tests/test_main.py b/tests/test_main.py index 948a538..6915d2b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -171,7 +171,7 @@ def test_main_default_args(self, mock_start) -> None: main() mock_start.assert_called_once_with( - "stdio", "127.0.0.1", 8000, False, False, False, "json", None + "stdio", "127.0.0.1", 8000, False, False, False, "json", None, False ) @patch("mistmcp.__main__.start") @@ -181,7 +181,7 @@ def test_main_with_debug(self, mock_start) -> None: main() mock_start.assert_called_once_with( - "stdio", "127.0.0.1", 8000, True, False, False, "json", None + "stdio", "127.0.0.1", 8000, True, False, False, "json", None, False ) def test_main_help_exits(self) -> None: @@ -210,9 +210,27 @@ def test_main_custom_host_and_port(self, mock_start) -> None: main() mock_start.assert_called_once_with( - "http", "0.0.0.0", 9000, False, False, False, "json", None + "http", "0.0.0.0", 9000, False, False, False, "json", None, False ) + @patch("mistmcp.__main__.start") + def test_main_stateless_flag(self, mock_start) -> None: + with patch("sys.argv", ["mistmcp", "--transport", "http", "--stateless"]): + main() + mock_start.assert_called_once_with( + "http", "127.0.0.1", 8000, False, False, False, "json", None, True + ) + + @patch("mistmcp.__main__.start", side_effect=ConfigurationError("bad combo")) + def test_main_exits_2_on_config_error(self, mock_start) -> None: + with patch( + "sys.argv", + ["mistmcp", "--transport", "http", "--stateless", "--enable-write-tools"], + ): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 2 + class TestStatelessStart: """Test stateless threading and launch path in start()""" From 1047efe178abb55f72ec7af8217aa1280a3fb15a Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 15 Jun 2026 18:56:33 -0700 Subject: [PATCH 14/15] test: parametrize disable_elicitation parse + named tuple destructuring Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_env_loading.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/tests/test_env_loading.py b/tests/test_env_loading.py index db020db..4394b65 100644 --- a/tests/test_env_loading.py +++ b/tests/test_env_loading.py @@ -225,16 +225,28 @@ def test_load_env_var_stateless_parsing(self) -> None: for value, expected in test_cases: env = {**base_env, "MISTMCP_STATELESS": value} with patch.dict(os.environ, env, clear=False): - result = load_env_var( + *_, stateless = load_env_var( "http", None, None, False, False, False, None, None, False ) - assert result[8] == expected, f"Failed for MISTMCP_STATELESS='{value}'" + assert stateless == expected, f"Failed for MISTMCP_STATELESS='{value}'" def test_load_env_var_disable_elicitation_parsing(self) -> None: + test_cases = [ + ("true", True), + ("TRUE", True), + ("1", True), + ("yes", True), + ("false", False), + ("0", False), + ("", False), + ] base_env = {"MIST_APITOKEN": "t", "MIST_HOST": "h"} - env = {**base_env, "MISTMCP_DISABLE_ELICITATION": "true"} - with patch.dict(os.environ, env, clear=False): - result = load_env_var( - "stdio", None, None, False, False, False, None, None, False - ) - assert result[5] is True # disable_elicitation + for value, expected in test_cases: + env = {**base_env, "MISTMCP_DISABLE_ELICITATION": value} + with patch.dict(os.environ, env, clear=False): + _, _, _, _, _, disable_elicitation, _, _, _ = load_env_var( + "stdio", None, None, False, False, False, None, None, False + ) + assert disable_elicitation == expected, ( + f"Failed for MISTMCP_DISABLE_ELICITATION='{value}'" + ) From ef583a7e69f9a96fd5a056b584c4964ee165e1a6 Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 15 Jun 2026 18:58:29 -0700 Subject: [PATCH 15/15] docs: document stateless HTTP mode and new env vars Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 300d38d..05c6566 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ OPTIONS: -d, --debug Enable debug output --enable-write-tools Enable write tools (by default only read tools are enabled for safety) --disable-elicitation DANGER ZONE! Disable elicitation for write tools + --stateless Only when transport==http, serve statelessly so clients survive a server restart (no server->client push) -h, --help Show help message TRANSPORT MODES: @@ -92,6 +93,7 @@ Set environment variables directly or via a `.env` file. Requirements differ by | MIST_ENV_FILE | No | Path to .env file | | MISTMCP_DEBUG | No | true/false (default: false) | | MISTMCP_ENABLE_WRITE_TOOLS | No | true/false (default: false) | +| MISTMCP_DISABLE_ELICITATION | No | DANGER ZONE! true/false (default: false) | ### HTTP Mode @@ -102,9 +104,35 @@ Set environment variables directly or via a `.env` file. Requirements differ by | MISTMCP_PORT | No | HTTP port (default: 8000) | | MISTMCP_DEBUG | No | true/false (default: false) | | MISTMCP_ENABLE_WRITE_TOOLS | No | true/false (default: false) | +| MISTMCP_DISABLE_ELICITATION | No | DANGER ZONE! true/false (default: false) | +| MISTMCP_STATELESS | No | true/false (default: false) — survive server restart, no server->client push | > **Note:** In HTTP mode, Mist API credentials are provided by the client (e.g. Claude, VS Code) via HTTP headers or query parameters, not as environment variables. +### Stateless HTTP mode + +Set `MISTMCP_STATELESS=true` (or `--stateless`) with `--transport http` to serve each +request on a fresh transport. There is no server session id to go stale, so an +already-connected MCP client survives a server restart without reconnecting. + +Trade-offs: + +- **No server→client push.** Notifications and in-band elicitation are disabled. +- **Writes require the DANGER ZONE.** Because elicitation can't prompt, write tools are + only available with `--enable-write-tools` **and** `--disable-elicitation` (or + `MISTMCP_DISABLE_ELICITATION=true`), which auto-accepts. Starting stateless + http + + `--enable-write-tools` without `--disable-elicitation` is refused at startup. +- **Read-only stays safe.** Without write tools, destructive upgrade/utility actions + fail closed with a clear error. + +Example: + +```bash +uv run mistmcp --transport http --stateless # read-only, restart-safe +uv run mistmcp --transport http --stateless \ + --enable-write-tools --disable-elicitation # writes (DANGER ZONE) +``` + ## Example: Claude Desktop / VS Code MCP Client