No description
  • Rust 98.4%
  • Nix 1.5%
  • Just 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-12 02:55:00 +08:00
assets docs: add project logo and center it in README 2026-09-20 17:50:38 +08:00
docs feat: add local status and reconnect control tools 2026-09-30 10:34:49 +08:00
skills/configure-mcp-harbor chore: decouple harness and simplify portable skill setup 2026-10-12 02:55:00 +08:00
src feat: add local status and reconnect control tools 2026-09-30 10:34:49 +08:00
tests feat: add local status and reconnect control tools 2026-09-30 10:34:49 +08:00
.envrc Initial commit 2026-09-20 05:12:34 +00:00
.gitignore chore: decouple harness and simplify portable skill setup 2026-10-12 02:55:00 +08:00
.muxrun.toml Initial commit 2026-09-20 05:12:34 +00:00
Cargo.lock feat: implement resilient async MCP proxy 2026-09-20 15:44:46 +08:00
Cargo.toml feat: implement resilient async MCP proxy 2026-09-20 15:44:46 +08:00
CONTEXT.md docs: record agreed proxy design and acceptance criteria 2026-09-20 13:32:13 +08:00
devenv.lock chore: decouple harness and simplify portable skill setup 2026-10-12 02:55:00 +08:00
devenv.nix chore: decouple harness and simplify portable skill setup 2026-10-12 02:55:00 +08:00
devenv.yaml chore: decouple harness and simplify portable skill setup 2026-10-12 02:55:00 +08:00
flake.lock feat: implement resilient async MCP proxy 2026-09-20 15:44:46 +08:00
flake.nix feat: implement resilient async MCP proxy 2026-09-20 15:44:46 +08:00
justfile chore: decouple harness and simplify portable skill setup 2026-10-12 02:55:00 +08:00
README.md chore: decouple harness and simplify portable skill setup 2026-10-12 02:55:00 +08:00

mcp-harbor logo

mcp-harbor

中文 | English

一个用 Rust 编写的 stdio MCP 代理:对本地 MCP 客户端表现为稳定的 MCP server,并将请求转发到可能暂时不可用的远程 Streamable HTTP MCP 后端。后端离线 时前端仍可完成 initialize 并继续服务磁盘上的最后一次目录快照。

行为规格见 docs/SPEC.md,实现选择与理由见 docs/DESIGN.md,术语见 CONTEXT.md。

中文

功能概览

  • 前端:stdio MCP server;后端:Streamable HTTP MCP client。
  • 后端离线时前端进程保持存活,initialize 不被后端连接阻塞。
  • 目录查询(tools/prompts/resources/resource templates)由代理本地持有的目录 提供,不逐次回源;离线时继续返回最后一次成功发现的有效快照。
  • tools/call、prompts/get、resources/read 实时转发到上游。
  • 发现完整目录(含全部分页)并以原子替换写入 --cache。
  • 目录变更按类别向前端发送 list-changed 通知。
  • 所有诊断输出到 stderr,stdout 保留给 MCP 协议;由 RUST_LOG 控制详细程度。

范围与不做的特性以 docs/SPEC.md 为准(无认证、无多上游聚合、无工具搜索、无 数据库、无 Web UI)。

CLI

mcp-harbor
  --upstream <URL>
  --cache <PATH>
  [--name <NAME>]                          (默认: mcp-harbor)
  [--description <TEXT>]                   (默认: 省略)
  [--revalidate-interval <SECONDS>]        (默认: 30)
  [--log-dir <DIR>]                        (默认: 省略)
  [--diagnostic-tool-name <NAME>]          (默认: mcp_harbor_get_instance)

--name / --description 是占位元数据:仅在既无实时后端元数据、又无有效缓存 元数据时使用。

示例(客户端通常以 stdio 子进程方式启动它):

mkdir -p ~/.cache/mcp-harbor
mcp-harbor --upstream https://example.invalid/mcp --cache ~/.cache/mcp-harbor/catalog.json

缓存文件的父目录不会被自动创建,需要先自行建立(如上)。

本地工具

