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
9 changes: 9 additions & 0 deletions docs/integrations/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,12 @@ claude mcp add mfs-context \
`claude mcp list` should report `mfs-context: ✔ Connected`, after which the agent
calls the tools on its own — "where is rate limiting implemented? search our
sources." Any MCP client works; point its stdio server config at the same command.

## Restrict its reach

By default the server can search and read everything the MFS server has indexed.
Add `--env MFS_ALLOWED_SCOPES=github://org/repo,file://local/abs/path` (a
comma-separated list of URI / path prefixes) to bound it: `search` only returns
hits under those prefixes (an empty scope searches all of them, not the whole
index) and `read` refuses any source outside them. This is enforced by the server,
unlike the per-query `scope` argument the agent chooses.
18 changes: 18 additions & 0 deletions examples/mfs-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,21 @@ Any MCP client works; point its stdio server config at the same command. The
server needs the [`mcp`](https://pypi.org/project/mcp/) package and the MFS Python
SDK (`mfs_sdk`, under [`sdks/python`](../../sdks/python)) on its path — the
`uv run --with …` invocation above pulls both in.

## Restrict its reach

By default the server can search and read everything the MFS server has indexed.
To bound it, set `MFS_ALLOWED_SCOPES` to a comma-separated list of URI / path
prefixes when you register it:

```bash
claude mcp add mfs-context \
--env MFS_URL=http://127.0.0.1:13619 \
--env MFS_ALLOWED_SCOPES=github://your-org/your-repo,file://local/abs/path \
-- uv run --with mcp --with /abs/path/to/mfs/sdks/python python /abs/path/to/mfs/examples/mfs-mcp/server.py
```

`search` then only returns hits under those prefixes — an empty scope searches all
of them, not the whole index — and `read` refuses any source outside them. Unlike
the per-query `scope` argument (which the agent chooses), this is enforced by the
server, so the client can't reach past it.
37 changes: 31 additions & 6 deletions examples/mfs-mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@

mcp = FastMCP("mfs-context")

# Optional access boundary: a comma-separated list of URI / path prefixes this
# server may search and read. Empty = unrestricted (the whole MFS index). Set it
# on the MCP registration, e.g.
# --env MFS_ALLOWED_SCOPES=github://org/repo,file://local/abs/path
_ALLOWED = [s.strip() for s in os.getenv("MFS_ALLOWED_SCOPES", "").split(",") if s.strip()]


def _within(uri: str) -> bool:
"""True when `uri` is one of, or under, an allowed prefix (or no allowlist is set)."""
if not _ALLOWED:
return True
return any(uri == p or uri.startswith(p.rstrip("/") + "/") for p in _ALLOWED)


def _token() -> str | None:
if os.getenv("MFS_TOKEN"):
Expand All @@ -48,15 +61,25 @@ def _api(api_cls):
@mcp.tool()
def search(query: str, scope: str = "", top_k: int = 8) -> str:
"""Search MFS-indexed sources (code, docs, issues, chat, databases) by meaning
or keyword. Leave ``scope`` empty to search everything, or pass a path / URI
prefix to narrow it (e.g. ``github://org/repo`` or a local path). Returns
ranked hits, each with a snippet and the ``source`` URI to pass to ``read``.
or keyword. Leave ``scope`` empty to search every allowed source, or pass a
path / URI prefix to narrow it (e.g. ``github://org/repo`` or a local path).
Returns ranked hits, each with a snippet and the ``source`` URI to pass to
``read``.
"""
resp = _api(mfs_sdk.RetrievalApi).search(q=query, path=scope or None, top_k=top_k)
if not resp.results:
if scope and not _within(scope):
return f"Refused: scope {scope!r} is outside the allowed scopes ({', '.join(_ALLOWED)})."
targets = [scope] if scope else (list(_ALLOWED) or [None])

api = _api(mfs_sdk.RetrievalApi)
hits = []
for target in targets:
hits.extend(api.search(q=query, path=target, top_k=top_k).results)
hits = [h for h in hits if _within(h.source)] # belt-and-suspenders
hits.sort(key=lambda h: h.score if h.score is not None else 0.0, reverse=True)
if not hits:
return "No matches."
blocks = []
for h in resp.results:
for h in hits[:top_k]:
score = f"{h.score:.2f}" if h.score is not None else "n/a"
blocks.append(f"## {h.source} (score={score})\n{h.content.strip()}")
return "\n\n".join(blocks)
Expand All @@ -67,6 +90,8 @@ def read(source: str, lines: str = "") -> str:
"""Read a source in full, or a line range like ``"40:80"``. ``source`` is the
URI from a ``search`` hit. Use this to pull the exact code or text into context
after ``search`` locates it."""
if not _within(source):
return f"Refused: {source!r} is outside the allowed scopes ({', '.join(_ALLOWED)})."
resp = _api(mfs_sdk.BrowseApi).cat(source, range=lines or None)
return resp.content

Expand Down