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: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,10 @@ on Windows):
}
```

Authenticate with your `MASTER_API_KEY`. Each key is limited to 60 requests/min
(configurable via `mcp.rate_limit_rpm`); the 61st in a minute returns `429`.
Authenticate with your `MASTER_API_KEY`. Each key is limited to 60 requests/min;
the 61st in a minute returns `429`. Change it in **Settings → Config → MCP
server** — the new limit applies to the next MCP request, no restart. (Setting
`mcp.rate_limit_rpm` in `config.yaml` works too, but is only read at startup.)

**Tools:** `list_workspaces`, `search_documents` (`query`, `collection_ids[]`,
`top_k`, `hybrid`, `filters`), `ingest_document` (container-local path),
Expand Down
12 changes: 11 additions & 1 deletion api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from api.adapters.base import EmbeddingAdapter, Reranker
from api.adapters.vector_store.pgvector import PgvectorAdapter
from api.db import AsyncSessionLocal
from api.models.config import AppConfig, SearchConfig
from api.models.config import AppConfig, MCPConfig, SearchConfig

# ---------------------------------------------------------------------------
# Adapter singletons — set once in lifespan(), read everywhere via Depends()
Expand Down Expand Up @@ -36,6 +36,16 @@ def get_search_config() -> SearchConfig:
return (_app_config or AppConfig()).search


def get_mcp_config() -> MCPConfig:
"""The live MCP config, or defaults before lifespan (mirrors ``get_search_config``).

Read per request by the MCP rate limiter so a ``rate_limit_rpm`` change applies to
the next request — ``apply_config`` swaps this singleton, and the mounted MCP app
is never rebuilt.
"""
return (_app_config or AppConfig()).mcp


def set_embedding_adapter(adapter: EmbeddingAdapter) -> None:
global _embedding_adapter
_embedding_adapter = adapter
Expand Down
7 changes: 6 additions & 1 deletion api/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,12 @@ class ParserConfig(BaseModel):

class MCPConfig(BaseModel):
enabled: bool = True
rate_limit_rpm: int = 60
# Per-key requests/min on the mounted /mcp endpoint. The limiter re-reads this on every
# request (see api/services/mcp/rate_limit.py), so a nonsense value bites the moment it is
# saved rather than at the next restart: 0 denies every request, and a negative rate accrues
# token debt that outlives the correction, since the bucket is never rebuilt. Bounded here,
# at the config boundary, so the limiter's hot path needs no clamp — a bad PUT is a clean 422.
rate_limit_rpm: Annotated[int, Field(ge=1)] = 60
max_results: int = 20


Expand Down
6 changes: 4 additions & 2 deletions api/services/mcp/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,15 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:


def build_mcp_middleware(
app: ASGIApp, *, rate_limit_rpm: int, master_key: str | None = None
app: ASGIApp, *, rate_limit_rpm: Callable[[], int], master_key: str | None = None
) -> MCPAuthRateLimitMiddleware:
"""Wire the MCP middleware from config + the configured master key.

Args:
app: The MCP ASGI app to protect.
rate_limit_rpm: Requests-per-minute ceiling per API key.
rate_limit_rpm: Returns the requests-per-minute ceiling per API key. Resolved
per request (not captured here), so a config change applies to the next
request — this middleware is built once at mount and never rebuilt.
master_key: Override the accepted key (defaults to ``settings.master_api_key``).

Returns:
Expand Down
30 changes: 19 additions & 11 deletions api/services/mcp/rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
at ``rpm / 60`` tokens per second. A request consumes one token; when the bucket
is empty the request is denied (the transport maps this to HTTP 429). The clock
is injectable so the refill behaviour can be tested without sleeping.

``rpm`` is supplied as a *callable* and resolved on every :meth:`allow` call rather
than captured at construction, so a config change takes effect on the next request
instead of at the next restart: the middleware is built once when the MCP app is
mounted at startup and is never rebuilt (``apply_config`` swaps the live config
singleton, not the mount). A raised limit widens the bucket immediately; a lowered
one is clamped down by the existing refill cap.
"""

from __future__ import annotations
Expand All @@ -14,21 +21,18 @@


class TokenBucketRateLimiter:
"""A per-key token-bucket limiter sized for "N requests per minute".

Attributes:
rpm: Bucket capacity and the steady-state requests-per-minute ceiling.
"""
"""A per-key token-bucket limiter sized for the *current* "N requests per minute"."""

def __init__(self, rpm: int, *, now: Callable[[], float] = time.monotonic) -> None:
def __init__(self, rpm: Callable[[], int], *, now: Callable[[], float] = time.monotonic) -> None:
"""Initialise the limiter.

Args:
rpm: Maximum requests per minute per key (also the burst capacity).
rpm: Returns the maximum requests per minute per key (also the burst
capacity). Called on every :meth:`allow`, so it can read live config
and a change applies without rebuilding the limiter.
now: Monotonic clock returning seconds; injectable for tests.
"""
self._capacity = float(rpm)
self._refill_per_sec = rpm / 60.0
self._rpm = rpm
self._now = now
self._buckets: dict[str, tuple[float, float]] = {}
self._lock = threading.Lock()
Expand All @@ -43,9 +47,13 @@ def allow(self, key: str) -> bool:
``True`` if a token was available and consumed, ``False`` otherwise.
"""
with self._lock:
rpm = self._rpm()
capacity = float(rpm)
now = self._now()
tokens, last = self._buckets.get(key, (self._capacity, now))
tokens = min(self._capacity, tokens + (now - last) * self._refill_per_sec)
tokens, last = self._buckets.get(key, (capacity, now))
# min() re-clamps to the *current* capacity, so a lowered rpm shrinks an
# already-full bucket on the next call instead of letting it drain at the old size.
tokens = min(capacity, tokens + (now - last) * (rpm / 60.0))
allowed = tokens >= 1.0
self._buckets[key] = (tokens - 1.0 if allowed else tokens, now)
return allowed
12 changes: 10 additions & 2 deletions api/services/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from api.db import AsyncSessionLocal
from api.dependencies import (
get_embedding_adapter,
get_mcp_config,
get_reranker,
get_search_config,
get_vector_store,
Expand Down Expand Up @@ -137,8 +138,13 @@ def build_mcp_server(*, max_results: int = 20) -> FastMCP:
def build_mcp_asgi_app(config: MCPConfig) -> tuple[ASGIApp, FastMCP]:
"""Build the streamable-HTTP ASGI app for ``/mcp``, guarded by auth + limits.

The rate limit is wired as a *live read* of the ``mcp`` config rather than the
``config`` snapshot passed here: this app is built once at startup and never
rebuilt, so capturing the value would freeze it until the next restart. Reading
it per request means a config save applies to the very next MCP call.

Args:
config: The resolved ``mcp`` config section (rate limit + result cap).
config: The resolved ``mcp`` config section, for the startup-time result cap.

Returns:
``(asgi_app, server)`` — the auth/rate-limit-wrapped streamable-HTTP app,
Expand All @@ -147,7 +153,9 @@ def build_mcp_asgi_app(config: MCPConfig) -> tuple[ASGIApp, FastMCP]:
"""
server = build_mcp_server(max_results=config.max_results)
http_app = server.streamable_http_app()
guarded = build_mcp_middleware(http_app, rate_limit_rpm=config.rate_limit_rpm)
guarded = build_mcp_middleware(
http_app, rate_limit_rpm=lambda: get_mcp_config().rate_limit_rpm
)
return guarded, server


Expand Down
11 changes: 7 additions & 4 deletions docs/mcp-claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,12 @@ A typical flow inside a session:

- Every request must carry the master key (`Authorization: Bearer …` or
`X-API-Key: …`). A missing or wrong key returns **401**.
- Requests are rate-limited per key — **60 requests/min** by default
(`mcp.rate_limit_rpm` in `config.yaml`). The 61st within a minute returns
**429**.
- Requests are rate-limited per key — **60 requests/min** by default. The 61st
within a minute returns **429**. Raise the ceiling in **Settings → Config →
MCP server**: the limiter re-reads the limit on every request, so a save binds
on the next call with no restart. Editing `mcp.rate_limit_rpm` in `config.yaml`
by hand works too, but nothing re-reads the file at runtime — that route only
takes effect when the API restarts.

## Remote / LAN access

Expand All @@ -139,7 +142,7 @@ own machine, keep `MASTER_API_KEY` strong and set
| `✗ Failed to connect`, `404 Not Found` | Wrong path — used `/mcp/` or dropped the `/api` prefix | Use `http://localhost:8000/api/mcp/` (API port, `/api` prefix, trailing slash), or `http://<console>/api/mcp/`. |
| Registered as `sse` / old `/api/mcp/sse` URL | Stale SSE registration | Remove and re-add with `--transport http` and the `/api/mcp/` URL (below). |
| `401 Missing or invalid API key` | Key not sent or wrong | Check the `Authorization`/`X-API-Key` header matches `MASTER_API_KEY`. |
| `429 Rate limit exceeded` | > 60 req/min on one key | Back off, or raise `mcp.rate_limit_rpm` in `config.yaml`. |
| `429 Rate limit exceeded` | > 60 req/min on one key | Back off, or raise the limit in Settings → Config → MCP server (applies immediately). |
| Connection refused | Stack not up / wrong port | `docker compose ps`; confirm the API port and `curl /healthz`. |
| `search_documents` errors / 500 | Embedding provider down | Start Ollama (`ollama serve`) and `ollama pull embeddinggemma`. |