代理始终额外提供三个本地工具,用于在不依赖上游的情况下报告本实例状态或请求恢复:

  • mcp_harbor_get_instance(默认名,可用 --diagnostic-tool-name <NAME> 覆盖): 返回本实例的本地诊断信息。名称必须是合法的 MCP 工具名(1–128 个字符,仅含 A-Z a-z 0-9 _ - .,不能有空格或逗号);空白名称会被拒绝。
  • mcp_harbor_get_status(固定名,无参数,只读):返回本地连接状态。
  • mcp_harbor_reconnect(固定名,控制工具):请求立即恢复。

mcp_harbor_get_instance 无参数,返回 JSON 文本,字段为:

  • instance_id:本实例标识,由进程启动时间的 Unix 秒与纳秒加 PID 组成。
  • started_at:进程启动时间,Unix epoch 秒的十进制字符串(UTC 表示)。
  • version:代理版本。
  • logging_enabled:是否启用了 --log-dir 文件日志。
  • log_file:启用时为日志文件名(不含目录),否则为 null。

mcp_harbor_get_status 无参数,立即在本地读取并按现有 JSON 文本响应返回:

  • connection_state:disconnected、connecting 或 connected。
  • ready:当前连接是否已完成完整 Discovery 且可转发请求;与内部 usable_backend 一致。
  • snapshot_available:是否确实存在一个成功从缓存加载或由完整 Discovery 得到的 Snapshot;空 Catalog 也算有 Snapshot。
  • last_error:最近一次恢复失败的安全本地分类字符串,例如 connect_failed、discovery_transport_failed、discovery_request_failed; 没有错误时为 null。它不会输出可能含 URL 或凭据的后端错误字符串。成功完成 完整 Discovery 后清除旧错误。

mcp_harbor_reconnect 请求后台 manager 立即恢复,并等待本代理变为 ready;它不 主动断开可用连接,也不在工具处理函数里新建连接。参数:

  • timeout_seconds(可选):非负整数秒,默认 60。0 表示只触发恢复、不等待。 未知参数、负数、非整数、类型错误和超出 u32 范围都会被拒绝。

若调用时已经 ready,立即返回 outcome=already_ready,不触发连接或刷新。否则推动 后台立即尝试并在完整 Discovery 成功后返回 outcome=ready;等待超时返回 outcome=timeout(这是正常的结构化结果,不是错误)。结果同时包含 elapsed_seconds 和与 get_status 同结构的 status。多个并发调用共享正在进行的 尝试。取消或超时只结束调用方的等待,不取消 manager 或连接;后台会按既有间隔继续 重试。客户端自身可能采用更短的超时,从而先于本工具的 timeout_seconds 返回。

三个本地工具都在本地由代理直接应答,从不访问上游。因此即使没有缓存、后端离线, 或上游未声明 tools 能力,tools/list 仍会列出它们,tools/call 也能成功。它们只 在下游 tools/list 中与上游工具合并返回,不写入上游快照,也不参与上游 catalog change 比较与通知;原有工具转发与元数据语义不变。

若有效缓存快照或任一次成功发现中的上游工具名与任一本地工具名冲突,代理不会静默 覆盖:进程会以非零状态 panic 退出。与可配置诊断名冲突时提示使用 --diagnostic-tool-name 改名;与固定控制名 mcp_harbor_get_status / mcp_harbor_reconnect 冲突时不会提示该参数(改名无法解决)。--diagnostic-tool-name 也不得等于两个固定控制名。

调试日志

默认日志级别为 info。不给 --log-dir 时诊断输出到 stderr;stdout 始终保留给 MCP 协议。也可在启动代理的 shell / 包装脚本中只重定向 stderr:

RUST_LOG=warn,mcp_harbor=debug mcp-harbor \
  --upstream http://127.0.0.1:8080/mcp \
  --cache /path/to/catalog.json 2>>/path/to/harbor-debug.log

