From 80b85dcfd8f3b87b27cdac506eb2cbc712460e56 Mon Sep 17 00:00:00 2001 From: jinliyl <6469360+jinliyl@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:36:17 +0800 Subject: [PATCH] chore(deps): upgrade dependencies and bump ReMe to 0.4.2.2 (#692) * chore(deps): upgrade dependencies and bump ReMe to 0.4.2.2 * test(mcp): disable camelCase compatibility in CI * fix(mcp): normalize implicit job input schemas --------- Co-authored-by: huangsen --- .github/workflows/ci-python-tests.yml | 4 +- docs/en/configuration.md | 2 +- docs/en/services.md | 4 ++ docs/zh/configuration.md | 2 +- docs/zh/services.md | 4 ++ integrations/claude_code/README.md | 2 +- integrations/claude_code/README_ZH.md | 2 +- .../claude_code/reme/hooks/mcp_bridge.py | 2 +- integrations/codex/README.md | 4 +- integrations/codex/README_ZH.md | 4 +- integrations/codex/plugins/reme/mcp_server.py | 10 +-- integrations/codex/plugins/reme/reme_tools.py | 7 +- pyproject.toml | 72 ++++++++++--------- reme/__init__.py | 2 +- reme/components/client/mcp_client.py | 2 +- .../file_store/zvec_local_file_store.py | 4 +- reme/components/service/mcp_tools.py | 2 + tests/unit/test_codex_plugin_mcp.py | 14 ++-- tests/unit/test_decision_config.py | 4 +- tests/unit/test_mcp_client.py | 40 ++++++++++- tests/unit/test_package_versions.py | 2 +- tests/unit/test_service.py | 34 +++++++-- 22 files changed, 151 insertions(+), 72 deletions(-) diff --git a/.github/workflows/ci-python-tests.yml b/.github/workflows/ci-python-tests.yml index 3ab84648..82bf5365 100644 --- a/.github/workflows/ci-python-tests.yml +++ b/.github/workflows/ci-python-tests.yml @@ -48,6 +48,8 @@ jobs: pip install coverage - name: Run unit tests + env: + FASTMCP_MCP_CAMELCASE_COMPAT: "false" run: | python -c "import tokenizers; print('tokenizers', tokenizers.__version__)" coverage run -m pytest tests/unit plugins/auto-fin plugins/daily_paper plugins/lme plugins/beam plugins/home/tests \ @@ -62,6 +64,6 @@ jobs: - name: Verify minimum tokenizer version if: matrix.python-version == '3.11' run: | - python -m pip install "tokenizers==0.21.0" + python -m pip install "tokenizers==0.23.3" python -c "import tokenizers; print('tokenizers', tokenizers.__version__)" python -m pytest tests/unit/test_qwen_token_tools.py tests/unit/test_package_versions.py -q diff --git a/docs/en/configuration.md b/docs/en/configuration.md index 3a8b4664..fedf7ac0 100644 --- a/docs/en/configuration.md +++ b/docs/en/configuration.md @@ -116,7 +116,7 @@ as `auto_memory`, `auto_resource`, `auto_dream`, and proactive refresh do. ## Decision Model Configuration `as_decision_model` is a provider-independent interface for probabilistic decisions. The current `agentscope` adapter -calls the TypeSafe API through AgentScope `2.0.10`. It evaluates named binary, choice, or score questions against shared text or a JSON object. +calls the TypeSafe API through AgentScope `2.0.11`. It evaluates named binary, choice, or score questions against shared text or a JSON object. Install the optional dependencies: diff --git a/docs/en/services.md b/docs/en/services.md index 585f98eb..fa49b7c0 100644 --- a/docs/en/services.md +++ b/docs/en/services.md @@ -53,6 +53,10 @@ MCP does not expose Stream Jobs. ## MCP +ReMe 0.4.2.2 uses FastMCP 4 and MCP Python SDK 2. Upgrade ReMe and host-side MCP bridges together; +standalone bridge environments require `fastmcp>=4.1.4,<5`. CLI tool catalogs use SDK 2 snake_case fields, +including `input_schema`, `output_schema`, and annotation hints. Update consumers that read the old camelCase fields. + The default HTTP service mounts streamable HTTP MCP at `/mcp`: ```yaml diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index af1538fe..6cec4614 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -119,7 +119,7 @@ components: ## 决策模型配置 -`as_decision_model` 是不依赖供应商的概率决策模型接口;当前的 `agentscope` 适配器通过 AgentScope `2.0.10` 调用 TypeSafe API。 +`as_decision_model` 是不依赖供应商的概率决策模型接口;当前的 `agentscope` 适配器通过 AgentScope `2.0.11` 调用 TypeSafe API。 它接受共享的文本或 JSON object 与命名问题,返回二分类概率、选项分类或评分结果。 安装可选依赖: diff --git a/docs/zh/services.md b/docs/zh/services.md index d0ee25e8..1cba192e 100644 --- a/docs/zh/services.md +++ b/docs/zh/services.md @@ -59,6 +59,10 @@ MCP 不暴露 Stream Job。 ## MCP +ReMe 0.4.2.2 使用 FastMCP 4 与 MCP Python SDK 2。升级时请同步更新 ReMe 和宿主侧 MCP bridge; +独立 bridge 环境需要 `fastmcp>=4.1.4,<5`。CLI 工具列表统一使用 SDK 2 的 snake_case 字段, +包括 `input_schema`、`output_schema` 和 annotation hints;读取旧驼峰字段的调用方需要同步更新。 + 默认 HTTP 服务会在 `/mcp` 挂载 streamable HTTP MCP: ```yaml diff --git a/integrations/claude_code/README.md b/integrations/claude_code/README.md index ba38c35e..9bc54ef9 100644 --- a/integrations/claude_code/README.md +++ b/integrations/claude_code/README.md @@ -107,7 +107,7 @@ sends old pending conversations to the new service. Remove a field to restore it The plugin uses a host-managed stdio process to forward MCP. Start the host with a `python3` environment containing FastMCP. If ReMe runs on another machine or in another Python environment, -run `python3 -m pip install "fastmcp>=3.1"` in the host environment. Hooks use only the standard library. +run `python3 -m pip install "fastmcp>=4.1.4,<5"` in the host environment. Hooks use only the standard library. ## Lifecycle and failure behavior diff --git a/integrations/claude_code/README_ZH.md b/integrations/claude_code/README_ZH.md index ca47f405..4f914b39 100644 --- a/integrations/claude_code/README_ZH.md +++ b/integrations/claude_code/README_ZH.md @@ -99,7 +99,7 @@ MCP 和 Hook 从同一份用户配置读取地址。Hook 在下一次调用时 插件通过宿主管理的 stdio 连接进程转发 MCP,请确保启动宿主时的 `python3` 环境包含 FastMCP。 如果 ReMe 服务运行在另一台机器或另一个 Python 环境,在宿主环境执行 -`python3 -m pip install "fastmcp>=3.1"`。Hook 本身仅使用标准库。 +`python3 -m pip install "fastmcp>=4.1.4,<5"`。Hook 本身仅使用标准库。 ## 生命周期与失败行为 diff --git a/integrations/claude_code/reme/hooks/mcp_bridge.py b/integrations/claude_code/reme/hooks/mcp_bridge.py index 666f5d6a..eaa57796 100644 --- a/integrations/claude_code/reme/hooks/mcp_bridge.py +++ b/integrations/claude_code/reme/hooks/mcp_bridge.py @@ -12,7 +12,7 @@ def main() -> None: from fastmcp.server.providers.proxy import ProxyClient except ImportError as exc: raise SystemExit( - "ReMe MCP requires fastmcp>=3.1 in the host's python3 environment (included in reme-ai).", + "ReMe MCP requires fastmcp>=4.1.4,<5 in the host's python3 environment (included in reme-ai).", ) from exc upstream = ProxyClient(config["mcp_url"], timeout=config["request_timeout"]) proxy = create_proxy(upstream, name="ReMe") diff --git a/integrations/codex/README.md b/integrations/codex/README.md index 8ed7d455..1f181d0f 100644 --- a/integrations/codex/README.md +++ b/integrations/codex/README.md @@ -13,14 +13,14 @@ The screenshots below were captured in Codex desktop. Click an image to view it - **Codex CLI >= 0.159.2** for installation and CLI use. Run `codex --version` to check. - **Codex desktop >= 26.924.22138** for the MCP settings form and status panel. Check the version in **About**. These are the versions validated for this guide; older releases have not been verified. -- Make Python 3.11+ and `fastmcp>=3.4.2` available as `python3` in the environment that launches Codex, +- Make Python 3.11+ and `fastmcp>=4.1.4,<5` available as `python3` in the environment that launches Codex, even when ReMe runs on another machine. Check with: ```bash python3 -c "import sys, fastmcp; print(sys.version); print(fastmcp.__version__)" ``` - If needed, install FastMCP there with `python3 -m pip install "fastmcp>=3.4.2"`. + If needed, install FastMCP there with `python3 -m pip install "fastmcp>=4.1.4,<5"`. - Configure ReMe's model for memory extraction using the [configuration guide](../../docs/en/configuration.md). Default BM25 search does not require an embedding model. - Have a local checkout of this repository for installation. diff --git a/integrations/codex/README_ZH.md b/integrations/codex/README_ZH.md index b8cf02c2..9aeba5fd 100644 --- a/integrations/codex/README_ZH.md +++ b/integrations/codex/README_ZH.md @@ -12,14 +12,14 @@ ReMe 可以自动记录、召回记忆,并每天整理。记忆文件保存在 - **Codex CLI >= 0.159.2**:用于安装和命令行使用,运行 `codex --version` 查看版本。 - **Codex 桌面端 >= 26.924.22138**:用于打开 MCP 设置表单和状态面板,可在 **关于(About)** 中查看版本。 以上为本指南已验证的版本基线,更早版本未经验证。 -- 启动 Codex 的环境中,`python3` 需要是 Python 3.11+,并安装 `fastmcp>=3.4.2`。 +- 启动 Codex 的环境中,`python3` 需要是 Python 3.11+,并安装 `fastmcp>=4.1.4,<5`。 即使 ReMe 在远程运行,Codex 所在机器也需要这个环境。检查命令: ```bash python3 -c "import sys, fastmcp; print(sys.version); print(fastmcp.__version__)" ``` - 如未安装,执行 `python3 -m pip install "fastmcp>=3.4.2"`。 + 如未安装,执行 `python3 -m pip install "fastmcp>=4.1.4,<5"`。 - 按[配置指南](../../docs/zh/configuration.md)配置 ReMe 用于提取记忆的模型。默认 BM25 检索不需要 embedding 模型。 - 准备本仓库的本地副本,用于安装。 diff --git a/integrations/codex/plugins/reme/mcp_server.py b/integrations/codex/plugins/reme/mcp_server.py index dda70310..b522099e 100644 --- a/integrations/codex/plugins/reme/mcp_server.py +++ b/integrations/codex/plugins/reme/mcp_server.py @@ -198,7 +198,7 @@ def create_server() -> FastMCP: }, "required": ["schema", "values"], }, - annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False), + annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False), ), ) server.add_tool( @@ -217,10 +217,10 @@ def create_server() -> FastMCP: }, output_schema={"type": "object", "properties": {"values": SCHEMA}, "required": ["values"]}, annotations=ToolAnnotations( - readOnlyHint=False, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, + read_only_hint=False, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), ), ) diff --git a/integrations/codex/plugins/reme/reme_tools.py b/integrations/codex/plugins/reme/reme_tools.py index 6bce9f6c..f1f29cda 100644 --- a/integrations/codex/plugins/reme/reme_tools.py +++ b/integrations/codex/plugins/reme/reme_tools.py @@ -11,6 +11,7 @@ from typing import Annotated from fastmcp import FastMCP from fastmcp.resources import ResourceContent, ResourceResult from fastmcp.tools import ToolResult +from mcp.types import ToolAnnotations from pydantic import Field from reme_config import load_config @@ -82,7 +83,7 @@ def register_tools(server: FastMCP) -> None: status_html = (root / "ui/status.html").read_text(encoding="utf-8") plugin_version = json.loads((root / ".codex-plugin/plugin.json").read_text(encoding="utf-8"))["version"] - @server.tool(name=SEARCH, title="Search ReMe memory", annotations={"readOnlyHint": True}) + @server.tool(name=SEARCH, title="Search ReMe memory", annotations=ToolAnnotations(read_only_hint=True)) async def search( query: Annotated[str, Field(min_length=1, description="Focused memory search query")], limit: Annotated[int | None, Field(ge=1, le=50)] = None, @@ -119,7 +120,7 @@ def register_tools(server: FastMCP) -> None: @server.tool( name=STATUS, title="ReMe status", - annotations={"readOnlyHint": True}, + annotations=ToolAnnotations(read_only_hint=True), app={"resourceUri": STATUS_URI, "visibility": ["app", "model"]}, ) async def status() -> ToolResult: @@ -142,7 +143,7 @@ def register_tools(server: FastMCP) -> None: @server.tool( name=RUN_DREAM, title="Consolidate ReMe memory now", - annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False}, + annotations=ToolAnnotations(read_only_hint=False, destructive_hint=True, idempotent_hint=False), ) async def run_dream(date: str = "", hint: str | None = None) -> str: """Only on user request: consolidate daily notes into digest files without changing the daily schedule.""" diff --git a/pyproject.toml b/pyproject.toml index b8a992aa..e508f170 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,66 +21,70 @@ classifiers = [ ] dependencies = [ - "aiofiles>=24.1.0", - "croniter>=2.0", - "fastapi>=0.135.1", - "fastmcp>=3.1.0", + "aiofiles>=25.1.0", + "croniter>=6.2.4", + "fastapi>=0.162.0", + "fastmcp>=4.1.4,<5", "httpx[socks]>=0.28.1", "loguru>=0.7.3", - "mistletoe>=1.5.1", - "numpy>=2.2.6", - "openai>=2.26.0", - "psutil>=5.9", - "pydantic>=2.12.5", - "python-frontmatter>=1.1.0", + "mcp>=2.3.0,<3", + "mistletoe>=1.6.0", + "numpy>=2.4.6; python_version < '3.12'", + "numpy>=2.5.4; python_version >= '3.12'", + "openai>=3.40.0", + "psutil>=7.2.2", + "pydantic>=2.14.1", + "python-frontmatter>=1.3.0", "pyyaml>=6.0.3", - "rich>=14.3.3", - "uvicorn>=0.41.0", - "watchfiles>=1.1.1", - "zstandard>=0.23.0", + "rich>=15.0.0", + "uvicorn>=0.54.0", + "watchfiles>=1.3.0", + "zstandard>=0.25.0", ] [project.optional-dependencies] as = [ - "agentscope[model-ollama]==2.0.10", + "agentscope[model-ollama]==2.0.11", ] decision = [ - "agentscope[classifier-jev]==2.0.10", + "agentscope[classifier-jev]==2.0.11", ] web = [ "reme_studio", ] core = [ "reme-ai[as]", - "pillow>=10.0.0", - "claude-agent-sdk>=0.2.126", - "openai-codex>=0.144.4", + "pillow>=13.0.0", + "claude-agent-sdk>=0.2.174", + "openai-codex>=0.163.0", "pproxy>=2.7.9,<3", - "faiss-cpu>=1.13.2", - "zvec>=0.6.0", + "faiss-cpu>=1.15.1", + "zvec>=0.8.0", "jieba>=0.42.1", - "rjieba>=0.2.1", - "neo4j>=6.2.0", - "networkx>=3.4.2", - "polars>=1.43.0", + "rjieba>=0.2.2", + "neo4j>=6.4.1", + "networkx>=3.6.1; python_version < '3.12'", + "networkx>=3.6; python_full_version == '3.14.1'", + "networkx>=3.7; python_version >= '3.12' and python_full_version != '3.14.1'", + "polars>=2.1.0", "reme_studio", ] qwen-tokenizer = [ - "tokenizers>=0.21,<1", + "tokenizers>=0.23.3,<1", ] image-heif = [ - "pillow>=10.0.0", - "pillow-heif>=0.13.0", + "pillow>=13.0.0", + "pillow-heif>=1.8.1", ] dev = [ - "packaging>=24.2", - "pre-commit>=4.6.1", - "pytest>=9.1.1", - "pytest-asyncio>=0.23", + "packaging>=26.3", + "pre-commit>=4.7.0", + "pytest>=9.2.0", + "pytest-asyncio>=1.4.1", ] benchmark-docker = [ "reme-ai[as]", - "aiodocker", + "aiodocker>=0.27.0", ] full = [ "reme-ai[core]", @@ -111,7 +115,7 @@ include-package-data = true version = { attr = "reme.__version__" } [build-system] -requires = ["setuptools>=45", "wheel"] +requires = ["setuptools>=84.2.1", "wheel>=0.48.0"] build-backend = "setuptools.build_meta" [tool.pytest.ini_options] diff --git a/reme/__init__.py b/reme/__init__.py index 9e0606d4..85bc11e1 100644 --- a/reme/__init__.py +++ b/reme/__init__.py @@ -1,6 +1,6 @@ """ReMe CLI package.""" -__version__ = "0.4.2.1" +__version__ = "0.4.2.2" from . import config from . import constants diff --git a/reme/components/client/mcp_client.py b/reme/components/client/mcp_client.py index 72e41f2b..5497381f 100644 --- a/reme/components/client/mcp_client.py +++ b/reme/components/client/mcp_client.py @@ -92,7 +92,7 @@ class MCPClient(BaseClient): yield self._extract_text(result) async def list_actions(self) -> list[dict]: - """Return raw MCP Tool dumps; each dict gets an `action` key (the tool name).""" + """Return MCP tool descriptors using the SDK's snake_case field names.""" if self.client is None: raise RuntimeError("Client not initialized. Call _start() first.") tools = await self.client.list_tools() diff --git a/reme/components/file_store/zvec_local_file_store.py b/reme/components/file_store/zvec_local_file_store.py index 8d81609d..45207bd2 100644 --- a/reme/components/file_store/zvec_local_file_store.py +++ b/reme/components/file_store/zvec_local_file_store.py @@ -51,7 +51,7 @@ class ZvecLocalFileStore(LocalFileStore): zvec is imported lazily inside ``__init__`` so that merely importing this module does not require the optional dependency; the backend targets the - zvec version declared in ``pyproject.toml`` (``zvec>=0.6.0``). + zvec version declared in ``pyproject.toml`` (``zvec>=0.8.0``). """ def __init__( @@ -75,7 +75,7 @@ class ZvecLocalFileStore(LocalFileStore): import zvec except ImportError as e: raise ImportError( - "zvec is required for ZvecLocalFileStore. Install with `pip install 'zvec>=0.6.0'`.", + "zvec is required for ZvecLocalFileStore. Install with `pip install 'zvec>=0.8.0'`.", ) from e return zvec diff --git a/reme/components/service/mcp_tools.py b/reme/components/service/mcp_tools.py index 6e0b4caf..77209f2c 100644 --- a/reme/components/service/mcp_tools.py +++ b/reme/components/service/mcp_tools.py @@ -33,6 +33,8 @@ def add_mcp_job( return response.answer parameters = dict(job.parameters or {}) + # MCP requires object inputs even when a job leaves its schema implicit. + parameters.setdefault("type", "object") injected_names = injected_job_kwargs.keys() if "properties" in parameters: parameters["properties"] = { diff --git a/tests/unit/test_codex_plugin_mcp.py b/tests/unit/test_codex_plugin_mcp.py index 5883a0bb..2c1c4504 100644 --- a/tests/unit/test_codex_plugin_mcp.py +++ b/tests/unit/test_codex_plugin_mcp.py @@ -13,7 +13,7 @@ from unittest.mock import AsyncMock import pytest from fastmcp import Client, FastMCP from fastmcp.exceptions import ToolError -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError PLUGIN = Path(__file__).resolve().parents[2] / "integrations/codex/plugins/reme" @@ -60,7 +60,7 @@ async def test_settings_remain_editable_offline(plugin, monkeypatch): connect = AsyncMock(side_effect=ConnectionError("offline")) monkeypatch.setattr(module.ReMeProvider, "_list_tools", connect) async with Client(module.create_server()) as client: - capability = client.initialize_result.capabilities.experimental["openai/settings"] + capability = client.server_capabilities.experimental["openai/settings"] assert capability == {"readTool": module.SETTINGS_READ, "updateTool": module.SETTINGS_UPDATE} tools = {tool.name: tool for tool in await client.list_tools()} assert {module.SETTINGS_READ, module.SETTINGS_UPDATE, module.STATUS} <= tools.keys() @@ -71,8 +71,8 @@ async def test_settings_remain_editable_offline(plugin, monkeypatch): assert settings["schema"]["properties"].keys() == config.DEFAULTS.keys() assert settings["layout"][0]["items"][1]["tool"] == module.STATUS assert len(settings["layout"][0]["items"]) == 2 - assert tools[module.SETTINGS_READ].annotations.readOnlyHint - assert tools[module.SETTINGS_READ].outputSchema + assert tools[module.SETTINGS_READ].annotations.read_only_hint + assert tools[module.SETTINGS_READ].output_schema assert not (config.data_dir() / "config.json").exists() saved = await client.call_tool(capability["updateTool"], {"set": {"autoMemoryEnabled": False}}) assert saved.structured_content["values"] == {**config.DEFAULTS, "autoMemoryEnabled": False} @@ -169,7 +169,7 @@ async def test_hooks_call_mcp_and_preserve_structured_answers(plugin, monkeypatc assert calls == ["fact"] with pytest.raises(ToolError, match="write failed"): await transport.call_async(config.load_config(), "auto_memory", {}, 2) - with pytest.raises((TimeoutError, McpError)): + with pytest.raises((TimeoutError, MCPError)): await transport.call_async(config.load_config(), "delayed", {}, 0.05) @@ -208,7 +208,7 @@ async def test_status_opens_a_self_contained_fullscreen_app_resource(plugin): ui = tools["reme_status"].meta["ui"] assert ui["visibility"] == ["app", "model"] content = (await client.read_resource(ui["resourceUri"]))[0] - assert content.mimeType == "text/html;profile=mcp-app" + assert content.mime_type == "text/html;profile=mcp-app" assert content.meta["openai/ui"] == { "preferredDisplayMode": "fullscreen", "availableDisplayModes": ["fullscreen"], @@ -323,7 +323,7 @@ async def test_manual_dream_uses_saved_settings_serializes_processes_and_tracks_ monkeypatch.setattr(features, "call_async", dream) async with Client(module.create_server()) as first, Client(module.create_server()) as second: tools = {tool.name: tool for tool in await first.list_tools()} - assert tools["reme_run_dream"].annotations.destructiveHint + assert tools["reme_run_dream"].annotations.destructive_hint task = asyncio.create_task(first.call_tool("reme_run_dream", {"date": "2026-09-30"})) await asyncio.wait_for(started.wait(), 2) assert state.local_status(current)["auto_dream"]["last_run"]["status"] == "running" diff --git a/tests/unit/test_decision_config.py b/tests/unit/test_decision_config.py index fa136e99..c26f8836 100644 --- a/tests/unit/test_decision_config.py +++ b/tests/unit/test_decision_config.py @@ -173,8 +173,8 @@ def test_extra_reuses_agentscope_pin(): """Installing core and decision models together must not request different AgentScope versions.""" config = tomllib.loads((Path(__file__).resolve().parents[2] / "pyproject.toml").read_text()) extras = config["project"]["optional-dependencies"] - assert extras["decision"] == ["agentscope[classifier-jev]==2.0.10"] - assert extras["as"] == ["agentscope[model-ollama]==2.0.10"] + assert extras["decision"] == ["agentscope[classifier-jev]==2.0.11"] + assert extras["as"] == ["agentscope[model-ollama]==2.0.11"] assert "reme-ai[decision]" in extras["full"] assert "reme-ai[decision]" not in extras["core"] diff --git a/tests/unit/test_mcp_client.py b/tests/unit/test_mcp_client.py index 7c6461b0..a7e31f17 100644 --- a/tests/unit/test_mcp_client.py +++ b/tests/unit/test_mcp_client.py @@ -1,4 +1,10 @@ -"""MCP client address defaults.""" +"""MCP client addresses and SDK tool descriptors.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from mcp.types import Tool, ToolAnnotations +import pytest from reme.components.client.mcp_client import MCPClient from reme.constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT @@ -18,3 +24,35 @@ def test_mcp_client_converts_wildcard_bind_address_to_loopback(): assert client.host == "127.0.0.1" assert client.port == 8123 + + +@pytest.mark.asyncio +async def test_mcp_catalog_uses_sdk_field_names(): + """Tool catalogs expose SDK 2 fields while preserving schemas and user metadata.""" + parameters = { + "type": "object", + "$defs": {"Query": {"type": "string"}}, + "properties": {"query": {"$ref": "#/$defs/Query"}}, + "required": ["query"], + } + output = {"type": "object", "properties": {"answer": {"type": "string"}}} + metadata = {"input_schema": "user-owned metadata", "_meta": {"keep": True}} + tool = Tool( + name="search", + input_schema=parameters, + output_schema=output, + annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False), + meta=metadata, + ) + client = MCPClient() + client.client = SimpleNamespace(list_tools=AsyncMock(return_value=[tool])) + + [action] = await client.list_actions() + + assert action["name"] == "search" + assert action["input_schema"] == parameters + assert action["output_schema"] == output + assert action["annotations"]["read_only_hint"] is True + assert action["annotations"]["open_world_hint"] is False + assert action["meta"] == metadata + assert {"inputSchema", "outputSchema", "_meta"}.isdisjoint(action) diff --git a/tests/unit/test_package_versions.py b/tests/unit/test_package_versions.py index bb9bb80f..559e62a9 100644 --- a/tests/unit/test_package_versions.py +++ b/tests/unit/test_package_versions.py @@ -261,7 +261,7 @@ def test_qwen_tokenizer_dependency_is_optional() -> None: """Exact Qwen tokenization is opt-in and included in the full install.""" config = tomllib.loads((REPOSITORY / "pyproject.toml").read_text(encoding="utf-8")) extras = config["project"]["optional-dependencies"] - assert extras["qwen-tokenizer"] == ["tokenizers>=0.21,<1"] + assert extras["qwen-tokenizer"] == ["tokenizers>=0.23.3,<1"] assert "tokenizers" not in {Requirement(value).name for value in config["project"]["dependencies"]} assert "tokenizers" not in {Requirement(value).name for value in extras["core"]} assert "reme-ai[qwen-tokenizer]" in extras["full"] diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index 184364c5..07b45d94 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -14,6 +14,12 @@ from reme.constants import REME_SERVICE_INFO from reme.schema import Response +@pytest.fixture(autouse=True) +def _isolate_service_info(monkeypatch): + """Restore discovery state after service lifespans modify the environment.""" + monkeypatch.setenv(REME_SERVICE_INFO, "") + + def _dummy_app(): """Minimal object needed by MCPService.build_service.""" @@ -91,14 +97,32 @@ def test_explicit_service_jobs_reject_missing_disabled_and_unsupported_jobs(opti stream_service.add_jobs(_app_with_jobs(stream=StreamJob(name="stream"))) -def test_mcp_service_registers_job_with_empty_parameters(): - """Empty job parameters must remain a dict for FastMCP FunctionTool validation.""" - service = MCPService() +@pytest.mark.asyncio +@pytest.mark.parametrize("service_class", [MCPService, HttpService]) +@pytest.mark.parametrize("mode", ["auto", "legacy"]) +@pytest.mark.parametrize("parameters", [None, {}, {"properties": {"query": {"type": "string"}}}]) +async def test_mcp_services_list_and_call_jobs_without_explicit_object_schema(service_class, mode, parameters): + """Optional job schemas must not break discovery or calls in either protocol era.""" + from fastmcp import Client + + service = service_class() service.build_service(_dummy_app()) - job = BaseJob(name="empty_params", parameters={}) - + job = BaseJob(name="optional_params", parameters=parameters) + original_parameters = dict(job.parameters) assert service.add_job(job) is True + assert service.add_job(BaseJob(name="typed_params", parameters={"type": "object"})) is True + + server = service.service if isinstance(service, MCPService) else service.mcp_server + async with Client(server, mode=mode) as client: + tools = {tool.name: tool for tool in await client.list_tools()} + assert set(tools) == {"optional_params", "typed_params"} + assert tools[job.name].input_schema == {"type": "object", **original_parameters} + assert tools["typed_params"].input_schema == {"type": "object"} + assert not (await client.call_tool(job.name, {"query": "hello"})).is_error + assert not (await client.call_tool("typed_params", {})).is_error + + assert job.parameters == original_parameters def test_mcp_service_reports_stream_job_skipped():