From 94f6fd1306e4eb7da1a22d7e2b68b61871505504 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 18 Aug 2026 13:50:53 +0800 Subject: [PATCH] Support MCP in packaged Python runtime --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 + ...-executable-sdk-runtime-distribution.zh.md | 2 + .../workflows/build-exe-for-python-sdk.yml | 3 + .github/workflows/python-release.yml | 1 + .gitlab-ci.yml | 3 +- pnpm-lock.yaml | 3 + python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 + python/sdk-runtime/README.zh.md | 2 + python/sdk-runtime/package.json | 1 + python/sdk/tests/test_smoke_model.py | 41 ++++ scripts/smoke-python-runtime.py | 191 +++++++++++++++++- 13 files changed, 253 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 37e39c7dbf..7594f17605 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 3715aa5b1148eb97faa04d5ac5778cceb47b97fc -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: a9632deb847d0aa24f376edb017bd70c4ff81302 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: a09c1438d61c3ad6b92d694360a1591764f3d67e +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ce6568d7303433a5a4ca57ac76f67051944ed997 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 3715aa5b11..a09c1438d6 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -38,6 +38,8 @@ Inside the exe's VFS sits a **real package tree in build-artifact form** (each p The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `apps/cli/config/agent-presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers or extend the bridge to MCP Resources and Prompts. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. + ### Build pipeline and artifacts [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index a9632deb84..ce6568d730 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -38,6 +38,8 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都显式列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server,也不将桥接范围扩展到 MCP Resources 和 Prompts。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 + ### 构建流水线与产物 [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 8c6569fa07..5fc6de459d 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -272,6 +272,8 @@ jobs: deepseek-harness-sdk=="$VERSION" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ --scenario sdk-default + "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ + --scenario sdk-mcp - name: Check Linux GLIBC requirements if: runner.os == 'Linux' @@ -309,6 +311,7 @@ jobs: /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness-sdk=="$VERSION" /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default + /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-mcp ' - uses: actions/upload-artifact@v7 diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 4a2f05b10b..a395a8e8bd 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -72,6 +72,7 @@ jobs: run: | python -m pip install --find-links dist "deepseek-harness-sdk==${{ steps.compatibility-version.outputs.version }}" python scripts/smoke-python-runtime.py --scenario sdk-default + python scripts/smoke-python-runtime.py --scenario sdk-mcp validate: name: Validate release candidate diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 87c4e8dccb..cf5a093f69 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -45,6 +45,7 @@ sdk-wheel: - python -m venv .wheel-smoke - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness-sdk=="$DSH_WHEEL_VERSION" - .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-default + - .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-mcp - | if [ "${PLATFORM#linux-}" != "$PLATFORM" ]; then readelf --version-info "$EXE" > glibc-versions.txt @@ -56,7 +57,7 @@ sdk-wheel: linux-arm64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; *) echo "Unsupported Linux platform $PLATFORM"; exit 1 ;; esac - docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_WHEEL_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" + docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_WHEEL_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-mcp" fi - | if [ "$PLATFORM" = macos-arm64 ]; then diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f36dbcf78..da4fb969dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8460,6 +8460,9 @@ importers: '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../packages/llm/llm-retry + '@deepseek-ai/dsh-mcp-client': + specifier: workspace:^ + version: link:../../packages/mcp/mcp-client '@deepseek-ai/dsh-output-retention': specifier: workspace:^ version: link:../../packages/util/output-retention diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 1d4b9fd739..d04296dd33 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md -README.md: 592ce091f3b6c4bc151dfdee9d313b7c970c95de -README.zh.md: 0a29281a39c885bc3c40e017bdc9475e0065605f +README.md: c00357dbad74f8de705789bce8e4a55cc5fd67b1 +README.zh.md: a62edb4cd98338d22332f8e124c3ecb3f2356ace diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 592ce091f3..c00357dbad 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -13,6 +13,8 @@ Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injecte Both carriers hold the same content, defined once: the [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. +The bundled plugin set includes `@deepseek-ai/dsh-mcp-client`, so an external Cordis config can connect to stdio or Streamable HTTP MCP servers and expose their tools to the model. The wheel does not bundle MCP server programs or credentials: a stdio config supplies its executable and arguments, while a Streamable HTTP config supplies its URL and headers. The bridge supports MCP tools; MCP Resources and Prompts remain unsupported. + A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. Each wheel contains exactly one runtime executable. The macOS wheel also contains its matching native spawn helper; a missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_14_0_arm64`; the macOS tag conservatively matches the bundled Node 24 executable's macOS 13.5 deployment target. This package's `platforms.json` owns the fixed tag and executable-name pairs used by both the repository release builder and the isolated build hook. The build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-v` release tag must match it. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 0a29281a39..a62edb4cd9 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -13,6 +13,8 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 +内置插件集合包含 `@deepseek-ai/dsh-mcp-client`,因此外部 Cordis 配置可以连接 stdio 或 Streamable HTTP MCP server,并向模型提供这些 server 的工具。wheel 包不包含 MCP server 程序或凭据:stdio 配置需要提供可执行程序及其参数,Streamable HTTP 配置需要提供 URL 和请求头。该桥接仅支持 MCP 工具,尚不支持 MCP Resources 与 Prompts。 + exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 node 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 每个 wheel 包只包含一个运行时可执行文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少伴随文件意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在运行时文件、存在多个运行时文件、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v` 发布标签必须与其匹配。 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 083a8a87d7..cd43257014 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", diff --git a/python/sdk/tests/test_smoke_model.py b/python/sdk/tests/test_smoke_model.py index d4be72032d..c0e6b9f70e 100644 --- a/python/sdk/tests/test_smoke_model.py +++ b/python/sdk/tests/test_smoke_model.py @@ -30,3 +30,44 @@ def test_child_prompt_precedes_runtime_context(prompt_name: str, expected: str) for chunk in chunks for choice in chunk.get("choices", []) ) + + +def test_mcp_smoke_requests_the_discovered_tool() -> None: + chunks = SMOKE["completion_chunks"]({ + "messages": [{"role": "user", "content": SMOKE["MCP_PROMPT"]}], + "tools": [{"type": "function", "function": {"name": "mcp__fixture__add"}}], + }) + + calls = [ + call + for chunk in chunks + for choice in chunk.get("choices", []) + for call in choice.get("delta", {}).get("tool_calls", []) + ] + assert calls[0]["function"] == { + "name": "mcp__fixture__add", + "arguments": '{"a": 19, "b": 23}', + } + + +def test_mcp_smoke_accepts_the_external_server_result() -> None: + chunks = SMOKE["completion_chunks"]({ + "messages": [ + {"role": "user", "content": SMOKE["MCP_PROMPT"]}, + { + "role": "assistant", + "tool_calls": [{ + "id": "mcp-add", + "type": "function", + "function": {"name": "mcp__fixture__add", "arguments": '{}'}, + }], + }, + {"role": "tool", "tool_call_id": "mcp-add", "content": "42"}, + ], + }) + + assert any( + choice.get("delta", {}).get("content") == SMOKE["MCP_TEXT"] + for chunk in chunks + for choice in chunk.get("choices", []) + ) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index b517006475..76f3e4f60a 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -9,6 +9,7 @@ import json import os import queue import subprocess +import sys import tempfile import threading import time @@ -32,6 +33,8 @@ MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant." FS_SEARCH_PROMPT = "Exercise the packaged filesystem search tools." FS_SEARCH_TEXT = "filesystem search smoke ok" FS_SEARCH_MARKER = "PACKAGED_FS_SEARCH_OK" +MCP_PROMPT = "Exercise the packaged MCP client with one external stdio server." +MCP_TEXT = "MCP client smoke ok" MINIMAL_CORDIS = ( Path(__file__).resolve().parent.parent / "examples" / "jsonrpc-agent" / "minimal.cordis.yml" ) @@ -143,6 +146,130 @@ FS_SEARCH_CORDIS = """\ config: sampleOverCapGlobResults: false """ +MCP_SERVER_SCRIPT = """\ +import json +import os +import sys + + +log_path = os.environ.get("MCP_SMOKE_LOG") + + +def send(message): + sys.stdout.write(json.dumps(message, separators=(",", ":")) + "\\n") + sys.stdout.flush() + + +for line in sys.stdin: + request = json.loads(line) + if log_path is not None: + with open(log_path, "a", encoding="utf-8") as log: + log.write(str(request.get("method")) + "\\n") + request_id = request.get("id") + if request_id is None: + continue + method = request.get("method") + if method == "initialize": + send({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": request["params"]["protocolVersion"], + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "python-wheel-fixture", "version": "1.0.0"}, + }, + }) + elif method == "tools/list": + send({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "tools": [{ + "name": "add", + "description": "Add two numbers.", + "inputSchema": { + "type": "object", + "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, + "required": ["a", "b"], + "additionalProperties": False, + }, + }], + }, + }) + elif method == "tools/call": + params = request["params"] + if params.get("name") != "add" or params.get("arguments") != {"a": 19, "b": 23}: + send({ + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "unexpected tool call"}, + }) + continue + send({ + "jsonrpc": "2.0", + "id": request_id, + "result": {"content": [{"type": "text", "text": "42"}]}, + }) + else: + send({ + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"unsupported method: {method}"}, + }) +""" + + +def mcp_cordis(server_script: Path) -> str: + """Build an external config that mounts the packaged MCP client.""" + return json.dumps([ + { + "id": "sdk-jsonrpc-server", + "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", + }, + { + "id": "agent-core", + "name": "@deepseek-ai/dsh-agent-spine-demo", + "config": { + "workspaceContext": False, + "skills": {"enabled": False}, + "toolBash": False, + }, + }, + { + "id": "sessions", + "name": "@deepseek-ai/dsh-session-persistence-jsonl", + "config": {"root": "./sessions", "compression": "none"}, + }, + { + "id": "mcp-fixture", + "name": "@deepseek-ai/dsh-mcp-client", + "config": { + "serverName": "fixture", + "transport": "stdio", + "command": sys.executable, + "args": [str(server_script)], + "env": {"MCP_SMOKE_LOG": str(server_script.with_suffix(".log"))}, + "failOnStartupError": True, + "reconnect": {"enabled": False}, + }, + }, + ], indent=2) + + +def wait_for_mcp_discovery(log_path: Path) -> None: + """Wait until the external server has answered initial tool discovery.""" + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if log_path.exists() and "tools/list" in log_path.read_text().splitlines(): + # The server records the request before flushing its response; give the + # client one scheduler interval to register the returned generation. + time.sleep(0.1) + return + time.sleep(0.025) + observed = log_path.read_text() if log_path.exists() else "" + raise AssertionError(f"packaged MCP client did not complete tool discovery: {observed}") + + class MockModelHandler(BaseHTTPRequestHandler): """Return deterministic text, worker, and orchestration completions.""" @@ -177,6 +304,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if latest.get("role") == "tool": call_id, tool_name = latest_tool_call(messages) tool_text = message_text(latest.get("content")) + mcp = mcp_tool_followup(call_id, tool_name, tool_text) + if mcp is not None: + return mcp fs_search = fs_search_tool_followup(call_id, tool_name, tool_text) if fs_search is not None: return fs_search @@ -222,6 +352,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: CODE_PROMPT, WORKFLOW_PROMPT, FS_SEARCH_PROMPT, + MCP_PROMPT, } prompt = next( (candidate for candidate in user_prompts if candidate in scenario_prompts), @@ -271,9 +402,29 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: "grep", {"pattern": FS_SEARCH_MARKER, "path": "."}, ) + if prompt == MCP_PROMPT: + assert_advertised_tool(body, "mcp__fixture__add") + return tool_call_chunks( + "mcp-add", + "mcp__fixture__add", + {"a": 19, "b": 23}, + ) return text_chunks(EXPECTED_TEXT) +def mcp_tool_followup( + call_id: str, + tool_name: str, + tool_text: str, +) -> list[dict[str, object]] | None: + """Verify one tool call through the packaged MCP client.""" + if call_id != "mcp-add": + return None + if tool_name != "mcp__fixture__add" or "42" not in tool_text: + raise AssertionError(f"packaged MCP call returned an unexpected result: {tool_name}: {tool_text}") + return text_chunks(MCP_TEXT) + + def fs_search_tool_followup( call_id: str, tool_name: str, @@ -538,7 +689,7 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-mcp", "sdk-snapshot", "direct"), default="all", ) parser.add_argument("--exe", type=Path) @@ -563,6 +714,8 @@ def main() -> None: if args.scenario in {"all", "sdk-fs-search"}: assert args.exe is not None smoke_sdk_fs_search(model.url, args.exe.resolve()) + if args.scenario in {"all", "sdk-mcp"}: + smoke_sdk_mcp(model.url, None if args.exe is None else args.exe.resolve()) if args.scenario in {"all", "sdk-snapshot"}: assert args.exe is not None smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots) @@ -686,6 +839,42 @@ def smoke_sdk_fs_search(base_url: str, executable: Path) -> None: assert_session_log(sessions, root, FS_SEARCH_TEXT, FS_SEARCH_MARKER, "needle.txt") +def smoke_sdk_mcp(base_url: str, executable: Path | None) -> None: + """Discover and call an external stdio MCP tool through the packaged client.""" + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-mcp-") as temporary: + root = Path(temporary).resolve() + sessions = root / "sessions" + server_script = root / "mcp_server.py" + server_script.write_text(MCP_SERVER_SCRIPT) + cordis = root / "cordis.yml" + cordis.write_text(mcp_cordis(server_script)) + discovery_log = server_script.with_suffix(".log") + with DeepSeekHarness( + provider="deepseek-official", + model="smoke-model", + cwd=str(root), + session_root=str(sessions), + cordis=str(cordis), + runtime_bin=None if executable is None else str(executable), + api_key="sk-keyless-smoke", + base_url=base_url, + request_timeout_seconds=60, + ) as harness: + wait_for_mcp_discovery(discovery_log) + result = harness.run(MCP_PROMPT, session_id="mcp-smoke") + + assert result.final_response == MCP_TEXT, result.final_response + assert discovery_log.read_text().splitlines() == [ + "initialize", + "notifications/initialized", + "tools/list", + "tools/call", + ] + assert_session_log(sessions, root, MCP_TEXT, "mcp__fixture__add", "42") + + def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: """Drive and compare the advanced SDK/executable behavioral snapshot.""" from deepseek_harness import DeepSeekHarness