From 927124e7b34d6594eb75442f00442b97d2bc7bfc Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Fri, 30 Oct 2026 16:20:46 +0100 Subject: [PATCH] feat(mcp): load code tools from modules named in the config Rebased onto main after #758, #759 and #760 were squash-merged. The tree is the same as the verified merge of main into the previous head (8a22c82). --- docs/concepts/mcp.md | 15 +- docs/user_guide/how_to_guides/mcp.md | 50 ++++++ redisvl/mcp/__init__.py | 2 + redisvl/mcp/config.py | 26 ++++ redisvl/mcp/server.py | 21 ++- redisvl/mcp/tools/code.py | 91 ++++++++++- tests/integration/test_mcp/test_code_tools.py | 48 +++++- tests/unit/test_mcp/test_code_tools_unit.py | 146 +++++++++++++++++- tests/unit/test_mcp/test_config.py | 45 ++++++ tests/unit/test_mcp/test_server.py | 1 + tests/unit/test_mcp/test_server_unit.py | 15 ++ 11 files changed, 449 insertions(+), 11 deletions(-) diff --git a/docs/concepts/mcp.md b/docs/concepts/mcp.md index f879f4a5..bc82ea9c 100644 --- a/docs/concepts/mcp.md +++ b/docs/concepts/mcp.md @@ -269,7 +269,7 @@ Listing the tenant claim under `auth.required_claims` is a cheap outer layer: th ## Code Tools -A **code tool** is an async Python function registered as an MCP tool. It is for logic a profile cannot express, such as re-ranking results, counting matches, or combining several queries into one answer. You register it on the server object from your own Python process: +A **code tool** is an async Python function registered as an MCP tool. It is for logic a profile cannot express, such as re-ranking results, counting matches, or combining several queries into one answer. There are two ways to register one. When you start the server from your own Python process, register it on the server object: ```python from redisvl.mcp import MCPSettings, RedisVLMCPServer, ToolContext @@ -290,6 +290,17 @@ The function's first parameter is the context and the rest are the tool's argume `index` pins the binding the tool works on. It may be omitted only when exactly one index is configured, and the model never chooses it. +When the server starts from `rvl mcp`, no code of yours runs unless the config names it. Mark each function with `@code_tool` from `redisvl.mcp`, which takes the same arguments as `code_tool`, and list its module in the config: + +```yaml +server: + redis_url: redis://localhost:6379 + allow_code_tools: true + code_tool_modules: [myapp.support_tools] +``` + +The server imports each module at startup and registers every function in it marked with `@code_tool`. Importing a module runs its code with the privileges of the process that started the server, so `code_tool_modules` is refused unless `allow_code_tools` is `true` as well. The setting defaults to `false`, which keeps a config that only uses profiles from running any Python. The modules must be importable by the server process: an installed package, or a directory on `PYTHONPATH`. `rvl` does not add the working directory to the import path. + ### What a Code Tool Can Reach A code tool never receives the server, the real `AsyncSearchIndex`, or a Redis client. It receives a `ToolContext` with: @@ -318,7 +329,7 @@ The handle is a guardrail, not a sandbox. A code tool is your own code, running The function is checked when it is decorated: it must be async, take a `ToolContext` first, annotate every other parameter, and have a description. Registering a tool after the server has started is refused, because tools register once per process. -The rest is checked at startup, against the loaded config: an `index` that is missing with several indexes configured or that names no binding; a name another custom tool already uses; a tool declared with `writes=True` on a read-only binding or server; and a code tool on a Redis index that a profile scopes with `lock.inject`. A code tool can run any query its handle allows, so nothing would show that it applies the tenant scope. Bind it to another index, or serve it from a separate server. +A module listed in `code_tool_modules` that cannot be imported, or that defines no `@code_tool` function, fails startup, so a misspelled module name does not quietly produce a server with fewer tools. The rest is checked at startup, against the loaded config: an `index` that is missing with several indexes configured or that names no binding; a name another custom tool already uses; a tool declared with `writes=True` on a read-only binding or server; and a code tool on a Redis index that a profile scopes with `lock.inject`. A code tool can run any query its handle allows, so nothing would show that it applies the tenant scope. Bind it to another index, or serve it from a separate server. ## Why Use MCP Instead of Direct RedisVL Calls diff --git a/docs/user_guide/how_to_guides/mcp.md b/docs/user_guide/how_to_guides/mcp.md index 7a918013..a64fc474 100644 --- a/docs/user_guide/how_to_guides/mcp.md +++ b/docs/user_guide/how_to_guides/mcp.md @@ -469,6 +469,52 @@ Rules worth knowing: A code tool bound to an index that a profile scopes with `lock.inject` refuses to start, because nothing shows that it applies the tenant scope. For the full contract, read the code tools section in {doc}`/concepts/mcp`. +### Code Tools Loaded From Modules + +To serve code tools from `rvl mcp`, put them in a module, mark each with `@code_tool`, and name the module in the config. `@code_tool` takes the same arguments as `server.code_tool`, but it only marks the function: nothing registers it unless its module is listed in `code_tool_modules`. When you build the server in Python, use `server.code_tool` instead. + +```python +# myapp/support_tools.py +from redisvl.mcp import ToolContext, code_tool +from redisvl.query import CountQuery +from redisvl.query.filter import Tag + + +@code_tool(index="tickets", namespace="support") +async def count_open_tickets(ctx: ToolContext, product: str) -> dict: + """Count open support tickets for a product.""" + open_for_product = (Tag("status") == "open") & (Tag("product") == product) + return {"open": await ctx.index.query(CountQuery(open_for_product))} +``` + +```yaml +server: + redis_url: redis://localhost:6379 + allow_code_tools: true + code_tool_modules: [myapp.support_tools] + +indexes: + tickets: + redis_name: support-tickets + search: + type: fulltext + runtime: + text_field_name: body +``` + +```bash +PYTHONPATH=/path/to/project rvl mcp --config /path/to/mcp_config.yaml +``` + +The client sees `support-count-open-tickets`, taking `product`. + +Rules worth knowing: + +- **`allow_code_tools: true` is required.** Importing a module runs its code with the server's privileges, so a config that lists `code_tool_modules` without it fails to load. It defaults to `false`. +- **The module must be importable.** Install it as a package or put its directory on `PYTHONPATH`; `rvl` does not add the working directory to the import path. Under `uvx`, the module and anything it imports must be installed in the environment `uvx` builds, for example with `--with` naming your package. +- **Only marked functions register.** Helpers in the same module are not published, and a tool imported into a second listed module registers once. +- **A module that adds nothing fails startup.** One that cannot be imported, or that has no `@code_tool` function, is reported by name. + ## Tool Contracts RedisVL MCP exposes a small, implementation-owned contract. @@ -866,3 +912,7 @@ The token is verified but its claim is unusable: missing, empty, padded with whi ### Code Tool Fails at Startup The error names the tool. Either its `index` is missing while several indexes are configured or names no configured binding, or another custom tool already uses its name, or it is registered with `writes=True` on a binding or server that is read-only. A code tool registered after the server has started is refused with a `RuntimeError`; register every tool before calling `run`, `run_async` or `startup`. A restart in the same process also fails with a `RuntimeError` if the code tools, or the indexes they bind to, have changed; restart the process instead. + +### Code Tool Module Fails to Load + +A config that lists `server.code_tool_modules` fails to load unless `server.allow_code_tools` is `true`. With the opt-in set, startup fails when a listed module cannot be imported, either because it is not on the import path or because importing it raised, and when it defines no function marked with `@code_tool`. The error names the module. Check that the server process can import it by running `python -c "import myapp.support_tools"` in the same environment and with the same `PYTHONPATH`, from a directory outside the project. Run from inside the project, `python -c` imports from the working directory, which `rvl` does not, so the check could pass while the server fails. diff --git a/redisvl/mcp/__init__.py b/redisvl/mcp/__init__.py index a94bae1b..693fb4e5 100644 --- a/redisvl/mcp/__init__.py +++ b/redisvl/mcp/__init__.py @@ -3,6 +3,7 @@ from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError, map_exception from redisvl.mcp.server import RedisVLMCPServer from redisvl.mcp.settings import MCPSettings +from redisvl.mcp.tools.code import code_tool __all__ = [ "IndexHandle", @@ -16,4 +17,5 @@ "WritableIndexHandle", "load_mcp_config", "map_exception", + "code_tool", ] diff --git a/redisvl/mcp/config.py b/redisvl/mcp/config.py index 562bbb96..3b0f9f87 100644 --- a/redisvl/mcp/config.py +++ b/redisvl/mcp/config.py @@ -255,6 +255,32 @@ class MCPServerConfig(BaseModel): builtin_tools: dict[str, Literal["enabled", "disabled"]] = Field( default_factory=dict ) + # Importing a module runs its code with the server's privileges, so naming + # modules does nothing until the operator opts in separately. + code_tool_modules: list[str] = Field(default_factory=list) + allow_code_tools: bool = False + + @model_validator(mode="after") + def _validate_code_tool_modules(self) -> "MCPServerConfig": + """Require the opt-in, and a usable module name, for each code module.""" + if self.code_tool_modules and not self.allow_code_tools: + raise ValueError( + "server.code_tool_modules imports Python code, which runs with " + "the server's privileges; set server.allow_code_tools: true to " + "allow it" + ) + seen: set[str] = set() + for module in self.code_tool_modules: + if not module or module != module.strip(): + raise ValueError( + f"server.code_tool_modules entry {module!r} is not a " "module name" + ) + if module in seen: + raise ValueError( + f"server.code_tool_modules lists '{module}' more than once" + ) + seen.add(module) + return self @model_validator(mode="after") def _validate_builtin_tools(self) -> "MCPServerConfig": diff --git a/redisvl/mcp/server.py b/redisvl/mcp/server.py index 8a575071..4568482c 100644 --- a/redisvl/mcp/server.py +++ b/redisvl/mcp/server.py @@ -1,6 +1,6 @@ import asyncio import logging -from collections.abc import Callable +from collections.abc import Callable, Iterable from contextlib import asynccontextmanager from enum import Enum, auto from importlib import import_module @@ -20,6 +20,7 @@ CodeToolSpec, _ToolFn, build_code_tool_spec, + load_code_tools, register_code_tool, ) from redisvl.mcp.tools.list_indexes import register_list_indexes_tool @@ -454,15 +455,19 @@ def _verify_injection_has_a_token(self, profiles: Any) -> None: "streamable-http) so the configured auth applies." ) - def _resolve_code_tools(self, config: Any) -> tuple[tuple[CodeToolSpec, str], ...]: + def _resolve_code_tools( + self, config: Any, loaded: Iterable[CodeToolSpec] = () + ) -> tuple[tuple[CodeToolSpec, str], ...]: """Pair each code tool with its binding, refusing any that cannot hold. - A code tool is declared in Python, so the rules the config model applies - to profiles are applied here instead, against the loaded config. + Covers tools registered with :meth:`code_tool` and those ``loaded`` + from ``server.code_tool_modules`` alike. A code tool is declared in + Python, so the rules the config model applies to profiles are applied + here instead, against the loaded config. """ taken = {profile.name for profile in config.custom_tools} resolved: list[tuple[CodeToolSpec, str]] = [] - for spec in self._code_tools: + for spec in [*self._code_tools, *loaded]: if spec.name in taken: raise ValueError( f"code tool name '{spec.name}' is already used by another " @@ -904,7 +909,11 @@ async def _initialize_runtime_resources(self) -> None: # Before any binding connects: both depend only on the loaded config, # so an unworkable one should not first need a reachable Redis. self._verify_injection_has_a_token(self.config.custom_tools) - self._resolved_code_tools = self._resolve_code_tools(self.config) + # Imported here, before any binding connects, so a module that fails to + # import is reported without first needing a reachable Redis. + self._resolved_code_tools = self._resolve_code_tools( + self.config, load_code_tools(self.config.server.code_tool_modules) + ) self._verify_no_unscoped_route_to_injected_indexes( self.config, self._resolved_code_tools ) diff --git a/redisvl/mcp/tools/code.py b/redisvl/mcp/tools/code.py index 38ea48d8..8641f4d3 100644 --- a/redisvl/mcp/tools/code.py +++ b/redisvl/mcp/tools/code.py @@ -14,9 +14,10 @@ """ import asyncio +import importlib import inspect import typing -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass from typing import Any, TypeVar @@ -27,6 +28,12 @@ from redisvl.mcp.context import ToolContext from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError, map_exception +# Where `code_tool` leaves a function's spec for the loader to find. An attribute on +# the function rather than a module-level registry, so importing a module +# registers nothing anywhere: two servers in one process, and a test that +# imports a tool module, stay independent of each other. +_SPEC_ATTRIBUTE = "__redisvl_mcp_tool__" + _ToolFn = TypeVar("_ToolFn", bound=Callable[..., Any]) _ACCEPTED_KINDS = ( @@ -230,3 +237,85 @@ async def code_tool(**kwargs: Any) -> Any: code_tool.__annotations__ = dict(spec.annotations) server.tool(name=spec.name, description=spec.description)(code_tool) + + +def code_tool( + *, + name: str | None = None, + namespace: str | None = None, + index: str | None = None, + writes: bool = False, + scope: str | None = None, + description: str | None = None, +) -> Callable[[_ToolFn], _ToolFn]: + """Mark an async function as a code tool for a server to load. + + Takes the same arguments as ``RedisVLMCPServer.code_tool``. The function + is checked here and returned unchanged; it is registered only on a server + whose config lists its module under ``server.code_tool_modules``. + """ + + def decorator(fn: _ToolFn) -> _ToolFn: + spec = build_code_tool_spec( + fn, + name=name, + namespace=namespace, + index=index, + writes=writes, + scope=scope, + description=description, + ) + setattr(fn, _SPEC_ATTRIBUTE, spec) + return fn + + return decorator + + +def load_code_tools(modules: Iterable[str]) -> list[CodeToolSpec]: + """Import each module and return the code tools it defines, in order. + + A module that cannot be imported, or that defines no tools, fails rather + than contributing nothing, so a typo in a module name is not silently a + server with fewer tools. A tool one module re-exports from another counts + once. + """ + specs: list[CodeToolSpec] = [] + seen: set[Callable[..., Any]] = set() + for module_name in modules: + try: + module = importlib.import_module(module_name) + except Exception as exc: + raise ValueError( + f"server.code_tool_modules entry '{module_name}' could not be " + f"imported: {exc}" + ) from exc + + # Functions only, and read from their own __dict__: a module attribute + # with a permissive __getattr__ (a lazy module proxy, a mock) would + # otherwise answer for any name, or import something just to say no. + found = [ + value + for value in vars(module).values() + if inspect.isfunction(value) + and isinstance(value.__dict__.get(_SPEC_ATTRIBUTE), CodeToolSpec) + ] + if not found: + raise ValueError( + f"server.code_tool_modules entry '{module_name}' defines no code " + "tools; mark each one with @code_tool" + ) + for fn in found: + spec = fn.__dict__[_SPEC_ATTRIBUTE] + if spec.fn is not fn: + # functools.wraps copies __dict__, so a decorator applied over + # @code_tool carries the marker while the spec still calls the + # inner function: the outer decorator would silently never run. + raise ValueError( + f"server.code_tool_modules entry '{module_name}': " + f"'{fn.__qualname__}' wraps a code tool in another decorator; " + "apply @code_tool outermost" + ) + if fn not in seen: + seen.add(fn) + specs.append(spec) + return specs diff --git a/tests/integration/test_mcp/test_code_tools.py b/tests/integration/test_mcp/test_code_tools.py index fd61be67..3ce690ae 100644 --- a/tests/integration/test_mcp/test_code_tools.py +++ b/tests/integration/test_mcp/test_code_tools.py @@ -1,4 +1,4 @@ -"""Code tools registered with ``server.code_tool``, against a real Redis. +"""Code tools, registered in Python or loaded from modules, against a real Redis. The unit suites own the validation and wrapping matrices. What lives here is what depends on a started server: that a tool's handle reaches the real index, @@ -8,6 +8,10 @@ """ import asyncio +import importlib +import sys +import textwrap +import uuid from pathlib import Path import pytest @@ -65,8 +69,12 @@ def factory( max_concurrency: int = 16, custom_tools: list[dict] | None = None, auth: dict | None = None, + tool_modules: list[str] | None = None, ) -> str: server: dict = {"redis_url": redis_url} + if tool_modules is not None: + server["code_tool_modules"] = tool_modules + server["allow_code_tools"] = True if auth is not None: server["auth"] = auth server["builtin_tools"] = { @@ -215,3 +223,41 @@ async def ticket_counts(ctx: ToolContext) -> dict: ) assert results == [{"open": 2, "resolved": 1}] * 2 + + +@pytest.fixture +def tool_module(tmp_path, monkeypatch): + """Write an importable tool module, unloading it afterwards.""" + monkeypatch.syspath_prepend(str(tmp_path)) + written: list[str] = [] + + def write(source: str) -> str: + name = f"code_tools_{uuid.uuid4().hex}" + (tmp_path / f"{name}.py").write_text(textwrap.dedent(source)) + # The directory may already be cached as scanned by an earlier import. + importlib.invalidate_caches() + written.append(name) + return name + + yield write + + for name in written: + sys.modules.pop(name, None) + + +async def test_a_tool_loaded_from_a_module_is_served(server_for, tool_module): + module = tool_module(""" + from redisvl.mcp import ToolContext, code_tool + from redisvl.query import CountQuery + from redisvl.query.filter import Tag + + @code_tool(namespace="support") + async def count_open(ctx: ToolContext) -> dict: + "Count open support tickets." + return {"open": await ctx.index.query(CountQuery(Tag("status") == "open"))} + """) + server = server_for(tool_modules=[module]) + + await server.startup() + + assert await _call(server, "support-count-open") == {"open": 2} diff --git a/tests/unit/test_mcp/test_code_tools_unit.py b/tests/unit/test_mcp/test_code_tools_unit.py index 6cbd01aa..c645aeee 100644 --- a/tests/unit/test_mcp/test_code_tools_unit.py +++ b/tests/unit/test_mcp/test_code_tools_unit.py @@ -1,7 +1,11 @@ """Code tools: what a valid one looks like, and what wraps every call.""" import asyncio +import importlib import inspect +import sys +import textwrap +import uuid from dataclasses import replace from types import SimpleNamespace from typing import Any @@ -19,7 +23,12 @@ from redisvl.mcp.runtime import BindingRuntime from redisvl.mcp.server import RedisVLMCPServer from redisvl.mcp.settings import MCPSettings -from redisvl.mcp.tools.code import build_code_tool_spec, register_code_tool +from redisvl.mcp.tools.code import ( + build_code_tool_spec, + code_tool, + load_code_tools, + register_code_tool, +) async def recent_tickets(ctx: ToolContext, query: str, limit: int = 5) -> dict: @@ -445,6 +454,141 @@ async def failing(ctx: ToolContext, query: str) -> dict: assert exc_info.value is error +# -------------------------------------------------------------------------- +# @code_tool and the module loader +# -------------------------------------------------------------------------- + + +@pytest.fixture +def tool_module(tmp_path, monkeypatch): + """Write importable modules under unique names, unloading them afterwards.""" + monkeypatch.syspath_prepend(str(tmp_path)) + written: list[str] = [] + + def write(source: str) -> str: + name = f"code_tools_{uuid.uuid4().hex}" + (tmp_path / f"{name}.py").write_text(textwrap.dedent(source)) + # The directory may already be cached as scanned by an earlier import. + importlib.invalidate_caches() + written.append(name) + return name + + yield write + + for name in written: + sys.modules.pop(name, None) + + +_TWO_TOOLS = """ + from redisvl.mcp import ToolContext, code_tool + + @code_tool(namespace="support") + async def open_tickets(ctx: ToolContext, query: str) -> dict: + '''List open tickets.''' + return {} + + @code_tool(writes=True) + async def close_ticket(ctx: ToolContext, ticket_id: str) -> dict: + '''Close a ticket.''' + return {} + + async def helper(ctx: ToolContext, query: str) -> dict: + '''Not a tool: never decorated.''' + return {} +""" + + +def test_tool_marks_the_function_and_returns_it_unchanged(): + async def triage(ctx: ToolContext, query: str) -> dict: + """Triage tickets.""" + return {} + + marked = code_tool(scope="tickets.triage")(triage) + + assert marked is triage + assert triage.__redisvl_mcp_tool__.scope == "tickets.triage" + + +def test_tool_checks_the_function_where_it_is_declared(): + def sync_tool(ctx: ToolContext, query: str) -> dict: + """Search.""" + return {} + + with pytest.raises(TypeError, match="must be an async function"): + code_tool()(sync_tool) + + +def test_the_loader_returns_a_modules_tools_in_definition_order(tool_module): + specs = load_code_tools([tool_module(_TWO_TOOLS)]) + + assert [(spec.name, spec.writes) for spec in specs] == [ + ("support-open-tickets", False), + ("close-ticket", True), + ] + + +def test_a_re_exported_tool_counts_once(tool_module): + original = tool_module(_TWO_TOOLS) + re_export = tool_module(f"from {original} import open_tickets\n") + + specs = load_code_tools([original, re_export]) + + assert [spec.name for spec in specs] == ["support-open-tickets", "close-ticket"] + + +def test_only_a_function_carrying_its_own_marker_counts(tool_module): + # A class or other object can hold a real spec in its __dict__; only a + # function the loader can register stands for a tool. + name = tool_module(_TWO_TOOLS + """ + class Holder: + pass + + Holder.__redisvl_mcp_tool__ = open_tickets.__redisvl_mcp_tool__ + """) + + assert len(load_code_tools([name])) == 2 + + +def test_a_decorator_over_code_tool_is_refused_rather_than_skipped(tool_module): + name = tool_module(""" + import functools + + from redisvl.mcp import ToolContext, code_tool + + def logged(fn): + @functools.wraps(fn) + async def wrapper(*args, **kwargs): + return await fn(*args, **kwargs) + return wrapper + + @logged + @code_tool() + async def open_tickets(ctx: ToolContext, query: str) -> dict: + "List open tickets." + return {} + """) + + with pytest.raises(ValueError, match="apply @code_tool outermost"): + load_code_tools([name]) + + +@pytest.mark.parametrize( + "source, message", + [ + pytest.param(None, "could not be imported: No module named", id="missing"), + pytest.param( + "raise RuntimeError('boom')\n", "could not be imported: boom", id="raises" + ), + pytest.param("VALUE = 1\n", "defines no code tools", id="no-tools"), + ], +) +def test_a_module_that_contributes_no_tools_fails(tool_module, source, message): + name = "code_tools_absent_module" if source is None else tool_module(source) + + with pytest.raises(ValueError, match=f"entry '{name}' {message}"): + load_code_tools([name]) + + @pytest.mark.parametrize("slot_free", [True, False], ids=["mid-call", "queued"]) async def test_a_timed_out_call_gives_back_its_concurrency_slot(monkeypatch, slot_free): # Against the real run_guarded: a tool cut off mid-call must release the diff --git a/tests/unit/test_mcp/test_config.py b/tests/unit/test_mcp/test_config.py index 4f76c77a..8dfc7337 100644 --- a/tests/unit/test_mcp/test_config.py +++ b/tests/unit/test_mcp/test_config.py @@ -1095,3 +1095,48 @@ def test_injection_composes_with_a_static_lock_on_another_field(): ) profile = MCPConfig.model_validate(config).custom_tools[0] assert [entry.field for entry in profile.lock.inject] == ["tenant_id"] + + +def test_code_tool_modules_load_once_the_operator_allows_code_tools(): + config = _valid_config() + config["server"].update( + {"code_tool_modules": ["myapp.tools"], "allow_code_tools": True} + ) + + assert MCPConfig.model_validate(config).server.code_tool_modules == ["myapp.tools"] + + +@pytest.mark.parametrize( + "server, message", + [ + pytest.param( + {"code_tool_modules": ["myapp.tools"]}, + "set server.allow_code_tools: true", + id="not-allowed", + ), + pytest.param( + {"code_tool_modules": [" myapp.tools"], "allow_code_tools": True}, + "is not a module name", + id="padded", + ), + pytest.param( + {"code_tool_modules": [""], "allow_code_tools": True}, + "is not a module name", + id="blank", + ), + pytest.param( + { + "code_tool_modules": ["myapp.tools", "myapp.tools"], + "allow_code_tools": True, + }, + "lists 'myapp.tools' more than once", + id="duplicate", + ), + ], +) +def test_unusable_code_tool_modules_fail_config_load(server, message): + config = _valid_config() + config["server"].update(server) + + with pytest.raises(ValueError, match=message): + MCPConfig.model_validate(config) diff --git a/tests/unit/test_mcp/test_server.py b/tests/unit/test_mcp/test_server.py index 1d6bc539..394ffe65 100644 --- a/tests/unit/test_mcp/test_server.py +++ b/tests/unit/test_mcp/test_server.py @@ -66,6 +66,7 @@ def _startup_config(indexes=None): # fingerprints its registered tool surface. builtin_tools={}, builtin_tool_enabled=lambda _name: True, + code_tool_modules=[], ), indexes=indexes or {"knowledge": _binding_namespace()}, custom_tools=[], diff --git a/tests/unit/test_mcp/test_server_unit.py b/tests/unit/test_mcp/test_server_unit.py index 23999d50..a5f14bcb 100644 --- a/tests/unit/test_mcp/test_server_unit.py +++ b/tests/unit/test_mcp/test_server_unit.py @@ -1312,3 +1312,18 @@ def test_a_restart_that_moves_a_code_tool_is_fatal(monkeypatch): def test_a_restart_with_the_same_code_tools_proceeds(monkeypatch): _restart_with(monkeypatch, registered_on="knowledge", restarted_on="knowledge") + + +def test_loaded_code_tools_are_resolved_with_registered_ones(): + server = _code_tool_server() + server.code_tool()(recent_tickets) + + async def close_ticket(ctx: ToolContext, ticket_id: str) -> dict: + """Close a ticket.""" + return {} + + loaded = [build_code_tool_spec(close_ticket)] + + resolved = server._resolve_code_tools(_config_with(), loaded) + + assert [spec.name for spec, _ in resolved] == ["recent-tickets", "close-ticket"]