Skip to content

Commit cb9acf9

Browse files
authored
feat(mcp): let operators disable individual built-in tools (#668)
`list-indexes`, `search-records`, and `upsert-records` all registered unconditionally, so an operator who wanted a narrower tool surface had no way to get one. A deployment that should never advertise writes still published `upsert-records` whenever any binding was writable, and a single-purpose server still published discovery. ## The config ```yaml server: builtin_tools: upsert-records: disabled ``` Omitted names stay enabled, so **existing configs are unaffected**. Only the three real names are accepted: `search_records` with underscores fails at startup rather than silently disabling nothing while reading as though it had. ## Two unusable-but-valid tool surfaces now warn Both shapes are legal config that presents to a client as a server that simply does not work, and both were previously silent: - **Everything disabled** leaves a server that connects and offers nothing. The warning deliberately does not name a cause, since `upsert-records` can also be absent because every binding is read-only. - **Discovery disabled on a multi-index server** leaves `search-records` demanding a logical index id that clients have no way to learn — its own description tells them to call `list-indexes` first. Neither is fatal, because an operator may be mid-rollout. The multi-index discovery case had **no coverage anywhere**, so this adds it, along with the negative case: with a sole binding the `index` argument defaults, so disabling discovery there is legitimate and stays quiet. I verified by mutation that the new test fails when the warning condition is neutered. ## Verification - MCP unit tests: 237 passing - `make check-types`: clean - Pre-commit: passing ## Note on sequencing This is independently useful and mergeable on its own. It is also a prerequisite for the custom tool profiles work that follows, since a profile name must be checked against the built-in names this PR introduces. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes which MCP tools clients see and discovery/write advertising; misconfiguration can yield empty or hard-to-use tool surfaces, though defaults preserve existing behavior and validation plus warnings reduce silent failures. > > **Overview** > Adds **`server.builtin_tools`** so operators can disable any of `list-indexes`, `search-records`, or `upsert-records` individually (omitted tools stay enabled). Unknown names fail at **startup** validation instead of being ignored. > > **Registration** gates each built-in on config; **`list-indexes`** sets **`upsert_available`** to false when upsert is disabled even if bindings are writable. On **multi-index** servers with discovery off, **search** and **upsert** tool descriptions **inline** logical index ids. **Startup warnings** cover empty tool lists, discovery disabled with multiple indexes, and **config changes** after tools were already registered (process restart required to change the published surface). > > Docs in **`docs/concepts/mcp.md`** describe the config and behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f323df5. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent d73e9d2 commit cb9acf9

11 files changed

Lines changed: 606 additions & 28 deletions

File tree

docs/concepts/mcp.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ MCP-reserved score metadata field names for the configured search mode.
8686

8787
## Read-Only and Read-Write Modes
8888

89-
RedisVL MCP always registers `search-records` and `list-indexes`.
89+
RedisVL MCP registers `search-records` and `list-indexes` by default (see [Tool Surface](#tool-surface) for turning a built-in off deliberately).
9090

9191
Write availability is enforced at two levels:
9292

@@ -105,12 +105,31 @@ For configuration and the gateway boundary, see {doc}`/user_guide/how_to_guides/
105105

106106
## Tool Surface
107107

108-
RedisVL MCP exposes up to three tools:
108+
RedisVL MCP exposes up to three built-in tools:
109109

110-
- `list-indexes` enumerates the configured logical indexes for discovery (always available)
110+
- `list-indexes` enumerates the configured logical indexes for discovery
111111
- `search-records` searches a selected index using that index's server-owned search mode
112112
- `upsert-records` validates and upserts records into a selected writable index, embedding them only when that capability is configured
113113

114+
Any of the three can be turned off with `server.builtin_tools` — useful for a server that should only ever read, or one that should not advertise discovery:
115+
116+
```yaml
117+
server:
118+
builtin_tools:
119+
upsert-records: disabled
120+
```
121+
122+
Only the three names above are accepted; anything else fails at startup rather than being silently ignored.
123+
124+
Disabling a built-in adjusts what the rest of the surface advertises, so the published contract never points at something the server withholds:
125+
126+
- `list-indexes` reports `upsert_available: false` for every binding when `upsert-records` is disabled, since a writable binding still cannot be written to through a tool that is not published.
127+
- On a multi-index server with `list-indexes` disabled, every tool that requires an `index` — `search-records` and `upsert-records` alike — names the available index ids in its own description instead of deferring to a discovery tool that does not exist. That server still logs a startup warning naming the affected tools, because inlining the ids is a fallback rather than an endorsement of the shape.
128+
129+
A server whose tool set ends up unusable — no tools at all, or discovery disabled on a multi-index server — logs a warning at startup.
130+
131+
Tools register once per process. `builtin_tools` is re-read on restart, but the registered tool set is not rebuilt, so a stop/start against an edited config keeps the previous tools and logs a warning saying so. Start a new process to change the tool surface.
132+
114133
These tools follow a stable contract:
115134

116135
- request validation happens before query or write execution

redisvl/mcp/config.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,23 @@
2626
)
2727

2828

29+
_BUILTIN_TOOL_NAMES = frozenset({"list-indexes", "search-records", "upsert-records"})
30+
31+
2932
def reserved_score_metadata_field_names() -> frozenset[str]:
3033
"""Return MCP-reserved score metadata field names."""
3134
return _RESERVED_SCORE_METADATA_FIELDS
3235

3336

37+
def builtin_tool_names() -> frozenset[str]:
38+
"""Return the names of the built-in MCP tools.
39+
40+
These register by default and can be turned off individually through
41+
``server.builtin_tools``, so they are not unconditionally available.
42+
"""
43+
return _BUILTIN_TOOL_NAMES
44+
45+
3446
class MCPRuntimeConfig(BaseModel):
3547
"""Runtime limits and validated field mappings for MCP requests."""
3648

@@ -200,6 +212,25 @@ class MCPServerConfig(BaseModel):
200212
redis_url: str = Field(..., min_length=1)
201213
auth: MCPAuthConfig | None = None
202214
transport_security: MCPTransportSecurityConfig | None = None
215+
builtin_tools: dict[str, Literal["enabled", "disabled"]] = Field(
216+
default_factory=dict
217+
)
218+
219+
@model_validator(mode="after")
220+
def _validate_builtin_tools(self) -> "MCPServerConfig":
221+
"""Reject disable/enable entries that do not name a built-in tool."""
222+
unknown = sorted(set(self.builtin_tools) - builtin_tool_names())
223+
if unknown:
224+
raise ValueError(
225+
"server.builtin_tools contains unknown tool names: "
226+
f"{', '.join(unknown)}; known built-ins: "
227+
f"{', '.join(sorted(builtin_tool_names()))}"
228+
)
229+
return self
230+
231+
def builtin_tool_enabled(self, tool_name: str) -> bool:
232+
"""Report whether a built-in tool should be registered."""
233+
return self.builtin_tools.get(tool_name, "enabled") == "enabled"
203234

204235

205236
class MCPIndexSearchConfig(BaseModel):

redisvl/mcp/server.py

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def __init__(self, settings: MCPSettings):
6868
self._bindings: dict[str, BindingRuntime] = {}
6969
self._semaphore: asyncio.Semaphore | None = None
7070
self._tools_registered = False
71+
self._registered_tool_fingerprint = ""
7172

7273
# Lifecycle management
7374
self._lifecycle_state = _LifecycleState.INITIAL # Server lifecycle
@@ -270,9 +271,32 @@ async def _probe_native_hybrid_search(index: AsyncSearchIndex) -> bool:
270271

271272
return hasattr(client.ft(index.schema.index.name), "hybrid_search")
272273

274+
@staticmethod
275+
def _tool_surface_fingerprint(config: Any) -> str:
276+
"""Summarize the config that a registered tool set baked in."""
277+
if config is None:
278+
return ""
279+
return repr(sorted(config.server.builtin_tools.items()))
280+
273281
def _register_tools(self) -> None:
274282
"""Register MCP tools once every binding is ready."""
275283
if self._tools_registered or not hasattr(self, "tool"):
284+
# Registration is deliberately once-per-process, since re-registering
285+
# the same names on the FastMCP object is not valid. Built-in tool
286+
# closures resolve their binding per call, so they survive a restart
287+
# unchanged -- but which built-ins exist is now a function of config,
288+
# and `startup()` re-reads that file. A stop/start against an edited
289+
# config therefore keeps the old tool set, and the dangerous direction
290+
# is an operator disabling a tool and believing the restart applied it.
291+
if self._tools_registered:
292+
current = self._tool_surface_fingerprint(getattr(self, "config", None))
293+
if current != self._registered_tool_fingerprint:
294+
logger.warning(
295+
"MCP built-in tool configuration changed since tools were "
296+
"registered, but tools register once per process. The "
297+
"previously registered tool set is still in effect; "
298+
"restart the process to apply the new configuration."
299+
)
276300
return
277301

278302
# The search description advertises schema-specific filter hints, which
@@ -282,17 +306,90 @@ def _register_tools(self) -> None:
282306
if len(self._bindings) == 1:
283307
search_schema = next(iter(self._bindings.values())).schema
284308

285-
# Discovery is always available so clients can enumerate indexes.
286-
register_list_indexes_tool(self)
287-
register_search_tool(self, search_schema)
309+
# An operator can turn off a built-in whose capability the server should
310+
# not offer at all -- a read-only deployment, or one that should not
311+
# advertise discovery.
312+
config = getattr(self, "config", None)
313+
enabled = (
314+
config.server.builtin_tool_enabled
315+
if config is not None
316+
else lambda _name: True
317+
)
318+
319+
registered: list[str] = []
320+
321+
# Discovery is on by default so clients can enumerate indexes.
322+
discovery_enabled = enabled("list-indexes")
323+
if discovery_enabled:
324+
register_list_indexes_tool(self)
325+
registered.append("list-indexes")
326+
327+
# `index` is required once several bindings exist, and without discovery
328+
# the logical ids cannot be learned any other way -- so every tool that
329+
# requires one has to name them inline instead of deferring to a tool that
330+
# is not published. Computed once so the two cannot drift apart.
331+
unlisted_index_ids = (
332+
sorted(self._bindings)
333+
if len(self._bindings) > 1 and not discovery_enabled
334+
else None
335+
)
336+
337+
if enabled("search-records"):
338+
register_search_tool(self, search_schema, index_ids=unlisted_index_ids)
339+
registered.append("search-records")
288340
# Expose upsert only when at least one binding is writable. A binding is
289341
# read-only under global read-only mode or its own read_only policy, both
290342
# of which are folded into effective_read_only; the per-call write check
291343
# in the tool then rejects writes to any individual read-only binding.
292-
if any(not rt.effective_read_only for rt in self._bindings.values()):
293-
register_upsert_tool(self)
344+
if enabled("upsert-records") and any(
345+
not rt.effective_read_only for rt in self._bindings.values()
346+
):
347+
register_upsert_tool(self, index_ids=unlisted_index_ids)
348+
registered.append("upsert-records")
349+
350+
self._warn_on_unusable_tool_surface(registered)
351+
self._registered_tool_fingerprint = self._tool_surface_fingerprint(config)
294352
self._tools_registered = True
295353

354+
def _warn_on_unusable_tool_surface(self, registered: list[str]) -> None:
355+
"""Warn about tool-set shapes that are valid config but unusable in practice.
356+
357+
Neither case is fatal -- an operator may be mid-rollout -- but both are
358+
silent otherwise, and both present to a client as a server that simply
359+
does not work.
360+
"""
361+
if not registered:
362+
# Deliberately does not attribute a cause: `upsert-records` can also
363+
# be absent because every binding is read-only, not because
364+
# `builtin_tools` disabled it.
365+
logger.warning(
366+
"MCP server registered no tools, so clients will see an empty "
367+
"tool list. Check server.builtin_tools and read-only settings."
368+
)
369+
return
370+
371+
# Both `search-records` and `upsert-records` require an `index` once
372+
# several bindings exist, so either one is affected by losing discovery --
373+
# naming them in the descriptions keeps the contract satisfiable, but an
374+
# operator who disabled discovery on a multi-index server probably did not
375+
# intend to. Checking only search would leave a write-only surface silent.
376+
index_requiring = sorted(
377+
{"search-records", "upsert-records"}.intersection(registered)
378+
)
379+
if (
380+
len(self._bindings) > 1
381+
and index_requiring
382+
and "list-indexes" not in registered
383+
):
384+
logger.warning(
385+
"MCP server has %d indexes and exposes %s, but list-indexes is "
386+
"disabled: clients cannot discover the logical index ids those "
387+
"tools require, so the ids are named inline in each tool "
388+
"description instead.",
389+
len(self._bindings),
390+
", ".join(index_requiring),
391+
)
392+
296393
@asynccontextmanager
297394
async def _server_lifespan(self, _server: Any):
298395
"""Bridge FastMCP lifespan hooks onto the server's explicit lifecycle."""

redisvl/mcp/tools/list_indexes.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,20 @@ def _binding_limits(binding_runtime: BindingRuntime) -> dict[str, int]:
4949
}
5050

5151

52-
def _describe_binding(binding_runtime: BindingRuntime) -> dict[str, Any]:
52+
def _describe_binding(
53+
binding_runtime: BindingRuntime, *, upsert_tool_available: bool = True
54+
) -> dict[str, Any]:
5355
"""Build the deterministic discovery payload for a single binding."""
5456
entry: dict[str, Any] = {"id": binding_runtime.binding_id}
5557
if binding_runtime.binding.description is not None:
5658
entry["description"] = binding_runtime.binding.description
57-
# Reflects both global read-only and the per-index read_only policy.
58-
entry["upsert_available"] = not binding_runtime.effective_read_only
59+
# Reflects global read-only, the per-index read_only policy, and whether the
60+
# tool is published at all. A writable binding on a server that disabled
61+
# `upsert-records` still cannot be written to, so reporting availability from
62+
# read-only state alone would advertise a tool the client cannot call.
63+
entry["upsert_available"] = (
64+
upsert_tool_available and not binding_runtime.effective_read_only
65+
)
5966
entry["fields"] = _binding_fields(binding_runtime)
6067
limits = _binding_limits(binding_runtime)
6168
if limits:
@@ -73,16 +80,26 @@ def list_indexes(server: "RedisVLMCPServer") -> dict[str, Any]:
7380
# client could misread as "no indexes configured".
7481
if not server._bindings:
7582
raise RuntimeError("MCP server has not been started")
83+
config = getattr(server, "config", None)
84+
upsert_tool_available = config is None or config.server.builtin_tool_enabled(
85+
"upsert-records"
86+
)
7687
return {
7788
"indexes": [
78-
_describe_binding(binding_runtime)
89+
_describe_binding(
90+
binding_runtime, upsert_tool_available=upsert_tool_available
91+
)
7992
for binding_runtime in server._bindings.values()
8093
],
8194
}
8295

8396

8497
def register_list_indexes_tool(server: "RedisVLMCPServer") -> None:
85-
"""Register the always-available, read-only `list-indexes` MCP tool."""
98+
"""Register the read-only `list-indexes` MCP tool.
99+
100+
Registered by default; an operator can turn it off through
101+
``server.builtin_tools``.
102+
"""
86103

87104
async def list_indexes_tool():
88105
"""FastMCP wrapper for the `list-indexes` tool."""

redisvl/mcp/tools/search.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,31 @@ def _build_return_fields_hint(schema: IndexSchema) -> str:
5151

5252

5353
def _build_search_tool_description(
54-
schema: IndexSchema | None, base_description: str | None = None
54+
schema: IndexSchema | None,
55+
base_description: str | None = None,
56+
*,
57+
index_ids: list[str] | None = None,
5558
) -> str:
5659
"""Build the `search-records` description from static text plus schema hints.
5760
5861
With multiple bindings configured the schema is ambiguous (the caller picks
59-
an index per call via `list-indexes`), so per-field hints are omitted and a
60-
routing note is appended instead.
62+
an index per call), so per-field hints are omitted and a routing note is
63+
appended instead.
64+
65+
``index_ids`` is supplied only when discovery is unavailable -- an operator
66+
can disable ``list-indexes``, and pointing clients at a tool the server does
67+
not publish would leave them unable to satisfy the required ``index``
68+
argument at all. Naming the ids inline is the only way they can learn them.
6169
"""
6270
description = (base_description or DEFAULT_SEARCH_DESCRIPTION).strip()
6371
if schema is None:
72+
if index_ids:
73+
return (
74+
description + " Multiple indexes are configured and discovery is "
75+
"disabled: pass one of these index ids as the `index` argument: "
76+
+ ", ".join(index_ids)
77+
+ "."
78+
)
6479
return (
6580
description + " Multiple indexes are configured: call list-indexes "
6681
"first, then pass the chosen index id as the `index` argument."
@@ -498,9 +513,12 @@ async def search_records(
498513
raise map_exception(exc) from exc
499514

500515

501-
def register_search_tool(server: Any, schema: IndexSchema | None) -> None:
516+
def register_search_tool(
517+
server: Any, schema: IndexSchema | None, *, index_ids: list[str] | None = None
518+
) -> None:
502519
"""Register the MCP `search-records` tool with its config-owned contract."""
503520
description = _build_search_tool_description(
521+
index_ids=index_ids,
504522
schema=schema,
505523
base_description=server.mcp_settings.tool_search_description,
506524
)

redisvl/mcp/tools/upsert.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -360,11 +360,24 @@ async def upsert_records(
360360
raise map_exception(exc) from exc
361361

362362

363-
def register_upsert_tool(server: Any) -> None:
364-
"""Register the MCP upsert tool on a server-like object."""
363+
def register_upsert_tool(server: Any, *, index_ids: list[str] | None = None) -> None:
364+
"""Register the MCP upsert tool on a server-like object.
365+
366+
``index_ids`` is supplied only when discovery is unavailable on a multi-index
367+
server. ``index`` is required there, and with ``list-indexes`` withheld the
368+
logical ids cannot be learned any other way, so naming them inline is what
369+
keeps the published contract satisfiable.
370+
"""
365371
description = (
366372
server.mcp_settings.tool_upsert_description or DEFAULT_UPSERT_DESCRIPTION
367373
)
374+
if index_ids:
375+
description = (
376+
description.strip() + " Multiple indexes are configured and discovery "
377+
"is disabled: pass one of these index ids as the `index` argument: "
378+
+ ", ".join(index_ids)
379+
+ "."
380+
)
368381

369382
async def upsert_records_tool(
370383
records: list[dict[str, Any]],

tests/integration/test_mcp/test_upsert_tool.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,7 @@ async def test_read_only_mode_excludes_upsert_tool(
491491
)
492492
monkeypatch.setattr(
493493
"redisvl.mcp.server.register_search_tool",
494-
lambda server, schema: None,
494+
lambda server, schema, index_ids=None: None,
495495
)
496496

497497
def fake_tool(*args: Any, **kwargs: Any):
@@ -506,7 +506,7 @@ def decorator(func: Any) -> Any:
506506

507507
called: list[bool] = []
508508

509-
def fake_register_upsert_tool(server: Any) -> None:
509+
def fake_register_upsert_tool(server: Any, index_ids: Any = None) -> None:
510510
called.append(server.mcp_settings.read_only)
511511

512512
monkeypatch.setattr(

0 commit comments

Comments
 (0)