父目录需预先存在;每个代理使用独立缓存和日志路径。2>> 追加日志,2> 覆盖; 不要用 2>&1,也不要把 stdout 重定向到日志。客户端直接执行 argv 时不会解释 重定向符,需由 shell 包装脚本执行上述命令。该过滤器只开启 harbor 的 DEBUG, 避免全局 debug / trace 让 SDK 输出协议正文;分享前仍需脱敏 URL、路径和目录名称。 日志含 connection generation、发现类别、工具转发开始/完成与耗时、快照保存阶段, 不新增请求参数或结果日志。发现开始却无后续完成可提示等待位置,但不能单凭日志 或缓存修改时间判定现场根因。30 秒刷新间隔是在本轮 await 完成后才开始等待, 不是请求超时。请同时记录实际运行的二进制路径和构建来源,版本号相同不代表代码相同。

指定 --log-dir <DIR> 时:

  • 该目录会被 create_dir_all 创建;每次启动用 create_new 新建 harbor-<instance-id>.log,已存在则追加碰撞序号,最终 instance_id 与文件名 一致。诊断仅写入该文件(禁用 ANSI 着色),不再写 stderr;RUST_LOG 仍生效, stdout 不变。
  • 日志文件只增不减,不做轮转,请自行清理旧文件;同一目录会被多次启动累积。
  • 启动时以 INFO 记录 instance id、启动时间、PID 与版本;上游工具名冲突时,panic 前也会先写一条 error 到文件,因此文件日志中可见冲突内容。
  • 目录创建或日志文件打开失败时,会在 stderr 报告原因并以非零状态退出。

不给出 --log-dir 时同样会生成 instance id(诊断工具仍会返回),只是不写文件。 instance id 仅承诺在同一日志目录内不覆盖,不承诺跨机器全局唯一。

缓存身份

  • 一个缓存文件对应用户认定的一个 MCP identity。代理不校验身份,也不把缓存与 --upstream URL 绑定。
  • 更换 --upstream 后复用同一缓存文件,即表示用户接受其中的旧目录;代理不做 自动身份检查。
  • 不支持多个进程同时写同一缓存文件;代理不加锁。请勿多进程共写同一路径。

构建与测试

开发环境由 devenv + direnv 提供(见下)。Rust 部分:

cargo test          # 单元测试与集成测试
cargo build --release

Nix(flakes)构建:

命令 产物
nix build .#mcp-harbor x86_64-linux 原生二进制
nix build .#mcp-harbor-windows x86_64-pc-windows-gnu PE 可执行文件(MinGW 交叉构建)
nix build .#mcp-harbor 的默认别名

Windows 目标仅验证构建成功;本项目不声明已验证 Windows 运行行为。两个构建共用 Cargo.lock。flake 固定到与 devenv.lock 相同的 nixpkgs revision,避免 flake 与开发 shell 出现不同版本的包集。

环境与 devenv

首次使用需要宿主机安装支持 flakes 的 Nix、devenv 和 direnv:

direnv allow      # 首次必须由宿主手动审查并授权 .envrc
just check        # 校验配置、Nix 格式化与 shell/recipe 语法

direnv allow 是宿主侧安全授权动作,agent 不代为执行。

入口

命令 作用
just 列出全部 recipe
just check 校验配置、Nix 格式化与 shell/recipe 语法

环境与 runtime

  • 全体开发者共享的包写在 devenv.nix;新增 input 需同步修改 devenv.yaml 并 重新生成 devenv.lock。
  • 修改 config、skills、instructions、MCP、agents、commands、plugins 或安装包 后需要重启 OpenCode;新建 chat session 不等于重载。

维护

  • 升级其他 input 时使用 devenv update,并把 devenv.lock 一并提交。
  • 升级 flake 的 nixpkgs 时更新 flake.nix 中的 rev 并重新生成 flake.lock; 保持与 devenv.lock 的 nixpkgs revision 一致。

English

A small async Rust MCP proxy. It presents a stable stdio MCP server to a local client and forwards to a remote Streamable HTTP MCP backend that may be unavailable, serving the last successfully discovered catalog from disk while the backend is offline.

Behavior is specified in docs/SPEC.md, implementation choices in docs/DESIGN.md, and terminology in CONTEXT.md.

Overview

  • Frontend: stdio MCP server. Backend: Streamable HTTP MCP client.
  • The frontend stays alive when the backend is down; initialize never blocks on the backend connection.
  • Catalog queries (tools/prompts/resources/resource templates) are served from the catalog the proxy holds, not by contacting the upstream on every request. A valid snapshot keeps serving while offline.
  • tools/call, prompts/get, and resources/read are forwarded in real time.
  • A complete discovery (all pages) is persisted to --cache with atomic replacement.
  • Catalog changes emit per-category list-changed notifications.
  • All diagnostics go to stderr; stdout is reserved for MCP. RUST_LOG controls verbosity.

Out-of-scope features are listed in docs/SPEC.md (no auth, no multi-upstream aggregation, no tool search, no database, no web UI).

CLI

mcp-harbor
  --upstream <URL>
  --cache <PATH>
  [--name <NAME>]                          (default: mcp-harbor)
  [--description <TEXT>]                   (omitted by default)
  [--revalidate-interval <SECONDS>]        (default: 30)
  [--log-dir <DIR>]                        (omitted by default)
  [--diagnostic-tool-name <NAME>]          (default: mcp_harbor_get_instance)

--name / --description are placeholder metadata, used only when neither live backend metadata nor valid cached metadata exists.

Example (clients normally launch it as a stdio child process):

mkdir -p ~/.cache/mcp-harbor
mcp-harbor --upstream https://example.invalid/mcp --cache ~/.cache/mcp-harbor/catalog.json

The cache file's parent directory is not created automatically; create it first, as above.

Local Tools

The proxy always adds three local tools that report this instance's state or request recovery without depending on the upstream:

  • mcp_harbor_get_instance (the configurable diagnostic tool; rename it with --diagnostic-tool-name <NAME>): reports this instance's local diagnostics. The name must be a valid MCP tool name (1-128 characters from A-Z a-z 0-9 _ - ., no spaces or commas); a blank name is rejected.
  • mcp_harbor_get_status (fixed name, no arguments, read-only): reports local connection status.
  • mcp_harbor_reconnect (fixed name, control tool): requests immediate recovery.

mcp_harbor_get_instance takes no arguments and returns JSON text with these fields:

  • instance_id: this instance's identifier, derived from the process start time (Unix seconds and nanoseconds) plus the PID.
  • started_at: the process start time as a decimal string of Unix epoch seconds (a UTC representation).
  • version: the proxy version.
  • logging_enabled: whether --log-dir file logging is active.
  • log_file: the log file name (no directory) when enabled, else null.

mcp_harbor_get_status takes no arguments, reads state immediately and locally, and returns JSON text in the existing response convention:

  • connection_state: disconnected, connecting, or connected.
  • ready: whether the current connection completed a full Discovery and can forward requests; identical to the internal usable_backend.
  • snapshot_available: whether a Snapshot was actually loaded from cache or produced by a complete Discovery; an empty catalog still counts as a Snapshot.
  • last_error: a safe local classification of the most recent failed recovery, such as connect_failed, discovery_transport_failed, or discovery_request_failed, or null. It never emits a backend error string that could contain a URL or credential. A successful complete Discovery clears the previous error.

mcp_harbor_reconnect asks the background manager to recover immediately and waits until this proxy is ready. It never disconnects a usable connection and never creates a connection inside the tool handler. Arguments:

  • timeout_seconds (optional): a non-negative integer number of seconds, default 60. 0 triggers recovery without waiting. Unknown arguments, negatives, non-integers, type errors, and values beyond the u32 range are rejected.

If already ready, it returns outcome=already_ready immediately without triggering a connection or refresh. Otherwise it makes the background manager try at once and returns outcome=ready after a complete Discovery succeeds, or outcome=timeout when the wait expires (a normal structured result, not an error). The result also carries elapsed_seconds and a status with the same shape as get_status. Concurrent calls share the in-flight attempt. A cancel or timeout ends only the caller's wait; it does not cancel the manager or the connection, which keeps retrying on the existing interval. A client may apply a shorter timeout of its own and return before this tool's timeout_seconds.

All three calls are answered locally and never contact the upstream. They are therefore listed by tools/list and callable even with no cache, an offline backend, or an upstream that declares no tools capability. They are combined into the downstream tools/list only, are never written to the upstream snapshot, and never participate in upstream catalog-change comparison or notifications; the existing tool forwarding and metadata semantics are unchanged.

If a tool name in a valid cached snapshot or in any successful discovery collides with any local tool name, the proxy does not silently override: the process panics and exits non-zero. A collision with the configurable diagnostic name points at --diagnostic-tool-name; a collision with the fixed control names mcp_harbor_get_status / mcp_harbor_reconnect does not suggest that flag (renaming cannot resolve it). --diagnostic-tool-name may not equal either fixed control name.

Debug Logs

The default level is info. Without --log-dir, diagnostics go to stderr and stdout stays reserved for MCP. You can also redirect only stderr in the launching shell or wrapper:

RUST_LOG=warn,mcp_harbor=debug mcp-harbor \
  --upstream http://127.0.0.1:8080/mcp \
  --cache /path/to/catalog.json 2>>/path/to/harbor-debug.log

Create parent directories first and use separate cache/log paths per proxy. 2>> appends; 2> overwrites. Never use 2>&1 or redirect stdout to the log. Clients that execute an argv array need a shell wrapper to interpret redirection. This filter enables only harbor DEBUG logs; global SDK debug/trace may expose protocol bodies. Redact URLs, paths, and catalog names before sharing logs. Stages include connection generation, discovery category, tool forwarding start/completion and duration, and snapshot saving, without adding argument or result logging. An unmatched start helps locate a wait, not prove a field root cause. The refresh interval starts after the current attempt completes; it is not a request timeout. Also record the executable path and build provenance: the same version number does not establish identical code.

With --log-dir <DIR>:

  • The directory is created with create_dir_all; each start creates a fresh harbor-<instance-id>.log with create_new, appending a collision sequence when the name is taken, and the final instance_id matches the file name. Diagnostics go only to that file (ANSI disabled), not stderr; RUST_LOG still applies and stdout is unchanged.
  • Log files only accumulate; there is no rotation, so clean up old files yourself. Repeated starts in one directory accumulate one file per start.
  • Startup logs an INFO record with the instance id, start time, PID, and version. On an upstream tool-name conflict an error is logged before the panic, so the conflict is visible in the file log.
  • A directory-creation or log-file-open failure is reported on stderr and exits non-zero.

Without --log-dir, an instance id is still generated (and reported by the diagnostic tool), only no file is written. The instance id is only promised to avoid overwriting within the same local log directory; it is not claimed to be globally unique across machines.

Cache Identity

  • One cache file corresponds to one user-asserted MCP identity. The proxy does not check identity and does not bind the cache to the --upstream URL.
  • Reusing a cache file after changing --upstream means the user accepts its old catalog; there is no automatic identity check.
  • Multiple processes writing the same cache file are not supported, and the proxy adds no locking. Do not let several processes share one cache path.

Build And Test

The development environment comes from devenv + direnv (below). For Rust:

cargo test          # unit and integration tests
cargo build --release

Nix (flakes) builds:

Command Artifact
nix build .#mcp-harbor native x86_64-linux binary
nix build .#mcp-harbor-windows x86_64-pc-windows-gnu PE executable (MinGW cross-build)
nix build alias defaulting to .#mcp-harbor

The Windows target is build-acceptance only; no Windows runtime behavior is claimed. Both builds share Cargo.lock. The flake pins the same nixpkgs revision as devenv.lock, so the flake and the development shell do not diverge.

Environment And devenv

First use requires a host with flakes-enabled Nix, devenv, and direnv:

direnv allow      # first authorization must be reviewed and granted on the host
just check        # validate config, Nix formatting, and shell/recipe syntax

direnv allow is a host-side authorization step the agent does not perform.

Entrypoints

Command Purpose
just List all recipes
just check Validate config, Nix formatting, and shell/recipe syntax

Environment And Runtime

  • Shared packages belong in devenv.nix; a new input also changes devenv.yaml and regenerates devenv.lock.
  • Restart OpenCode after changing config, skills, instructions, MCP, agents, commands, plugins, or packages; a new chat session is not a reload.

Maintenance

  • Use devenv update for other inputs and commit devenv.lock with them.
  • To upgrade the flake's nixpkgs, bump the rev in flake.nix and regenerate flake.lock, keeping it aligned with the devenv.lock nixpkgs revision.