mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
f88cda0200
commit
623d380966
4 files changed
+381
-26
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user