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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,14 @@ JINA_DIMENSIONS=768
QDRANT_URL=http://localhost:6333
QDRANT_COLLECTION=code_symbols

# MCP Server transport: streamable-http | stdio
# MCP Server transport: streamable-http | sse | stdio
MCP_TRANSPORT=streamable-http
MCP_HOST=0.0.0.0
MCP_PORT=8090
# Serve streamable-http without session tracking (default), so replicas can sit behind
# a plain round-robin load balancer with no sticky sessions. Uncomment and set to false
# to restore per-client MCP sessions. streamable-http only.
# MCP_STATELESS_HTTP=true

# Path to the services config file
CONFIG_PATH=./config.yaml
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ For `/reindex-history` the `phase` value is `discovery|embedding|upserting` and
| `CODE_CONTEXT_CACHE_TTL` | `900` | Seconds a cached file content stays valid |
| `MCP_TRANSPORT` | `streamable-http` | One of `streamable-http`, `sse`, `stdio` |
| `MCP_HOST` / `MCP_PORT` | `127.0.0.1` / `8090` | Server bind address |
| `MCP_STATELESS_HTTP` | `true` | Serve `streamable-http` without session tracking (no sticky sessions needed); `false` restores per-client MCP sessions. `streamable-http` only |
| `CONFIG_PATH` | `./config.yaml` | Path to the services config file |

## Embedding providers
Expand Down
11 changes: 6 additions & 5 deletions docs/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,12 @@ Used when `EMBEDDINGS_PROVIDER=ollama`. Requires a running [Ollama](https://olla

### MCP Server

| Variable | Default | Description |
|----------|---------|-------------|
| `MCP_TRANSPORT` | `streamable-http` | Transport protocol. One of: `streamable-http`, `sse`, `stdio`. |
| `MCP_HOST` | `127.0.0.1` | Bind address |
| `MCP_PORT` | `8090` | Listen port |
| Variable | Default | Description |
|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `MCP_TRANSPORT` | `streamable-http` | Transport protocol. One of: `streamable-http`, `sse`, `stdio`. |
| `MCP_HOST` | `127.0.0.1` | Bind address |
| `MCP_PORT` | `8090` | Listen port |
| `MCP_STATELESS_HTTP` | `true` | Serve `streamable-http` without session tracking. Set to `false` to restore per-client MCP sessions. Only applies to `streamable-http`; setting it to `true` with another transport is a startup error. |

### General

Expand Down
22 changes: 22 additions & 0 deletions server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,28 @@ def _apply_default_embedding_max_chars(self) -> Settings:
mcp_host: str = Field(default="127.0.0.1", alias="MCP_HOST")
mcp_port: int = Field(default=8090, alias="MCP_PORT")

# Stateless mode creates a fresh transport per request instead of tracking MCP
# sessions, so replicas can sit behind a plain round-robin load balancer with no
# sticky sessions. On by default. Only the streamable-http transport supports it;
# SSE is session-based by construction and its app factory has no such option,
# and stdio has no HTTP layer at all — for those it is simply inert.
mcp_stateless_http: bool = Field(default=True, alias="MCP_STATELESS_HTTP")

@model_validator(mode="after")
def _reject_stateless_on_unsupported_transport(self) -> Settings:
# Defaulted-on is inert for non-streamable-http transports; only an explicit
# opt-in that cannot be honoured is worth refusing to start over.
if (
"mcp_stateless_http" in self.model_fields_set
and self.mcp_stateless_http
and self.mcp_transport != "streamable-http"
):
raise ValueError(
"MCP_STATELESS_HTTP is only supported with MCP_TRANSPORT="
f"'streamable-http' (got {self.mcp_transport!r})."
)
return self

# get_code_context fetches file contents from GitHub. Contents are cached by git
# blob SHA so repeated calls for the same file in a session are served locally.
code_context_cache_size: int = Field(default=128, alias="CODE_CONTEXT_CACHE_SIZE")
Expand Down
28 changes: 19 additions & 9 deletions server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,24 @@ async def combined(scope_app: Starlette) -> AsyncIterator[None]:
app.router.lifespan_context = combined


def build_http_app() -> Starlette:
# `host` must match the bind address: the app factories auto-enable DNS
# rebinding protection (allowed_hosts = localhost only) when host is a
# loopback address, which would reject container traffic on 0.0.0.0.
#
# `stateless_http` only exists on the streamable-http factory; SSE is
# session-based by construction, so the setting is inert there.
if settings.mcp_transport == "streamable-http":
app = mcp.streamable_http_app(
host=settings.mcp_host,
stateless_http=settings.mcp_stateless_http,
)
else:
app = mcp.sse_app(host=settings.mcp_host)
_wrap_http_lifespan(app)
return app


def main() -> None:
from server.prompts.service import register_service_prompts
from server.prompts.system import register_system_prompts
Expand All @@ -122,15 +140,7 @@ def main() -> None:
register_http_routes(mcp)

if settings.mcp_transport in _HTTP_TRANSPORTS:
# `host` must match the bind address: the app factories auto-enable DNS
# rebinding protection (allowed_hosts = localhost only) when host is a
# loopback address, which would reject container traffic on 0.0.0.0.
app = (
mcp.streamable_http_app(host=settings.mcp_host)
if settings.mcp_transport == "streamable-http"
else mcp.sse_app(host=settings.mcp_host)
)
_wrap_http_lifespan(app)
app = build_http_app()
uvicorn.run(
app,
host=settings.mcp_host,
Expand Down
36 changes: 36 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import pytest
from pydantic import ValidationError

from server.config import Settings

Expand Down Expand Up @@ -28,6 +29,41 @@ def test_embedding_max_chars_explicit_override_wins() -> None:
assert settings.embedding_max_chars == 12345


def test_stateless_http_defaults_to_true() -> None:
settings = Settings(_env_file=None)
assert settings.mcp_stateless_http is True


def test_stateless_http_can_be_disabled_via_env_alias() -> None:
settings = Settings(_env_file=None, MCP_STATELESS_HTTP=False)
assert settings.mcp_stateless_http is False


@pytest.mark.parametrize("transport", ["sse", "stdio"])
def test_stateless_http_default_is_inert_for_other_transports(transport: str) -> None:
settings = Settings(_env_file=None, MCP_TRANSPORT=transport)
assert settings.mcp_transport == transport
assert settings.mcp_stateless_http is True


@pytest.mark.parametrize("transport", ["sse", "stdio"])
def test_explicit_stateless_http_rejected_for_other_transports(
transport: str,
) -> None:
with pytest.raises(ValidationError, match="only supported with"):
Settings(_env_file=None, MCP_TRANSPORT=transport, MCP_STATELESS_HTTP=True)


@pytest.mark.parametrize("transport", ["sse", "stdio"])
def test_explicit_stateless_http_disabled_is_allowed_for_other_transports(
transport: str,
) -> None:
settings = Settings(
_env_file=None, MCP_TRANSPORT=transport, MCP_STATELESS_HTTP=False
)
assert settings.mcp_stateless_http is False


def test_load_services_returns_empty_when_config_file_missing() -> None:
settings = Settings(_env_file=None, CONFIG_PATH="/nonexistent/config.yaml")
assert settings.load_services() == []
Expand Down
41 changes: 41 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from __future__ import annotations

import pytest

from server import main as main_module
from server.config import settings


@pytest.fixture
def streamable_http(monkeypatch) -> None:
monkeypatch.setattr(settings, "mcp_transport", "streamable-http")


def test_build_http_app_is_stateless_by_default(streamable_http) -> None:
main_module.build_http_app()
assert main_module.mcp.session_manager.stateless is True


def test_build_http_app_is_stateful_when_disabled(streamable_http, monkeypatch) -> None:
monkeypatch.setattr(settings, "mcp_stateless_http", False)
main_module.build_http_app()
assert main_module.mcp.session_manager.stateless is False


def test_build_http_app_wraps_lifespan(streamable_http) -> None:
app = main_module.build_http_app()
assert app.router.lifespan_context.__name__ == "combined"


def test_build_http_app_sse_transport_ignores_stateless(monkeypatch) -> None:
# `sse_app()` has no `stateless_http` parameter, so the defaulted-on setting
# must never be forwarded to it.
monkeypatch.setattr(settings, "mcp_transport", "sse")
monkeypatch.setattr(settings, "mcp_stateless_http", True)

app = main_module.build_http_app()

paths = {getattr(route, "path", None) for route in app.routes}
assert "/sse" in paths
assert "/messages" in paths or "/messages/" in paths
assert app.router.lifespan_context.__name__ == "combined"
2 changes: 1 addition & 1 deletion uv.lock

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

Loading