feat(plugins): convert packaged Node MCP servers through reviewed native bundles

Add explicit --stdio-root selections for static OpenCode v1/v2 and DSH MCP
client declarations using node plus a contained .mjs entry. Copy the chosen
working directory with resources and dependency files; preserve disabled
state, representable timeout values and OpenCode environment-name references.
Reuse native install, capability review, hash-bound trust and MCP discovery.
Reject unsupported launchers, source links, hidden/credential files, literal
credentials and loader environment overrides. Update English and Chinese guides.

Validation: Python converter suite 39 passed, 0 failed (including synthetic
MCP initialize/list/call with sibling ESM import and cwd resource). The new
positive CLI test fails against the unchanged predecessor converter.
Isolated existing 0.9.13-dev Runtime API artifact: 9 lifecycle checks passed,
0 failed, from disabled/untrusted install through disclosure, trust, enable,
discovery, disable and revocation. Binary SHA256:
af1b4e0adb85cea0a17167b7daf819c05347d45036effe66e324406323033065.
This is an existing artifact receipt, not a source rebuild or hosted CI proof.

Core npm test: FAIL, Missing script: "test". Core npm run check:web: FAIL,
Missing script: "check:web". No npm gate is claimed in this Rust repository.
No Cargo, provider request, GUI operation, public push or deployment ran.
.js/.cjs entrypoints remain rejected after the native artifact failed all
three CommonJS/ESM sibling-import discovery cases; no Cordis runtime claim.
Durable receipt: codewhale-ops/evidence/plugin-node-interop-20260909.
This commit is contained in:
CodeWhale Bot committed 2026-09-09 01:30:39 -07:00
1 parent f88cda0200
commit 623d380966
4 files changed
+381 -26

No files matched your search

+62 -4
View File
@@ -164,7 +164,8 @@ before adding a hook.
## Convert an existing plugin
[`scripts/convert-plugin.py`](../scripts/convert-plugin.py) converts explicitly
selected remote MCP declarations and portable Skills into a native bundle.
selected remote MCP declarations, packaged local Node MCP servers, and portable
Skills into a native bundle.
It requires Python 3.10+ and PyYAML 6+; install those separately if absent.
The converter installs no dependencies, scans no ambient configuration or
credentials, makes no network requests, and executes no source code.
@@ -196,7 +197,7 @@ python3 scripts/convert-plugin.py --format opencode-v1 \
Choose `--format opencode-v2` for the `mcp.servers.<name>` layout, whose server
flag is `disabled` instead of `enabled`. Select the format from the data;
filenames and upstream branch names do not determine its version. Both formats
require explicit `oauth: false`. Remote MCP output uses **Streamable HTTP only**;
require explicit `oauth: false` for remote servers. Remote MCP output uses **Streamable HTTP only**;
OpenCode's fallback to legacy SSE is not reproduced. For an SSE-only endpoint,
author native `mcp.json` with `type: "sse"` and use the same review flow.
@@ -231,6 +232,62 @@ python3 scripts/convert-plugin.py --format dsh \
The DSH input may also be JSON, but must be the plain entry list, not a full
profile or patch composition. Each row must name `@deepseek-ai/dsh-mcp-client`.
### Local Node MCP servers
For an already packaged Node MCP server, select its original process working
directory explicitly. The converter copies that directory into `mcp/<server>`
and sets the native server's working directory to the reviewed copy. Relative
entrypoint imports and read-only resources keep the same layout.
```json
{
"mcp": {
"localdocs": {
"type": "local",
"command": ["node", "server.mjs"],
"environment": {"API_TOKEN": "{env:LOCALDOCS_TOKEN}"},
"enabled": false
}
}
}
```
```sh
python3 scripts/convert-plugin.py --format opencode-v1 \
--config ./local-mcp.json --stdio-root localdocs=./packaged-localdocs \
--name local-tools --output ./migrated-local
```
Repeat `--stdio-root SERVER=DIRECTORY` for every local server in the selected
configuration. OpenCode v2 uses `mcp.servers` and `disabled`. Static DSH entries
use `transport: stdio`, `command: node`, and `args: [server.mjs]`; DSH `env`
must be absent or empty because its literals/expressions are not OpenCode
environment references. Optional DSH/v2 `cwd` must be absent, empty, or `.`;
the selected root explicitly supplies the original working directory.
Only `node` plus one relative `.mjs` entry is supported. The native macOS
reviewed-launch adapter preserves sibling imports for `.mjs`; `.js` and `.cjs`
entrypoints are not qualified by this converter and require a manual port.
Package dependencies and read-only resources first, inside the selected root.
No package manager, install script, module loader or server runs during
conversion. Links/reparse points, hard-linked files, hidden files/directories
(including `.gitignore`, `.env*`, `.npmrc` and `node_modules/.bin`), common
credential filenames, and private-key containers are refused. Prepare a clean
package directory; ignore rules are not used to silently omit files. Inspect
every selected file for embedded credentials before conversion. The existing
4,096-file / 64 MiB aggregate bundle limit applies.
The converter rejects shell launchers, Node flags, extra arguments, non-Node
interpreters, literal environment values, and loader-changing environment
names. Stateful servers that write into their working directory, depend on the
live workspace, or import files outside the package need a manual native port.
Copying files does not statically verify JavaScript import closure or sandbox
arbitrary code. Local MCP processes run with host-user authority; their network
and filesystem access are not restricted by the remote endpoint host list.
The same native install, capability review, hash-bound trust, and enable steps
are required before Codewhale launches the server. This adds a packaged Node
MCP subset; it does not execute DSH/Cordis plugin modules.
### Review the result
Both examples preserve disabled servers and use a placeholder endpoint. Replace
@@ -253,13 +310,14 @@ DSH header expressions, and URL file/environment substitution are refused.
Configured timeouts must be whole seconds expressed in milliseconds, from
`1000` through `3600000`. Omitted timeouts use Codewhale's defaults.
Executable plugins and hooks, stdio servers, automatic OAuth, JavaScript,
Executable foreign plugins and hooks, other stdio launchers, automatic OAuth,
configuration JavaScript,
YAML aliases/tags, `__jsExpr`, and unsupported skill runtime fields (including
`user-invocable: false`) require a manual port. Conversion does not reproduce
another client's runtime or bypass Codewhale's credential and sandbox rules.
Read the generated `CONVERSION.md`, `plugin.json`, `mcp.json` when present, and
all selected skill files. Then use `/plugin install ./migrated-opencode` (or the
all selected skill and MCP source files. Then use `/plugin install ./migrated-opencode` (or the
DSH output path), `/plugin validate <name>`, and the same hash-bound trust and
enable flow above. Conversion alone proves neither connectivity nor runtime
compatibility; the output is not installed, trusted, or enabled.
+51 -4
View File
@@ -1,7 +1,7 @@
# 编写你的第一个 Codewhale 插件
> 英文原文:[PLUGIN_AUTHORING.md](../PLUGIN_AUTHORING.md)。
> 最后与英文同步日期(last synced with English revision):2026-09-08。
> 最后与英文同步日期(last synced with English revision):2026-09-09。
先从一个 skill 开始:把 Markdown 指令文件放进一个小型插件包。
[hello-codewhale 示例](../examples/plugins/hello-codewhale/plugin.json)
@@ -156,7 +156,7 @@ MCP 使用[插件验证契约](../PLUGIN_BUNDLES.md#validation-both-formats)规
## 转换现有插件
[`scripts/convert-plugin.py`](../../scripts/convert-plugin.py) 将明确选定的远程 MCP
声明和可移植 Skills 转换为原生插件包。需要 Python 3.10+ 和 PyYAML 6+;
声明、已打包的本地 Node MCP 服务器和可移植 Skills 转换为原生插件包。需要 Python 3.10+ 和 PyYAML 6+;
如果缺少,请单独安装。转换器不会安装依赖、扫描现有应用配置或凭据、
发送网络请求,也不会执行源代码。
@@ -186,7 +186,7 @@ python3 scripts/convert-plugin.py --format opencode-v1 \
如果数据采用 `mcp.servers.<name>` 结构,请选择 `--format opencode-v2`;
该格式的服务器开关是 `disabled`,不是 `enabled`。根据数据选择格式,
不要依据文件名或上游分支名推断版本。两种格式都要求明确设置 `oauth: false`。
不要依据文件名或上游分支名推断版本。两种格式的远程服务器都要求明确设置 `oauth: false`。
远程 MCP 输出**仅使用 Streamable HTTP**,不会复现 OpenCode 回退到旧版 SSE 的行为。
对于仅支持 SSE 的端点,请手工编写包含 `type: "sse"` 的原生 `mcp.json`,
并使用相同的审查流程。
@@ -219,6 +219,52 @@ python3 scripts/convert-plugin.py --format dsh \
DSH 输入也可以使用 JSON,但必须是普通条目列表,不能是完整 profile 或
patch 组合。每个条目的 `name` 都必须为 `@deepseek-ai/dsh-mcp-client`。
### 本地 Node MCP 服务器
对于已打包的 Node MCP 服务器,使用 `--stdio-root SERVER=DIRECTORY` 明确选择
原进程的工作目录。转换器将整个目录复制到 `mcp/<server>`,并把原生服务器的
工作目录设为经审查的副本,保留相对入口导入和只读资源的目录布局。
```json
{
"mcp": {
"localdocs": {
"type": "local",
"command": ["node", "server.mjs"],
"environment": {"API_TOKEN": "{env:LOCALDOCS_TOKEN}"},
"enabled": false
}
}
}
```
```sh
python3 scripts/convert-plugin.py --format opencode-v1 \
--config ./local-mcp.json --stdio-root localdocs=./packaged-localdocs \
--name local-tools --output ./migrated-local
```
每个本地服务器都必须提供对应的 `--stdio-root`。OpenCode v2 使用 `mcp.servers`
和 `disabled`。静态 DSH 条目使用 `transport: stdio`、`command: node`、
`args: [server.mjs]`;DSH 的 `env` 必须省略或为空,其字面量和表达式不按
OpenCode 环境引用解释。DSH/v2 的 `cwd` 只能省略、为空或为 `.`;选定目录明确
指定原进程的工作目录。只有远程 OpenCode 服务器需要 `oauth: false`。
仅支持 `node` 加一个相对 `.mjs` 入口。原生 macOS 经审查启动适配器保留 `.mjs`
的同级模块导入;此转换器尚未验证 `.js` 和 `.cjs` 入口,需手工移植。依赖和只读资源必须
预先打包在该目录内;转换时不运行包管理器、安装脚本、模块加载器或服务器。
符号链接/reparse point、硬链接文件、隐藏文件或目录(包括 `.gitignore`、
`.env*`、`.npmrc` 和 `node_modules/.bin`)、常见凭据文件名及私钥容器会被拒绝。
请准备干净的打包目录;不会根据忽略规则静默遗漏文件。转换前检查每个选定文件
是否嵌入凭据。整个输出仍受 4,096 个文件 / 64 MiB 限制。
Shell 启动器、Node 标志、额外参数、其他解释器、环境变量字面值以及改变模块
加载方式的环境变量名均不支持。写入工作目录、依赖实时工作区或导入包外文件的
服务器需要手工移植。复制文件不等于静态验证 JavaScript 依赖闭包,也不提供
代码沙箱。本地 MCP 以宿主用户权限运行;远程端点的主机声明不会限制本地进程
的网络或文件访问。Codewhale 启动服务器前仍需完成原生安装、能力审查、哈希绑定
信任和启用流程。此功能是已打包 Node MCP 子集,不执行 DSH/Cordis 插件模块。
### 审查转换结果
两个示例都保留服务器的停用状态,并使用占位端点。准备连接时,先替换源文件中的
@@ -238,7 +284,8 @@ patch 组合。每个条目的 `name` 都必须为 `@deepseek-ai/dsh-mcp-client`
URL 中的文件或环境变量替换都会被拒绝。配置的超时值以毫秒表示,
必须是 `1000` 到 `3600000` 之间的整秒值。未指定的超时使用 Codewhale 的默认值。
可执行插件和 hooks、stdio 服务器、自动 OAuth、JavaScript、YAML 别名或标签、
外部运行时的可执行插件和 hooks、其他 stdio 启动方式、自动 OAuth、配置中的
JavaScript、YAML 别名或标签、
`__jsExpr`,以及不支持的 skill 运行时字段(包括 `user-invocable: false`)
需要手工移植。转换不会复现其他客户端的运行时,也不会绕过 Codewhale 的凭据
和沙箱规则。
+114 -15
View File
@@ -185,20 +185,13 @@ def timeout(value):
return value // 1000
def remote_server(config, dialect, defaults):
def server_options(config, dialect, defaults):
extension = {}
if dialect == "dsh":
mapping(config)
require(config.get("transport") == "streamable-http", "Local MCP processes require manual native authoring.")
mapping(config, {"serverName", "transport", "url", "headers", "toolCallTimeoutMs", "failOnStartupError"})
require(config.get("failOnStartupError", False) is False, "DSH startup-failure policy requires a manual port.")
if "toolCallTimeoutMs" in config:
extension["execute_timeout"] = timeout(config["toolCallTimeoutMs"])
else:
mapping(config)
require(config.get("type") == "remote", "Local MCP processes require manual native authoring.")
mapping(config, {"type", "url", "headers", "oauth", "enabled" if dialect == "opencode-v1" else "disabled", "timeout"})
require(config.get("oauth") is False, "Set oauth:false explicitly; plugin OAuth and upstream auto-OAuth cannot be converted.")
disabled = not config.get("enabled", True) if dialect == "opencode-v1" else config.get("disabled", False)
flag = config.get("enabled", True) if dialect == "opencode-v1" else config.get("disabled", False)
require(type(flag) is bool, "MCP enablement must be a boolean.")
@@ -213,6 +206,19 @@ def remote_server(config, dialect, defaults):
for old, new in (("startup", "connect_timeout"), ("request", "execute_timeout")):
if old in limits:
extension[new] = timeout(limits[old])
return extension
def remote_server(config, dialect, defaults):
mapping(config)
if dialect == "dsh":
require(config.get("transport") == "streamable-http", "Unsupported MCP transport.")
mapping(config, {"serverName", "transport", "url", "headers", "toolCallTimeoutMs", "failOnStartupError"})
else:
require(config.get("type") == "remote", "Unsupported MCP transport.")
mapping(config, {"type", "url", "headers", "oauth", "enabled" if dialect == "opencode-v1" else "disabled", "timeout"})
require(config.get("oauth") is False, "Set oauth:false explicitly; plugin OAuth and upstream auto-OAuth cannot be converted.")
extension = server_options(config, dialect, defaults)
url = config.get("url")
require(isinstance(url, str) and not re.search(r"[\s\\{}]", url), "MCP URL must be a literal endpoint without interpolation.")
try:
@@ -254,7 +260,69 @@ def remote_server(config, dialect, defaults):
"extensions": {"net.codewhale": extension}}, host
def mcp_config(path, dialect):
def stdio_server(config, dialect, defaults, name, root):
require(root is not None, "Local MCP needs an explicit --stdio-root SERVER=DIRECTORY containing its packaged Node source.")
if dialect == "dsh":
mapping(config, {"serverName", "transport", "command", "args", "env", "cwd", "toolCallTimeoutMs", "failOnStartupError"})
command, arguments = config.get("command"), config.get("args", [])
require(not mapping(config.get("env", {})), "DSH stdio env values and expressions require a manual native port.")
environment = {}
else:
allowed = {"type", "command", "environment", "timeout", "enabled" if dialect == "opencode-v1" else "disabled"}
if dialect == "opencode-v2":
allowed.add("cwd")
mapping(config, allowed)
argv = config.get("command")
require(isinstance(argv, list) and len(argv) == 2, "Local MCP command must be exactly [\"node\", \"relative-entry.mjs\"].")
command, arguments = argv[0], argv[1:]
environment = mapping(config.get("environment", {}))
require(command == "node" and isinstance(arguments, list) and len(arguments) == 1,
"Only node with one packaged .mjs entry is supported; no launcher flags, package managers or shell commands.")
entry = arguments[0]
require(isinstance(entry, str) and re.fullmatch(r"(?:\./)?[A-Za-z0-9_][A-Za-z0-9_./-]*\.mjs", entry)
and ".." not in entry.split("/"), "Node entry must be a contained relative .mjs file; other entry formats require a manual port.")
require(config.get("cwd", "") in ("", "."), "Select the original process working directory with --stdio-root; other cwd values require a manual port.")
require(plain_path(root / entry).is_file(), "Packaged Node entry does not exist.")
require(len(environment) <= 64, "At most 64 environment mappings are supported.")
env = {}
for key, value in environment.items():
require(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key) is not None
and key.upper() not in {"PLUGIN_ROOT", "PLUGIN_DATA", "NODE_OPTIONS", "NODE_PATH", "PATH",
"LD_PRELOAD", "LD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"},
"Invalid, reserved or loader-changing stdio environment name.")
reference = re.fullmatch(r"\{env:([A-Za-z_][A-Za-z0-9_]*)\}", value) if isinstance(value, str) else None
require(reference is not None, "Local MCP environment values must be exact OpenCode {env:NAME} references; literals are not copied.")
env[key] = "${" + reference[1] + "}"
return {"type": "stdio", "command": "node", "args": [entry], "cwd": f"mcp/{name}",
"env": env, "extensions": {"net.codewhale": server_options(config, dialect, defaults)}}
def stdio_files(root, name, max_files, max_bytes):
"""Copy an explicitly packaged directory, never resolve/install dependencies."""
def fail_walk(error):
raise error
files, visited, used_bytes = {}, 0, 0
for current, dirs, children in os.walk(root, followlinks=False, onerror=fail_walk):
for child in dirs + children:
visited += 1
require(visited <= MAX_FILES, "Packaged MCP source has too many filesystem entries.")
candidate = plain_path(Path(current) / child)
lower = candidate.name.lower()
require(not lower.startswith(".") and lower not in {"credentials", "credentials.json", "secrets.json", "id_rsa", "id_ed25519"}
and candidate.suffix.lower() not in {".pem", ".key", ".p12", ".pfx"},
"Package MCP source without hidden files, repository metadata or credential files; nothing was copied.")
if candidate.is_dir():
continue
require(len(files) < max_files, "Selected components exceed the file budget.")
content = read_file(candidate, max_bytes - used_bytes)
files[f"mcp/{name}/{candidate.relative_to(root).as_posix()}"] = content
used_bytes += len(content)
return files
def mcp_config(path, dialect, stdio_roots=None):
stdio_roots = stdio_roots or {}
document = data(text_file(path), json_only=dialect != "dsh")
ignored = 0
if dialect == "dsh":
@@ -285,14 +353,22 @@ def mcp_config(path, dialect):
entries = [(key, value, False) for key, value in servers.items()]
require(len(entries) <= 64, "At most 64 MCP servers can be converted at once.")
result, hosts = {}, set()
local_names = set()
for name, config, disabled in entries:
require(isinstance(name, str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,31}", name), "Invalid or missing MCP server name.")
require(name not in result, "Duplicate MCP server name; no entries were written.")
converted, host = remote_server(config, dialect, defaults)
mapping(config)
local = config.get("transport") == "stdio" if dialect == "dsh" else config.get("type") == "local"
if local:
converted = stdio_server(config, dialect, defaults, name, stdio_roots.get(name))
local_names.add(name)
else:
converted, host = remote_server(config, dialect, defaults)
hosts.add(host)
if disabled:
converted["extensions"]["net.codewhale"]["disabled"] = True
result[name] = converted
hosts.add(host)
require(set(stdio_roots) == local_names, "Every --stdio-root must name a selected local MCP server.")
return result, sorted(hosts), ignored
@@ -310,19 +386,40 @@ def convert(args):
require(name not in skill_names, "Duplicate skill name; no files were written.")
skill_names.add(name)
files.update(additions)
servers, hosts, ignored = mcp_config(args.config, args.format) if args.config else ({}, [], 0)
roots = {}
for specification in getattr(args, "stdio_root", []):
name, separator, directory = specification.partition("=")
require(separator and directory and name not in roots, "Use unique --stdio-root SERVER=DIRECTORY selections.")
source = plain_path(directory)
require(source.is_dir(), "The selected stdio root must be a directory.")
require(source != output and source not in output.parents, "Output must be outside the selected MCP source.")
roots[name] = source
require(not roots or args.config, "--stdio-root requires a selected MCP configuration.")
servers, hosts, ignored = mcp_config(args.config, args.format, roots) if args.config else ({}, [], 0)
for name, source in roots.items():
files.update(stdio_files(source, name, MAX_FILES - len(files), MAX_BYTES - sum(map(len, files.values()))))
require(files or servers, "No portable components selected. Use --skill and/or --config.")
manifest = {"$schema": "https://agent-plugins.org/schemas/plugin.json", "name": args.name}
extension = {}
if hosts:
manifest["extensions"] = {"net.codewhale": {"capabilities": {"network_hosts": hosts}}}
extension["capabilities"] = {"network_hosts": hosts}
if roots:
extension["when"] = {"binaries": ["node"]}
if extension:
manifest["extensions"] = {"net.codewhale": extension}
files["plugin.json"] = (json.dumps(manifest, indent=2) + "\n").encode()
if servers:
files["mcp.json"] = (json.dumps({"mcpServers": servers}, indent=2) + "\n").encode()
files["CONVERSION.md"] = (f"# Conversion receipt\n\nSource dialect: {args.format}.\n"
f"Converted {len(skill_names)} selected Skills and {len(servers)} remote MCP declarations.\n"
f"Converted {len(skill_names)} selected Skills, {len(servers) - len(roots)} remote and {len(roots)} local MCP declarations.\n"
f"Ignored {ignored} unrelated top-level application settings.\n\n"
"No source code, package manager, install hook, network request or credential lookup ran.\n"
"Companion skill files were copied as data; review them before loading a skill.\n"
"Selected Node source, dependencies and resources were copied as data into mcp/<server>.\n"
"Each --stdio-root is the original process working directory; the staged copy becomes its cwd.\n"
"Review all copied files and environment names. Node runs only through the native trusted MCP lifecycle.\n"
"Local MCP runs with host-user process authority, not an OS sandbox; stdio does not confine its network or files.\n"
"Mutable workspace state, writable package resources and external module dependencies require a manual port.\n"
"This output is not installed, trusted or enabled. Run `/plugin install <directory>`,\n"
"then `/plugin validate <name>` and review the exact trust token before enabling.\n"
"Remote MCP output uses Streamable HTTP only; OpenCode's legacy SSE fallback is not reproduced.\n"
@@ -356,6 +453,8 @@ def main():
parser.add_argument("--format", choices=("opencode-v1", "opencode-v2", "dsh"), required=True)
parser.add_argument("--config", type=Path, help="OpenCode JSON or static DSH Cordis YAML/JSON (optional)")
parser.add_argument("--skill", type=Path, action="append", default=[], help="Explicit skill directory or Markdown file; repeatable")
parser.add_argument("--stdio-root", action="append", default=[], metavar="SERVER=DIRECTORY",
help="Explicit packaged Node MCP working directory; repeat for each selected local server")
parser.add_argument("--name", required=True, help="Name for the new native bundle")
parser.add_argument("--output", type=Path, required=True, help="Fresh directory whose parent already exists")
args = parser.parse_args()
@@ -365,7 +464,7 @@ def main():
message = str(error) if isinstance(error, ConversionError) else "File operation failed; source and output must be accessible regular paths."
print(f"Conversion refused: {message}", file=sys.stderr)
return 1
print(f"Prepared {skills} Skills and {servers} remote MCP declarations; {ignored} unrelated settings omitted.")
print(f"Prepared {skills} Skills and {servers} MCP declarations; {ignored} unrelated settings omitted.")
print("Review CONVERSION.md, then use the existing /plugin install, validate, trust and enable commands.")
return 0
+154 -3
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env python3
"""Offline conversion contracts; no foreign code or MCP server is executed."""
"""Offline conversion contracts; only the synthetic Node fixture is executed."""
import argparse
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
@@ -64,9 +65,9 @@ class ConversionTests(unittest.TestCase):
(path / "SKILL.md").write_text(text, encoding="utf-8")
return path
def args(self, *, config=None, skills=(), dialect="opencode-v1", output=None, name="converted-demo"):
def args(self, *, config=None, skills=(), dialect="opencode-v1", output=None, name="converted-demo", stdio_roots=()):
return argparse.Namespace(config=config, skill=list(skills), format=dialect,
output=output or self.fresh("output"), name=name)
output=output or self.fresh("output"), name=name, stdio_root=list(stdio_roots))
def cli(self, args, *, env=None):
command = [sys.executable, "-B", str(SCRIPT), "--format", args.format,
@@ -75,6 +76,8 @@ class ConversionTests(unittest.TestCase):
command += ["--config", str(args.config)]
for skill in args.skill:
command += ["--skill", str(skill)]
for root in args.stdio_root:
command += ["--stdio-root", root]
environment = dict(os.environ)
environment.update(env or {})
environment["PYTHONDONTWRITEBYTECODE"] = "1"
@@ -135,6 +138,154 @@ class ConversionTests(unittest.TestCase):
"type": "streamable-http", "url": "https://docs.example.invalid/mcp",
"extensions": {"net.codewhale": {"disabled": True, "execute_timeout": 19}}})
def node_source(self):
root = self.fresh("packaged-node")
root.mkdir()
(root / "server.mjs").write_text("throw new Error('converter must never execute this');\n")
return root
def local(self, **changes):
return {"type": "local", "command": ["node", "server.mjs"], **changes}
def test_cli_local_node_copies_source_without_execution_or_credential_lookup(self):
root = self.node_source()
(root / "resource.json").write_text('{"answer":42}')
args = self.args(config=self.config({"mcp": {"localdocs": self.local(
enabled=False, timeout=7000, environment={"API_TOKEN": "{env:CONVERSION_TEST_TOKEN}"})}}),
stdio_roots=[f"localdocs={root}"])
result = self.cli(args, env={"CONVERSION_TEST_TOKEN": CANARY})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.servers(args.output)["localdocs"], {
"type": "stdio", "command": "node", "args": ["server.mjs"], "cwd": "mcp/localdocs",
"env": {"API_TOKEN": "${CONVERSION_TEST_TOKEN}"},
"extensions": {"net.codewhale": {"disabled": True, "connect_timeout": 7, "execute_timeout": 7}}})
self.assertEqual((args.output / "mcp/localdocs/server.mjs").read_bytes(), (root / "server.mjs").read_bytes())
self.assertEqual((args.output / "mcp/localdocs/resource.json").read_bytes(), (root / "resource.json").read_bytes())
extension = json.loads((args.output / "plugin.json").read_text())["extensions"]["net.codewhale"]
self.assertEqual(extension, {"when": {"binaries": ["node"]}})
self.assert_no_canary(result, args.output)
def test_local_node_v2_and_dsh_preserve_disable_cwd_and_timeouts(self):
root = self.node_source()
cases = [("opencode-v2", {"mcp": {"timeout": {"startup": 4000, "request": 11000}, "servers": {
"docs": self.local(cwd=".", disabled=True, timeout={"request": 23000})}}},
{"disabled": True, "connect_timeout": 4, "execute_timeout": 23}),
("dsh", [{"name": "@deepseek-ai/dsh-mcp-client", "disabled": True, "config": {
"serverName": "docs", "transport": "stdio", "command": "node", "args": ["server.mjs"],
"cwd": ".", "env": {}, "toolCallTimeoutMs": 19000}}],
{"disabled": True, "execute_timeout": 19})]
for dialect, document, extension in cases:
with self.subTest(dialect=dialect):
args = self.args(config=self.config(document), dialect=dialect, stdio_roots=[f"docs={root}"])
self.assertEqual(converter.convert(args), (0, 1, 0))
server = self.servers(args.output)["docs"]
self.assertEqual(server["cwd"], "mcp/docs")
self.assertEqual(server["extensions"]["net.codewhale"], extension)
@unittest.skipUnless(shutil.which("node"), "Node is needed for the synthetic MCP fixture")
def test_packaged_node_mcp_discovers_and_calls_tool_with_sibling_and_cwd_resource(self):
root = self.node_source()
(root / "resource.json").write_text('{"answer":42}')
(root / "sibling.mjs").write_text("export const name = 'fixture_answer';\n")
(root / "server.mjs").write_text('''import readline from 'node:readline';
import { readFileSync } from 'node:fs';
import { name } from './sibling.mjs';
for await (const line of readline.createInterface({ input: process.stdin })) {
const request = JSON.parse(line);
if (request.id === undefined) continue;
const result = request.method === 'initialize'
? { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'fixture', version: '1' } }
: request.method === 'tools/list'
? { tools: [{ name, description: 'Read the packaged answer', inputSchema: { type: 'object' } }] }
: { content: [{ type: 'text', text: String(JSON.parse(readFileSync('resource.json', 'utf8')).answer) }] };
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n');
}
''')
args = self.args(config=self.config({"mcp": {"docs": self.local()}}), stdio_roots=[f"docs={root}"])
converter.convert(args)
# Mutating original resources cannot change the converted package.
(root / "resource.json").write_text('{"answer":99}')
server = self.servers(args.output)["docs"]
requests = [
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
{"jsonrpc": "2.0", "method": "notifications/initialized"},
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "fixture_answer", "arguments": {}}},
]
result = subprocess.run([shutil.which("node"), *server["args"]], cwd=args.output / server["cwd"],
input="".join(json.dumps(request) + "\n" for request in requests), text=True, capture_output=True,
timeout=10, check=False, env={"PATH": str(Path(shutil.which("node")).parent)})
self.assertEqual(result.returncode, 0, result.stderr)
responses = [json.loads(line) for line in result.stdout.splitlines()]
self.assertEqual(responses[1]["result"]["tools"][0]["name"], "fixture_answer")
self.assertEqual(responses[2]["result"]["content"], [{"type": "text", "text": "42"}])
def test_local_node_requires_matching_explicit_roots_and_safe_launcher(self):
root = self.node_source()
for command in (["node", "../server.mjs"], ["node", "/server.mjs"], ["node", "C:\\server.mjs"],
["node", "server.js"], ["node", "server.cjs"],
["node", "--eval", "process.exit()"], ["node", "server.mjs", CANARY],
["npx", "some-server"], ["sh", "server.mjs"], ["node", "https://example.invalid/server.mjs"],
["node", "{env:ENTRY}.mjs"], ["node", 1]):
with self.subTest(command=command):
self.refuse(self.args(config=self.config({"mcp": {"docs": self.local(command=command)}}),
stdio_roots=[f"docs={root}"]))
config = self.config({"mcp": {"docs": self.local()}})
for roots in ([], [f"other={root}"], [f"docs={root}", f"docs={root}"], [f"docs={root}", f"other={root}"]):
self.refuse(self.args(config=config, stdio_roots=roots))
self.refuse(self.args(skills=[self.skill()], stdio_roots=[f"docs={root}"]))
self.refuse(self.args(config=self.config(self.v1()), stdio_roots=[f"docs={root}"]))
self.refuse(self.args(config=config, stdio_roots=[f"docs={root}"], output=root / "output"))
def test_local_node_rejects_literal_environment_loader_overrides_and_nonportable_cwd(self):
root = self.node_source()
for environment in ({"TOKEN": CANARY}, {"TOKEN": "{file:/private/key}"}, {"PLUGIN_ROOT": "{env:TOKEN}"},
{"node_options": "{env:OPTIONS}"}, {"NODE_PATH": "{env:IMPORTS}"}, {"PATH": "{env:PATH}"},
{"DYLD_INSERT_LIBRARIES": "{env:LIBRARY}"}, {"LD_PRELOAD": "{env:LIBRARY}"}):
args = self.args(config=self.config({"mcp": {"docs": self.local(environment=environment)}}),
stdio_roots=[f"docs={root}"])
result = self.cli(args)
self.assertEqual(result.returncode, 1)
self.assertFalse(args.output.exists())
self.assert_no_canary(result, args.output)
for cwd in ("..", "./workspace", "/workspace", ["."]):
self.refuse(self.args(config=self.config({"mcp": {"servers": {"docs": self.local(cwd=cwd)}}}),
dialect="opencode-v2", stdio_roots=[f"docs={root}"]))
for extra in ({"env": {"TOKEN": "{env:TOKEN}"}}, {"failOnStartupError": True}, {"reconnect": {}}):
config = {"serverName": "docs", "transport": "stdio", "command": "node", "args": ["server.mjs"], **extra}
self.refuse(self.args(config=self.config([{"name": "@deepseek-ai/dsh-mcp-client", "config": config}]),
dialect="dsh", stdio_roots=[f"docs={root}"]))
def test_local_node_refuses_secret_and_linked_dependencies_atomically(self):
for name in (".env.local", ".gitignore", ".npmrc", "credentials.json", "server.key", "identity.pem"):
root = self.node_source()
(root / name).write_text(CANARY)
args = self.args(config=self.config({"mcp": {"docs": self.local()}}), stdio_roots=[f"docs={root}"])
result = self.cli(args)
self.assertEqual(result.returncode, 1)
self.assert_no_canary(result, args.output)
self.assertFalse(args.output.exists())
for kind in ("symlink", "hardlink", "directory-link"):
root = self.node_source()
outside = self.write("outside", ".mjs")
if kind == "hardlink":
os.link(outside, root / "dependency.mjs")
elif kind == "directory-link":
(root / "node_modules").symlink_to(self.root, target_is_directory=True)
else:
(root / "dependency.mjs").symlink_to(outside)
self.refuse(self.args(config=self.config({"mcp": {"docs": self.local()}}), stdio_roots=[f"docs={root}"]))
self.assertEqual(outside.read_text(), "outside")
def test_local_node_shares_aggregate_copy_budget_with_skills_and_servers(self):
root = self.node_source()
(root / "resource.bin").write_bytes(b"a" * 2300)
config = self.config({"mcp": {"docs": self.local(), "second": self.local()}})
with mock.patch.object(converter, "MAX_BYTES", 4000):
self.refuse(self.args(config=config, stdio_roots=[f"docs={root}", f"second={root}"]))
with mock.patch.object(converter, "MAX_FILES", 4):
self.refuse(self.args(config=config, stdio_roots=[f"docs={root}", f"second={root}"]))
def test_skill_keeps_explicit_invocation_and_metadata_out_of_frontmatter(self):
extra = {"name": "wrong-name", "invocation": "automatic", "description": "wrong description"}
skill = self.skill(metadata={"disable-model-invocation": True, "metadata": extra,