Skip to content

Commit ba99ee0

Browse files
authored
examples(mcp): enforce an MFS_ALLOWED_SCOPES access boundary (#148)
Add an optional MFS_ALLOWED_SCOPES allowlist to the MFS MCP server: search only returns hits under an allowed URI/path prefix (empty scope fans out across the allowed prefixes, not the whole index) and read refuses sources outside them, enforced in the server. Empty = unrestricted. Verified end to end against two indexed corpora.
1 parent 2b47cc7 commit ba99ee0

3 files changed

Lines changed: 58 additions & 6 deletions

File tree

docs/integrations/mcp.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,12 @@ claude mcp add mfs-context \
8181
`claude mcp list` should report `mfs-context: ✔ Connected`, after which the agent
8282
calls the tools on its own — "where is rate limiting implemented? search our
8383
sources." Any MCP client works; point its stdio server config at the same command.
84+
85+
## Restrict its reach
86+
87+
By default the server can search and read everything the MFS server has indexed.
88+
Add `--env MFS_ALLOWED_SCOPES=github://org/repo,file://local/abs/path` (a
89+
comma-separated list of URI / path prefixes) to bound it: `search` only returns
90+
hits under those prefixes (an empty scope searches all of them, not the whole
91+
index) and `read` refuses any source outside them. This is enforced by the server,
92+
unlike the per-query `scope` argument the agent chooses.

examples/mfs-mcp/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,21 @@ Any MCP client works; point its stdio server config at the same command. The
7171
server needs the [`mcp`](https://pypi.org/project/mcp/) package and the MFS Python
7272
SDK (`mfs_sdk`, under [`sdks/python`](../../sdks/python)) on its path — the
7373
`uv run --with …` invocation above pulls both in.
74+
75+
## Restrict its reach
76+
77+
By default the server can search and read everything the MFS server has indexed.
78+
To bound it, set `MFS_ALLOWED_SCOPES` to a comma-separated list of URI / path
79+
prefixes when you register it:
80+
81+
```bash
82+
claude mcp add mfs-context \
83+
--env MFS_URL=http://127.0.0.1:13619 \
84+
--env MFS_ALLOWED_SCOPES=github://your-org/your-repo,file://local/abs/path \
85+
-- uv run --with mcp --with /abs/path/to/mfs/sdks/python python /abs/path/to/mfs/examples/mfs-mcp/server.py
86+
```
87+
88+
`search` then only returns hits under those prefixes — an empty scope searches all
89+
of them, not the whole index — and `read` refuses any source outside them. Unlike
90+
the per-query `scope` argument (which the agent chooses), this is enforced by the
91+
server, so the client can't reach past it.

examples/mfs-mcp/server.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@
2727

2828
mcp = FastMCP("mfs-context")
2929

30+
# Optional access boundary: a comma-separated list of URI / path prefixes this
31+
# server may search and read. Empty = unrestricted (the whole MFS index). Set it
32+
# on the MCP registration, e.g.
33+
# --env MFS_ALLOWED_SCOPES=github://org/repo,file://local/abs/path
34+
_ALLOWED = [s.strip() for s in os.getenv("MFS_ALLOWED_SCOPES", "").split(",") if s.strip()]
35+
36+
37+
def _within(uri: str) -> bool:
38+
"""True when `uri` is one of, or under, an allowed prefix (or no allowlist is set)."""
39+
if not _ALLOWED:
40+
return True
41+
return any(uri == p or uri.startswith(p.rstrip("/") + "/") for p in _ALLOWED)
42+
3043

3144
def _token() -> str | None:
3245
if os.getenv("MFS_TOKEN"):
@@ -48,15 +61,25 @@ def _api(api_cls):
4861
@mcp.tool()
4962
def search(query: str, scope: str = "", top_k: int = 8) -> str:
5063
"""Search MFS-indexed sources (code, docs, issues, chat, databases) by meaning
51-
or keyword. Leave ``scope`` empty to search everything, or pass a path / URI
52-
prefix to narrow it (e.g. ``github://org/repo`` or a local path). Returns
53-
ranked hits, each with a snippet and the ``source`` URI to pass to ``read``.
64+
or keyword. Leave ``scope`` empty to search every allowed source, or pass a
65+
path / URI prefix to narrow it (e.g. ``github://org/repo`` or a local path).
66+
Returns ranked hits, each with a snippet and the ``source`` URI to pass to
67+
``read``.
5468
"""
55-
resp = _api(mfs_sdk.RetrievalApi).search(q=query, path=scope or None, top_k=top_k)
56-
if not resp.results:
69+
if scope and not _within(scope):
70+
return f"Refused: scope {scope!r} is outside the allowed scopes ({', '.join(_ALLOWED)})."
71+
targets = [scope] if scope else (list(_ALLOWED) or [None])
72+
73+
api = _api(mfs_sdk.RetrievalApi)
74+
hits = []
75+
for target in targets:
76+
hits.extend(api.search(q=query, path=target, top_k=top_k).results)
77+
hits = [h for h in hits if _within(h.source)] # belt-and-suspenders
78+
hits.sort(key=lambda h: h.score if h.score is not None else 0.0, reverse=True)
79+
if not hits:
5780
return "No matches."
5881
blocks = []
59-
for h in resp.results:
82+
for h in hits[:top_k]:
6083
score = f"{h.score:.2f}" if h.score is not None else "n/a"
6184
blocks.append(f"## {h.source} (score={score})\n{h.content.strip()}")
6285
return "\n\n".join(blocks)
@@ -67,6 +90,8 @@ def read(source: str, lines: str = "") -> str:
6790
"""Read a source in full, or a line range like ``"40:80"``. ``source`` is the
6891
URI from a ``search`` hit. Use this to pull the exact code or text into context
6992
after ``search`` locates it."""
93+
if not _within(source):
94+
return f"Refused: {source!r} is outside the allowed scopes ({', '.join(_ALLOWED)})."
7095
resp = _api(mfs_sdk.BrowseApi).cat(source, range=lines or None)
7196
return resp.content
7297

0 commit comments

Comments
 (0)