MCP Terminal Controller
# MCP Terminal Controller
**MCP Terminal Controller** 是一个通过 **MCP stdio** 提供受限、异步 **POSIX** 进程会话的本地服务器。它使用官方 **MCP SDK 2.3.0**,要求 **Python >= 3.11**。客户端只能调用配置所有者(Owner)预先批准的 `command_id`;不能提交 `argv`、shell 命令、重定向或命令替换。
> 这是本地进程控制器,不是沙箱,不提供远程访问、Web/云端 UI,也不能在服务重启后恢复会话。
## 当前状态
- 项目版本:`0.1.0`。
- 默认 profile 和 `demo_process.py` 启动的都是**真实 OS 子进程**,不是模拟器或内存假实现;它们仅用于可控的本地演示与测试。
- 2026-10-22 实测:本机全量 **83 passed**,新目录/新虚拟环境复现 **83 passed**;标准 MCP 客户端独立完成 16 个场景,安装后的 console entry 也完成真实闭环。完整范围与证据见 [ACCEPTANCE_REPORT.md](ACCEPTANCE_REPORT.md)。
- Manus 初始连接已实测创建、交互输入、游标续读和终止;加载最终修复后的重连返回 transport closed,原因 UNKNOWN。**最终版本以官方标准客户端验收,不声称 Manus 最终重载成功。**
## 工作方式与边界
1. 服务进程只使用标准输入/输出(`stdio`)承载 MCP 协议。**不得向服务的 stdout 写普通日志或业务文本**;入口将日志写入 stderr。
2. `terminal_start` 接收固定 profile 的 `command_id`,由 Owner 本地 JSON 配置映射为固定、真实的 `argv`(其中可执行项 `argv[0]` 必须是绝对路径)。服务使用 `asyncio.create_subprocess_exec()`,**不启动 shell**。
3. 每个会话分别连接子进程的 `stdin`、`stdout`、`stderr`。它们是 **pipe,不是 PTY(伪终端)**:没有终端模拟、TTY 行编辑、窗口尺寸、控制序列解释或 job control 语义。交互程序可能因为 `isatty()` 为假而表现不同。
4. 输出以带 `stream: "stdout" | "stderr"` 标签的记录返回。两个 pipe 的跨流到达顺序由调度决定;请勿假定 stdout 与 stderr 有全局行序。
5. 输出 buffer、会话记录和子进程状态都只在内存中保存。服务器重启、崩溃或被强制杀死后没有会话重新连接、历史回放或孤儿进程恢复机制。
## 安装
在项目根目录选择一种方式安装测试额外依赖:
```bash
uv sync --locked --extra test
source .venv/bin/activate
```
或:
```bash
python -m pip install '.[test]'
```
上述依赖声明包括 `mcp==2.3.0`;项目要求 Python `>=3.11`。仅支持具有 POSIX 进程组的系统;非 POSIX 平台会拒绝配置。
## 生成 Owner 本地配置
仓库提供 `scripts/make_config.py`,它调用 `Config.defaults()`,把 dataclass 字段导出为 JSON。生成内容中的工作目录、允许根目录、Python 可执行文件和演示脚本路径均为**本机绝对路径**,不含 `~` 或相对路径。
```bash
python scripts/make_config.py \
--root /绝对/允许/工作目录 \
--output /绝对/路径/mcp-terminal-controller.json
```
选项:
| 选项 | 含义 |
|---|---|
| `--output 路径` | 必填;要写入的本地 Owner JSON 文件。父目录不存在时会创建。 |
| `--root 根目录` | 可选;默认是运行脚本时的当前工作目录(`cwd`)。该目录必须存在,且将成为默认工作目录和唯一允许根。 |
该脚本只生成本机的 Owner 配置;**它不能、也不会开放客户端任意命令执行**。生成的默认 `stream`、`interactive` 等 profile 仍然是指向本地 `demo_process.py` 的固定真实子进程。生产 Owner 应审核并按需要替换为最小权限的固定 profile,而不是把用户输入拼进 `argv`。
### 配置格式与校验
启动时传入的 JSON 只能含 `Config` 的 dataclass 字段;未知字段会被拒绝。所有 `allowed_roots` 和 `default_cwd` 必须是存在的绝对目录,实际使用时会解析符号链接,并要求 `cwd` 位于至少一个允许根内。
```json
{
"allowed_roots": ["/absolute/allowed/root"],
"default_cwd": "/absolute/allowed/root",
"commands": {
"approved-job": {
"argv": ["/absolute/path/to/executable", "--fixed-option"],
"allowed_env": ["SAFE_LABEL"]
}
},
"max_sessions": 8,
"max_records": 32,
"buffer_bytes": 1048576,
"buffer_records": 65536,
"default_timeout_ms": 30000,
"max_timeout_ms": 300000,
"retention_seconds": 300,
"max_input_bytes": 65536
}
```
`commands` 中的每个 profile 必须有非空、固定的 `argv`,且 `argv[0]` 必须是绝对路径。`command_id` 最长 128 个字符;最多 128 个 profile。参数含义与合法范围如下。
| 字段 | 默认值 | 合法范围/规则 |
|---|---:|---|
| `allowed_roots` | 无 | 至少一个存在的目录;客户端 `cwd` 只能在其内。 |
| `default_cwd` | 生成时的 `root` | 存在且位于允许根内。 |
| `commands` | 默认演示 profiles | 字典;每项为固定 `argv` 与可选 `allowed_env`。 |
| `max_sessions` | 8 | 1–64 个同时未结束会话。 |
| `max_records` | 32 | 1–1024 个保留会话记录;不得小于 `max_sessions`。 |
| `buffer_bytes` | 1,048,576 | 4–16,777,216,单会话保留输出的 UTF-8 字节上限。 |
| `buffer_records` | 65,536 | 1–1,048,576,单会话保留输出记录上限。 |
| `default_timeout_ms` | 30,000 | 100–3,600,000;不得大于 `max_timeout_ms`。 |
| `max_timeout_ms` | 300,000 | 100–3,600,000。 |
| `retention_seconds` | 300 | 1–86,400;最终状态会话记录的 TTL。 |
| `max_input_bytes` | 65,536 | 1–1,048,576;一次 `terminal_write` 的 UTF-8 字节上限。 |
子进程环境从固定的 `PATH=/usr/bin:/bin`、`LANG=C.UTF-8`、`LC_ALL=C.UTF-8` 开始,**不会继承服务进程的 `os.environ`**。客户端最多传 32 个环境变量,只有 profile `allowed_env` 中的名称才可覆写;名称还会拒绝 `PYTHON*`、`LD_*`、`DYLD_*`、`PATH`、`HOME`、`SHELL`、`ENV`、`BASH_ENV`、`IFS`、`CDPATH` 以及包含 `TOKEN`、`SECRET`、`PASSWORD`、`CREDENTIAL`、`API_KEY`、`PRIVATE_KEY` 的名称。另外拒绝 `NODE_OPTIONS`、`PERL5OPT`、`RUBYOPT`、`GCONV_PATH`、Java 启动选项等已知运行时注入变量。其他允许名称仍需 Owner 审核业务语义;黑名单不是通用安全保证。
## 启动与客户端配置
先激活已安装本项目的环境,生成或审核 Owner JSON,然后启动:
```bash
python -m mcp_terminal stdio --config /绝对/路径/mcp-terminal-controller.json
```
省略 `--config` 会使用当前目录作为允许根的安全默认演示 profiles;生产使用建议显式提供 Owner 审核过的配置。`stdio` 是唯一 transport 参数。
以下是**标准 `mcpServers` 配置形状示例**。请用本机的绝对 Python 路径和配置路径替换占位符;示例不会、也不应修改用户现有客户端配置文件。
```json
{
"mcpServers": {
"terminal-controller": {
"command": "/absolute/path/to/python",
"args": [
"-m",
"mcp_terminal",
"stdio",
"--config",
"/absolute/path/to/mcp-terminal-controller.json"
]
}
}
}
```
客户端通过 MCP 的 `tools/list` 发现工具,并通过协议的 JSON 请求调用它们;不要把交互式 shell 文本写到服务 stdin。若客户端启动器需要环境变量,应由客户端自行管理,不要用它替代 Owner JSON 的命令批准策略。
## 五个 MCP 工具
所有应用错误均使用 `{"ok": false, "error": {"code": "…", "message": "…"}}` 的 MCP tool error 返回;原始 OS 异常文本不会返回给客户端。工具 调用入口拒绝未知参数,且整数为严格整数(布尔值和数字字符串不被当作整数接受)。
| 工具 | 参数(默认值) | 行为 |
|---|---|---|
| `terminal_start` | `command_id`(必填字符串,1–128);`cwd`(可选字符串);`env`(可选字符串字典);`timeout_ms`(可选严格整数,100–3,600,000,且受配置上限约束) | 启动 Owner 批准的固定 profile 并立即返回会话快照。没有 `argv` 参数,也不接受 shell 语法。`cwd` 必须在允许根内;环境覆写须获该 profile 批准。 |
| `terminal_read` | `session_id`(必填);`cursor=0`(0–2^63−1);`max_bytes=65536`(4–65536);`wait_ms=0`(0–2000) | 从绝对 cursor 非消费式读取输出。无新记录且会话仍运行时,可最多等待 `wait_ms`。 |
| `terminal_write` | `session_id`(必填);`text`(必填字符串);`close_stdin=false`(严格布尔值) | 将精确 UTF-8 文本写入运行中的子进程。需要换行时由客户端自行在 `text` 中加入。可在写入后关闭 stdin。 |
| `terminal_status` | `session_id`(必填) | 查询会话状态、真实退出码/信号、终止原因、输出可用性和 cursors。 |
| `terminal_terminate` | `session_id`(必填);`grace_ms=500`(0–2000) | 幂等地终止该会话进程组:发送 `SIGTERM`、等待宽限期、必要时 `SIGKILL`,并等待最终状态确认。 |
启动、状态和终止快照包含 `session_id`、`command_id`、`process_id`、`status`、`exit_code`、`exit_signal`、`termination_reason`、`stdin_closed`、`input_uncertain`、`base_cursor`、`next_cursor`、`created_at`、`last_activity_at`、`output_complete`、`output_incomplete`。`terminal_read` 返回 `records`、`base_cursor`、`next_cursor`、`truncated`、`has_more`,以及当前 `status` 和输出完成标志。
### 推荐读取流程
1. 调用 `terminal_start`,保存返回的 `session_id`,初始读取 cursor 使用 `0`,避免跳过启动期间已生成的输出。
2. 调用 `terminal_read(session_id, cursor=上次 next_cursor)`;处理每一条 `{cursor, stream, text}` 记录。
3. 始终将下一次 cursor 更新为响应的 `next_cursor`。当 `has_more=true` 时立即继续读取;运行中无输出时可使用有限的 `wait_ms`。
4. 用 `terminal_status` 或 read 响应的 `output_complete` 判断尾部输出是否已完全排空。进程退出并不等于 pipe 尾部已经读取完毕。
5. 需要中止时调用 `terminal_terminate`;最终记录仍会在 TTL 内可读。
### Cursor、Unicode 与截断恢复
Cursor 是**绝对输出记录序号,不是字节偏移,也不是行号**。buffer 将文本拆成 Python Unicode **码点**,每个记录都保留 `stdout` 或 `stderr` 标签。`max_bytes` 约束所返回文本的 UTF-8 字节总量,不是 JSON 线长。每页还固定最多返回 1,024 条记录,达到上限时 `has_more=true`;它不改变 cursor 的计数单位。
buffer 达到 `buffer_bytes` 或 `buffer_records` 上限时,会淘汰最旧记录,并将 `truncated` 设为 `true`。如果客户端请求的 cursor 小于当前 `base_cursor`,服务返回 `CURSOR_EXPIRED` 和可恢复的 `base_cursor`,而不是静默丢失数据。此时表示已经出现不可见输出缺口;客户端应记录该缺口,将 cursor 移至错误中给出的 `base_cursor`,再继续读取。cursor 大于 `next_cursor` 则为 `INVALID_CURSOR`。
从子进程 pipe 读取时采用增量 UTF-8 解码;无效字节以替换字符表示。输出 reader 被取消、失败或在尾部排空期限内未完成时,`output_incomplete=true`,此时不要把已收到输出当作完整审计记录。
### 写入确认与背压
`terminal_write` 成功的 `accepted_bytes` 仅代表数据已被控制器接受并完成 pipe `drain()`;**它不是目标应用已经读取、解析或处理输入的确认**。如需应用级确认,应由固定 profile 的协议通过 stdout/stderr 显式返回。
单次写入受 `max_input_bytes` 限制。若 drain 在 1 秒内无法完成,服务会关闭该会话 stdin 并返回 `INPUT_BACKPRESSURE`;此时字节可能已部分送达,**不得盲目重试同一输入**。进程关闭输入时会返回 `STDIN_CLOSED`;结束会话写入则为 `SESSION_NOT_RUNNING`。
写入排队后若请求被取消,已发送字节不能撤回;控制器关闭 stdin,并在状态中设置 `input_uncertain=true`。同一会话再次写入会被拒绝,客户端不得把取消视为“从未写入”,也不能跨新会话盲目重放。
## 资源配额、超时与生命周期
整个实例所有保留会话的 `max_records × buffer_records` 不得超过 2,097,152 条,`max_records × buffer_bytes` 不得超过 67,108,864 字节;超额配置被拒绝。这限制对象数与文本容量,不是操作系统 RSS/CPU 的硬隔离。默认 32 个保留会话。MCP 输入帧解析前限制为 1 MiB(含换行),实际 text/env 仍受更低的配置或字节限制。
- 同时运行会话达到 `max_sessions` 时,新的启动返回 `SESSION_LIMIT`。
- 已保留会话(包括最终状态但尚未到 TTL 的会话)达到 `max_records` 时,新的启动返回 `RECORD_LIMIT`;服务不会为腾出空间而静默驱逐仍承诺保留的最终输出。
- 会话到达 `timeout_ms` 后,控制器按超时原因终止它;默认超时和最大可选超时由 Owner 配置决定。
- 完成后,会话输出和状态保存 `retention_seconds`。TTL 到期后记录被移除,随后查询会得到 `SESSION_NOT_FOUND`。TTL 是最终记录的保留时间,不是进程运行时限。
- 最终状态为 `EXITED`、`TERMINATED` 或 `FAILED`;运行中可见 `RUNNING`,终止过程中可见 `TERMINATING`。自然退出的原因是 `natural`;用户终止、超时、正常服务关闭分别会标为 `user`、`timeout`、`shutdown`。
## 进程组清理与其边界
每个 profile 通过 `start_new_session=True` 启动,POSIX 会为它建立新 session/进程组(`setsid` 语义)。控制器以子进程 leader PID 为进程组 ID,使用 `killpg()` 向该组发信号:用户终止/超时/关闭时先 `SIGTERM`,等待所选宽限期后才 `SIGKILL`;若 leader 自然结束,仍会对其原组发送 `SIGKILL`,以避免已知同组后代继续持有 pipe 或存活。
这是有限的进程组清理,不是容器隔离:
- 只有仍在这个进程组内的后代会被处理。主动 `setsid()`、另建进程组、委托给守护进程/服务管理器或跨越其他权限边界的工作可能逃离,控制器不保证回收它们。
- `SIGKILL` 不可捕获,目标应用没有机会执行优雅清理、刷新文件或发送自己的完成确认;因此可能产生不完整输出或外部副作用。
- 无法向进程组发信号、退出无法在确认期限内完成或服务遭到非正常杀死时,不能承诺清理成功。服务正常关闭会尝试终止已知会话,但不存在重启后的恢复/补救。
如果业务需要强隔离、网络限制、文件系统隔离、资源控制或可恢复编排,请在本控制器之外使用操作系统账户、容器/VM、服务管理器和相应审计机制。
## 默认演示 profiles
默认配置包含以下固定 profile:`stream`、`interactive`、`long`、`unicode`、`flood`、`exit7`、`empty`、`children`、`closed-stdin`、`split-utf8`、`invalid-utf8`、`eof`、`ignore-term`。它们均由真实 Python 子进程运行 `demo_process.py`:例如 `interactive` 读取并回显输入,`flood` 产生足以触发 buffer 截断的输出,`children` 建立同进程组后代,`ignore-term` 用于验证 TERM→KILL 路径。它们不是生产命令批准清单。
## 测试与独立 MCP 验证
测试与验证脚本是**两条独立命令**,请分开运行。随包 evidence 保存已执行结果;这不替代你在目标环境的复现。
```bash
pytest
# 仅真实 wire-level 测试:pytest -m e2e
```
`pytest` 会发现 `tests/`。端到端测试会通过官方 MCP stdio client 启动新服务器子进程;其他测试还覆盖会话和输出 buffer 行为。
独立证据脚本不在进程内调用 `SessionManager`,而是使用官方 `StdioServerParameters` 和 `ClientSession` 启动实际 `python -m mcp_terminal stdio`,从 wire protocol 记录响应。示例:
```bash
python scripts/verify_mcp.py --output /tmp/mcp-evidence.json
```
可用 `--scenario 名称` 重复选择单个或多个场景;省略时运行全部。`--output` 对验证脚本是必填项,并且无论成功或失败都会写出 JSON 证据;失败时脚本以非零状态退出。该脚本使用其自身路径定位项目,可从无关当前目录运行。运行前请确认依赖已安装,并自行检查 JSON 中的 `success`、`failure`、断言和调用记录。
## 安全说明
部署、配置所有权、威胁模型和漏洞报告方式见 [SECURITY.md](SECURITY.md)。最重要的原则是:仅让可信 Owner 写配置,仅批准确有必要的固定可执行文件与根目录,并将本服务视为在其运行用户权限下执行真实本地代码的接口。
## SDK 兼容说明与来源
官方 SDK 2.3.0 的默认 stdio 文件读取在 stdin 保持打开时可能阻塞 SIGTERM 停机。本项目 `transport.py` 用 asyncio fd readiness 提供可取消的文件适配器,仍由官方 `stdio_server` 解析、序列化及处理协议。该模块集中使用 SDK `_lowlevel_server` 入口;另在不完整输出清理时使用 asyncio Process 的私有 transport 关闭句柄。它们是已记录的兼容风险,升级 SDK/Python 必须重跑真实协议与停机测试。支持 pipe,未实现 PTY、网络服务或重启恢复。
- [官方 Python MCP SDK](https://github.com/modelcontextprotocol/python-sdk)
- [官方 Client 文档](https://py.sdk.modelcontextprotocol.io/client/)
- [官方 stdio 与运行指南](https://py.sdk.modelcontextprotocol.io/run/)
依赖包含 `uv.lock` 及带哈希的 `requirements.lock` / `requirements-test.lock`。需要 pip 哈希锁定时,在新虚拟环境中安装 `python -m pip install --require-hashes -r requirements-test.lock`,再执行 `python -m pip install --no-deps .`。不要把示例绝对路径直接复制到另一台机器;用 make_config.py 重新生成。
TDQS
Scored across 5 tools
Each tool maps to a distinct lifecycle stage: start, write, read, status, terminate. The only near-overlap is status reporting 'output availability' versus read returning output, but the descriptions make clear status is metadata-only while read consumes records from a cursor.
All five tools share a uniform terminal_ prefix with a single-word verb suffix (start, write, read, status, terminate). Fully predictable and consistent.
Five tools is exactly the minimal complete set for process session control, with no redundant operations. Each tool earns its place in the lifecycle.
Start/write/read/status/terminate covers the full session lifecycle including graceful and forced termination. Minor gaps exist for interactive/pty use: no terminal_resize and no dedicated signal-send tool (Ctrl-C must be emulated via write), and no session listing, but agents can work around these.