Expand Down
1 change: 1 addition & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2887,6 +2887,7 @@ components:
default: true
rate_limit_rpm:
type: integer
minimum: 1.0
title: Rate Limit Rpm
default: 60
max_results:
Expand Down
4 changes: 3 additions & 1 deletion tests/integration/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,9 @@ async def _ok_app(scope, receive, send):

def _guarded(rpm: int) -> MCPAuthRateLimitMiddleware:
return MCPAuthRateLimitMiddleware(
_ok_app, authenticate=lambda k: k == "secret", rate_limiter=TokenBucketRateLimiter(rpm)
_ok_app,
authenticate=lambda k: k == "secret",
rate_limiter=TokenBucketRateLimiter(lambda: rpm),
)


Expand Down
17 changes: 17 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import warnings

import pytest
from pydantic import ValidationError

from api.constants import POSTGRES_PORT
from api.models.config import AppConfig

Expand All @@ -19,6 +22,20 @@ def test_defaults_are_populated():
assert cfg.mcp.rate_limit_rpm == 60


@pytest.mark.parametrize("rpm", [0, -60])
def test_mcp_rate_limit_rejects_values_below_one(rpm):
"""The MCP limiter re-reads rate_limit_rpm per request, so a 0/negative value would brick the
endpoint the instant it is saved — 0 denies everything, and a negative rate accrues token debt
that outlives the correction (the bucket is never rebuilt). Reject it at the boundary: a bad
PUT /config is a 422 instead of a live lockout."""
with pytest.raises(ValidationError):
AppConfig.model_validate({"mcp": {"rate_limit_rpm": rpm}})


