refactor(codex): use MCP tools and native plugin settings

This commit is contained in:
huangsen 2026-09-30 13:58:02 +08:00
parent 8691778a44
commit d0c7d2bc32
15 changed files with 723 additions and 273 deletions

View file

@ -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 `<reme-context>` 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.

View file

@ -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`,用 `<reme-context>` 包裹有长度限制的结果,明确其为不可信历史数据。失败不阻止用户消息。
- 同步 `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 许可证。

View file

@ -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",

View file

@ -3,10 +3,12 @@
"reme": {
"command": "python3",
"args": [
"hooks/mcp_bridge.py"
"mcp_server.py"
],
"cwd": ".",
"env_vars": ["CODEX_HOME"]
"env_vars": [
"CODEX_HOME"
]
}
}
}

View file

@ -1,6 +1,5 @@
{
"mcp_url": "http://127.0.0.1:2333/mcp",
"api_url": "",
"auto_recall": true,
"auto_memory": true,
"recall_limit": 5,

View file

@ -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"<reme-context\b[^>]*>.*?</reme-context>", "", value, flags=re.DOTALL)

View file

@ -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()

View file

@ -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}"
}
}
}

View file

@ -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)

View file

@ -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"
]
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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):