diff --git a/integrations/codex/README.md b/integrations/codex/README.md index 5514cb6f..fc62ae9e 100644 --- a/integrations/codex/README.md +++ b/integrations/codex/README.md @@ -15,8 +15,9 @@ Start ReMe → Install the ReMe plugin → Use memory in Codex ## Requirements -- Python 3.11+ available as `python3` in the host's environment, with FastMCP >= 3.1 (included with ReMe). +- Python 3.11+ available as `python3` in the host's environment, with FastMCP >= 3.4.2 (included with ReMe). - Codex CLI 0.145.0 or a compatible client supporting local plugins, command hooks, and hook trust review. +- A graphical host supporting the `openai/settings` MCP extension for the native settings UI. CLI 0.145.0 supports hooks and tools but does not render this UI. - A running ReMe HTTP service exposing `search`, `auto_memory`, and `health_check`, with MCP enabled. - A working model configuration on the ReMe server for memory extraction. Default BM25 recall does not need embeddings. @@ -54,68 +55,57 @@ For an explicit query, ask “What did we decide about Project Juniper? Cite the Automatic recording sends completed user/assistant text to the configured ReMe service. Set `auto_memory` to `false` for recall-only use. -## Optional configuration +## Configure through MCP settings -The default service address works immediately after plugin installation. Create a configuration -file only when you want to change the defaults below. +Open the installed ReMe MCP server's settings in a host that supports `openai/settings`. +The native form exposes the service address, automatic recall and memory switches, batch size, +recall limits, timeouts, and timezone. Save changes, then use **Check connection** to call +`health_check` on the saved address without sending conversation content. The settings remain +available when ReMe is offline, so a wrong address can be corrected there. -Edit the user configuration file directly to change addresses and behavior. Plugin upgrades preserve -this file, and only overrides need to be saved. - -Settings live at `${CODEX_HOME:-$HOME/.codex}/reme/config.json`. For a new file, copy the bundled example; edit an existing file in place: - -```bash -mkdir -p "${CODEX_HOME:-$HOME/.codex}/reme" -cp integrations/codex/plugins/reme/config.example.json "${CODEX_HOME:-$HOME/.codex}/reme/config.json" -``` +Settings are atomically saved to `${CODEX_HOME:-$HOME/.codex}/reme/config.json`, outside the plugin +cache. The next MCP tool call and hook invocation use the new values without a reconnect or reinstall. +An in-flight call keeps its original endpoint and settings. Disabling `auto_memory` pauses capture +and queued retries; explicit memory tools remain available. | Option | Default | Meaning | | --- | --- | --- | -| `mcp_url` | `http://127.0.0.1:2333/mcp` | MCP address; removing the trailing `/mcp` gives the default HTTP base for hooks | -| `api_url` | Empty string | Optional HTTP Job API base; required for custom MCP paths | +| `mcp_url` | `http://127.0.0.1:2333/mcp` | MCP address for both explicit tools and automatic hooks; custom paths are supported | | `auto_recall` | `true` | Search before a user prompt | | `auto_memory` | `true` | Queue and submit completed turns; false also disables retries | | `recall_limit` | `5` | Search result limit | | `recall_min_score` | `0` | Minimum recall score | -| `recall_timeout` | `5` | Foreground HTTP timeout in seconds, at most 10 | +| `recall_timeout` | `5` | Foreground MCP timeout in seconds, at most 10 | | `request_timeout` | `600` | Memory-write and MCP request timeout in seconds, at most 600 | | `memory_interval` | `5` | Completed turns per batch; use 1 for immediate per-turn writes | | `shutdown_timeout` | `2` | Total best-effort exit drain budget in seconds, at most 2 | | `context_max_chars` | `8000` | Maximum recalled payload characters | | `timezone` | `Asia/Shanghai` | Daily batch timezone; match the ReMe workspace | -For example, use another port and disable automatic recording: +The JSON file remains user-owned and can also be edited directly; only overrides are required. +See [config.example.json](plugins/reme/config.example.json) for a complete example. Remove a field +from the file to restore its default. Unknown fields and invalid values fail validation; hooks skip +work and log `hook_failed` instead of falling back to another service. UI updates reject invalid +values without replacing the saved file. -```json -{ - "mcp_url": "http://127.0.0.1:2444/mcp", - "auto_memory": false, - "auto_recall": true, - "memory_interval": 1 -} -``` +Existing configurations keep working. The deprecated `api_url` field is accepted on read but is +no longer used and is removed on the next UI save. All operations use `mcp_url`. Standard `/mcp` +endpoints retain existing queue and receipt identities. Pending queues from a custom legacy +`api_url` remain untouched: they are not automatically retargeted. Address changes never forward +old pending conversations to the new service; switch back to the original endpoint to retry its queue. -Both MCP and hooks read addresses from this user configuration. Hooks pick up edits on their next -invocation. After changing an address or the MCP timeout, reconnect MCP or restart the host so the -existing MCP process also reads the new settings. No source edits, cache edits, or reinstall are needed. -For a custom MCP path such as `https://memory.example.com/tools`, also set `api_url` to the HTTP Job -API base, for example `https://memory.example.com/reme`. - -Unknown fields and invalid values fail validation; hooks skip work and log `hook_failed` instead of -falling back to another service. Disabling `auto_memory` stops automatic capture and retries while -preserving pending batches; explicit MCP tools remain available. Changing the service address never -sends old pending conversations to the new service. Remove a field to restore its default. - -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. +Both the host-managed MCP server and short-lived hooks require FastMCP in the host's `python3` +environment. If ReMe runs elsewhere, install it with `python3 -m pip install "fastmcp>=3.4.2"`. +The plugin does not launch ReMe. Its native form follows the pinned +[OpenAI MCP Extensions settings contract](https://github.com/openai/mcp-extensions/blob/node-v0.1.0/docs/spec.md#structured-settings). +Clients without that extension can still use tools and hooks, but have no native settings form. ## Lifecycle and failure behavior - `UserPromptSubmit` calls `search` synchronously with a short timeout. Results are bounded and wrapped in `` as untrusted historical evidence. A failure does not reject the prompt. - Synchronous `Stop` hooks extract only the completed user/final-assistant text from the - hook's local transcript, then submit `auto_memory` batches through the JSON Job API. Tool output, + hook's local transcript, then submit `auto_memory` batches through MCP. Tool output, reasoning, and injected ReMe context are excluded. The server does not need access to host files. - Session and message IDs are stable and host-scoped. Repeated Stops are deduplicated. Per-session process locks serialize writes; batches never mix dates. Subagent events and hook continuations @@ -127,7 +117,7 @@ run `python3 -m pip install "fastmcp>=3.1"` in the host environment. Hooks use o A due memory batch or a startup retry can delay completion by up to `request_timeout`. Claude Code uses its own native asynchronous adapter. - Shutdown and delivery are best-effort. A host killed before the Stop hook captures a turn can - lose that turn. A lost HTTP acknowledgement can cause a retry; stable message IDs prevent duplicate + lose that turn. A lost MCP acknowledgement can cause a retry; stable message IDs prevent duplicate source messages, but memory extraction is **at least once**, not exactly once. Short residual batches survive for the next session when shutdown has insufficient time. - Queue files contain source conversation text; retain them until delivery completes. Logs in @@ -155,10 +145,10 @@ than guessed. Codex transcript formats are not a stable public interface. Check In Codex 0.145.0, an unattended `codex exec` MCP call can return `user cancelled MCP tool call` when approval cannot be collected. Verify explicit tools in an interactive session and approve the -specific call. Automatic hooks use the Job API and do not depend on MCP tool approval. +specific call. Trusted automatic hooks act as MCP clients themselves; they do not request per-call model tool approval. ## Source and validation -Run `pytest tests/unit/test_coding_agent_plugins.py -v` from the ReMe checkout. Tests isolate host +Run `pytest tests/unit/test_coding_agent_plugins.py tests/unit/test_codex_plugin_mcp.py -v` from the ReMe checkout. Tests isolate host state and mock service calls. Native hook behavior is described in the [Codex hook reference](https://learn.chatgpt.com/docs/hooks). ReMe is developed at [agentscope-ai/ReMe](https://github.com/agentscope-ai/ReMe) under Apache-2.0. diff --git a/integrations/codex/README_ZH.md b/integrations/codex/README_ZH.md index 0923c2c5..0df87061 100644 --- a/integrations/codex/README_ZH.md +++ b/integrations/codex/README_ZH.md @@ -14,8 +14,9 @@ ## 环境要求 -- 宿主环境中有 Python 3.11+,可通过 `python3` 执行,并已安装 FastMCP >= 3.1(ReMe 自带此依赖)。 +- 宿主环境中有 Python 3.11+,可通过 `python3` 执行,并已安装 FastMCP >= 3.4.2(ReMe 自带此依赖)。 - Codex CLI 0.145.0 或兼容版本,支持插件、`UserPromptSubmit`、`Stop`、同步命令 Hook 和 `last_assistant_message`。 +- 原生配置界面需要支持 `openai/settings` MCP 扩展的图形宿主;CLI 0.145.0 支持 Hook 和工具,但不渲染该界面。 - 已启动 ReMe HTTP 服务,开放 `search`、`auto_memory`、`health_check`,并启用 MCP。 - ReMe 服务端有可用于自动记忆提取的模型配置。默认 BM25 召回不要求 Embedding。 @@ -49,65 +50,53 @@ codex plugin add reme@reme-codex 自动记录会把完成的用户/助手文本发送到配置的 ReMe 服务;仅需召回时,将 `auto_memory` 设为 `false`。 -## 可选配置 +## 通过 MCP 设置界面配置 -使用默认服务地址时,安装插件后即可使用。需要调整下列默认行为时,再创建配置文件。 +在支持 `openai/settings` 的宿主中,打开已安装的 ReMe MCP Server 设置。 +原生表单提供服务地址、自动召回与记录开关、批次大小、召回限制、超时和时区。 +保存后点击 **Check connection**,会在已保存地址调用 `health_check`,不发送对话内容。 +ReMe 服务离线时仍可读取和修改设置,因此可以在此修正错误地址。 -直接编辑用户配置文件即可调整地址和行为,升级插件会保留配置。文件中只需写入要覆盖的选项。 - -配置位于 `${CODEX_HOME:-$HOME/.codex}/reme/config.json`。首次创建时,可从仓库根目录复制示例;已有文件请直接编辑: - -```bash -mkdir -p "${CODEX_HOME:-$HOME/.codex}/reme" -cp integrations/codex/plugins/reme/config.example.json "${CODEX_HOME:-$HOME/.codex}/reme/config.json" -``` +配置原子保存到 `${CODEX_HOME:-$HOME/.codex}/reme/config.json`,位于插件缓存之外,升级后保留。 +下一次 MCP 工具调用和 Hook 调用直接使用新配置,无需重新连接或重装。 +正在执行的调用继续使用原来的地址与配置。关闭 `auto_memory` 会暂停自动捕获与队列重试,显式工具仍可使用。 | 配置项 | 默认值 | 作用 | | --- | --- | --- | -| `mcp_url` | `http://127.0.0.1:2333/mcp` | ReMe MCP 地址;默认去掉末尾 `/mcp` 得到 Hook 的 HTTP 地址 | -| `api_url` | 空字符串 | 可选 HTTP Job API 基址;MCP 使用自定义路径时需显式填写 | +| `mcp_url` | `http://127.0.0.1:2333/mcp` | 显式工具和自动 Hook 共用的 MCP 地址,支持自定义路径 | | `auto_recall` | `true` | 用户提交消息时自动检索 | | `auto_memory` | `true` | 自动捕获与提交;关闭后也不重试已有队列 | | `recall_limit` | `5` | 检索结果上限 | | `recall_min_score` | `0` | 自动召回最低分数 | -| `recall_timeout` | `5` | 前台 HTTP 超时秒数,最大 10 | +| `recall_timeout` | `5` | 前台 MCP 超时秒数,最大 10 | | `request_timeout` | `600` | 写入与 MCP 请求超时秒数,最大 600 | | `memory_interval` | `5` | 每批完成的对话轮数;设为 1 则逐轮提交 | | `shutdown_timeout` | `2` | 退出时尽力刷新队列的总秒数,最大 2 | | `context_max_chars` | `8000` | 召回正文字符上限 | | `timezone` | `Asia/Shanghai` | 批次按此时区分日,应与 ReMe workspace 一致 | -例如,改用另一个端口并关闭自动记录: +JSON 配置仍由用户掌控,也可直接编辑,只需写入要覆盖的选项。 +完整示例见 [config.example.json](plugins/reme/config.example.json);从文件移除某项即可恢复其默认值。 +未知字段和无效值会使校验失败,Hook 跳过并记录 `hook_failed`,不会回退到其他服务。 +界面保存无效值时会返回错误,保留原配置文件。 -```json -{ - "mcp_url": "http://127.0.0.1:2444/mcp", - "auto_memory": false, - "auto_recall": true, - "memory_interval": 1 -} -``` +已有配置仍可读取。旧 `api_url` 字段不再参与请求,下次在界面保存时会移除;所有操作统一使用 `mcp_url`。 +标准 `/mcp` 地址继续使用原来的队列和收据标识。自定义旧 `api_url` 下的待提交记录原样保留, +不会自动迁移目标地址。切换地址不会把旧对话发往新服务;切回原地址可继续重试该地址的队列。 -MCP 和 Hook 从同一份用户配置读取地址。Hook 在下一次调用时读取修改;更改地址或 MCP 超时后, -请重新连接 MCP 或重启宿主,确保现有 MCP 进程也使用新配置。无需修改插件源码、安装缓存或重装插件。 -使用自定义 MCP 路径时,例如 `https://memory.example.com/tools`,同时设置 -`api_url` 为 HTTP Job API 基址,例如 `https://memory.example.com/reme`。 - -未知字段或无效值会使配置校验失败,Hook 会跳过并记录 `hook_failed`,不会回退到其他服务。 -关闭 `auto_memory` 会停止自动捕获和重试,保留已有队列;显式 MCP 工具仍可使用。 -修改服务地址不会把旧地址下的待提交对话发送到新服务。移除配置项即可恢复该项默认值。 - -插件通过宿主管理的 stdio 连接进程转发 MCP,请确保启动宿主时的 `python3` 环境包含 FastMCP。 -如果 ReMe 服务运行在另一台机器或另一个 Python 环境,在宿主环境执行 -`python3 -m pip install "fastmcp>=3.1"`。Hook 本身仅使用标准库。 +宿主管理的 MCP Server 和短生命周期 Hook 都依赖宿主 `python3` 环境中的 FastMCP。 +如果 ReMe 运行在其他环境,请执行 `python3 -m pip install "fastmcp>=3.4.2"`。插件不会启动 ReMe 服务。 +原生表单遵循固定版本的 +[OpenAI MCP Extensions 设置协议](https://github.com/openai/mcp-extensions/blob/node-v0.1.0/docs/spec.md#structured-settings)。 +不支持该扩展的宿主仍可使用工具和 Hook,但不会显示原生设置表单。 ## 生命周期与失败行为 - `UserPromptSubmit` 在短超时范围内同步调用 `search`,用 `` 包裹有长度限制的结果,明确其为不可信历史数据。失败不阻止用户消息。 -- 同步 `Stop` Hook 从本地 transcript 提取完成轮次的用户与最终助手文本,通过 JSON Job API 分批调用 `auto_memory`。工具输出、推理内容和注入的 ReMe 上下文不作为对话来源。服务端无需读取宿主文件。 +- 同步 `Stop` Hook 从本地 transcript 提取完成轮次的用户与最终助手文本,通过 MCP 分批调用 `auto_memory`。工具输出、推理内容和注入的 ReMe 上下文不作为对话来源。服务端无需读取宿主文件。 - 使用带宿主命名空间的稳定会话和消息 ID,重复 Stop 去重;每个会话串行提交,跨日拆分批次。排除子 Agent 事件和 Hook 续写。插件不启动独立守护进程,也不自动下载依赖或启动 ReMe。 - 队列与确认收据保存在宿主的 `reme/queue/`,按服务地址隔离;失败或超时不会丢弃队列。`SessionStart` 同步重试,`SessionEnd` 在短时间预算内同步尝试刷新。 -- 退出刷新是尽力而为:宿主在捕获前被强制结束时可能丢失该轮。HTTP 确认丢失时可能重复提取;稳定消息 ID 避免重复存储来源,但记忆提取是至少一次语义。未满批次或未及时完成的任务留待下次会话处理。 +- 退出刷新是尽力而为:宿主在捕获前被强制结束时可能丢失该轮。MCP 确认丢失时可能重复提取;稳定消息 ID 避免重复存储来源,但记忆提取是至少一次语义。未满批次或未及时完成的任务留待下次会话处理。 - 队列包含原始对话文本,请在交付完成前保留。`reme/hooks.log` 仅记录事件及异常类型。关闭 `auto_memory` 保留队列且停止重试。 - `auto_dream` 整理继续由 ReMe 服务管理。与 OpenClaw 常驻 Gateway 不同,短生命周期 Hook 不另建定时调度器。 @@ -129,10 +118,10 @@ ReMe 服务是否运行,以及 `service.jobs` 是否开放所需 Job。Hook Codex transcript 不是稳定公开接口;未知格式或缺失文件会跳过并记录状态,不猜测对话。 召回正常但未记录时,检查 `capture_skipped`、`hook_failed`。 -在 Codex 0.145.0 中,无人值守的 `codex exec` 无法收集工具审批时,MCP 调用可能返回 `user cancelled MCP tool call`。请在交互会话中批准具体调用来验证显式工具。自动 Hook 使用 Job API,不依赖 MCP 工具审批。 +在 Codex 0.145.0 中,无人值守的 `codex exec` 无法收集工具审批时,MCP 调用可能返回 `user cancelled MCP tool call`。请在交互会话中批准具体调用来验证显式工具。已信任的自动 Hook 自身作为 MCP 客户端调用服务,不逐次请求模型工具审批。 ## 源码与验证 -在 ReMe 仓库运行 `pytest tests/unit/test_coding_agent_plugins.py -v`。测试使用隔离目录和模拟服务。 +在 ReMe 仓库运行 `pytest tests/unit/test_coding_agent_plugins.py tests/unit/test_codex_plugin_mcp.py -v`。测试使用隔离目录和模拟服务。 宿主行为以 [Codex Hook 文档](https://learn.chatgpt.com/docs/hooks) 为准。 ReMe 在 [agentscope-ai/ReMe](https://github.com/agentscope-ai/ReMe) 开发,采用 Apache-2.0 许可证。 diff --git a/integrations/codex/plugins/reme/.codex-plugin/plugin.json b/integrations/codex/plugins/reme/.codex-plugin/plugin.json index 674dcdba..d088e88a 100644 --- a/integrations/codex/plugins/reme/.codex-plugin/plugin.json +++ b/integrations/codex/plugins/reme/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "reme", - "version": "0.1.0+codex.20260928104620", - "description": "Automatic recall, batched conversation recording, and explicit MCP memory tools for Codex.", + "version": "0.1.0+codex.20260930055341", + "description": "MCP-native automatic memory and editable plugin settings for Codex.", "author": { "name": "EconML team of Alibaba Tongyi Lab", "email": "jinli.yl@alibaba-inc.com" @@ -20,7 +20,7 @@ "mcpServers": "./.mcp.json", "interface": { "displayName": "ReMe", - "shortDescription": "File-native long-term memory for Codex", + "shortDescription": "Long-term memory for agents", "longDescription": "Recall past decisions and record completed conversations in your own ReMe workspace.", "developerName": "AgentScope", "category": "Productivity", diff --git a/integrations/codex/plugins/reme/.mcp.json b/integrations/codex/plugins/reme/.mcp.json index 727f2753..1ac54415 100644 --- a/integrations/codex/plugins/reme/.mcp.json +++ b/integrations/codex/plugins/reme/.mcp.json @@ -3,10 +3,12 @@ "reme": { "command": "python3", "args": [ - "hooks/mcp_bridge.py" + "mcp_server.py" ], "cwd": ".", - "env_vars": ["CODEX_HOME"] + "env_vars": [ + "CODEX_HOME" + ] } } } diff --git a/integrations/codex/plugins/reme/config.example.json b/integrations/codex/plugins/reme/config.example.json index 7e154eb1..9e94666f 100644 --- a/integrations/codex/plugins/reme/config.example.json +++ b/integrations/codex/plugins/reme/config.example.json @@ -1,6 +1,5 @@ { "mcp_url": "http://127.0.0.1:2333/mcp", - "api_url": "", "auto_recall": true, "auto_memory": true, "recall_limit": 5, diff --git a/integrations/codex/plugins/reme/hooks/auto_memory.py b/integrations/codex/plugins/reme/hooks/auto_memory.py index cef8bc0f..3b8ace03 100644 --- a/integrations/codex/plugins/reme/hooks/auto_memory.py +++ b/integrations/codex/plugins/reme/hooks/auto_memory.py @@ -1,118 +1,26 @@ #!/usr/bin/env python3 -"""Codex lifecycle adapter. Requires only Python's standard library.""" +"""Codex lifecycle adapter using the same ReMe MCP connection as explicit tools.""" from __future__ import annotations -import contextlib import hashlib import json -import math -import os import re import sys -import tempfile import time -import urllib.request from datetime import datetime, timezone from pathlib import Path -from urllib.parse import urlsplit from zoneinfo import ZoneInfo +# Hook scripts run directly from the installed plugin, independently of its MCP process. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +# pylint: disable=wrong-import-position +import reme_config # noqa: E402 +from reme_config import data_dir, load_config, lock, write_json # noqa: E402 +from reme_mcp import call # noqa: E402 + +HOME_ENV = reme_config.HOME_ENV HOST = "codex" -HOME_ENV = "CODEX_HOME" -HOME_DEFAULT = "~/.codex" -DEFAULTS = { - "mcp_url": "http://127.0.0.1:2333/mcp", - "api_url": "", - "auto_recall": True, - "auto_memory": True, - "recall_limit": 5, - "recall_min_score": 0.0, - "recall_timeout": 5.0, - "request_timeout": 600.0, - "memory_interval": 5, - "shutdown_timeout": 2.0, - "context_max_chars": 8000, - "timezone": "Asia/Shanghai", -} - - -def data_dir() -> Path: - """Keep configuration and retry state outside the versioned plugin cache.""" - return Path(os.environ.get(HOME_ENV) or HOME_DEFAULT).expanduser() / "reme" - - -def load_config() -> dict: - """Read the same persistent settings used by the MCP bridge and lifecycle hooks.""" - path = data_dir() / "config.json" - values = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} - return validate_config(values) - - -def validate_config(values: dict) -> dict: - """Validate user overrides before either transport can use them.""" - if not isinstance(values, dict) or values.keys() - DEFAULTS.keys(): - raise ValueError("Unknown ReMe configuration fields") - config = {**DEFAULTS, **values} - for key, default in DEFAULTS.items(): - value = config[key] - if isinstance(default, bool): - if not isinstance(value, bool): - raise ValueError(f"{key} must be a boolean") - elif isinstance(default, str): - if not isinstance(value, str): - raise ValueError(f"{key} must be a string") - elif isinstance(default, (int, float)): - if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): - raise ValueError(f"{key} must be a finite number") - if value < 0 or (value == 0 and key != "recall_min_score"): - raise ValueError(f"{key} must be positive") - if isinstance(default, int) and not isinstance(value, int): - raise ValueError(f"{key} must be an integer") - if config["recall_timeout"] > 10 or config["request_timeout"] > 600 or config["shutdown_timeout"] > 2: - raise ValueError("Timeout exceeds the lifecycle hook budget") - try: - ZoneInfo(config["timezone"]) - except (KeyError, ValueError) as exc: - raise ValueError("timezone must be an IANA timezone") from exc - config["mcp_url"] = validate_url(config["mcp_url"]) - if config["api_url"]: - config["endpoint"] = validate_url(config["api_url"]) - elif config["mcp_url"].endswith("/mcp"): - config["endpoint"] = config["mcp_url"][:-4] - else: - raise ValueError("Set api_url explicitly when mcp_url does not end in /mcp") - return config - - -def validate_url(url: str) -> str: - """Accept HTTP endpoints without embedded credentials or ambiguous URL suffixes.""" - url = url.rstrip("/") - parsed = urlsplit(url) - if ( - parsed.scheme not in {"http", "https"} - or not parsed.hostname - or any((parsed.username is not None, parsed.password is not None, parsed.query, parsed.fragment)) - or any(character.isspace() for character in url) - ): - raise ValueError("ReMe URLs must be absolute HTTP(S) URLs without credentials, query, or fragment") - _ = parsed.port - return url - - -def call(config: dict, action: str, payload: dict, timeout: float) -> dict: - """Require the Job API's explicit success acknowledgement before committing a batch.""" - request = urllib.request.Request( - f"{config['endpoint']}/{action}", - data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), - headers={"Content-Type": "application/json", "Accept": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(request, timeout=timeout) as response: - result = json.load(response) - if not isinstance(result, dict) or result.get("success") is not True: - raise RuntimeError("ReMe did not acknowledge the action") - return result def digest(value: str) -> str: @@ -132,59 +40,6 @@ def log_status(event: str, error: Exception | None = None) -> None: pass -@contextlib.contextmanager -def lock(path: Path): - """Use a non-blocking OS lock, released automatically if a hook is killed.""" - path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - with path.open("a+b") as handle: - if os.name == "nt": - import msvcrt - - handle.write(b"\0") - handle.flush() - handle.seek(0) - - def acquire(): - msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) - - def release(): - msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) - - else: - import fcntl - - def acquire(): - fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) - - def release(): - fcntl.flock(handle, fcntl.LOCK_UN) - - try: - acquire() - except OSError: - yield False - return - try: - yield True - finally: - release() - - -def write_json(path: Path, value: dict) -> None: - """Atomically persist retry metadata with owner-only file permissions.""" - path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".reme-") - try: - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(value, handle, ensure_ascii=False) - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary, path) - finally: - if os.path.exists(temporary): - os.unlink(temporary) - - def clean_text(value: str) -> str: """Exclude automatic recall and injected reminders from source messages.""" value = re.sub(r"]*>.*?", "", value, flags=re.DOTALL) diff --git a/integrations/codex/plugins/reme/hooks/mcp_bridge.py b/integrations/codex/plugins/reme/hooks/mcp_bridge.py deleted file mode 100644 index 666f5d6a..00000000 --- a/integrations/codex/plugins/reme/hooks/mcp_bridge.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -"""Host-managed stdio connection to the user's independently running ReMe service.""" - -from auto_memory import load_config - - -def main() -> None: - """Resolve persistent settings on connection; leave MCP transport handling to FastMCP.""" - config = load_config() - try: - from fastmcp.server import create_proxy - 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).", - ) from exc - upstream = ProxyClient(config["mcp_url"], timeout=config["request_timeout"]) - proxy = create_proxy(upstream, name="ReMe") - proxy.run(transport="stdio", show_banner=False) - - -if __name__ == "__main__": - main() diff --git a/integrations/codex/plugins/reme/mcp.json b/integrations/codex/plugins/reme/mcp.json new file mode 100644 index 00000000..a8cb5454 --- /dev/null +++ b/integrations/codex/plugins/reme/mcp.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "reme": { + "type": "stdio", + "command": "python3", + "args": [ + "${PLUGIN_ROOT}/mcp_server.py" + ], + "cwd": "${PLUGIN_ROOT}" + } + } +} diff --git a/integrations/codex/plugins/reme/mcp_server.py b/integrations/codex/plugins/reme/mcp_server.py new file mode 100644 index 00000000..1cceb3f0 --- /dev/null +++ b/integrations/codex/plugins/reme/mcp_server.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Codex MCP tools and native settings for the independently running ReMe service.""" + +from __future__ import annotations + +from fastmcp import FastMCP +from fastmcp.server.dependencies import get_context +from fastmcp.server.providers import Provider +from fastmcp.server.providers.proxy import ProxyClient, ProxyProvider +from fastmcp.tools import FunctionTool, ToolResult +from mcp.types import ToolAnnotations + +from reme_config import DEFAULTS, load_config, settings_values, update_settings +from reme_mcp import call_async + +SETTINGS_READ = "reme_settings_read" +SETTINGS_UPDATE = "reme_settings_update" +CHECK_CONNECTION = "reme_check_connection" + +# OpenAI MCP Extensions node-v0.1.0, docs/spec.md#structured-settings. +# Use its legacy capability advertisement with ReMe's existing FastMCP runtime. +FIELDS = { + "mcp_url": {"type": "string", "title": "ReMe MCP address", "description": "URL of your running ReMe MCP service."}, + "auto_recall": {"type": "boolean", "title": "Automatic recall"}, + "auto_memory": { + "type": "boolean", + "title": "Automatic memory", + "description": "Send completed user/assistant turns to ReMe. Disabling also pauses queued retries.", + }, + "memory_interval": {"type": "integer", "title": "Turns per memory batch", "minimum": 1}, + "recall_limit": {"type": "integer", "title": "Recall result limit", "minimum": 1}, + "recall_min_score": {"type": "number", "title": "Minimum recall score", "minimum": 0}, + "context_max_chars": {"type": "integer", "title": "Recall character limit", "minimum": 1}, + "recall_timeout": { + "type": "number", + "title": "Recall timeout (seconds)", + "minimum": 0, + "description": "Must be greater than zero.", + "maximum": 10, + }, + "request_timeout": { + "type": "number", + "title": "Memory / tool timeout (seconds)", + "minimum": 0, + "description": "Must be greater than zero.", + "maximum": 600, + }, + "shutdown_timeout": { + "type": "number", + "title": "Exit flush budget (seconds)", + "minimum": 0, + "description": "Must be greater than zero.", + "maximum": 2, + }, + "timezone": {"type": "string", "title": "Memory timezone", "description": "IANA timezone, such as Asia/Shanghai."}, +} +SCHEMA = {"type": "object", "properties": FIELDS, "additionalProperties": False} +LAYOUT = [ + { + "kind": "group", + "title": "Connection", + "items": [ + {"kind": "property", "property": "mcp_url"}, + {"kind": "tool", "tool": CHECK_CONNECTION, "title": "Check connection"}, + ], + }, + { + "kind": "group", + "title": "Automatic memory", + "items": [ + {"kind": "property", "property": key} + for key in ("auto_recall", "auto_memory", "memory_interval", "timezone") + ], + }, + { + "kind": "group", + "title": "Recall", + "items": [ + {"kind": "property", "property": key} for key in ("recall_limit", "recall_min_score", "context_max_chars") + ], + }, + { + "kind": "group", + "title": "Timeouts", + "items": [ + {"kind": "property", "property": key} for key in ("recall_timeout", "request_timeout", "shutdown_timeout") + ], + }, +] + + +class ReMeProvider(Provider): + """Resolve tools against one settings snapshot, without caching across endpoints.""" + + @staticmethod + def snapshot() -> ProxyProvider: + """Keep an in-flight tool's schema and execution on the same endpoint.""" + config = load_config() + return ProxyProvider( + lambda: ProxyClient( + config["mcp_url"], + timeout=config["request_timeout"], + init_timeout=config["recall_timeout"], + ), + cache_ttl=0, + ) + + async def _list_tools(self): + return [ + tool + for tool in await self.snapshot().list_tools() + if tool.name not in {SETTINGS_READ, SETTINGS_UPDATE, CHECK_CONNECTION} + ] + + async def _get_tool(self, name, version=None): + # Local settings are authoritative and never depend on upstream availability. + if name in {SETTINGS_READ, SETTINGS_UPDATE, CHECK_CONNECTION}: + return None + return await self.snapshot().get_tool(name, version) + + async def get_tasks(self): + """Proxy tools do not provide background MCP tasks.""" + return [] + + +def create_server() -> FastMCP: + """Keep settings available even when the configured upstream server is offline.""" + server = FastMCP( + "ReMe", + experimental_capabilities={"openai/settings": {"readTool": SETTINGS_READ, "updateTool": SETTINGS_UPDATE}}, + ) + server.add_provider(ReMeProvider()) + + def read_settings() -> ToolResult: + """Return editable values and their native settings layout.""" + return ToolResult( + content=[], + structured_content={ + "schema": SCHEMA, + "values": settings_values(load_config()), + "layout": LAYOUT, + }, + ) + + async def save_settings(**arguments) -> ToolResult: + """Persist a partial settings update and refresh upstream discovery.""" + values = update_settings(arguments["set"]) + await get_context().session.send_tool_list_changed() + return ToolResult(content=[], structured_content={"values": values}) + + server.add_tool( + FunctionTool( + name=SETTINGS_READ, + title="ReMe settings", + description="Read ReMe plugin settings for the host settings UI.", + fn=read_settings, + parameters={"type": "object", "additionalProperties": False}, + output_schema={ + "type": "object", + "properties": { + "schema": {"type": "object"}, + "values": SCHEMA, + "layout": {"type": "array", "items": {"type": "object"}}, + }, + "required": ["schema", "values"], + }, + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False), + ), + ) + server.add_tool( + FunctionTool( + name=SETTINGS_UPDATE, + title="Save ReMe settings", + description="Save changes requested in the ReMe settings UI.", + fn=save_settings, + parameters={ + "type": "object", + "properties": { + "set": {**SCHEMA, "minProperties": 1}, + }, + "required": ["set"], + "additionalProperties": False, + }, + output_schema={"type": "object", "properties": {"values": SCHEMA}, "required": ["values"]}, + annotations=ToolAnnotations( + readOnlyHint=False, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, + ), + ), + ) + + @server.tool(name=CHECK_CONNECTION, title="Check ReMe connection", annotations={"readOnlyHint": True}) + async def check_connection() -> str: + """Check the saved MCP endpoint without sending conversation content.""" + config = load_config() + result = await call_async(config, "health_check", {}, config["recall_timeout"]) + return str(result["answer"]) + + assert FIELDS.keys() == DEFAULTS.keys() + return server + + +if __name__ == "__main__": + create_server().run(transport="stdio", show_banner=False) diff --git a/integrations/codex/plugins/reme/plugin.json b/integrations/codex/plugins/reme/plugin.json new file mode 100644 index 00000000..c06b4a07 --- /dev/null +++ b/integrations/codex/plugins/reme/plugin.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "reme", + "version": "0.1.0+codex.20260930055341", + "description": "MCP-native automatic memory and editable plugin settings for Codex.", + "author": { + "name": "EconML team of Alibaba Tongyi Lab", + "email": "jinli.yl@alibaba-inc.com" + }, + "homepage": "https://reme.agentscope.io", + "repository": "https://github.com/agentscope-ai/ReMe", + "license": "Apache-2.0", + "keywords": [ + "memory", + "reme", + "mcp", + "long-term-memory", + "agent" + ] +} diff --git a/integrations/codex/plugins/reme/reme_config.py b/integrations/codex/plugins/reme/reme_config.py new file mode 100644 index 00000000..5899bfba --- /dev/null +++ b/integrations/codex/plugins/reme/reme_config.py @@ -0,0 +1,166 @@ +"""Persistent Codex plugin settings and owner-only, atomic local storage.""" + +from __future__ import annotations + +import contextlib +import json +import math +import os +import tempfile +from pathlib import Path +from urllib.parse import urlsplit +from zoneinfo import ZoneInfo + +HOME_ENV = "CODEX_HOME" +HOME_DEFAULT = "~/.codex" +DEFAULTS = { + "mcp_url": "http://127.0.0.1:2333/mcp", + "auto_recall": True, + "auto_memory": True, + "recall_limit": 5, + "recall_min_score": 0.0, + "recall_timeout": 5.0, + "request_timeout": 600.0, + "memory_interval": 5, + "shutdown_timeout": 2.0, + "context_max_chars": 8000, + "timezone": "Asia/Shanghai", +} + + +def data_dir() -> Path: + """Keep configuration and retry state outside the versioned plugin cache.""" + return Path(os.environ.get(HOME_ENV) or HOME_DEFAULT).expanduser() / "reme" + + +def load_config() -> dict: + """Read the persistent settings shared by the MCP server and lifecycle hooks.""" + path = data_dir() / "config.json" + values = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + return validate_config(values) + + +def validate_config(values: dict) -> dict: + """Validate user overrides before the MCP server or hooks can use them.""" + if not isinstance(values, dict) or values.keys() - (DEFAULTS.keys() | {"api_url"}): + raise ValueError("Unknown ReMe configuration fields") + # Accept the former HTTP base only for migration; no request uses it. + legacy_api = values.get("api_url", "") + if not isinstance(legacy_api, str): + raise ValueError("api_url must be a string") + if legacy_api: + validate_url(legacy_api) + config = {**DEFAULTS, **{key: value for key, value in values.items() if key != "api_url"}} + for key, default in DEFAULTS.items(): + value = config[key] + if isinstance(default, bool): + if not isinstance(value, bool): + raise ValueError(f"{key} must be a boolean") + elif isinstance(default, str): + if not isinstance(value, str): + raise ValueError(f"{key} must be a string") + elif isinstance(default, (int, float)): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValueError(f"{key} must be a finite number") + if value < 0 or (value == 0 and key != "recall_min_score"): + raise ValueError(f"{key} must be positive") + if isinstance(default, int) and not isinstance(value, int): + raise ValueError(f"{key} must be an integer") + if config["recall_timeout"] > 10 or config["request_timeout"] > 600 or config["shutdown_timeout"] > 2: + raise ValueError("Timeout exceeds the lifecycle hook budget") + try: + ZoneInfo(config["timezone"]) + except (KeyError, ValueError) as exc: + raise ValueError("timezone must be an IANA timezone") from exc + config["mcp_url"] = validate_url(config["mcp_url"]) + # Preserve standard /mcp retry-directory identities from the HTTP adapter. + # Custom legacy api_url queues remain untouched rather than being sent elsewhere. + config["endpoint"] = config["mcp_url"][:-4] if config["mcp_url"].endswith("/mcp") else "mcp:" + config["mcp_url"] + return config + + +def validate_url(url: str) -> str: + """Accept HTTP endpoints without embedded credentials or ambiguous URL suffixes.""" + url = url.rstrip("/") + parsed = urlsplit(url) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or any((parsed.username is not None, parsed.password is not None, parsed.query, parsed.fragment)) + or any(character.isspace() for character in url) + ): + raise ValueError("ReMe URLs must be absolute HTTP(S) URLs without credentials, query, or fragment") + _ = parsed.port + return url + + +@contextlib.contextmanager +def lock(path: Path): + """Use a non-blocking OS lock, released automatically if a hook is killed.""" + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + with path.open("a+b") as handle: + if os.name == "nt": + import msvcrt + + handle.write(b"\0") + handle.flush() + handle.seek(0) + + def acquire(): + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + + def release(): + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + + else: + import fcntl + + def acquire(): + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + + def release(): + fcntl.flock(handle, fcntl.LOCK_UN) + + try: + acquire() + except OSError: + yield False + return + try: + yield True + finally: + release() + + +def write_json(path: Path, value: dict) -> None: + """Atomically persist retry metadata with owner-only file permissions.""" + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".reme-") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(value, handle, ensure_ascii=False) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def settings_values(config: dict) -> dict: + """Expose only editable settings, excluding the derived retry-directory identity.""" + return {key: config[key] for key in DEFAULTS} + + +def update_settings(changes: dict) -> dict: + """Validate and merge a UI patch under the same cross-process lock as other editors.""" + if not isinstance(changes, dict) or not changes or changes.keys() - DEFAULTS.keys(): + raise ValueError("Provide at least one supported ReMe setting") + with lock(data_dir() / "config.lock") as acquired: + if not acquired: + raise RuntimeError("Another settings update is in progress; please retry") + current = settings_values(load_config()) + config = validate_config({**current, **changes}) + values = settings_values(config) + write_json(data_dir() / "config.json", values) + return values diff --git a/integrations/codex/plugins/reme/reme_mcp.py b/integrations/codex/plugins/reme/reme_mcp.py new file mode 100644 index 00000000..a0e186f5 --- /dev/null +++ b/integrations/codex/plugins/reme/reme_mcp.py @@ -0,0 +1,29 @@ +"""ReMe MCP calls shared by hooks and the plugin's connection check.""" + +from __future__ import annotations + +import asyncio + + +async def call_async(config: dict, action: str, payload: dict, timeout: float) -> dict: + """Acknowledge a write only after a successful MCP result, including connection time.""" + from fastmcp import Client + + async with asyncio.timeout(timeout): + async with Client(config["mcp_url"], timeout=timeout) as client: + result = await client.call_tool(action, payload, raise_on_error=True) + if result.is_error: + raise RuntimeError("ReMe MCP did not acknowledge the action") + answer = result.data + if answer is None: + answer = "\n".join(block.text for block in result.content if block.type == "text") + return {"success": True, "answer": answer} + + +def call(config: dict, action: str, payload: dict, timeout: float) -> dict: + """Run one bounded MCP operation in a short-lived native hook process.""" + try: + return asyncio.run(call_async(config, action, payload, timeout)) + except Exception as exc: + # Keep the hook's retry loop independent of MCP transport exception classes. + raise RuntimeError("ReMe MCP request failed") from exc diff --git a/integrations/codex/plugins/reme/skills/reme-memory/SKILL.md b/integrations/codex/plugins/reme/skills/reme-memory/SKILL.md index c10f9290..7bbb717e 100644 --- a/integrations/codex/plugins/reme/skills/reme-memory/SKILL.md +++ b/integrations/codex/plugins/reme/skills/reme-memory/SKILL.md @@ -22,14 +22,17 @@ say there is no relevant memory. Do not turn inference into recalled facts. ## Status and recording -Call `health_check` and `version` when the user asks to check the connection. Missing tools can +Call `reme_check_connection` when the user asks to check the saved connection. +Use upstream `health_check` and `version` for additional service details. Missing tools can mean the plugin is disabled, the MCP connection failed, or the server's job allowlist excludes them. Check the plugin and `/mcp` status before concluding the service is stopped. The default service is started with `reme start workspace_dir=/absolute/path/to/workspace service.backend=http`. -The host's `reme/config.json` supplies `mcp_url` and hook settings to both the MCP bridge and -automatic hooks. Users can edit this file directly. Reconnect MCP after changing its address; -hook changes apply on the next invocation. Content-free logs live in the host's `reme/` directory. +Users configure the plugin through the ReMe MCP server's native settings UI, including its address, +`auto_recall`, and `auto_memory`. The UI saves the host's `reme/config.json`; MCP calls and hooks +read the same file on each invocation. No reconnect is needed. Use `reme_settings_read` to inspect +settings for troubleshooting. Only change settings when the user explicitly requests it; never +turn on recording to resolve an unrelated tool error. Content-free logs live in the host's `reme/` directory. Hooks batch five completed turns by default; short batches flush at session boundaries, subject to a bounded shutdown budget. Failed batches remain for a later attempt. Do not manually submit the same conversation just because a queued diff --git a/tests/unit/test_codex_plugin_mcp.py b/tests/unit/test_codex_plugin_mcp.py new file mode 100644 index 00000000..018c21d1 --- /dev/null +++ b/tests/unit/test_codex_plugin_mcp.py @@ -0,0 +1,179 @@ +"""Native MCP settings, endpoint isolation, and MCP-only hook transport.""" + +# pylint: disable=missing-function-docstring,redefined-outer-name,protected-access + +import asyncio +import importlib +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest +from fastmcp import Client, FastMCP +from fastmcp.exceptions import ToolError +from mcp.shared.exceptions import McpError + +PLUGIN = Path(__file__).resolve().parents[2] / "integrations/codex/plugins/reme" + + +@pytest.fixture +def plugin(tmp_path, monkeypatch): + monkeypatch.syspath_prepend(str(PLUGIN)) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "host")) + config = importlib.import_module("reme_config") + server = importlib.import_module("mcp_server") + transport = importlib.import_module("reme_mcp") + return config, server, transport + + +@pytest.fixture +def upstreams(plugin, monkeypatch): + config, server, _ = plugin + calls = [] + servers = {} + for name in ("first", "second"): + upstream = FastMCP(name) + + def search(query: str, _name=name) -> str: + calls.append((_name, query)) + return f"{_name}: daily/fact.md" + + upstream.tool(search) + servers[f"http://{name}.test/mcp"] = upstream + + def client(url, **kwargs): + return Client(servers[url], **kwargs) + + monkeypatch.setattr(server, "ProxyClient", client) + config.update_settings({"mcp_url": "http://first.test/mcp"}) + return calls + + +@pytest.mark.asyncio +async def test_settings_remain_editable_offline(plugin, monkeypatch): + config, module, _ = plugin + 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"] + 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.CHECK_CONNECTION} <= tools.keys() + read = await client.call_tool(capability["readTool"], {}) + settings = read.structured_content + assert settings["values"] == config.DEFAULTS + assert settings["schema"]["properties"].keys() == config.DEFAULTS.keys() + assert settings["layout"][0]["items"][-1]["tool"] == module.CHECK_CONNECTION + assert tools[module.SETTINGS_READ].annotations.readOnlyHint + assert tools[module.SETTINGS_READ].outputSchema + assert not (config.data_dir() / "config.json").exists() + saved = await client.call_tool(capability["updateTool"], {"set": {"auto_memory": False}}) + assert saved.structured_content["values"] == {**config.DEFAULTS, "auto_memory": False} + assert not config.load_config()["auto_memory"] + + +@pytest.mark.asyncio +async def test_settings_patch_validation_and_migration(plugin, upstreams): + config, module, _ = plugin + path = config.data_dir() / "config.json" + config.write_json(path, {"api_url": "http://first.test", "mcp_url": "http://first.test/mcp", "recall_limit": 9}) + before = path.read_bytes() + async with Client(module.create_server()) as client: + for changes in ( + {}, + {"bogus": 1}, + {"auto_memory": "false"}, + {"memory_interval": 0}, + {"mcp_url": "http://user:secret@first.test/mcp"}, + {"timezone": "Invalid/Zone"}, + {"request_timeout": 0}, + ): + with pytest.raises(ToolError): + await client.call_tool(module.SETTINGS_UPDATE, {"set": changes}) + assert path.read_bytes() == before + await client.call_tool(module.SETTINGS_UPDATE, {"set": {"auto_recall": False}}) + assert config.load_config()["recall_limit"] == 9 + assert config.load_config()["endpoint"] == "http://first.test" + assert "api_url" not in path.read_text() + assert path.stat().st_mode & 0o777 == 0o600 + with config.lock(config.data_dir() / "config.lock"): + with pytest.raises(RuntimeError, match="in progress"): + config.update_settings({"auto_recall": True}) + assert not config.load_config()["auto_recall"] + assert not upstreams + + +@pytest.mark.asyncio +async def test_live_endpoint_switch_and_inflight_snapshot(plugin, upstreams): + config, module, _ = plugin + server = module.create_server() + provider = module.ReMeProvider() + previous = await provider.get_tool("search") + + @server.tool + async def captured_search(query: str): + return await previous.run({"query": query}) + + async with Client(server) as client: + assert (await client.call_tool("search", {"query": "one"})).data == "first: daily/fact.md" + await client.call_tool(module.SETTINGS_UPDATE, {"set": {"mcp_url": "http://second.test/mcp"}}) + assert (await client.call_tool("search", {"query": "two"})).data == "second: daily/fact.md" + # A resolved call retains its original endpoint even when settings change before execution. + await client.call_tool("captured_search", {"query": "in flight"}) + assert upstreams == [("first", "one"), ("second", "two"), ("first", "in flight")] + assert config.load_config()["mcp_url"] == "http://second.test/mcp" + + +def test_custom_endpoint_does_not_alias_standard_or_legacy_queue(plugin): + config, _, _ = plugin + standard = config.validate_config({"mcp_url": "http://service.test/mcp"}) + custom = config.validate_config({"mcp_url": "http://service.test", "api_url": "http://service.test"}) + assert standard["endpoint"] == "http://service.test" + assert custom["endpoint"] != standard["endpoint"] + + +@pytest.mark.asyncio +async def test_hooks_call_mcp_and_preserve_structured_answers(plugin, monkeypatch): + config, _, transport = plugin + upstream = FastMCP("test") + calls = [] + + @upstream.tool + def search(query: str) -> dict: + calls.append(query) + return {"path": "daily/fact.md"} + + @upstream.tool + def auto_memory() -> str: + raise ToolError("write failed") + + @upstream.tool + async def delayed() -> str: + await asyncio.sleep(1) + return "too late" + + monkeypatch.setattr("fastmcp.Client", lambda *args, **kwargs: Client(upstream, **kwargs)) + result = await transport.call_async(config.load_config(), "search", {"query": "fact"}, 2) + assert result == {"success": True, "answer": {"path": "daily/fact.md"}} + 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)): + await transport.call_async(config.load_config(), "delayed", {}, 0.05) + + +@pytest.mark.asyncio +async def test_connection_action_uses_current_endpoint_and_propagates_failure(plugin, upstreams, monkeypatch): + config, module, _ = plugin + call = AsyncMock(return_value={"success": True, "answer": "healthy"}) + monkeypatch.setattr(module, "call_async", call) + async with Client(module.create_server()) as client: + await client.call_tool( + module.SETTINGS_UPDATE, + {"set": {"mcp_url": "http://second.test/mcp", "recall_timeout": 3}}, + ) + assert (await client.call_tool(module.CHECK_CONNECTION, {})).data == "healthy" + assert call.call_args.args == (config.load_config(), "health_check", {}, 3) + call.side_effect = ConnectionError("offline") + with pytest.raises(ToolError, match="offline"): + await client.call_tool(module.CHECK_CONNECTION, {}) + assert not upstreams diff --git a/tests/unit/test_coding_agent_plugins.py b/tests/unit/test_coding_agent_plugins.py index a8fe8808..5d6766e3 100644 --- a/tests/unit/test_coding_agent_plugins.py +++ b/tests/unit/test_coding_agent_plugins.py @@ -75,10 +75,21 @@ def test_native_manifests_resolve_within_their_own_plugin(): if host == "claude-code": assert mcp["args"] == ["${CLAUDE_PLUGIN_ROOT}/hooks/mcp_bridge.py"] else: - assert mcp["args"] == ["hooks/mcp_bridge.py"] + assert mcp["args"] == ["mcp_server.py"] + assert (root / "mcp_server.py").is_file() + portable = json.loads((root / "plugin.json").read_text()) + assert portable["name"] == manifest["name"] + assert portable["version"] == manifest["version"] + assert not {"skills", "mcpServers", "interface"} & portable.keys() + portable_mcp = json.loads((root / "mcp.json").read_text())["mcpServers"]["reme"] + assert portable_mcp["type"] == "stdio" + assert portable_mcp["args"] == ["${PLUGIN_ROOT}/mcp_server.py"] + assert portable_mcp["cwd"] == "${PLUGIN_ROOT}" + assert portable_mcp["command"] == mcp["command"] assert mcp["cwd"] == "." assert "CODEX_HOME" in mcp["env_vars"] - assert (root / "hooks/mcp_bridge.py").is_file() + if host == "claude-code": + assert (root / "hooks/mcp_bridge.py").is_file() hooks = json.loads((root / "hooks/hooks.json").read_text())["hooks"] assert "SubagentStop" not in hooks for event, groups in hooks.items(): @@ -117,7 +128,6 @@ def test_defaults_and_mcp_endpoint_agree(adapter): {"mcp_url": "http://example.com/mcp?token=secret"}, {"mcp_url": "http://example.com/mcp#fragment"}, {"mcp_url": "http://example.com:invalid/mcp"}, - {"mcp_url": "http://example.com/other"}, {"mcp_url": "http://example.com/\nmcp"}, {"api_url": 12}, {"api_url": "relative/path"}, @@ -144,7 +154,17 @@ def test_custom_mcp_path_and_http_prefix(adapter): "api_url": "https://example.com/jobs/reme/", }, ) - assert adapter.load_config()["endpoint"] == "https://example.com/jobs/reme" + expected = ( + "https://example.com/jobs/reme" if adapter.HOST == "claude-code" else "mcp:https://example.com/tools/reme" + ) + assert adapter.load_config()["endpoint"] == expected + + +@pytest.mark.parametrize("adapter", ["claude-code"], indirect=True) +def test_claude_custom_mcp_path_still_requires_http_base(adapter): + adapter.write_json(adapter.data_dir() / "config.json", {"mcp_url": "https://example.com/tools"}) + with pytest.raises(ValueError): + adapter.load_config() def test_config_edit_takes_effect_on_next_hook(adapter, monkeypatch): @@ -162,6 +182,7 @@ def test_config_edit_takes_effect_on_next_hook(adapter, monkeypatch): assert call.call_args.args[0]["endpoint"] == "http://127.0.0.1:2444" +@pytest.mark.parametrize("adapter", ["claude-code"], indirect=True) def test_mcp_bridge_uses_persistent_configuration(adapter, monkeypatch): import fastmcp.server import fastmcp.server.providers.proxy @@ -286,6 +307,7 @@ def test_disabling_memory_leaves_pending_queue_untouched(adapter, tmp_path, monk assert len(list(adapter.queue_root(config).rglob("*.json"))) == 1 +@pytest.mark.parametrize("adapter", ["claude-code"], indirect=True) @pytest.mark.parametrize("response", [{"success": False}, {}, [], {"success": "true"}]) def test_http_job_failure_is_never_acknowledged(adapter, monkeypatch, response): def urlopen(request, timeout):