Skip to content

Commit 39b5302

Browse files
authored
feat(mcp): streamable-HTTP transport for the hosted mcp.fojin.ai endpoint (#1157)
fojin-mcp 0.2.0 — the PyPI stdio server becomes hostable: - MCP SDK v2 migration (mcp>=2,<3). Required regardless of hosting: 2.0 removed mcp.server.fastmcp, so 0.1.0 breaks on any fresh install today. - --transport streamable-http: stateless json_response mode (each call is one plain JSON POST — Cloudflare-proxy- and multi-worker-safe), Host validation pinned to the public hostname, per-client-IP sliding-window rate limiting, JSON access/tool logs, /healthz. - Shared process-wide FojinClient (one pool + text_id→cbeta_id cache, collapsing get_parallels' repeat fan-out) behind a concurrency gate. - client fixes: lookup_entity sent size= where /kg/entities takes limit= (silently ignored, always 20 rows); resolve_urn pre-encoded '#' and got double-encoded by httpx (server saw a literal %23). - Deploy: mcp compose service (loopback-bound, healthchecked, talks to backend over the compose network) + mcp.fojin.ai nginx block. - backend: requests without X-Forwarded-For (docker-network/loopback callers — nginx always appends it) skip the per-IP limiter, so all hosted-MCP users don't share one 20/min semantic-search bucket; the MCP layer rate-limits per real client instead.
1 parent cd413e0 commit 39b5302

13 files changed

Lines changed: 842 additions & 39 deletions

File tree

‎backend/app/core/rate_limit.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ async def dispatch(self, request: Request, call_next):
4747
if not redis_client:
4848
return await call_next(request)
4949

50+
# Internal callers — the hosted fojin-mcp container, compose
51+
# healthchecks — reach this port over the docker network or loopback.
52+
# The only public path is nginx, and nginx always appends
53+
# X-Forwarded-For, so a request without that header cannot have come
54+
# from the internet. Per-IP limiting such traffic is worse than
55+
# useless: every internal caller shares one 172.x address, so all
56+
# fojin-mcp users would burn a single /api/search/semantic budget.
57+
if "x-forwarded-for" not in request.headers:
58+
return await call_next(request)
59+
5060
# Behind Nginx reverse proxy, request.client.host is always the
5161
# internal Docker IP. Use the shared helper to extract the real
5262
# client IP from X-Forwarded-For — taking the LAST entry, since
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Internal (no-X-Forwarded-For) traffic must bypass the per-IP rate limiter.
2+
3+
The only public route to the backend is nginx, which always appends
4+
X-Forwarded-For. Direct docker-network callers — the hosted fojin-mcp
5+
container, compose healthchecks — carry no XFF and all share one internal
6+
address, so keying them into the per-IP window would make every fojin-mcp
7+
user worldwide share a single /api/search/semantic budget (20/min): the
8+
hosted MCP endpoint would 429 itself to death on day one.
9+
"""
10+
11+
from types import SimpleNamespace
12+
13+
import pytest
14+
from starlette.requests import Request
15+
from starlette.responses import PlainTextResponse
16+
17+
from app.core.rate_limit import RateLimitMiddleware
18+
19+
20+
class _FakeRedis:
21+
def __init__(self):
22+
self.incr_calls = 0
23+
24+
async def incr(self, key):
25+
self.incr_calls += 1
26+
return 1
27+
28+
async def expire(self, key, ttl):
29+
return True
30+
31+
32+
def _request(headers: dict[str, str], redis) -> Request:
33+
scope = {
34+
"type": "http",
35+
"method": "GET",
36+
"path": "/api/search/semantic",
37+
"headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()],
38+
"query_string": b"",
39+
"client": ("172.18.0.9", 40000),
40+
"app": SimpleNamespace(state=SimpleNamespace(redis=redis)),
41+
}
42+
return Request(scope)
43+
44+
45+
async def _ok(_request):
46+
return PlainTextResponse("ok")
47+
48+
49+
@pytest.mark.asyncio
50+
async def test_no_xff_skips_rate_limiting():
51+
"""No X-Forwarded-For ⇒ internal caller ⇒ no redis window consumed."""
52+
redis = _FakeRedis()
53+
mw = RateLimitMiddleware(app=None)
54+
resp = await mw.dispatch(_request({}, redis), _ok)
55+
assert resp.status_code == 200
56+
assert redis.incr_calls == 0
57+
58+
59+
@pytest.mark.asyncio
60+
async def test_xff_traffic_still_rate_limited():
61+
"""Anything nginx forwarded keeps consuming its per-IP window."""
62+
redis = _FakeRedis()
63+
mw = RateLimitMiddleware(app=None)
64+
resp = await mw.dispatch(
65+
_request({"X-Forwarded-For": "1.2.3.4, 5.6.7.8"}, redis), _ok
66+
)
67+
assert resp.status_code == 200
68+
assert redis.incr_calls == 1

‎deploy/host-nginx/fojin.conf‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,38 @@ server {
160160
server_name stream.fojin.ai;
161161
return 301 https://$host$request_uri;
162162
}
163+
164+
# mcp.fojin.ai — hosted MCP endpoint (behind Cloudflare proxy, HTTP only).
165+
# Unlike stream.fojin.ai this CAN sit behind Cloudflare: the MCP server runs
166+
# stateless JSON request/response (json_response=True — no long SSE streams),
167+
# so CF's 125s proxy timeout and response buffering never come into play.
168+
# The container does its own per-client rate limiting keyed on
169+
# CF-Connecting-IP (passed through by proxy_pass untouched).
170+
server {
171+
listen 80;
172+
server_name mcp.fojin.ai;
173+
include /etc/nginx/cloudflare-real-ip.conf;
174+
175+
add_header X-Content-Type-Options "nosniff" always;
176+
177+
location = /healthz {
178+
proxy_pass http://127.0.0.1:8765;
179+
proxy_set_header Host $host;
180+
}
181+
182+
location = /mcp {
183+
# Matches the MCP server's own 4 MB request-body cap.
184+
client_max_body_size 4m;
185+
proxy_pass http://127.0.0.1:8765;
186+
proxy_http_version 1.1;
187+
proxy_set_header Host $host;
188+
proxy_set_header X-Real-IP $remote_addr;
189+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
190+
proxy_set_header X-Forwarded-Proto $scheme;
191+
proxy_read_timeout 120s;
192+
}
193+
194+
location / {
195+
return 404;
196+
}
197+
}

‎docker-compose.yml‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,36 @@ services:
296296
# a proxy can set FRONTEND_BIND=0.0.0.0.
297297
- "${FRONTEND_BIND:-127.0.0.1}:${FRONTEND_PORT:-3000}:80"
298298

299+
# Hosted MCP endpoint (mcp.fojin.ai) — fojin's corpus tools for AI agents.
300+
# Talks to the backend directly over the compose network (no nginx hop):
301+
# internal traffic carries no X-Forwarded-For, which is what exempts it from
302+
# the backend's per-IP limiter (app/core/rate_limit.py) — this container
303+
# runs its own per-client window keyed on CF-Connecting-IP instead.
304+
mcp:
305+
build:
306+
context: ./mcp-server
307+
dockerfile: Dockerfile
308+
container_name: fojin-mcp
309+
restart: always
310+
logging: *default-logging
311+
mem_limit: 192m
312+
cpus: 0.25
313+
environment:
314+
FOJIN_API_BASE_URL: http://backend:8000/api
315+
FOJIN_MCP_PUBLIC_HOSTS: ${FOJIN_MCP_PUBLIC_HOSTS:-mcp.fojin.ai}
316+
FOJIN_MCP_RATE_LIMIT: ${FOJIN_MCP_RATE_LIMIT:-30}
317+
ports:
318+
- "127.0.0.1:${MCP_PORT:-8765}:8765"
319+
healthcheck:
320+
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8765/healthz')"]
321+
interval: 30s
322+
timeout: 5s
323+
retries: 5
324+
start_period: 10s
325+
depends_on:
326+
backend:
327+
condition: service_healthy
328+
299329
volumes:
300330
pgdata:
301331
esdata:

‎mcp-server/Dockerfile‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Hosted fojin-mcp (mcp.fojin.ai) — streamable-HTTP MCP endpoint.
2+
# The stdio install path for end users stays PyPI (`uvx fojin-mcp`); this
3+
# image only exists so docker-compose can run the hosted endpoint.
4+
FROM python:3.12-slim
5+
6+
WORKDIR /srv/fojin-mcp
7+
8+
# Package metadata + source only; tests/CI never enter the image.
9+
COPY pyproject.toml README.md ./
10+
COPY fojin_mcp ./fojin_mcp
11+
12+
RUN pip install --no-cache-dir ".[http]" && \
13+
useradd --system --no-create-home fojin-mcp
14+
15+
USER fojin-mcp
16+
17+
EXPOSE 8765
18+
19+
# --host 0.0.0.0 is container-internal only: compose publishes the port
20+
# bound to the host's loopback, nginx/Cloudflare are the public path.
21+
CMD ["fojin-mcp", "--transport", "streamable-http", "--host", "0.0.0.0", "--port", "8765"]

‎mcp-server/README.md‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ instead of inventing scripture.
99

1010
Read-only by construction: it only issues GETs against fojin's public API.
1111

12+
Two ways to use it:
13+
14+
1. **Hosted endpoint (zero install)** — `https://mcp.fojin.ai/mcp`, streamable
15+
HTTP, anonymous, rate-limited. One line in Claude Code:
16+
17+
```bash
18+
claude mcp add --transport http fojin https://mcp.fojin.ai/mcp
19+
```
20+
21+
2. **Local over stdio** — `uvx fojin-mcp`, the classic MCP install (below).
22+
1223
## Tools
1324

1425
| Tool | What it returns |
@@ -70,7 +81,35 @@ from a checkout.) Restart Claude Desktop; the six fojin tools then appear.
7081
### ChatGPT / other MCP clients
7182

7283
Any client that speaks MCP over stdio can launch `uvx fojin-mcp` (or the
73-
`fojin-mcp` console script) and will discover the six tools automatically.
84+
`fojin-mcp` console script); any client that speaks streamable HTTP can point
85+
at `https://mcp.fojin.ai/mcp`. Both discover the six tools automatically.
86+
87+
## Hosting the HTTP endpoint (self-host)
88+
89+
The same package serves streamable HTTP (requires the `http` extra, which adds
90+
uvicorn):
91+
92+
```bash
93+
pip install 'fojin-mcp[http]'
94+
fojin-mcp --transport streamable-http --port 8765 --public-host mcp.example.com
95+
```
96+
97+
The hosted mode is **stateless JSON request/response** (`stateless_http` +
98+
`json_response`): every tool call is one plain POST — no SSE streams, so it
99+
sits safely behind Cloudflare's proxy and behind multi-worker uvicorn. It adds:
100+
101+
- **Per-client rate limiting** — sliding window keyed on `CF-Connecting-IP` /
102+
`X-Real-IP` (falling back to the socket peer). Tune with
103+
`--rate-limit`/`--rate-window` or `FOJIN_MCP_RATE_LIMIT`/`FOJIN_MCP_RATE_WINDOW`.
104+
- **Host-header validation** (DNS-rebinding protection) pinned to
105+
`--public-host` / `FOJIN_MCP_PUBLIC_HOSTS` (default `mcp.fojin.ai`).
106+
- **Access + tool logs** — one JSON line per request (`fojin_mcp.access`) and
107+
per tool call (`fojin_mcp.tools`) on stdout/stderr.
108+
- **`/healthz`** for container/proxy healthchecks.
109+
110+
The production deployment is the `mcp` service in the repo's
111+
`docker-compose.yml` (it talks to the backend container directly) plus the
112+
`mcp.fojin.ai` server block in `deploy/host-nginx/fojin.conf`.
74113

75114
## Design
76115

@@ -83,7 +122,9 @@ Any client that speaks MCP over stdio can launch `uvx fojin-mcp` (or the
83122
`get_parallels` enriches each parallel's URN via a bounded, concurrent,
84123
best-effort `text_id → cbeta_id` lookup (a miss just leaves `urn: null`).
85124
- **Testable core.** All HTTP + reshaping lives in `client.py` and is tested
86-
against a mocked transport; `server.py` is a thin FastMCP wiring layer.
125+
against a mocked transport; `server.py` is a thin MCP wiring layer (SDK v2 —
126+
`mcp>=2` is required; 2.0 removed the 1.x module this package imported
127+
through 0.1.0), and the hosted-edge concerns live in `http.py`.
87128
- **Fails soft.** An upstream error returns `{"error": "..."}` to the model
88129
rather than crashing the tool call.
89130

@@ -101,7 +142,7 @@ every change under `mcp-server/`. To release to PyPI, bump `version` in
101142
`pyproject.toml`, then push a tag:
102143

103144
```bash
104-
git tag mcp-v0.1.0 && git push origin mcp-v0.1.0
145+
git tag mcp-v0.2.0 && git push origin mcp-v0.2.0
105146
```
106147

107148
`.github/workflows/mcp-publish.yml` builds and uploads. It needs credentials

‎mcp-server/fojin_mcp/__init__.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
"""fojin MCP server package — cross-canon Buddhist corpus tools over stdio."""
1+
"""fojin MCP server package — cross-canon Buddhist corpus tools over stdio or
2+
streamable HTTP (the hosted endpoint at mcp.fojin.ai)."""
23

3-
__version__ = "0.1.0"
4+
__version__ = "0.2.0"

‎mcp-server/fojin_mcp/client.py‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ def __init__(
5757
timeout=timeout, headers={"User-Agent": USER_AGENT}
5858
)
5959
self._owns_client = client is None
60+
# text_id → cbeta_id is static corpus metadata; caching it collapses the
61+
# 1+N fan-out of repeated get_parallels calls on a long-lived client.
62+
self._cbeta_cache: dict[int, str] = {}
6063

6164
async def __aenter__(self) -> FojinClient:
6265
return self
@@ -113,12 +116,23 @@ async def get_parallels(self, text_id: int, juan_num: int) -> dict[str, Any]:
113116
return shaped
114117

115118
async def _text_cbeta_id(self, text_id: int) -> str | None:
116-
"""Best-effort text_id → cbeta_id via /texts/{id}; None on any failure."""
119+
"""Best-effort text_id → cbeta_id via /texts/{id}; None on any failure.
120+
121+
Successful lookups are cached for the client's lifetime (the mapping is
122+
static); failures are not, so a transient error can heal on retry."""
123+
if text_id in self._cbeta_cache:
124+
return self._cbeta_cache[text_id]
117125
try:
118126
meta = await self._get(f"/texts/{text_id}")
119127
except FojinAPIError:
120128
return None
121-
return meta.get("cbeta_id") if isinstance(meta, dict) else None
129+
cbeta_id = meta.get("cbeta_id") if isinstance(meta, dict) else None
130+
if isinstance(cbeta_id, str) and cbeta_id:
131+
if len(self._cbeta_cache) >= 100_000: # paranoia bound, ~10K texts real
132+
self._cbeta_cache.clear()
133+
self._cbeta_cache[text_id] = cbeta_id
134+
return cbeta_id
135+
return None
122136

123137
async def _enrich_parallel_urns(self, parallels: list[dict[str, Any]]) -> None:
124138
"""Fill each parallel's ``urn`` in place from its reader_ref.text_id.
@@ -158,15 +172,19 @@ async def lookup_dictionary(self, term: str, limit: int = 10) -> dict[str, Any]:
158172

159173
async def lookup_entity(self, query: str, limit: int = 10) -> dict[str, Any]:
160174
"""Knowledge-graph entity search (persons, places, works, terms)."""
175+
# /kg/entities takes `limit` (not `size` like search/dictionary do);
176+
# FastAPI silently drops unknown params, so the wrong name means the
177+
# caller's limit never arrives.
161178
data = await self._get(
162-
"/kg/entities", {"q": query, "size": _clamp(limit, 1, 50)}
179+
"/kg/entities", {"q": query, "limit": _clamp(limit, 1, 50)}
163180
)
164181
return {"query": query, "entities": _as_list(data, "results", "entities")}
165182

166183
async def resolve_urn(self, urn: str) -> dict[str, Any]:
167184
"""Resolve a fojin URN → reader URL + existence (server-authoritative)."""
168-
# Anchor '#' must be percent-encoded or the server sees an anchorless URN.
169-
return await self._get("/urn/resolve", {"urn": urn.replace("#", "%23")})
185+
# httpx percent-encodes '#' in query params; pre-encoding here would
186+
# double-encode it into a literal "%23" on the server side.
187+
return await self._get("/urn/resolve", {"urn": urn})
170188

171189

172190
# ── pure reshaping (unit-tested with plain dicts) ────────────────────────

0 commit comments

Comments
 (0)