Skip to main content
Glama
Quinnverse

MCP Terminal Controller

by Quinnverse
README.md
# 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

A4/5.0

Scored across 5 tools

Disambiguation5/5

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.

Naming Consistency5/5

All five tools share a uniform terminal_ prefix with a single-word verb suffix (start, write, read, status, terminate). Fully predictable and consistent.

Tool Count5/5

Five tools is exactly the minimal complete set for process session control, with no redundant operations. Each tool earns its place in the lifecycle.

Completeness4/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues