- Rust 98.4%
- Nix 1.5%
- Just 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| assets | ||
| docs | ||
| skills/configure-mcp-harbor | ||
| src | ||
| tests | ||
| .envrc | ||
| .gitignore | ||
| .muxrun.toml | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CONTEXT.md | ||
| devenv.lock | ||
| devenv.nix | ||
| devenv.yaml | ||
| flake.lock | ||
| flake.nix | ||
| justfile | ||
| README.md | ||
mcp-harbor
一个用 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。代理不校验身份,也不把缓存与
--upstreamURL 绑定。 - 更换
--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;
initializenever 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, andresources/readare forwarded in real time.- A complete discovery (all pages) is persisted to
--cachewith atomic replacement. - Catalog changes emit per-category list-changed notifications.
- All diagnostics go to stderr; stdout is reserved for MCP.
RUST_LOGcontrols 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 fromA-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-dirfile logging is active.log_file: the log file name (no directory) when enabled, elsenull.
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, orconnected.ready: whether the current connection completed a full Discovery and can forward requests; identical to the internalusable_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 asconnect_failed,discovery_transport_failed, ordiscovery_request_failed, ornull. 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.0triggers recovery without waiting. Unknown arguments, negatives, non-integers, type errors, and values beyond theu32range 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 freshharbor-<instance-id>.logwithcreate_new, appending a collision sequence when the name is taken, and the finalinstance_idmatches the file name. Diagnostics go only to that file (ANSI disabled), not stderr;RUST_LOGstill 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
--upstreamURL. - Reusing a cache file after changing
--upstreammeans 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 changesdevenv.yamland regeneratesdevenv.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 updatefor other inputs and commitdevenv.lockwith them. - To upgrade the flake's nixpkgs, bump the rev in
flake.nixand regenerateflake.lock, keeping it aligned with thedevenv.locknixpkgs revision.