def test_mcp_rate_limit_accepts_one():
assert AppConfig.model_validate({"mcp": {"rate_limit_rpm": 1}}).mcp.rate_limit_rpm == 1


def test_nested_override_applies():
cfg = AppConfig.model_validate(
{
Expand Down
43 changes: 37 additions & 6 deletions tests/unit/test_mcp_rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

Drives :class:`TokenBucketRateLimiter` with an injected fake clock so the
"60 requests per minute, 61st denied" behaviour is asserted deterministically
without sleeping.
without sleeping. The rpm provider is likewise injected, which also covers the
limit changing live (a config save) without the limiter being rebuilt.
"""

from __future__ import annotations
Expand All @@ -25,7 +26,7 @@ def advance(self, seconds: float) -> None:

def test_allows_up_to_capacity_then_denies() -> None:
clock = _FakeClock()
limiter = TokenBucketRateLimiter(rpm=60, now=clock)
limiter = TokenBucketRateLimiter(rpm=lambda: 60, now=clock)

# All 60 tokens are available immediately (no time has elapsed).
assert all(limiter.allow("key-a") for _ in range(60))
Expand All @@ -35,7 +36,7 @@ def test_allows_up_to_capacity_then_denies() -> None:

def test_refills_one_token_per_second() -> None:
clock = _FakeClock()
limiter = TokenBucketRateLimiter(rpm=60, now=clock)
limiter = TokenBucketRateLimiter(rpm=lambda: 60, now=clock)
for _ in range(60):
limiter.allow("key-a")
assert limiter.allow("key-a") is False
Expand All @@ -47,7 +48,7 @@ def test_refills_one_token_per_second() -> None:

def test_refill_is_capped_at_capacity() -> None:
clock = _FakeClock()
limiter = TokenBucketRateLimiter(rpm=60, now=clock)
limiter = TokenBucketRateLimiter(rpm=lambda: 60, now=clock)
limiter.allow("key-a") # consume one

clock.advance(3600.0) # an hour of idle must not exceed capacity
Expand All @@ -56,7 +57,7 @@ def test_refill_is_capped_at_capacity() -> None:

def test_buckets_are_isolated_per_key() -> None:
clock = _FakeClock()
limiter = TokenBucketRateLimiter(rpm=5, now=clock)
limiter = TokenBucketRateLimiter(rpm=lambda: 5, now=clock)
assert all(limiter.allow("key-a") for _ in range(5))
assert limiter.allow("key-a") is False
# A different key has its own full bucket.
Expand All @@ -66,7 +67,37 @@ def test_buckets_are_isolated_per_key() -> None:

def test_rpm_is_configurable() -> None:
clock = _FakeClock()
limiter = TokenBucketRateLimiter(rpm=2, now=clock)
limiter = TokenBucketRateLimiter(rpm=lambda: 2, now=clock)
assert limiter.allow("k") is True
assert limiter.allow("k") is True
assert limiter.allow("k") is False


def test_raised_rpm_applies_without_rebuilding_the_limiter() -> None:
"""A config save must take effect on the next request, not the next restart.

Regression guard: rpm used to be captured in ``__init__``, so the limiter built when
the MCP app was mounted at startup kept the boot-time limit forever — ``apply_config``
swaps the live config singleton but never rebuilds the mount.
"""
clock = _FakeClock()
rpm = 2
limiter = TokenBucketRateLimiter(rpm=lambda: rpm, now=clock)
assert all(limiter.allow("k") for _ in range(2))
assert limiter.allow("k") is False # bucket empty at the old limit

rpm = 60 # the config PUT lands — same limiter instance, no restart
clock.advance(1.0) # 60 rpm => 1 token/sec, the NEW refill rate
assert limiter.allow("k") is True


def test_lowered_rpm_shrinks_an_already_full_bucket() -> None:
"""Dropping the limit must bite immediately — a bucket filled at the old, higher
capacity is re-clamped on the next call instead of serving one last oversized burst."""
clock = _FakeClock()
rpm = 60
limiter = TokenBucketRateLimiter(rpm=lambda: rpm, now=clock)
limiter.allow("k") # bucket sitting at 59 tokens under the old capacity

rpm = 5
assert sum(limiter.allow("k") for _ in range(100)) == 5 # clamped to the new capacity
13 changes: 13 additions & 0 deletions ui/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,18 @@ export interface StorageConfig {
temp_retention_hours: number // 0 = temporary uploads never expire (feature off)
}

/**
* MCP transport config. `rate_limit_rpm` caps requests per minute per API key on the
* mounted `/api/mcp` endpoint; the limiter reads it from live config per request, so a
* save applies to the next MCP call. `enabled` needs a restart (it drives the mount) and
* `max_results` is read at startup; both round-trip untouched from the UI.
*/
export interface MCPConfig {
enabled: boolean
rate_limit_rpm: number
max_results: number
}

/** `GET /config/accelerator` — GPU suitability for the docling PDF backend. */
export interface Accelerator {
device: string // "cuda" | "cpu"
Expand All @@ -465,6 +477,7 @@ export interface AppConfig {
search: SearchConfig
parsers: ParserConfig
storage: StorageConfig
mcp: MCPConfig
[section: string]: unknown
}

Expand Down
Loading
Loading