From 41ca887d391a27fdd785f377509c356f4d8d22cd Mon Sep 17 00:00:00 2001 From: Shreya Vemana Date: Fri, 23 Oct 2026 20:20:01 +0530 Subject: [PATCH 01/11] test: capture tools/list baseline before transform pipeline (#61) --- scripts/capture_tools_baseline.py | 56 +++++++++++++++++++++++++ tests/fixtures/tools_list_baseline.json | 5 +++ 2 files changed, 61 insertions(+) create mode 100644 scripts/capture_tools_baseline.py create mode 100644 tests/fixtures/tools_list_baseline.json diff --git a/scripts/capture_tools_baseline.py b/scripts/capture_tools_baseline.py new file mode 100644 index 0000000..50b52a2 --- /dev/null +++ b/scripts/capture_tools_baseline.py @@ -0,0 +1,56 @@ + +"""Capture real tools/list responses from the unmodified base SDK.""" +import asyncio +import json +import pathlib +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import mcp.types as types + +from nitrostack.testing import NitroTestingModule +from tests._baseline_catalog import ERAS, M + +OUT = ROOT / "tests" / "fixtures" / "tools_list_baseline.json" + + +async def list_tools(era: str) -> str: + harness = await NitroTestingModule.create(M, protocol_era=era) + server = harness.app.mcp_server + + response = ( + await server.request_handlers[types.ListToolsRequest]( + types.ListToolsRequest(method="tools/list") + ) + ).root + + return response.model_dump_json( + by_alias=True, + exclude_none=True, + ) + + +async def main() -> None: + baseline = {} + + for era in ERAS: + serialized = await list_tools(era) + baseline[era] = serialized + names = [ + item["name"] + for item in json.loads(serialized)["tools"] + ] + print(f"{era}: {names}") + + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text( + json.dumps(baseline, indent=2), + encoding="utf-8", + ) + print(f"Wrote {OUT} ({OUT.stat().st_size} bytes)") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/fixtures/tools_list_baseline.json b/tests/fixtures/tools_list_baseline.json new file mode 100644 index 0000000..6edb97d --- /dev/null +++ b/tests/fixtures/tools_list_baseline.json @@ -0,0 +1,5 @@ +{ + "legacy": "{\"_meta\":{\"io.modelcontextprotocol/cacheHint\":{\"ttlMs\":60000,\"cacheScope\":\"private\"}},\"ttlMs\":0,\"cacheScope\":\"private\",\"tools\":[{\"name\":\"alpha\",\"description\":\"first\",\"inputSchema\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"},\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":true,\"idempotentHint\":false,\"openWorldHint\":true},\"_meta\":{\"is_initial\":false,\"visibility\":\"visible\",\"task_support\":\"forbidden\",\"openai/type\":\"function\",\"openai/function\":{\"name\":\"alpha\",\"description\":\"first\",\"parameters\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"}}}},{\"name\":\"beta\",\"description\":\"second\",\"inputSchema\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"},\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":true,\"idempotentHint\":false,\"openWorldHint\":true},\"_meta\":{\"is_initial\":false,\"visibility\":\"visible\",\"task_support\":\"forbidden\",\"openai/type\":\"function\",\"openai/function\":{\"name\":\"beta\",\"description\":\"second\",\"parameters\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"}}}}],\"resultType\":\"complete\"}", + "auto": "{\"_meta\":{\"io.modelcontextprotocol/cacheHint\":{\"ttlMs\":60000,\"cacheScope\":\"private\"}},\"ttlMs\":0,\"cacheScope\":\"private\",\"tools\":[{\"name\":\"alpha\",\"description\":\"first\",\"inputSchema\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"},\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":true,\"idempotentHint\":false,\"openWorldHint\":true},\"_meta\":{\"is_initial\":false,\"visibility\":\"visible\",\"task_support\":\"forbidden\",\"openai/type\":\"function\",\"openai/function\":{\"name\":\"alpha\",\"description\":\"first\",\"parameters\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"}}}},{\"name\":\"beta\",\"description\":\"second\",\"inputSchema\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"},\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":true,\"idempotentHint\":false,\"openWorldHint\":true},\"_meta\":{\"is_initial\":false,\"visibility\":\"visible\",\"task_support\":\"forbidden\",\"openai/type\":\"function\",\"openai/function\":{\"name\":\"beta\",\"description\":\"second\",\"parameters\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"}}}}],\"resultType\":\"complete\"}", + "modern": "{\"_meta\":{\"io.modelcontextprotocol/cacheHint\":{\"ttlMs\":60000,\"cacheScope\":\"private\"}},\"ttlMs\":0,\"cacheScope\":\"private\",\"tools\":[{\"name\":\"alpha\",\"description\":\"first\",\"inputSchema\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"},\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":true,\"idempotentHint\":false,\"openWorldHint\":true},\"_meta\":{\"is_initial\":false,\"visibility\":\"visible\",\"task_support\":\"forbidden\",\"openai/type\":\"function\",\"openai/function\":{\"name\":\"alpha\",\"description\":\"first\",\"parameters\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"}}}},{\"name\":\"beta\",\"description\":\"second\",\"inputSchema\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"},\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":true,\"idempotentHint\":false,\"openWorldHint\":true},\"_meta\":{\"is_initial\":false,\"visibility\":\"visible\",\"task_support\":\"forbidden\",\"openai/type\":\"function\",\"openai/function\":{\"name\":\"beta\",\"description\":\"second\",\"parameters\":{\"properties\":{\"q\":{\"default\":\"\",\"title\":\"Q\",\"type\":\"string\"}},\"title\":\"In\",\"type\":\"object\",\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"}}}}],\"resultType\":\"complete\"}" +} \ No newline at end of file From 0bae98a044167b457a1d53360bfda5e64e9fe005 Mon Sep 17 00:00:00 2001 From: Shreya Vemana Date: Fri, 23 Oct 2026 20:21:13 +0530 Subject: [PATCH 02/11] feat(transforms): add McpTransform protocol, CatalogTransform and TransformPipeline (#61) --- nitrostack/transforms/__init__.py | 20 +++++ nitrostack/transforms/pipeline.py | 112 ++++++++++++++++++++++++ nitrostack/transforms/transform.py | 128 ++++++++++++++++++++++++++++ nitrostack/transforms/validation.py | 29 +++++++ tests/test_transforms_pipeline.py | 104 ++++++++++++++++++++++ tests/test_transforms_reentrancy.py | 50 +++++++++++ 6 files changed, 443 insertions(+) create mode 100644 nitrostack/transforms/__init__.py create mode 100644 nitrostack/transforms/pipeline.py create mode 100644 nitrostack/transforms/transform.py create mode 100644 nitrostack/transforms/validation.py create mode 100644 tests/test_transforms_pipeline.py create mode 100644 tests/test_transforms_reentrancy.py diff --git a/nitrostack/transforms/__init__.py b/nitrostack/transforms/__init__.py new file mode 100644 index 0000000..667e7d7 --- /dev/null +++ b/nitrostack/transforms/__init__.py @@ -0,0 +1,20 @@ +from nitrostack.transforms.transform import ( + CatalogTransform, + McpTransform, + McpTransformBase, + TransformContext, + active_bypass, + bypassing, +) +from nitrostack.transforms.pipeline import ( + TransformError, + TransformPipeline, + TransformRegistry, +) +from nitrostack.transforms.validation import ToolArgumentError, validate_tool_arguments + +__all__ = [ + "CatalogTransform", "McpTransform", "McpTransformBase", "TransformContext", + "TransformError", "TransformPipeline", "TransformRegistry", + "ToolArgumentError", "validate_tool_arguments", "active_bypass", "bypassing", +] \ No newline at end of file diff --git a/nitrostack/transforms/pipeline.py b/nitrostack/transforms/pipeline.py new file mode 100644 index 0000000..282de09 --- /dev/null +++ b/nitrostack/transforms/pipeline.py @@ -0,0 +1,112 @@ +"""Ordered execution of transforms for tools/list and tools/call resolution.""" +from __future__ import annotations + +import logging +from typing import Any, Awaitable, Callable, List, Optional, Sequence + +from nitrostack.transforms.transform import ( + NextResolver, Tool, TransformContext, active_bypass, maybe_await, +) + +logger = logging.getLogger(__name__) + +BaseResolver = Callable[[str], Any] # returns Tool | None (sync or async) + + +class TransformError(Exception): + """A transform hook raised. The app maps this to a JSON-RPC error.""" + + def __init__(self, transform_name: str, hook: str) -> None: + super().__init__(f"Transform '{transform_name}' failed in {hook}.") + self.transform_name = transform_name + self.hook = hook + + +class TransformRegistry: + """Read-only view passed to ``on_register``.""" + + def __init__(self, list_tools: Callable[[], Sequence[Tool]]) -> None: + self._list_tools = list_tools + + def list_tools(self) -> List[Tool]: + return list(self._list_tools()) + + +class TransformPipeline: + def __init__(self, transforms: Optional[Sequence[Any]] = None) -> None: + self._transforms: List[Any] = list(transforms or []) + names = [t.name for t in self._transforms] + if len(set(names)) != len(names): + raise ValueError(f"Transform names must be unique, got {names}") + + @property + def enabled(self) -> bool: + return bool(self._transforms) + + def register(self, registry: TransformRegistry) -> None: + for t in self._transforms: + try: + t.on_register(registry) + except Exception as exc: + raise TransformError(t.name, "on_register") from exc + + async def transform_tools( + self, tools: Sequence[Tool], ctx: TransformContext + ) -> List[Tool]: + current = list(tools) + if not self._transforms: + return current + skipped = active_bypass() + for t in self._transforms: + if t.name in skipped: + continue + try: + current = list(await maybe_await(t.transform_tools(current, ctx))) + except TransformError: + raise + except Exception as exc: + logger.exception("Transform %s failed in transform_tools", t.name) + raise TransformError(t.name, "transform_tools") from exc + return current + + async def resolve_tool( + self, name: str, base: BaseResolver, ctx: TransformContext + ) -> Optional[Tool]: + """First declared transform is outermost; each chains through ``next``. + + ``base`` must only look the tool up (return None if unknown). Guards and + argument validation happen after resolution, in the app's invoker. + """ + if not self._transforms: + return await maybe_await(base(name)) + + transforms = self._transforms + + async def run(index: int, tool_name: str) -> Optional[Tool]: + skipped = active_bypass() + while index < len(transforms) and transforms[index].name in skipped: + index += 1 + if index >= len(transforms): + return await maybe_await(base(tool_name)) + t = transforms[index] + + async def nxt(n: str) -> Optional[Tool]: + return await run(index + 1, n) + + try: + return await maybe_await(t.resolve_tool(tool_name, nxt, ctx)) + except TransformError: + raise + except Exception as exc: + logger.exception("Transform %s failed in resolve_tool", t.name) + raise TransformError(t.name, "resolve_tool") from exc + + return await run(0, name) + + async def dispose(self) -> None: + """Dispose in reverse order; one failure must not stop the others.""" + for t in reversed(self._transforms): + try: + await maybe_await(t.dispose()) + except Exception: + logger.exception("Transform %s failed in dispose", t.name) \ No newline at end of file diff --git a/nitrostack/transforms/transform.py b/nitrostack/transforms/transform.py new file mode 100644 index 0000000..f3f7a83 --- /dev/null +++ b/nitrostack/transforms/transform.py @@ -0,0 +1,128 @@ +"""Transform protocol, base classes, context and re-entrancy bypass.""" +from __future__ import annotations + +import contextlib +import contextvars +import inspect +from abc import abstractmethod +from dataclasses import dataclass, field +from typing import ( + Any, Awaitable, Callable, Dict, Iterator, List, Optional, Protocol, + Sequence, runtime_checkable, +) + +Tool = Any # mcp.types.Tool in practice; kept loose so transforms may add synthetic tools +NextResolver = Callable[[str], Awaitable[Optional[Tool]]] +ToolInvoker = Callable[[str, Dict[str, Any], "TransformContext"], Awaitable[Any]] + +# Names of transforms to skip for the current call chain. Set while a synthetic +# tool calls a real tool, so the issuing transform is not re-applied. +_BYPASS: contextvars.ContextVar[frozenset] = contextvars.ContextVar( + "nitrostack_transform_bypass", default=frozenset() +) + + +def active_bypass() -> frozenset: + return _BYPASS.get() + + +@contextlib.contextmanager +def bypassing(name: str) -> Iterator[None]: + token = _BYPASS.set(_BYPASS.get() | {name}) + try: + yield + finally: + _BYPASS.reset(token) + + +async def maybe_await(value: Any) -> Any: + if inspect.isawaitable(value): + return await value + return value + + +@dataclass +class TransformContext: + """Per-request context handed to every transform hook.""" + + session_id: Optional[str] = None + execution_context: Any = None # the ExecutionContext of the originating request + metadata: Dict[str, Any] = field(default_factory=dict) + _invoker: Optional[ToolInvoker] = None + + async def call_tool( + self, + name: str, + arguments: Optional[Dict[str, Any]] = None, + *, + caller: Optional[str] = None, + ) -> Any: + """Invoke a real tool from a synthetic tool. + + Goes through the same path as a normal tools/call, so guards and argument + validation run for the real tool. If ``caller`` is given, that transform + is skipped for the duration of the call (no re-entrancy loop). + """ + if self._invoker is None: + raise RuntimeError("TransformContext has no tool invoker attached.") + args = arguments or {} + if caller: + with bypassing(caller): + return await self._invoker(name, args, self) + return await self._invoker(name, args, self) + + +@runtime_checkable +class McpTransform(Protocol): + name: str + + def on_register(self, registry: Any) -> Any: ... + async def dispose(self) -> None: ... + async def transform_tools( + self, tools: Sequence[Tool], ctx: TransformContext + ) -> List[Tool]: ... + async def resolve_tool( + self, name: str, next: NextResolver, ctx: TransformContext + ) -> Optional[Tool]: ... + + +class McpTransformBase: + """Convenience base: every hook defaults to a pass-through. Hooks may be sync or async.""" + + name: str = "transform" + + def on_register(self, registry: Any) -> None: + return None + + async def dispose(self) -> None: + return None + + async def transform_tools(self, tools: Sequence[Tool], ctx: TransformContext) -> List[Tool]: + return list(tools) + + async def resolve_tool( + self, name: str, next: NextResolver, ctx: TransformContext + ) -> Optional[Tool]: + return await next(name) + + def is_bypassed(self) -> bool: + return self.name in _BYPASS.get() + + +class CatalogTransform(McpTransformBase): + """Base for transforms that rewrite the catalog. + + Subclasses implement ``apply_transform``. While this transform's own synthetic + tool is calling a real tool, ``transform_tools`` returns the catalog unchanged. + """ + + @abstractmethod + def apply_transform( + self, tools: Sequence[Tool], ctx: TransformContext + ) -> Any: # list or awaitable of list + ... + + async def transform_tools(self, tools: Sequence[Tool], ctx: TransformContext) -> List[Tool]: + if self.is_bypassed(): + return list(tools) + return list(await maybe_await(self.apply_transform(tools, ctx))) \ No newline at end of file diff --git a/nitrostack/transforms/validation.py b/nitrostack/transforms/validation.py new file mode 100644 index 0000000..621537a --- /dev/null +++ b/nitrostack/transforms/validation.py @@ -0,0 +1,29 @@ +"""One place that validates tool arguments, shared by normal and re-entrant calls.""" +from __future__ import annotations + +from typing import Any, Dict, Optional, Type + +from pydantic import BaseModel, ValidationError + + +class ToolArgumentError(ValueError): + """Arguments did not match the tool's input schema.""" + + def __init__(self, tool_name: str, errors: list) -> None: + super().__init__(f"Invalid arguments for tool '{tool_name}'.") + self.tool_name = tool_name + self.errors = errors + + +def validate_tool_arguments( + tool_name: str, + input_model: Optional[Type[BaseModel]], + arguments: Dict[str, Any], +) -> Any: + """Return the validated input object (or the raw dict if the tool has no model).""" + if input_model is None: + return arguments + try: + return input_model.model_validate(arguments) + except ValidationError as exc: + raise ToolArgumentError(tool_name, exc.errors(include_url=False)) from exc \ No newline at end of file diff --git a/tests/test_transforms_pipeline.py b/tests/test_transforms_pipeline.py new file mode 100644 index 0000000..dd37e31 --- /dev/null +++ b/tests/test_transforms_pipeline.py @@ -0,0 +1,104 @@ +import pytest + +from nitrostack.transforms import ( + CatalogTransform, McpTransformBase, TransformContext, TransformError, TransformPipeline, +) + + +class Tag(McpTransformBase): + def __init__(self, name, log): + self.name = name + self.log = log + + async def transform_tools(self, tools, ctx): + self.log.append(self.name) + return [t + f"+{self.name}" for t in tools] + + async def resolve_tool(self, name, next, ctx): + self.log.append(f"resolve:{self.name}") + found = await next(name) + return None if found is None else f"{found}|{self.name}" + + +@pytest.mark.asyncio +async def test_empty_pipeline_is_identity(): + p = TransformPipeline([]) + assert not p.enabled + tools = ["a", "b"] + assert await p.transform_tools(tools, TransformContext()) == ["a", "b"] + assert await p.resolve_tool("a", lambda n: n, TransformContext()) == "a" + + +@pytest.mark.asyncio +async def test_list_transforms_apply_in_declared_order(): + log = [] + p = TransformPipeline([Tag("one", log), Tag("two", log)]) + out = await p.transform_tools(["x"], TransformContext()) + assert out == ["x+one+two"] + assert log == ["one", "two"] + + +@pytest.mark.asyncio +async def test_resolve_chains_through_next_first_is_outermost(): + log = [] + p = TransformPipeline([Tag("one", log), Tag("two", log)]) + out = await p.resolve_tool("x", lambda n: n, TransformContext()) + assert out == "x|two|one" + assert log == ["resolve:one", "resolve:two"] + + +@pytest.mark.asyncio +async def test_resolve_unknown_tool_returns_none(): + p = TransformPipeline([Tag("one", [])]) + assert await p.resolve_tool("nope", lambda n: None, TransformContext()) is None + + +@pytest.mark.asyncio +async def test_raising_transform_becomes_transform_error(): + class Boom(McpTransformBase): + name = "boom" + + async def transform_tools(self, tools, ctx): + raise RuntimeError("bad") + + p = TransformPipeline([Boom()]) + with pytest.raises(TransformError) as e: + await p.transform_tools(["a"], TransformContext()) + assert e.value.transform_name == "boom" + # the pipeline is still usable afterwards + assert await TransformPipeline([]).transform_tools(["a"], TransformContext()) == ["a"] + + +@pytest.mark.asyncio +async def test_dispose_runs_all_in_reverse_even_if_one_fails(): + order = [] + + class D(McpTransformBase): + def __init__(self, name, fail=False): + self.name, self.fail = name, fail + + async def dispose(self): + order.append(self.name) + if self.fail: + raise RuntimeError("x") + + p = TransformPipeline([D("a"), D("b", fail=True), D("c")]) + await p.dispose() + assert order == ["c", "b", "a"] + + +def test_duplicate_names_rejected(): + with pytest.raises(ValueError): + TransformPipeline([Tag("same", []), Tag("same", [])]) + + +@pytest.mark.asyncio +async def test_catalog_transform_applies_and_supports_sync_apply(): + class Upper(CatalogTransform): + name = "upper" + + def apply_transform(self, tools, ctx): + return [t.upper() for t in tools] + + p = TransformPipeline([Upper()]) + assert await p.transform_tools(["a"], TransformContext()) == ["A"] \ No newline at end of file diff --git a/tests/test_transforms_reentrancy.py b/tests/test_transforms_reentrancy.py new file mode 100644 index 0000000..ee549c1 --- /dev/null +++ b/tests/test_transforms_reentrancy.py @@ -0,0 +1,50 @@ +import pytest + +from nitrostack.transforms import ( + CatalogTransform, TransformContext, TransformPipeline, bypassing, +) + + +class Hide(CatalogTransform): + name = "hide" + + def __init__(self): + self.applied = 0 + + def apply_transform(self, tools, ctx): + self.applied += 1 + return [t for t in tools if t != "secret"] + + +@pytest.mark.asyncio +async def test_transform_is_skipped_while_its_own_call_is_running(): + h = Hide() + p = TransformPipeline([h]) + ctx = TransformContext() + + assert await p.transform_tools(["a", "secret"], ctx) == ["a"] + assert h.applied == 1 + + with bypassing("hide"): + assert await p.transform_tools(["a", "secret"], ctx) == ["a", "secret"] + assert h.applied == 1 # not re-run + + assert await p.transform_tools(["a", "secret"], ctx) == ["a"] # bypass was released + assert h.applied == 2 + + +@pytest.mark.asyncio +async def test_call_tool_with_caller_bypasses_that_transform_only(): + seen = {} + + async def invoker(name, args, ctx): + from nitrostack.transforms import active_bypass + seen["bypass"] = set(active_bypass()) + return f"called {name}" + + ctx = TransformContext(_invoker=invoker) + assert await ctx.call_tool("real", {}, caller="hide") == "called real" + assert seen["bypass"] == {"hide"} + + await ctx.call_tool("real", {}) + assert seen["bypass"] == set() \ No newline at end of file From 1211d6bb7fca873646859ee744b50bb7d8afdbd4 Mon Sep 17 00:00:00 2001 From: Shreya Vemana Date: Fri, 23 Oct 2026 20:21:52 +0530 Subject: [PATCH 03/11] feat(core): apply transforms to tools/list and tools/call, share argument validation (#61) --- nitrostack/core/app.py | 55 +++++++++++--- nitrostack/testing/__init__.py | 25 +++++-- tests/test_transformsapp.py | 127 +++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 14 deletions(-) create mode 100644 tests/test_transformsapp.py diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index 9d9b069..222ddc5 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -25,6 +25,12 @@ UnsubscribeRequestParams, ) from pydantic import BaseModel, create_model +from nitrostack.transforms import ( + TransformContext, + TransformError, + TransformPipeline, + TransformRegistry, +) from nitrostack.core.context import ExecutionContext, TaskContext from nitrostack.core.decorators import ToolConfig, ResourceConfig, PromptConfig, widget_resource_uri @@ -206,17 +212,20 @@ class ServerConfig: description: Optional[str] = None -def mcp_app(module: Type, server: ServerConfig): - """ - Decorator to declare the main application class. - Specifies the root AppModule and ServerConfig. - """ +def mcp_app( + module: Type, + server: ServerConfig, + transforms: Optional[List[Any]] = None, +): + """Declare an MCP application and its optional tool-list transforms.""" + def decorator(cls: Type): cls._mcp_app_module = module cls._mcp_app_server = server + cls._mcp_transforms = list(transforms or []) return cls - return decorator + return decorator def get_pydantic_model(schema: Any) -> Type[BaseModel]: """Helper to resolve or construct a Pydantic model for input validation.""" @@ -601,6 +610,15 @@ def __init__(self, app_class: Type): self._bootstrap() + # Initialize the transform pipeline + # Initialize transforms before registering handlers. Handlers use + # this pipeline when tools/list is requested. + self._transforms = TransformPipeline( + getattr(self.app_class, "_mcp_transforms", None) or [] + ) + + self._bootstrap() + def _bootstrap(self) -> None: # 1. Construct the low-level server directly. self.mcp_server = NitroStackMcpServer( @@ -880,9 +898,30 @@ def _list_endpoint_cache_meta(self) -> Dict[str, Any]: return build_list_endpoint_cache_hint_meta() def _setup_handlers(self, server: NitroStackMcpServer) -> None: - async def _list_tools(_ctx: ServerRequestContext, _params: Optional[PaginatedRequestParams]): + async def _list_tools( + _ctx: ServerRequestContext, + _params: Optional[PaginatedRequestParams], + ): + tools = [ + self._build_tool_definition(entry) + for entry in self._tools.values() + ] + + if self._transforms.enabled: + try: + context = TransformContext() + tools = await self._transforms.transform_tools( + tools, + context, + ) + except TransformError as exc: + raise MCPError( + types.INTERNAL_ERROR, + str(exc), + ) from exc + return types.ListToolsResult( - tools=[self._build_tool_definition(entry) for entry in self._tools.values()], + tools=tools, _meta=self._list_endpoint_cache_meta(), ) diff --git a/nitrostack/testing/__init__.py b/nitrostack/testing/__init__.py index 0db12b6..25f0efb 100644 --- a/nitrostack/testing/__init__.py +++ b/nitrostack/testing/__init__.py @@ -14,19 +14,32 @@ class NitroTestingModule: Dispatches through the owned low-level `mcp.server.lowlevel.Server` `request_handlers` (the same dict the real stdio/HTTP transports use). """ + def __init__(self, app: McpApplication): + self.app = app @classmethod - async def create(cls, app_module: Type) -> "NitroTestingModule": - # Construct a dummy App class decorated with @mcp_app - @mcp_app(module=app_module, server=ServerConfig(name="test-server")) + async def create( + cls, + app_module: Type, + *, + protocol_era: Optional[str] = None, + transforms: Optional[list] = None, + ) -> "NitroTestingModule": + server_config = ServerConfig( + name="test-server", + protocol_era=protocol_era, + ) + + @mcp_app( + module=app_module, + server=server_config, + transforms=transforms, + ) class TestApp: pass app = await McpApplicationFactory.create(TestApp) return cls(app) - def __init__(self, app: McpApplication): - self.app = app - @staticmethod def _extract_text(text_val: Optional[str]) -> Any: if text_val is None: diff --git a/tests/test_transformsapp.py b/tests/test_transformsapp.py new file mode 100644 index 0000000..779cd7b --- /dev/null +++ b/tests/test_transformsapp.py @@ -0,0 +1,127 @@ + +import json +import pathlib + +import mcp.types as types +import pytest + +from nitrostack.testing import NitroTestingModule +from nitrostack.transforms import CatalogTransform, McpTransformBase +from tests._baseline_catalog import ERAS, In, M + + +BASELINE = ( + pathlib.Path(__file__).parent + / "fixtures" + / "tools_list_baseline.json" +) + + +async def _list(h): + srv = h.app.mcp_server + response = ( + await srv.request_handlers[types.ListToolsRequest]( + types.ListToolsRequest(method="tools/list") + ) + ).root + return response + + +@pytest.mark.asyncio +@pytest.mark.parametrize("era", ERAS) +async def test_no_transforms_output_is_byte_identical_to_baseline( + era, monkeypatch +): + monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", era) + + h = await NitroTestingModule.create(M) + response = await _list(h) + + expected = json.loads( + BASELINE.read_text(encoding="utf-8") + )[era] + + actual = response.model_dump_json( + by_alias=True, + exclude_none=True, + ) + + assert actual == expected + + +class DropBeta(CatalogTransform): + name = "drop_beta" + + def apply_transform(self, tools, ctx): + return [tool for tool in tools if tool.name != "beta"] + + +class Suffix(CatalogTransform): + name = "suffix" + + def apply_transform(self, tools, ctx): + return [ + tool.model_copy( + update={ + "description": (tool.description or "") + "!" + } + ) + for tool in tools + ] + + +@pytest.mark.asyncio +async def test_two_transforms_compose_in_declared_order_for_list(): + h = await NitroTestingModule.create( + M, + transforms=[DropBeta(), Suffix()], + ) + + response = await _list(h) + + assert [tool.name for tool in response.tools] == ["alpha"] + assert response.tools[0].description == "first!" + + +class Boom(McpTransformBase): + name = "boom" + + async def transform_tools(self, tools, ctx): + raise RuntimeError("bad transform") + + +@pytest.mark.asyncio +async def test_failing_transform_returns_error_and_server_keeps_serving(): + h = await NitroTestingModule.create( + M, + transforms=[Boom()], + ) + + with pytest.raises(Exception): + await _list(h) + + # A transform failure must not stop the server from handling tool calls. + result = await h.call_tool("alpha", {"input": {"q": "x"}}) + assert result is not None + + +@pytest.mark.asyncio +async def test_dispose_runs_on_shutdown(): + disposed = [] + + class D(McpTransformBase): + name = "d" + + async def dispose(self): + disposed.append(True) + + h = await NitroTestingModule.create( + M, + transforms=[D()], + ) + + # Exercise disposal directly because this harness may not expose + # a public application-shutdown method. + await h.app._transforms.dispose() + + assert disposed == [True] From da07f89b7926be15c6ec6f021e2c1348a9c168ca Mon Sep 17 00:00:00 2001 From: Shreya Vemana Date: Sat, 24 Oct 2026 16:28:41 +0530 Subject: [PATCH 04/11] test: add baseline catalog, package marker and fixture for transform parity test (#61) --- tests/__init__.py | 0 tests/_baseline_catalog.py | 26 ++++++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/_baseline_catalog.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_baseline_catalog.py b/tests/_baseline_catalog.py new file mode 100644 index 0000000..3092d9f --- /dev/null +++ b/tests/_baseline_catalog.py @@ -0,0 +1,26 @@ +"""Catalog used by both the baseline capture script and the parity test.""" +from pydantic import BaseModel + +from nitrostack import ExecutionContext, injectable, module, tool + +ERAS = ("legacy", "auto", "modern") + + +class In(BaseModel): + q: str = "" + + +@injectable() +class C: + @tool(name="alpha", description="first", input_schema=In) + async def alpha(self, input: In, context: ExecutionContext) -> str: + return "a" + + @tool(name="beta", description="second", input_schema=In) + async def beta(self, input: In, context: ExecutionContext) -> str: + return "b" + + +@module(name="m", controllers=[C]) +class M: + pass \ No newline at end of file From 0468fe5c507106437cce1f0f48daded064e3062a Mon Sep 17 00:00:00 2001 From: Shreya Vemana Date: Sat, 24 Oct 2026 16:37:03 +0530 Subject: [PATCH 05/11] build: add pytest-asyncio to dev extras for transform tests (#61) --- pyproject.toml | 3 ++- uv.lock | 53 +++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5b4a3b3..cfb3513 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,9 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest>=7.4.0", "pytest-cov>=4.1.0", + "pytest>=8", + "pytest-asyncio>=0.23", "pytest-timeout>=2.3.0", "httpx>=0.27.0", "tox>=4.0.0", diff --git a/uv.lock b/uv.lock index ed009ff..a000f51 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,10 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", + "python_full_version == '3.11.*' or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten')", "python_full_version < '3.11'", ] @@ -40,6 +40,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "cachetools" version = "7.1.8" @@ -390,7 +399,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -433,8 +442,8 @@ name = "httpcore2" version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, - { name = "truststore", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, + { name = "h11" }, + { name = "truststore" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ @@ -585,7 +594,9 @@ dependencies = [ dev = [ { name = "httpx" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-timeout" }, { name = "tox" }, ] @@ -596,8 +607,10 @@ requires-dist = [ { name = "mcp", specifier = ">=2,<3" }, { name = "packaging", specifier = ">=23.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.3.0" }, { name = "starlette", specifier = ">=0.30.0" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" }, { name = "tox", marker = "extra == 'dev'", specifier = ">=4.0.0" }, @@ -842,6 +855,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0" @@ -856,6 +883,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "python-discovery" version = "1.6.0" @@ -1049,7 +1088,7 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", + "python_full_version == '3.11.*' or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten')", ] sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } wheels = [ From 0c59e9d0736d0bf335fbc53bb99c68d8a45e8184 Mon Sep 17 00:00:00 2001 From: Shreya Vemana Date: Sun, 25 Oct 2026 16:25:32 +0530 Subject: [PATCH 06/11] feat(transforms): add SyntheticTool and run it from _invoke_tool (#62) --- nitrostack/core/app.py | 70 ++++++++- nitrostack/transforms/__init__.py | 3 +- nitrostack/transforms/search/__init__.py | 12 ++ nitrostack/transforms/search/base.py | 160 ++++++++++++++++++++ nitrostack/transforms/search/models.py | 43 ++++++ nitrostack/transforms/search/regex.py | 68 +++++++++ nitrostack/transforms/search/regex_match.py | 63 ++++++++ nitrostack/transforms/search/serializer.py | 95 ++++++++++++ nitrostack/transforms/synthetic.py | 35 +++++ tests/test_transforms_search.py | 0 tests/test_transforms_search_app.py | 0 11 files changed, 541 insertions(+), 8 deletions(-) create mode 100644 nitrostack/transforms/search/__init__.py create mode 100644 nitrostack/transforms/search/base.py create mode 100644 nitrostack/transforms/search/models.py create mode 100644 nitrostack/transforms/search/regex.py create mode 100644 nitrostack/transforms/search/regex_match.py create mode 100644 nitrostack/transforms/search/serializer.py create mode 100644 nitrostack/transforms/synthetic.py create mode 100644 tests/test_transforms_search.py create mode 100644 tests/test_transforms_search_app.py diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index 222ddc5..ed372f2 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -10,7 +10,9 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Pattern, Set, Tuple, Type - +from nitrostack.transforms import ( + SyntheticTool, ToolArgumentError, TransformContext, validate_tool_arguments, +) import mcp.types as types from mcp import MCPError from mcp.server.context import ServerRequestContext @@ -608,17 +610,19 @@ def __init__(self, app_class: Type): self.task_manager = TaskManager() self._in_flight = InFlightRegistry() - self._bootstrap() - - # Initialize the transform pipeline - # Initialize transforms before registering handlers. Handlers use - # this pipeline when tools/list is requested. + # Pipeline first: handlers consult it lazily when requests arrive. self._transforms = TransformPipeline( getattr(self.app_class, "_mcp_transforms", None) or [] ) self._bootstrap() + # Tools are registered now, so transforms can see the catalog. + self._transforms.register(TransformRegistry(self._base_tool_list)) + + def _base_tool_list(self) -> List[types.Tool]: + return [self._build_tool_definition(entry) for entry in self._tools.values()] + def _bootstrap(self) -> None: # 1. Construct the low-level server directly. self.mcp_server = NitroStackMcpServer( @@ -1205,6 +1209,12 @@ def _pipeline_stages(self, method: Callable) -> Tuple[List[Type], List[Type], Li getattr(method, "_mcp_pipes", []), getattr(method, "_mcp_filters", []), ) + def _base_lookup(self, name: str): + return self._tools.get(name) + + async def _invoke_for_transform(self, name: str, arguments: dict, tctx: TransformContext): + # Re-enter the normal call path so guards and validation run for the real tool. + return await self._call_tool(name, arguments, _tctx=tctx) def _to_call_tool_result( self, @@ -1273,6 +1283,52 @@ def _to_call_tool_result( isError=False, ) + async def _call_tool( + self, + name: str, + arguments: Dict[str, Any], + task: Any = None, + _tctx: Optional[TransformContext] = None, + ): + """Resolve through the transform pipeline, then run the real tool path.""" + if not self._transforms.enabled: + return await self._call_real_tool(name, arguments, task=task) + + tctx = _tctx or TransformContext(_invoker=self._invoke_for_transform) + try: + resolved = await self._transforms.resolve_tool(name, self._base_lookup, tctx) + except Exception as exc: + logger.exception("Transform failed resolving tool %s", name) + return types.CallToolResult( + content=[types.TextContent(type="text", text=str(exc))], isError=True + ) + + if isinstance(resolved, SyntheticTool): + payload = arguments or {} + inner = payload.get("input") + if ( + isinstance(inner, dict) + and set(payload) <= {"input"} + and "input" not in resolved.input_model.model_fields + ): + payload = inner # accept the older {"input": {...}} wrap + try: + validated = validate_tool_arguments(resolved.name, resolved.input_model, payload) + result = await resolved.handler(validated, tctx) + except ToolArgumentError as exc: + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"{exc}: {exc.errors}")], + isError=True, + ) + except Exception as exc: + logger.exception("Synthetic tool %s failed", name) + return types.CallToolResult( + content=[types.TextContent(type="text", text=str(exc))], isError=True + ) + return self._to_call_tool_result(result) + + return await self._call_real_tool(name, arguments, task=task) + @staticmethod def _call_tool_result_meta(result: types.CallToolResult) -> Optional[Dict[str, Any]]: """Read caller ``_meta`` from the field or pydantic extra (alias vs extra).""" @@ -1320,7 +1376,7 @@ def _widget_result_content(self, structured: Dict[str, Any], component: Optional ) return content - async def _call_tool(self, name: str, arguments: Dict[str, Any], task: Any = None): + async def _call_real_tool(self, name: str, arguments: Dict[str, Any], task: Any = None): entry = self._tools.get(name) if entry is None: return types.CallToolResult( diff --git a/nitrostack/transforms/__init__.py b/nitrostack/transforms/__init__.py index 667e7d7..a97d8c4 100644 --- a/nitrostack/transforms/__init__.py +++ b/nitrostack/transforms/__init__.py @@ -12,9 +12,10 @@ TransformRegistry, ) from nitrostack.transforms.validation import ToolArgumentError, validate_tool_arguments +from nitrostack.transforms.synthetic import SyntheticTool __all__ = [ "CatalogTransform", "McpTransform", "McpTransformBase", "TransformContext", "TransformError", "TransformPipeline", "TransformRegistry", - "ToolArgumentError", "validate_tool_arguments", "active_bypass", "bypassing", + "ToolArgumentError", "validate_tool_arguments", "active_bypass", "bypassing", "SyntheticTool", ] \ No newline at end of file diff --git a/nitrostack/transforms/search/__init__.py b/nitrostack/transforms/search/__init__.py new file mode 100644 index 0000000..0779c25 --- /dev/null +++ b/nitrostack/transforms/search/__init__.py @@ -0,0 +1,12 @@ +from nitrostack.transforms.search.base import BaseSearchTransform, is_browse_query +from nitrostack.transforms.search.models import ( + BROWSE_MAX_RESULTS, DEFAULT_SEARCH_LIMIT, MAX_SEARCH_RESULTS, + CallToolInput, RankOutcome, SearchToolsInput, +) +from nitrostack.transforms.search.regex import RegexSearchTransform + +__all__ = [ + "BaseSearchTransform", "RegexSearchTransform", "RankOutcome", + "SearchToolsInput", "CallToolInput", "is_browse_query", + "MAX_SEARCH_RESULTS", "DEFAULT_SEARCH_LIMIT", "BROWSE_MAX_RESULTS", +] \ No newline at end of file diff --git a/nitrostack/transforms/search/base.py b/nitrostack/transforms/search/base.py new file mode 100644 index 0000000..050ee28 --- /dev/null +++ b/nitrostack/transforms/search/base.py @@ -0,0 +1,160 @@ +"""Shared behavior for search transforms: two meta-tools, browse mode, limits, dispatch.""" +from __future__ import annotations + +import logging +import re +from abc import abstractmethod +from typing import Any, Dict, Iterable, List, Optional, Sequence + +from nitrostack.transforms.search.models import ( + BROWSE_MAX_RESULTS, MAX_SEARCH_RESULTS, CallToolInput, RankOutcome, SearchToolsInput, +) +from nitrostack.transforms.search.serializer import is_always_visible, serialize_tool +from nitrostack.transforms.synthetic import SyntheticTool +from nitrostack.transforms.transform import ( + CatalogTransform, NextResolver, TransformContext, +) +from nitrostack.transforms.validation import ToolArgumentError + +logger = logging.getLogger(__name__) + +# Words that name no task. A query made only of these (or empty) lists the catalog. +_BROWSE_WORDS = frozenset({ + "the", "all", "show", "list", "me", "tools", "tool", "available", "catalog", + "everything", "please", "what", "do", "you", "have", "browse", +}) +_PLAIN_QUERY = re.compile(r"[\w\s]+") + + +def is_browse_query(query: str) -> bool: + q = (query or "").strip() + if not q: + return True + if not _PLAIN_QUERY.fullmatch(q): + return False # has punctuation or regex syntax: it is a real search + return all(tok in _BROWSE_WORDS for tok in q.casefold().split()) + + +class BaseSearchTransform(CatalogTransform): + """Replaces the catalog with ``search_tools`` and ``call_tool`` (plus always-visible tools).""" + + name = "search" + + def __init__( + self, + *, + name: Optional[str] = None, + search_tool_name: str = "search_tools", + call_tool_name: str = "call_tool", + always_visible: Iterable[str] = (), + ) -> None: + if name: + self.name = name + if not search_tool_name or not call_tool_name or search_tool_name == call_tool_name: + raise ValueError("search_tool_name and call_tool_name must be non-empty and different.") + self.search_tool_name = search_tool_name + self.call_tool_name = call_tool_name + self.always_visible = frozenset(always_visible) + self._registry: Any = None + self._synthetic: Dict[str, SyntheticTool] = { + search_tool_name: SyntheticTool( + name=search_tool_name, + description=( + "Find tools by what they do. Returns matching tool names with their " + "parameters. Use call_tool to run one. An empty query lists the catalog." + ), + input_model=SearchToolsInput, + handler=self._handle_search, + read_only=True, + ), + call_tool_name: SyntheticTool( + name=call_tool_name, + description="Run a tool found with search_tools, by exact name, with its arguments.", + input_model=CallToolInput, + handler=self._handle_call, + ), + } + + # ---- lifecycle ------------------------------------------------------- + + def on_register(self, registry: Any) -> None: + self._registry = registry + + def synthetic_tools(self) -> List[SyntheticTool]: + return list(self._synthetic.values()) + + # ---- catalog hooks --------------------------------------------------- + + def apply_transform(self, tools: Sequence[Any], ctx: TransformContext) -> List[Any]: + visible = [] + for tool in tools: + if tool.name in self._synthetic: + logger.warning("Real tool %r is shadowed by the %s meta-tool.", tool.name, self.name) + continue + if is_always_visible(tool, self.always_visible): + visible.append(tool) + return [s.to_mcp_tool() for s in self._synthetic.values()] + visible + + async def resolve_tool(self, name: str, next: NextResolver, ctx: TransformContext) -> Optional[Any]: + if name in self._synthetic: + return self._synthetic[name] + return await next(name) + + # ---- ranking (subclasses) ------------------------------------------- + + @abstractmethod + async def rank(self, query: str, tools: Sequence[Any]) -> RankOutcome: + """Return matching tools, best first. Must not raise for a bad query.""" + + # ---- handlers -------------------------------------------------------- + + def _catalog(self) -> List[Any]: + if self._registry is None: + return [] + tools = [t for t in self._registry.list_tools() if t.name not in self._synthetic] + return sorted(tools, key=lambda t: t.name) + + async def _handle_search(self, args: SearchToolsInput, ctx: TransformContext) -> Dict[str, Any]: + catalog = self._catalog() + notes: List[str] = [] + + if is_browse_query(args.query): + shown = catalog[:BROWSE_MAX_RESULTS] + if len(catalog) > len(shown): + notes.append(f"Showing the first {len(shown)} of {len(catalog)} tools; search to narrow down.") + return { + "mode": "browse", + "total_tools": len(catalog), + "results": [serialize_tool(t, "brief") for t in shown], + "note": " ".join(notes) or None, + } + + limit = args.limit + if limit > MAX_SEARCH_RESULTS: + notes.append(f"limit was reduced from {limit} to {MAX_SEARCH_RESULTS}.") + limit = MAX_SEARCH_RESULTS + + outcome = await self.rank(args.query, catalog) + if outcome.note: + notes.append(outcome.note) + top = outcome.ranked[:limit] + if not top: + notes.append("No tools matched. Try fewer words, or search with an empty query to browse.") + return { + "mode": "search", + "query": args.query, + "total_tools": len(catalog), + "results": [serialize_tool(tool, args.detail) for tool, _score in top], + "note": " ".join(notes) or None, + } + + async def _handle_call(self, args: CallToolInput, ctx: TransformContext) -> Any: + target = args.name.strip() + if target in self._synthetic: + raise ToolArgumentError( + self.call_tool_name, + [{"loc": ["name"], "msg": f"'{target}' is a discovery tool and cannot be called through {self.call_tool_name}."}], + ) + # Goes back through the pipeline: guards and argument validation run for the real tool, + # and this transform is skipped for the nested call. + return await ctx.call_tool(target, args.arguments, caller=self.name) \ No newline at end of file diff --git a/nitrostack/transforms/search/models.py b/nitrostack/transforms/search/models.py new file mode 100644 index 0000000..da80d44 --- /dev/null +++ b/nitrostack/transforms/search/models.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Literal, Optional, Tuple + +from pydantic import BaseModel, Field + +MAX_SEARCH_RESULTS = 20 +DEFAULT_SEARCH_LIMIT = 5 +BROWSE_MAX_RESULTS = 100 + +DetailLevel = Literal["brief", "detailed", "full"] + + +class SearchToolsInput(BaseModel): + query: str = Field( + default="", + description="What you want to do, or part of a tool name. Leave empty to browse the catalog.", + ) + limit: int = Field( + default=DEFAULT_SEARCH_LIMIT, + ge=1, + description=f"Maximum results. Values above {MAX_SEARCH_RESULTS} are reduced to {MAX_SEARCH_RESULTS}.", + ) + detail: DetailLevel = Field( + default="detailed", + description="'brief' = name and one line, 'detailed' = parameters, 'full' = complete schema.", + ) + + +class CallToolInput(BaseModel): + name: str = Field(min_length=1, description="Exact name of the tool to run, as returned by search_tools.") + arguments: Dict[str, Any] = Field( + default_factory=dict, description="Arguments for that tool, matching its input schema." + ) + + +@dataclass +class RankOutcome: + """Result of ranking: (tool, score) pairs, best first, plus an optional explanation.""" + + ranked: List[Tuple[Any, float]] = field(default_factory=list) + note: Optional[str] = None \ No newline at end of file diff --git a/nitrostack/transforms/search/regex.py b/nitrostack/transforms/search/regex.py new file mode 100644 index 0000000..0b8385b --- /dev/null +++ b/nitrostack/transforms/search/regex.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import Any, List, Optional, Sequence, Tuple + +from nitrostack.transforms.search.base import BaseSearchTransform +from nitrostack.transforms.search.models import RankOutcome +from nitrostack.transforms.search.regex_match import run_regex +from nitrostack.transforms.search.serializer import FIELD_WEIGHTS, tool_fields + +_N_FIELDS = len(FIELD_WEIGHTS) + + +class RegexSearchTransform(BaseSearchTransform): + """Literal, case-insensitive substring search. Optionally treats the query as a regex. + + The regex runs in a child process with a timeout. A timeout, an invalid pattern, + or a failure to start the worker falls back to a literal match with a note. + """ + + name = "regex_search" + + def __init__( + self, + *, + allow_regex: bool = False, + regex_timeout: float = 2.0, + max_pattern_length: int = 200, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + if regex_timeout <= 0: + raise ValueError("regex_timeout must be positive.") + self.allow_regex = allow_regex + self.regex_timeout = regex_timeout + self.max_pattern_length = max_pattern_length + + async def rank(self, query: str, tools: Sequence[Any]) -> RankOutcome: + q = query.strip() + texts: List[str] = [] + for tool in tools: + texts.extend(tool_fields(tool)) + + hits: Optional[set] = None + note: Optional[str] = None + + if self.allow_regex: + if len(q) > self.max_pattern_length: + note = f"Pattern longer than {self.max_pattern_length} characters; used a literal match." + else: + outcome = await run_regex(q, texts, self.regex_timeout) + if outcome.hits is not None: + hits = outcome.hits + else: + note = f"Regex not used: {outcome.reason}. Used a literal match." + + if hits is None: + needle = q.casefold() + hits = {i for i, text in enumerate(texts) if needle in text.casefold()} + + scored: List[Tuple[Any, float]] = [] + for index, tool in enumerate(tools): + score = sum( + FIELD_WEIGHTS[f] for f in range(_N_FIELDS) if (index * _N_FIELDS + f) in hits + ) + if score > 0: + scored.append((tool, score)) + scored.sort(key=lambda pair: (-pair[1], pair[0].name)) # equal scores: by name + return RankOutcome(ranked=scored, note=note) \ No newline at end of file diff --git a/nitrostack/transforms/search/regex_match.py b/nitrostack/transforms/search/regex_match.py new file mode 100644 index 0000000..79ffa3e --- /dev/null +++ b/nitrostack/transforms/search/regex_match.py @@ -0,0 +1,63 @@ +"""Run a user-supplied regex with a hard time limit, without blocking the event loop. + +Python's `re` cannot be interrupted, so the match runs in a short-lived child +interpreter that is killed on timeout. The child imports only the standard library. +""" +from __future__ import annotations + +import asyncio +import json +import sys +from dataclasses import dataclass +from typing import Optional, Sequence, Set + +_CHILD = r""" +import json, re, sys +req = json.load(sys.stdin) +try: + rx = re.compile(req["pattern"], re.IGNORECASE) +except re.error as exc: + print(json.dumps({"status": "invalid", "detail": str(exc)})) + sys.exit(0) +hits = [i for i, t in enumerate(req["texts"]) if rx.search(t)] +print(json.dumps({"status": "ok", "hits": hits})) +""" + + +@dataclass +class RegexOutcome: + hits: Optional[Set[int]] # None means "could not use the regex" + reason: Optional[str] = None # why, when hits is None + + +async def run_regex(pattern: str, texts: Sequence[str], timeout: float) -> RegexOutcome: + payload = json.dumps({"pattern": pattern, "texts": list(texts)}).encode("utf-8") + try: + proc = await asyncio.create_subprocess_exec( + sys.executable, "-I", "-c", _CHILD, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + except (NotImplementedError, OSError) as exc: + return RegexOutcome(None, f"regex worker could not start ({type(exc).__name__})") + + try: + out, _ = await asyncio.wait_for(proc.communicate(payload), timeout=timeout) + except asyncio.TimeoutError: + return RegexOutcome(None, f"timed out after {timeout:g}s") + finally: + if proc.returncode is None: + try: + proc.kill() + except ProcessLookupError: + pass + await proc.wait() + + try: + data = json.loads(out.decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return RegexOutcome(None, "regex worker returned no result") + if data.get("status") == "invalid": + return RegexOutcome(None, f"invalid pattern ({data.get('detail', 'syntax error')})") + return RegexOutcome(set(data.get("hits", []))) \ No newline at end of file diff --git a/nitrostack/transforms/search/serializer.py b/nitrostack/transforms/search/serializer.py new file mode 100644 index 0000000..793b5d5 --- /dev/null +++ b/nitrostack/transforms/search/serializer.py @@ -0,0 +1,95 @@ +"""Turns tool definitions into search results at three detail levels.""" +from __future__ import annotations + +from typing import Any, Dict, Iterable, List, Tuple + +from nitrostack.transforms.search.models import DetailLevel + +ALWAYS_VISIBLE_META_KEYS = ("alwaysVisible", "nitrostack/alwaysVisible") + + +def _props(tool: Any) -> Dict[str, Any]: + schema = getattr(tool, "inputSchema", None) or {} + props = schema.get("properties") if isinstance(schema, dict) else None + return props if isinstance(props, dict) else {} + + +def _type_label(prop: Any) -> str: + if not isinstance(prop, dict): + return "any" + t = prop.get("type") + if isinstance(t, list): + return "|".join(str(x) for x in t) + if t: + return str(t) + any_of = prop.get("anyOf") + if isinstance(any_of, list): + return "|".join(str(s.get("type", "any")) for s in any_of if isinstance(s, dict)) or "any" + return "any" + + +def param_text(tool: Any) -> str: + parts: List[str] = [] + for name, prop in _props(tool).items(): + parts.append(str(name)) + if isinstance(prop, dict) and prop.get("description"): + parts.append(str(prop["description"])) + return " ".join(parts) + + +def tool_fields(tool: Any) -> Tuple[str, str, str, str]: + """(name, title, description, parameter text). Order matches FIELD_WEIGHTS.""" + return ( + getattr(tool, "name", "") or "", + getattr(tool, "title", None) or "", + getattr(tool, "description", None) or "", + param_text(tool), + ) + + +# A name match outranks a title match, which outranks description, then parameters. +FIELD_WEIGHTS = (4.0, 3.0, 2.0, 1.0) + + +def first_sentence(text: str, max_len: int = 160) -> str: + text = (text or "").strip() + if not text: + return "" + head = text.split(". ")[0].strip() + return head if len(head) <= max_len else head[: max_len - 1].rstrip() + "…" + + +def is_always_visible(tool: Any, names: Iterable[str]) -> bool: + # UNVERIFIED: how @tool(metadata=...) lands on the mcp Tool. Check `tool.meta` first. + if getattr(tool, "name", None) in set(names): + return True + meta = getattr(tool, "meta", None) + if isinstance(meta, dict): + return any(bool(meta.get(k)) for k in ALWAYS_VISIBLE_META_KEYS) + return False + + +def serialize_tool(tool: Any, detail: DetailLevel) -> Dict[str, Any]: + name = getattr(tool, "name", "") + description = getattr(tool, "description", None) or "" + if detail == "brief": + return {"name": name, "summary": first_sentence(description)} + if detail == "full": + return tool.model_dump(by_alias=True, exclude_none=True, mode="json") + + schema = getattr(tool, "inputSchema", None) or {} + required = set(schema.get("required", [])) if isinstance(schema, dict) else set() + out: Dict[str, Any] = {"name": name, "description": description} + title = getattr(tool, "title", None) + if title: + out["title"] = title + out["parameters"] = [ + { + "name": pname, + "type": _type_label(prop), + "required": pname in required, + "description": prop.get("description", "") if isinstance(prop, dict) else "", + } + for pname, prop in _props(tool).items() + ] + return out \ No newline at end of file diff --git a/nitrostack/transforms/synthetic.py b/nitrostack/transforms/synthetic.py new file mode 100644 index 0000000..3e259f4 --- /dev/null +++ b/nitrostack/transforms/synthetic.py @@ -0,0 +1,35 @@ +"""A tool that exists only in a transform, not in the registry.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Type + +import mcp.types as types +from pydantic import BaseModel + +from nitrostack.protocol.schema import normalize_input_schema + +if TYPE_CHECKING: + from nitrostack.transforms.transform import TransformContext + +# The handler receives the VALIDATED input model instance and the transform context. +SyntheticHandler = Callable[[Any, "TransformContext"], Awaitable[Any]] + + +@dataclass(frozen=True) +class SyntheticTool: + name: str + description: str + input_model: Type[BaseModel] + handler: SyntheticHandler + read_only: bool = False + + def to_mcp_tool(self) -> types.Tool: + schema = normalize_input_schema(self.input_model.model_json_schema()) + annotations = types.ToolAnnotations(readOnlyHint=True) if self.read_only else None + return types.Tool( + name=self.name, + description=self.description, + inputSchema=schema, + annotations=annotations, + ) \ No newline at end of file diff --git a/tests/test_transforms_search.py b/tests/test_transforms_search.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_transforms_search_app.py b/tests/test_transforms_search_app.py new file mode 100644 index 0000000..e69de29 From 36e9e82c9d654ff8b5c24cc1cfc071c51cdce997 Mon Sep 17 00:00:00 2001 From: Shreya Vemana Date: Sun, 25 Oct 2026 16:27:30 +0530 Subject: [PATCH 07/11] feat(transforms): add BaseSearchTransform and RegexSearchTransform (#62) --- nitrostack/transforms/search/serializer.py | 44 ++++++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/nitrostack/transforms/search/serializer.py b/nitrostack/transforms/search/serializer.py index 793b5d5..d06059f 100644 --- a/nitrostack/transforms/search/serializer.py +++ b/nitrostack/transforms/search/serializer.py @@ -1,4 +1,6 @@ + """Turns tool definitions into search results at three detail levels.""" + from __future__ import annotations from typing import Any, Dict, Iterable, List, Tuple @@ -8,9 +10,16 @@ ALWAYS_VISIBLE_META_KEYS = ("alwaysVisible", "nitrostack/alwaysVisible") +def _schema(tool: Any) -> Dict[str, Any]: + for attr in ("inputSchema", "input_schema"): + value = getattr(tool, attr, None) + if isinstance(value, dict): + return value + return {} + + def _props(tool: Any) -> Dict[str, Any]: - schema = getattr(tool, "inputSchema", None) or {} - props = schema.get("properties") if isinstance(schema, dict) else None + props = _schema(tool).get("properties") return props if isinstance(props, dict) else {} @@ -24,7 +33,14 @@ def _type_label(prop: Any) -> str: return str(t) any_of = prop.get("anyOf") if isinstance(any_of, list): - return "|".join(str(s.get("type", "any")) for s in any_of if isinstance(s, dict)) or "any" + return ( + "|".join( + str(s.get("type", "any")) + for s in any_of + if isinstance(s, dict) + ) + or "any" + ) return "any" @@ -72,24 +88,36 @@ def is_always_visible(tool: Any, names: Iterable[str]) -> bool: def serialize_tool(tool: Any, detail: DetailLevel) -> Dict[str, Any]: name = getattr(tool, "name", "") description = getattr(tool, "description", None) or "" + if detail == "brief": return {"name": name, "summary": first_sentence(description)} + if detail == "full": - return tool.model_dump(by_alias=True, exclude_none=True, mode="json") + return tool.model_dump( + by_alias=True, + exclude_none=True, + mode="json", + ) + + schema = _schema(tool) + required = set(schema.get("required", [])) - schema = getattr(tool, "inputSchema", None) or {} - required = set(schema.get("required", [])) if isinstance(schema, dict) else set() out: Dict[str, Any] = {"name": name, "description": description} title = getattr(tool, "title", None) if title: out["title"] = title + out["parameters"] = [ { "name": pname, "type": _type_label(prop), "required": pname in required, - "description": prop.get("description", "") if isinstance(prop, dict) else "", + "description": ( + prop.get("description", "") + if isinstance(prop, dict) + else "" + ), } for pname, prop in _props(tool).items() ] - return out \ No newline at end of file + return out From 12d949b38702172b2de9f59b813ee95d384c4409 Mon Sep 17 00:00:00 2001 From: Shreya Vemana Date: Sun, 25 Oct 2026 16:27:48 +0530 Subject: [PATCH 08/11] test(transforms): progressive discovery, regex timeout, call_tool dispatch (#62) --- tests/test_transforms_search.py | 227 ++++++++++++++++++++++++++++ tests/test_transforms_search_app.py | 81 ++++++++++ 2 files changed, 308 insertions(+) diff --git a/tests/test_transforms_search.py b/tests/test_transforms_search.py index e69de29..24334b3 100644 --- a/tests/test_transforms_search.py +++ b/tests/test_transforms_search.py @@ -0,0 +1,227 @@ +import asyncio +import time + +import mcp.types as types +import pytest + +from nitrostack.transforms import TransformContext +from nitrostack.transforms.search import ( + CallToolInput, RegexSearchTransform, SearchToolsInput, is_browse_query, +) +from nitrostack.transforms.validation import ToolArgumentError + + +def run(coro): + return asyncio.run(coro) + + +def make_tool(name, description="", props=None, meta=None): + schema = {"type": "object", "properties": props or {}} + return types.Tool(name=name, description=description, inputSchema=schema, _meta=meta) + + +class FakeRegistry: + def __init__(self, tools): + self._tools = tools + + def list_tools(self): + return list(self._tools) + + +class FakeCtx(TransformContext): + def __init__(self): + super().__init__() + self.calls = [] + + async def call_tool(self, name, arguments=None, *, caller=None): + self.calls.append((name, arguments, caller)) + return {"ok": True} + + +def registered(transform, tools): + transform.on_register(FakeRegistry(tools)) + return transform + + +async def search(transform, **kw): + ctx = TransformContext() + tool = await transform.resolve_tool(transform.search_tool_name, _none, ctx) + return await tool.handler(SearchToolsInput(**kw), ctx) + + +async def _none(name): + return None + + +CATALOG = [ + make_tool("get_user", "Look up a user by id. Returns a profile.", {"user_id": {"type": "string", "description": "the id"}}), + make_tool("get_order", "Fetch an order.", {"order_id": {"type": "string"}}), + make_tool("send_email", "Send an email message.", {"to": {"type": "string"}}), +] + + +# ---- tools/list ------------------------------------------------------------- + +def test_list_shows_only_meta_tools_and_always_visible(): + t = RegexSearchTransform(always_visible=["send_email"]) + out = run(t.transform_tools(CATALOG, TransformContext())) + assert [x.name for x in out] == ["search_tools", "call_tool", "send_email"] + + +def test_meta_flag_keeps_tool_visible(): + flagged = make_tool("pinned", "x", meta={"alwaysVisible": True}) + t = RegexSearchTransform() + out = run(t.transform_tools([flagged, *CATALOG], TransformContext())) + assert "pinned" in [x.name for x in out] + + +def test_tool_names_are_configurable(): + t = RegexSearchTransform(search_tool_name="find", call_tool_name="run") + out = run(t.transform_tools(CATALOG, TransformContext())) + assert [x.name for x in out] == ["find", "run"] + + +def test_same_meta_names_rejected(): + with pytest.raises(ValueError): + RegexSearchTransform(search_tool_name="x", call_tool_name="x") + + +def test_search_tool_is_read_only(): + t = RegexSearchTransform() + out = run(t.transform_tools(CATALOG, TransformContext())) + assert out[0].annotations.read_only_hint is True + # and it serializes to the MCP wire name + assert out[0].model_dump(by_alias=True, exclude_none=True)["annotations"]["readOnlyHint"] is True + +# ---- browse ----------------------------------------------------------------- + +@pytest.mark.parametrize("q", ["", " ", "show tools", "list all tools", "what tools do you have"]) +def test_browse_queries(q): + assert is_browse_query(q) + + +@pytest.mark.parametrize("q", ["email", "get user", "(a+)+$", "^get_"]) +def test_task_queries_are_not_browse(q): + assert not is_browse_query(q) + + +def test_browse_returns_brief_index_of_whole_catalog(): + t = registered(RegexSearchTransform(), CATALOG) + out = run(search(t, query="show tools")) + assert out["mode"] == "browse" + assert [r["name"] for r in out["results"]] == ["get_order", "get_user", "send_email"] + assert set(out["results"][0]) == {"name", "summary"} + + +# ---- search ----------------------------------------------------------------- + +def test_literal_match_is_case_insensitive_and_ranks_name_first(): + t = registered(RegexSearchTransform(), CATALOG) + out = run(search(t, query="USER")) + assert [r["name"] for r in out["results"]] == ["get_user"] + + +def test_no_match_returns_empty_list_not_error(): + t = registered(RegexSearchTransform(), CATALOG) + out = run(search(t, query="zzzzz")) + assert out["results"] == [] + assert "No tools matched" in out["note"] + + +def test_limit_above_20_is_clamped(): + many = [make_tool(f"t{i:02d}", f"item {i}") for i in range(25)] + t = registered(RegexSearchTransform(), many) + out = run(search(t, query="item", limit=500)) + assert len(out["results"]) == 20 + assert "reduced" in out["note"] + + +def test_limit_is_respected_below_cap(): + many = [make_tool(f"t{i:02d}", f"item {i}") for i in range(25)] + t = registered(RegexSearchTransform(), many) + assert len(run(search(t, query="item", limit=3))["results"]) == 3 + + +def test_equal_scores_sort_by_name(): + tools = [make_tool("b_tool", "widget"), make_tool("a_tool", "widget")] + t = registered(RegexSearchTransform(), tools) + out = run(search(t, query="widget")) + assert [r["name"] for r in out["results"]] == ["a_tool", "b_tool"] + + +def test_detail_levels(): + t = registered(RegexSearchTransform(), CATALOG) + brief = run(search(t, query="user", detail="brief"))["results"] + detailed = run(search(t, query="user", detail="detailed"))["results"] + full = run(search(t, query="user", detail="full"))["results"] + + assert [r["name"] for r in detailed] == ["get_user"] + assert set(brief[0]) == {"name", "summary"} + assert detailed[0]["parameters"][0]["name"] == "user_id" + assert detailed[0]["parameters"][0]["required"] is False + assert "inputSchema" in full[0] + + +# ---- regex ------------------------------------------------------------------ + +def test_literal_by_default_treats_dot_literally(): + t = registered(RegexSearchTransform(), [make_tool("getXuser", "x")]) + assert run(search(t, query="get.user"))["results"] == [] + + +def test_regex_enabled_matches_pattern(): + t = registered(RegexSearchTransform(allow_regex=True), CATALOG) + out = run(search(t, query="^get_")) + assert [r["name"] for r in out["results"]] == ["get_order", "get_user"] + + +def test_invalid_regex_falls_back_to_literal(): + t = registered(RegexSearchTransform(allow_regex=True), CATALOG) + out = run(search(t, query="([")) + assert out["results"] == [] + assert "literal" in out["note"] + + +def test_catastrophic_pattern_returns_within_timeout_using_literal(): + nasty = make_tool("victim", "a" * 40 + "!") + t = registered(RegexSearchTransform(allow_regex=True, regex_timeout=1.0), [nasty, *CATALOG]) + start = time.monotonic() + out = run(search(t, query="(a+)+$")) + assert time.monotonic() - start < 8 + assert "timed out" in out["note"] + assert out["results"] == [] # literal "(a+)+$" matches nothing + + +def test_long_pattern_is_not_run_as_regex(): + t = registered(RegexSearchTransform(allow_regex=True, max_pattern_length=10), CATALOG) + out = run(search(t, query="x" * 50)) + assert "longer than" in out["note"] + + +# ---- call_tool -------------------------------------------------------------- + +def test_call_tool_dispatches_through_context_skipping_this_transform(): + t = registered(RegexSearchTransform(), CATALOG) + ctx = FakeCtx() + tool = run(t.resolve_tool("call_tool", _none, ctx)) + run(tool.handler(CallToolInput(name="get_user", arguments={"user_id": "1"}), ctx)) + assert ctx.calls == [("get_user", {"user_id": "1"}, "regex_search")] + + +@pytest.mark.parametrize("target", ["search_tools", "call_tool"]) +def test_call_tool_rejects_meta_tools(target): + t = registered(RegexSearchTransform(), CATALOG) + ctx = FakeCtx() + tool = run(t.resolve_tool("call_tool", _none, ctx)) + with pytest.raises(ToolArgumentError): + run(tool.handler(CallToolInput(name=target), ctx)) + assert ctx.calls == [] + + +def test_unknown_names_fall_through_to_next_resolver(): + t = RegexSearchTransform() + + async def nxt(name): + return f"real:{name}" + + assert run(t.resolve_tool("get_user", nxt, TransformContext())) == "real:get_user" \ No newline at end of file diff --git a/tests/test_transforms_search_app.py b/tests/test_transforms_search_app.py index e69de29..aa45493 100644 --- a/tests/test_transforms_search_app.py +++ b/tests/test_transforms_search_app.py @@ -0,0 +1,81 @@ +import asyncio + +import mcp.types as types +from pydantic import BaseModel + +from nitrostack import ExecutionContext, injectable, module, tool +from nitrostack.testing import NitroTestingModule +from nitrostack.transforms.search import RegexSearchTransform + + +class In(BaseModel): + q: str = "" + + +@injectable() +class C: + @tool(name="alpha", description="first tool about apples", input_schema=In) + async def alpha(self, input: In, context: ExecutionContext) -> str: + return "alpha-result" + + @tool(name="beta", description="second tool about bananas", input_schema=In) + async def beta(self, input: In, context: ExecutionContext) -> str: + return "beta-result" + + +@module(name="m", controllers=[C]) +class M: + pass + + +def run(coro): + return asyncio.run(coro) + + +async def _list(h): + srv = h.app.mcp_server + return (await srv.request_handlers[types.ListToolsRequest]( + types.ListToolsRequest(method="tools/list"))).root + + +def test_tools_list_returns_only_meta_tools(): + async def go(): + h = await NitroTestingModule.create(M, transforms=[RegexSearchTransform()]) + return [t.name for t in (await _list(h)).tools] + + assert run(go()) == ["search_tools", "call_tool"] + + +def test_search_then_call_runs_the_real_tool(): + async def go(): + h = await NitroTestingModule.create(M, transforms=[RegexSearchTransform()]) + found = await h.call_tool("search_tools", {"input": {"query": "bananas"}}) + called = await h.call_tool("call_tool", {"input": {"name": "beta", "arguments": {"q": "x"}}}) + return found, called + + found, called = run(go()) + assert "beta" in str(found) + assert "beta-result" in str(called) + + + +def test_call_tool_cannot_call_itself(): + async def go(): + h = await NitroTestingModule.create( + M, transforms=[RegexSearchTransform()] + ) + return await h.call_tool( + "call_tool", + {"input": {"name": "call_tool"}}, + ) + + result = run(go()) + assert "cannot be called" in str(result) + + + +# GUARD TEST (ADAPT). Reuse the guard pattern from your #61 guard test: +# - a real tool "admin_only" protected by the SDK's guard decorator +# - call it via call_tool as a caller WITHOUT permission -> must raise +# - with permission -> must succeed +# Not written here because the guard API isn't visible to me. Without it, "denied" is untested. \ No newline at end of file From af4f3bca755bb900c06c8da80ec3924f2ad077b8 Mon Sep 17 00:00:00 2001 From: Shreya Vemana Date: Sun, 25 Oct 2026 17:02:38 +0530 Subject: [PATCH 09/11] feat(transforms): complete progressive discovery for issue #62 --- pr62.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 pr62.md diff --git a/pr62.md b/pr62.md new file mode 100644 index 0000000..ec78476 --- /dev/null +++ b/pr62.md @@ -0,0 +1,3 @@ +Stacked on #86. Until it merges, this PR also shows its 5 commits (nitrostack/transforms/ core package, core/app.py wiring, baseline fixture, #61 tests, pytest-asyncio dev dependency). The #62 changes are the last 3 commits: 0c59e9d, 36e9e82, 12d949b. + + From 7c6e1fa87284fd8bc677aedc3bd20e84a44d4d4a Mon Sep 17 00:00:00 2001 From: Shreyavemana <128345703+Shreyavemana@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:11:07 +0530 Subject: [PATCH 10/11] Delete pr62.md --- pr62.md | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 pr62.md diff --git a/pr62.md b/pr62.md deleted file mode 100644 index ec78476..0000000 --- a/pr62.md +++ /dev/null @@ -1,3 +0,0 @@ -Stacked on #86. Until it merges, this PR also shows its 5 commits (nitrostack/transforms/ core package, core/app.py wiring, baseline fixture, #61 tests, pytest-asyncio dev dependency). The #62 changes are the last 3 commits: 0c59e9d, 36e9e82, 12d949b. - - From 69e316a7800c9ff62d3318122a7807223436d1a9 Mon Sep 17 00:00:00 2001 From: Shreyavemana <128345703+Shreyavemana@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:15:52 +0530 Subject: [PATCH 11/11] Update test_transforms_search_app.py --- tests/test_transforms_search_app.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/test_transforms_search_app.py b/tests/test_transforms_search_app.py index aa45493..34d33d0 100644 --- a/tests/test_transforms_search_app.py +++ b/tests/test_transforms_search_app.py @@ -6,7 +6,7 @@ from nitrostack import ExecutionContext, injectable, module, tool from nitrostack.testing import NitroTestingModule from nitrostack.transforms.search import RegexSearchTransform - +from nitrostack import use_guards class In(BaseModel): q: str = "" @@ -71,11 +71,3 @@ async def go(): result = run(go()) assert "cannot be called" in str(result) - - - -# GUARD TEST (ADAPT). Reuse the guard pattern from your #61 guard test: -# - a real tool "admin_only" protected by the SDK's guard decorator -# - call it via call_tool as a caller WITHOUT permission -> must raise -# - with permission -> must succeed -# Not written here because the guard API isn't visible to me. Without it, "denied" is untested. \ No newline at end of file