Merge remote-tracking branch 'origin/master' into codex/rfc-subagent-background-tasks
# Conflicts: # docs/config-catalog.md # docs/module-graph.md # examples/coding-agent/README.md # examples/coding-agent/cordis.yml # packages/README.md # packages/examples/acp-demo/README.md # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/package.json # packages/examples/agent-spine-demo/src/index.ts # packages/examples/stdio-demo/README.md # pnpm-lock.yaml # scripts/doc-budgets.manifest.json # scripts/verify-package-readme-model-experience.ts
This commit is contained in:
@@ -28,7 +28,8 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/ACP/JSON-RPC bins) the leaves load | Support — example infra |
|
||||
| [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
|
||||
|
||||
@@ -38,6 +39,6 @@ Groups distinguish product API from support infrastructure. New packages join an
|
||||
|
||||
The inter-package dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
|
||||
|
||||
**Extension plugins depend on interfaces, never the concrete loop.** Composition bundles such as `dsh-agent-core` intentionally assemble that loop. Swappable capabilities split into interface, implementation, and consumer packages; see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md).
|
||||
The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-spine-demo`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
|
||||
Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# context/ — optional request context
|
||||
|
||||
Opt-in plugins that add bounded model-visible request context without defining a tool or service. The default `dsh-agent-core` bundle excludes them.
|
||||
Opt-in plugins that add bounded model-visible request context without defining a tool or service. The default `dsh-agent-spine-demo` bundle excludes them.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-time-context
|
||||
|
||||
Opt-in dynamic system-prompt context with the current zoned time and elapsed time since the latest model-visible message before the turn. `dsh-agent-core` and shipped examples do not mount it. Decision record: [the time-context RFC](../../../docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md).
|
||||
Opt-in dynamic system-prompt context with the current zoned time and elapsed time since the latest model-visible message before the turn. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the time-context RFC](../../../docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md).
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
name: '@deepseek-ai/dsh-time-context'
|
||||
|
||||
- id: stdio-agent
|
||||
name: '@deepseek-ai/dsh-stdio-agent'
|
||||
name: '@deepseek-ai/dsh-stdio-demo'
|
||||
config:
|
||||
model: mock-echo
|
||||
persona: 'Test the time-context plugin.'
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { foldRequestHeader, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const binScript = fileURLToPath(new URL('../../../ui/stdio-agent/src/bin.ts', import.meta.url))
|
||||
const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url))
|
||||
const configPath = fileURLToPath(new URL('./fixtures/cordis.yml', import.meta.url))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
|
||||
@@ -10,10 +10,9 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co
|
||||
| `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` |
|
||||
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
|
||||
| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
|
||||
| `agent-core/` | Bundle plugin: the default executor-less/UI-less spine as code | (loads the spine) |
|
||||
|
||||
`scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle.
|
||||
|
||||
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable.
|
||||
|
||||
`agent-core` is the composition counterpart: one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds the swappable backends plus any optional product tools it wants to expose. It lives in `core/` because it composes the shared control spine while leaving executors, LLM adapters, alternate skill providers, and UI front doors outside the bundle.
|
||||
The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door.
|
||||
|
||||
20
packages/examples/README.md
Normal file
20
packages/examples/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# examples/ — ready-to-run demo bundles
|
||||
|
||||
Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling the spine and a front door by hand. These are **demo / reference** packages — the `-demo` npm suffix marks each one as non-product surface, readable straight off the package name. The runnable leaves under the repo-root [`examples/`](../../examples/AGENTS.md) and the [Python SDK runtime](../../python/sdk-runtime/README.md) are the consumers; each is just its swappable backends plus one bundle entry.
|
||||
|
||||
| Package | npm name | Role |
|
||||
|---|---|---|
|
||||
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + `tool-skill` + `agent-loop`) |
|
||||
| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal stdio chat app: the spine + console logger + readline UI + a pre-created `main` agent, with a boot `bin` |
|
||||
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
|
||||
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
|
||||
|
||||
`agent-spine-demo` is the shared bundle; `stdio-demo` and `acp-demo` compose it with opposite front-door clusters (console logger + readline UI vs the stdout-owning ACP bridge) and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
|
||||
|
||||
These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), the bridges/channels/boot-glue in [`ui/`](../ui/README.md), and the swappable backends (LLM adapter, bash executor) in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely.
|
||||
|
||||
Do not confuse this group with the repo-root [`examples/`](../../examples/AGENTS.md): that directory holds the runnable `cordis.yml` **leaves**; this group holds the **bundles** those leaves load.
|
||||
|
||||
## The jsonrpc bin/exe names are legacy
|
||||
|
||||
`jsonrpc-demo` renamed like its siblings, but its bin is still `dsh-jsonrpc-agent` and the single-file executable is still `dsh-jsonrpc-agent-pkg` (referenced across the [Python distribution](../../python/sdk-runtime/README.md)). Those names are the SDK's runtime-startup surface; they are reconciled when the SDK unifies that startup flow, not by this move.
|
||||
@@ -1,8 +1,8 @@
|
||||
# @deepseek-ai/dsh-acp-agent
|
||||
# @deepseek-ai/dsh-acp-demo
|
||||
|
||||
The **ACP server app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster an [Agent Client Protocol](../acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio.
|
||||
The **ACP server app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with the front-door cluster an [Agent Client Protocol](../../ui/acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio.
|
||||
|
||||
It is the structured counterpart to [`@deepseek-ai/dsh-stdio-agent`](../stdio-agent/README.md): both consume the same spine, but this one bakes in the OPPOSITE front-door cluster.
|
||||
It is the structured counterpart to [`@deepseek-ai/dsh-stdio-demo`](../stdio-demo/README.md): both consume the same spine, but this one bakes in the OPPOSITE front-door cluster.
|
||||
|
||||
## What it bakes in — and what it deliberately omits
|
||||
|
||||
@@ -10,13 +10,13 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
|
||||
|
||||
| Plugin | Why |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) |
|
||||
| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) |
|
||||
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
|
||||
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool |
|
||||
| ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately |
|
||||
| ~~`@deepseek-ai/dsh-user-approval`~~ | **omitted by default** — permission policy is deployment-specific; sandbox/approval leaves opt in and the ACP bridge then supplies the answerer |
|
||||
| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../acp/README.md)) |
|
||||
| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../../ui/acp/README.md)) |
|
||||
| ~~`hmr`~~ | **omitted** — the editor owns the subprocess |
|
||||
|
||||
Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends, so the common mistake — copying a console-logger entry from the stdio config — has no place here. (A leaf author technically *can* still add `@cordisjs/plugin-logger-console` as a sibling entry; the package can't forbid that. So the rule stands: never add a stdout logger to an ACP leaf — stdout is the JSON-RPC channel. Use a stderr exporter if you need logs.)
|
||||
@@ -28,17 +28,17 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
|
||||
| `model` | (required) | the per-session agent template the bridge creates agents from |
|
||||
| `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
|
||||
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
|
||||
| `tools` | `{ mode: 'native' }` | tool-registry presentation config through `agent-core` |
|
||||
| `skills` | owner defaults | skill registry, local provider, and model-facing skill-tool config through `agent-core` |
|
||||
| `toolBash` | owner defaults | model-facing bash config through `agent-core`, including bash's producer-local `enableRunInBackground` |
|
||||
| `toolTasks` | owner defaults | generic `task_output` wait bounds through `agent-core` |
|
||||
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
|
||||
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
|
||||
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
|
||||
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
|
||||
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||
|
||||
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor.
|
||||
|
||||
## The bin
|
||||
|
||||
`dsh-acp-agent [--config path-to-cordis.yml]` (short form `-c`; default `./cordis.yml`):
|
||||
`dsh-acp-demo [--config path-to-cordis.yml]` (short form `-c`; default `./cordis.yml`):
|
||||
|
||||
- loads a gitignored `.env` from the cwd — **skipped** in snapshot REPLAY so a stray key can never trigger a live call;
|
||||
- honors `DSH_SNAPSHOT=replay` by booting the sibling `cordis.snapshot.yml` (the keyless replay tree, `llm-replay` in place of `llm-deepseek`);
|
||||
@@ -50,7 +50,7 @@ All diagnostics go to **stderr** — stdout is the protocol.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-agent-core` and `dsh-acp`, which compose each ACP agent's prompt, tools, and message history; this app bundle adds no model-bound content itself.
|
||||
Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, tools, and message history; this app bundle adds no model-bound content itself.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-acp-agent",
|
||||
"description": "ACP server app: the agent-core spine + JSONL persistence + the ACP bridge (no stdout logger, no hmr, no pre-created agents), with a bin to boot a leaf cordis.yml over JSON-RPC stdio",
|
||||
"name": "@deepseek-ai/dsh-acp-demo",
|
||||
"description": "ACP server app: the agent-spine-demo bundle + JSONL persistence + the ACP bridge (no stdout logger, no hmr, no pre-created agents), with a bin to boot a leaf cordis.yml over JSON-RPC stdio",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"bin": {
|
||||
"dsh-acp-agent": "lib/bin.js"
|
||||
"dsh-acp-demo": "lib/bin.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -34,7 +34,7 @@
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-app-boot": "^0.0.1",
|
||||
"@deepseek-ai/dsh-acp": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-core": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
@@ -47,7 +47,7 @@
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-acp": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-core": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
@@ -1,19 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Boot an ACP stdio server from `cordis.yml`; usage is
|
||||
* `dsh-acp-agent [--config path]`, defaulting to `./cordis.yml`. Shared env
|
||||
* `dsh-acp-demo [--config path]`, defaulting to `./cordis.yml`. Shared env
|
||||
* loading, Loader guards, snapshot config selection, and settled-tree boot live
|
||||
* in dsh-app-boot. Replay skips `.env` and selects sibling
|
||||
* `cordis.snapshot.yml` so a stray key cannot trigger a model call. EOF disposes
|
||||
* and flushes snapshot runs; editors normally own process lifetime. Stdout is
|
||||
* reserved for JSON-RPC, so diagnostics go only to stderr.
|
||||
* @module @deepseek-ai/dsh-acp-agent/bin
|
||||
* @module @deepseek-ai/dsh-acp-demo/bin
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util'
|
||||
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
|
||||
|
||||
const NAME = 'dsh-acp-agent'
|
||||
const NAME = 'dsh-acp-demo'
|
||||
|
||||
/* v8 ignore start -- thin self-executing composition over the unit-tested
|
||||
dsh-app-boot helpers; exercised end-to-end by the snapshot suite and the
|
||||
@@ -1,23 +1,23 @@
|
||||
/**
|
||||
* The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-core}),
|
||||
* The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}),
|
||||
* JSONL session persistence, and the {@link @deepseek-ai/dsh-acp} bridge. It
|
||||
* writes nothing to stdout.
|
||||
* It pre-creates no agents and leaves adapters, executors, and optional tools to
|
||||
* the leaf, which must likewise avoid stdout loggers. Named exports are
|
||||
* required so Loader retains this plugin's `Config` schema (see
|
||||
* docs/postmortem/0001).
|
||||
* @module @deepseek-ai/dsh-acp-agent
|
||||
* @module @deepseek-ai/dsh-acp-demo
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import * as acp from '@deepseek-ai/dsh-acp'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-core'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
export const name = 'acp-agent'
|
||||
export const name = 'acp-demo'
|
||||
|
||||
/**
|
||||
* App config: the swappable per-deployment values. `model` configures the
|
||||
@@ -26,7 +26,7 @@ export const name = 'acp-agent'
|
||||
* deployment persona (forwarded to the system-prompt plugin); `toolOrder` is
|
||||
* the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
||||
* `tools` is the tool registry's config (its presentation `mode`, forwarded
|
||||
* through agent-core); `persistenceRoot` is the JSONL backend's directory.
|
||||
* through agent-spine-demo); `persistenceRoot` is the JSONL backend's directory.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Model name for ACP-created agents (must have a registered adapter). */
|
||||
@@ -35,11 +35,11 @@ export interface Config {
|
||||
persona?: string
|
||||
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
|
||||
toolOrder?: string[]
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */
|
||||
tools?: ToolsConfig
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/** Model-facing bash tool config forwarded through agent-core. */
|
||||
toolBash?: NonNullable<agentCore.Config['toolBash']>
|
||||
@@ -68,7 +68,7 @@ export const Config: z<Config> = z.object({
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Compose the spine with the ACP front door. The agent-core bundle pre-creates
|
||||
* Compose the spine with the ACP front door. The agent-spine-demo bundle pre-creates
|
||||
* NO agents (its `agents` list defaults to `[]`) and carries the deployment
|
||||
* `persona`; the JSONL backend persists under `persistenceRoot`; the ACP
|
||||
* bridge owns stdout for JSON-RPC and creates one agent per `session/new`
|
||||
@@ -10,7 +10,7 @@ import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import * as acpAgent from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* In-process unit coverage for the @deepseek-ai/dsh-acp-agent composition:
|
||||
* In-process unit coverage for the @deepseek-ai/dsh-acp-demo composition:
|
||||
* mounting it brings up the agent-core spine + JSONL persistence + the ACP
|
||||
* bridge in one `ctx.plugin`. Unlike the stdio app, this one loads NO
|
||||
* Loader-only plugin (no hmr), so it mounts in a plain Context.
|
||||
@@ -30,7 +30,7 @@ async function mount(config: acpAgent.Config, withBash = false): Promise<Context
|
||||
}
|
||||
|
||||
async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<acpAgent.Config['skills']>> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-acp-agent-skills-'))
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-acp-demo-skills-'))
|
||||
return {
|
||||
local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
|
||||
...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {},
|
||||
@@ -49,7 +49,7 @@ async function composePrefix(ctx: Context): Promise<Message[]> {
|
||||
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-acp-agent-default-skills-'))
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-acp-demo-default-skills-'))
|
||||
process.env.DSH_HOME = join(home, '.dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(home, '.agents')
|
||||
try {
|
||||
@@ -68,9 +68,9 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-acp-agent composition', () => {
|
||||
describe('dsh-acp-demo composition', () => {
|
||||
it('brings up the spine + persistence + the ACP bridge', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test', skills: await isolatedSkillsConfig() })
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-demo-test', skills: await isolatedSkillsConfig() })
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('sessions')).toBeDefined()
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
@@ -127,7 +127,7 @@ describe('dsh-acp-agent composition', () => {
|
||||
})
|
||||
|
||||
it('exposes its plugin shape', () => {
|
||||
expect(acpAgent.name).toBe('acp-agent')
|
||||
expect(acpAgent.name).toBe('acp-demo')
|
||||
expect(acpAgent.Config).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('dsh-acp-agent composition', () => {
|
||||
const ctx = await mount({
|
||||
model: 'mock',
|
||||
toolOrder: ['zulu', TOOL_ORDER_REST],
|
||||
persistenceRoot: '/tmp/dsh-acp-agent-test-tool-order',
|
||||
persistenceRoot: '/tmp/dsh-acp-demo-test-tool-order',
|
||||
})
|
||||
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
|
||||
// this providerless mount, so register two plain tools to order.
|
||||
@@ -161,7 +161,7 @@ describe('dsh-acp-agent composition', () => {
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(acpAgent) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(acpAgent)
|
||||
expect(unwrapped.name).toBe('acp-agent')
|
||||
expect(unwrapped.name).toBe('acp-demo')
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
@@ -25,14 +25,14 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const acpBin = join(repoRoot, 'packages/ui/acp-agent/lib/bin.js')
|
||||
const acpBin = join(repoRoot, 'packages/examples/acp-demo/lib/bin.js')
|
||||
|
||||
const dshPackages = [
|
||||
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash',
|
||||
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
|
||||
'session-persistence/session-persistence',
|
||||
'session-persistence/session-persistence-jsonl', 'ui/acp', 'ui/acp-agent',
|
||||
'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo',
|
||||
]
|
||||
const vendorPackages = [
|
||||
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
|
||||
@@ -82,7 +82,7 @@ async function makeConsumer(): Promise<string> {
|
||||
'- id: bash',
|
||||
' name: \'@deepseek-ai/dsh-bash-local\'',
|
||||
'- id: acp-agent',
|
||||
' name: \'@deepseek-ai/dsh-acp-agent\'',
|
||||
' name: \'@deepseek-ai/dsh-acp-demo\'',
|
||||
' config:',
|
||||
' model: deepseek-v4-flash',
|
||||
' persona: \'test agent\'',
|
||||
@@ -100,7 +100,7 @@ afterEach(async () => {
|
||||
consumer = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('boots the published bin and answers an initialize JSON-RPC frame on stdout', async () => {
|
||||
consumer = await makeConsumer()
|
||||
child = spawn(process.execPath, ['--expose-internals', acpBin, '--config', './cordis.yml'], {
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
|
||||
const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
// Repo root is four levels up from packages/ui/acp-agent/tests.
|
||||
// Repo root is four levels up from packages/examples/acp-demo/tests.
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
// A minimal leaf that loads this app + the two backends — the same shape as
|
||||
@@ -40,7 +40,7 @@ const CORDIS_YML = `
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
- id: acp-agent
|
||||
name: '@deepseek-ai/dsh-acp-agent'
|
||||
name: '@deepseek-ai/dsh-acp-demo'
|
||||
config:
|
||||
model: deepseek-v4-flash
|
||||
persona: 'You are a test agent.'
|
||||
@@ -105,7 +105,7 @@ async function boot(): Promise<Spawned & { cwd: string }> {
|
||||
return { ...spawned, cwd }
|
||||
}
|
||||
|
||||
describe('dsh-acp-agent real-load-path smoke (bin + Loader, keyless)', () => {
|
||||
describe('dsh-acp-demo real-load-path smoke (bin + Loader, keyless)', () => {
|
||||
it('boots via its bin and answers initialize → session/new → session/load', async () => {
|
||||
const { client, cwd, stderr } = await boot()
|
||||
// initialize: a broken export shape (collapsed bridge plugin, dropped inject)
|
||||
@@ -18,22 +18,22 @@
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../app-boot"
|
||||
"path": "../../ui/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../acp"
|
||||
"path": "../../ui/acp"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent-core"
|
||||
"path": "../agent-spine-demo"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
"path": "../../ui/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../tool-ask-user"
|
||||
"path": "../../ui/tool-ask-user"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
@@ -1,4 +1,4 @@
|
||||
# @deepseek-ai/dsh-agent-core
|
||||
# @deepseek-ai/dsh-agent-spine-demo
|
||||
|
||||
The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends.
|
||||
|
||||
@@ -33,14 +33,14 @@ The spine is everything COMMON to every front door. The swappable and front-door
|
||||
- **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`).
|
||||
- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
|
||||
- **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings.
|
||||
- **presentation + per-app infra** — the stdio UI / ACP bridge, a console logger, `hmr`. These form the coupled "front-door cluster" that the app packages ([`dsh-stdio-agent`](../../ui/stdio-agent/README.md), [`dsh-acp-agent`](../../ui/acp-agent/README.md)) bake in. `timer` is in the spine (common to both, stdout-silent); a console logger is NOT (it writes to stdout, which the ACP bridge reserves for JSON-RPC).
|
||||
- **presentation + per-app infra** — the stdio UI / ACP bridge, a console logger, `hmr`. These form the coupled "front-door cluster" that the app packages ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)) bake in. `timer` is in the spine (common to both, stdout-silent); a console logger is NOT (it writes to stdout, which the ACP bridge reserves for JSON-RPC).
|
||||
|
||||
This is the [interface/implementation/consumer seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door.
|
||||
|
||||
## Config
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-core'
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
// { agents?, persona?, toolOrder?, tools?, skills?, toolBash?, toolTasks? }
|
||||
// The schema intersects the owner schemas,
|
||||
// so validation and defaulting can never drift from the owners.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-agent-core",
|
||||
"name": "@deepseek-ai/dsh-agent-spine-demo",
|
||||
"description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + tasks + invariants + tool-bash + tool-skill + tool-tasks + agent-loop)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
@@ -1,46 +1,11 @@
|
||||
/**
|
||||
* The providerless, executor-less, UI-less agent spine as ONE bundle plugin.
|
||||
*
|
||||
* Loads the fixed set of services every harness agent needs — `timer`, the LLM
|
||||
* service, the session store, system-prompt assembly, the tool registry, the
|
||||
* skill registry plus local provider, the agent registry, the background task
|
||||
* registry + its `task_*` controls, the dev-mode invariants, the model-facing
|
||||
* `bash` and `skill` tools, and the concrete `agent-loop` — and forwards the loop's `agents`
|
||||
* list as its OWN config (default `[]`), so each app supplies its own
|
||||
* pre-created agents.
|
||||
*
|
||||
* It is deliberately NOT the whole app: the swappable choices stay OUTSIDE the
|
||||
* bundle, picked by whatever loads it.
|
||||
* - the LLM ADAPTER (`llm-deepseek`/`llm-pi-ai`/`llm-replay`) — the bundle
|
||||
* ships the abstract `llm` service + `tool-bash` consumer schema; the leaf
|
||||
* registers a concrete adapter on `ctx.llm`.
|
||||
* - the bash EXECUTOR (`bash-local` or a sandboxed impl) — the bundle ships
|
||||
* the `bash` tool consumer; the leaf provides `ctx.bash`.
|
||||
* - the PRESENTATION (stdio UI / ACP bridge / a logger) and the per-app infra
|
||||
* (a console logger, `hmr`) — these are the coupled "front-door cluster" the
|
||||
* app packages ({@link @deepseek-ai/dsh-stdio-agent},
|
||||
* {@link @deepseek-ai/dsh-acp-agent}) bake in, NOT the shared spine.
|
||||
*
|
||||
* This is the interface/implementation/consumer seam at the composition level:
|
||||
* the bundle owns the shared spine, the leaf owns the backends, the app package
|
||||
* owns the front door. `timer` is in the spine (common to every front door — it
|
||||
* writes nothing to stdout); the console logger is NOT (it writes to stdout,
|
||||
* which the ACP bridge reserves for its JSON-RPC channel).
|
||||
*
|
||||
* Services register in the root store keyed by their isolate symbol, so a child
|
||||
* loaded here via `ctx.plugin(...)` is visible to the bundle's SIBLINGS (the
|
||||
* leaf's adapter and executor) exactly as a nested `plugin-include` subtree's
|
||||
* services were before this bundle existed — cordis gates every read on
|
||||
* `inject`, never on load order, so the fixed child set resolves regardless of
|
||||
* which entry loads first.
|
||||
*
|
||||
* Plugin export shape: named `name`/`Config`/`apply`, NO default export — the
|
||||
* cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray
|
||||
* default would collapse the module to the bare `apply` function and drop the
|
||||
* `Config` schema (see docs/postmortem/0001). The keyless Loader-path smokes in
|
||||
* the app packages guard this end-to-end.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent-core
|
||||
* Default executor-less, UI-less agent spine. It bundles the common services,
|
||||
* background-task registry and controls, concrete loop, local skill provider,
|
||||
* and model-facing bash/skill consumers; deployments still choose the LLM
|
||||
* adapter, bash executor, and presentation.
|
||||
* The plugin intentionally exposes named exports only because Loader default
|
||||
* unwrapping would discard its `Config` schema (see docs/postmortem/0001).
|
||||
* @module @deepseek-ai/dsh-agent-spine-demo
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
@@ -60,7 +25,7 @@ import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop'
|
||||
|
||||
export const name = 'agent-core'
|
||||
export const name = 'agent-spine-demo'
|
||||
|
||||
/** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */
|
||||
export interface SkillConfig {
|
||||
@@ -19,7 +19,7 @@ async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unit coverage for the @deepseek-ai/dsh-agent-core bundle: mounting it brings
|
||||
* Unit coverage for the @deepseek-ai/dsh-agent-spine-demo bundle: mounting it brings
|
||||
* up the whole default spine in one `ctx.plugin`, and the forwarded
|
||||
* `agents` config reaches the loop (default `[]`, or a pre-created agent).
|
||||
*
|
||||
@@ -31,8 +31,8 @@ async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
||||
async function mount(config?: agentCore.Config, withBash = false): Promise<Context> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-'))
|
||||
process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-agents-'))
|
||||
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-home-'))
|
||||
process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-agents-'))
|
||||
const ctx = new Context()
|
||||
if (withBash) ctx.provide('bash', { sandboxMode: undefined })
|
||||
try {
|
||||
@@ -58,8 +58,8 @@ async function mount(config?: agentCore.Config, withBash = false): Promise<Conte
|
||||
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-'))
|
||||
process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-agents-'))
|
||||
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-home-'))
|
||||
process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-agents-'))
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
@@ -76,7 +76,7 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-agent-core bundle', () => {
|
||||
describe('dsh-agent-spine-demo bundle', () => {
|
||||
it('brings up the full default spine', async () => {
|
||||
const ctx = await mount()
|
||||
// One service from each layer of the spine proves the children loaded.
|
||||
@@ -133,9 +133,9 @@ describe('dsh-agent-core bundle', () => {
|
||||
})
|
||||
|
||||
it('forwards skill config to the registry, local provider, and model-facing consumer', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-home-'))
|
||||
const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-agents-'))
|
||||
const custom = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-custom-'))
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-home-'))
|
||||
const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-agents-'))
|
||||
const custom = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-custom-'))
|
||||
await mkdir(custom, { recursive: true })
|
||||
await writeFile(join(custom, 'custom-skill.md'), '---\nname: custom-skill\ndescription: Custom skill\n---\n\nCustom body.\n')
|
||||
const ctx = await mount({
|
||||
@@ -212,7 +212,7 @@ describe('dsh-agent-core bundle', () => {
|
||||
|
||||
it('re-exports the loop config schema as its own', () => {
|
||||
expect(agentCore.Config).toBeDefined()
|
||||
expect(agentCore.name).toBe('agent-core')
|
||||
expect(agentCore.name).toBe('agent-spine-demo')
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
|
||||
@@ -224,7 +224,7 @@ describe('dsh-agent-core bundle', () => {
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(agentCore) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(agentCore)
|
||||
expect(unwrapped.name).toBe('agent-core')
|
||||
expect(unwrapped.name).toBe('agent-spine-demo')
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
@@ -1,10 +1,10 @@
|
||||
# @deepseek-ai/dsh-jsonrpc-agent
|
||||
# @deepseek-ai/dsh-jsonrpc-demo
|
||||
|
||||
Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../jsonrpc/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. `lib/bin.js` is also the [single-executable runtime](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) entry.
|
||||
Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../ui/jsonrpc/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. `lib/bin.js` is also the [single-executable runtime](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) entry.
|
||||
|
||||
## Config discovery
|
||||
|
||||
The first non-empty channel wins: `$DSH_CORDIS_CONFIG`, then positional `argv[2]`. If neither names an existing file, the bin prints one-line usage to stderr and exits 1; there is no working-directory or built-in fallback. [`dsh-app-boot`](../app-boot/README.md) makes plugin load failures fatal. This protocol does not use `DSH_SNAPSHOT`.
|
||||
The first non-empty channel wins: `$DSH_CORDIS_CONFIG`, then positional `argv[2]`. If neither names an existing file, the bin prints one-line usage to stderr and exits 1; there is no working-directory or built-in fallback. [`dsh-app-boot`](../../ui/app-boot/README.md) makes plugin load failures fatal. This protocol does not use `DSH_SNAPSHOT`.
|
||||
|
||||
A config without `dsh-jsonrpc` is valid and serves nothing; the bin does not designate a server plugin.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-jsonrpc-agent",
|
||||
"name": "@deepseek-ai/dsh-jsonrpc-demo",
|
||||
"description": "Bin that boots an external Cordis config for the stdio JSON-RPC SDK runtime",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
@@ -7,7 +7,7 @@
|
||||
* stdin EOF and SIGTERM dispose the root context and exit 0; SIGINT exits 130.
|
||||
* Protocol `shutdown` belongs to the server plugin. Stdout is reserved for frames.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-jsonrpc-agent/bin
|
||||
* @module @deepseek-ai/dsh-jsonrpc-demo/bin
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
@@ -3,7 +3,7 @@
|
||||
* process exit. This module exports no composition plugin; the config chooses
|
||||
* whether to load the {@link @deepseek-ai/dsh-jsonrpc} serving plugin.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-jsonrpc-agent
|
||||
* @module @deepseek-ai/dsh-jsonrpc-demo
|
||||
*/
|
||||
|
||||
export {}
|
||||
@@ -15,7 +15,7 @@
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../app-boot"
|
||||
"path": "../../ui/app-boot"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
# @deepseek-ai/dsh-stdio-agent
|
||||
# @deepseek-ai/dsh-stdio-demo
|
||||
|
||||
The **terminal stdio chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`.
|
||||
The **terminal stdio chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`.
|
||||
|
||||
It is the readline counterpart to [`@deepseek-ai/dsh-acp-agent`](../acp-agent/README.md): both consume the same spine, but each bakes in the OPPOSITE front-door cluster.
|
||||
It is the readline counterpart to [`@deepseek-ai/dsh-acp-demo`](../acp-demo/README.md): both consume the same spine, but each bakes in the OPPOSITE front-door cluster.
|
||||
|
||||
## What it bakes in
|
||||
|
||||
@@ -11,7 +11,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
|
||||
| Plugin | Why it is here |
|
||||
|---|---|
|
||||
| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) |
|
||||
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model` with `process.cwd()` as the fresh session cwd and carrying its `persona` |
|
||||
| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's `model` with `process.cwd()` as the fresh session cwd and carrying its `persona` |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
|
||||
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
|
||||
@@ -28,19 +28,19 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
|
||||
| `model` | (required) | the pre-created `main` agent's model |
|
||||
| `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
|
||||
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
|
||||
| `tools` | `{ mode: 'native' }` | tool-registry presentation config through `agent-core` |
|
||||
| `skills` | owner defaults | skill registry, local provider, and model-facing skill-tool config through `agent-core` |
|
||||
| `toolBash` | owner defaults | model-facing bash config through `agent-core`, including bash's producer-local `enableRunInBackground` |
|
||||
| `toolTasks` | owner defaults | generic `task_output` wait bounds through `agent-core` |
|
||||
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
|
||||
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
|
||||
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
|
||||
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
|
||||
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||
| `welcome` | `ready.` | the stdin-chat banner |
|
||||
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
|
||||
|
||||
Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-agent` was started. Resumed sessions keep the cwd stored in the persisted session header.
|
||||
Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-demo` was started. Resumed sessions keep the cwd stored in the persisted session header.
|
||||
|
||||
## The bin
|
||||
|
||||
`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`, or install the Loader's optional `node-addon-require-builtin` fallback, so the Loader can resolve the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages). The `demo:echo` / `demo:repl` scripts use `--expose-internals`.
|
||||
`dsh-stdio-demo [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`, or install the Loader's optional `node-addon-require-builtin` fallback, so the Loader can resolve the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages). The `demo:echo` / `demo:repl` scripts use `--expose-internals`.
|
||||
|
||||
## Example leaf `cordis.yml`
|
||||
|
||||
@@ -60,7 +60,7 @@ Fresh stdio sessions use the process launch directory as `session.header.cwd`, s
|
||||
config:
|
||||
timeoutMs: 60000
|
||||
- id: stdio-agent
|
||||
name: '@deepseek-ai/dsh-stdio-agent'
|
||||
name: '@deepseek-ai/dsh-stdio-demo'
|
||||
config:
|
||||
model: deepseek-v4-flash
|
||||
persona: 'You are a coding assistant powered by the {{model}} model.'
|
||||
@@ -72,7 +72,7 @@ Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo —
|
||||
|
||||
### Composed terminal agent request
|
||||
|
||||
**What the model sees**: Through `dsh-agent-core`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each readline submission becomes a user message.
|
||||
**What the model sees**: Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each readline submission becomes a user message.
|
||||
|
||||
**Token effect**: Child prompt and schema costs repeat per request; user input and tool history grow until compaction. The welcome banner, logger output, and rendered transcript are terminal-only and add zero model tokens.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-stdio-agent",
|
||||
"description": "Terminal stdio chat app: the agent-core spine + console logger + readline UI + a pre-created main agent, with a bin to boot a leaf cordis.yml",
|
||||
"name": "@deepseek-ai/dsh-stdio-demo",
|
||||
"description": "Terminal stdio chat app: the agent-spine-demo bundle + console logger + readline UI + a pre-created main agent, with a bin to boot a leaf cordis.yml",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"bin": {
|
||||
"dsh-stdio-agent": "lib/bin.js"
|
||||
"dsh-stdio-demo": "lib/bin.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -36,7 +36,7 @@
|
||||
"@cordisjs/plugin-logger-console": "^1.0.0",
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-core": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-stdio": "^0.0.1",
|
||||
@@ -53,7 +53,7 @@
|
||||
"@cordisjs/plugin-logger-console": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-core": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-agent [config]`, defaulting to the
|
||||
* Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-demo [config]`, defaulting to the
|
||||
* cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in
|
||||
* dsh-app-boot. The echo and REPL demos invoke this bin with their own leaf configs.
|
||||
* @module @deepseek-ai/dsh-stdio-agent/bin
|
||||
* @module @deepseek-ai/dsh-stdio-demo/bin
|
||||
*/
|
||||
|
||||
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
|
||||
|
||||
const NAME = 'dsh-stdio-agent'
|
||||
const NAME = 'dsh-stdio-demo'
|
||||
|
||||
/* v8 ignore start -- thin self-executing composition over the unit-tested
|
||||
dsh-app-boot helpers; exercised end-to-end by the keyless Loader-path and
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-core}) plus the
|
||||
* The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) plus the
|
||||
* coupled front-door cluster a terminal chat needs — a console logger, the independently
|
||||
* packaged readline UI, JSONL session persistence, the user-interaction seam with its
|
||||
* `ask_user_question` tool, and a pre-created `main` agent the UI drives.
|
||||
* Swappable adapters, executors, optional tools, and HMR stay in the leaf. This
|
||||
* Loader plugin intentionally exposes named exports only; a default export
|
||||
* would hide its `Config` schema (see docs/postmortem/0001).
|
||||
* @module @deepseek-ai/dsh-stdio-agent
|
||||
* @module @deepseek-ai/dsh-stdio-demo
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
@@ -15,18 +15,18 @@ import z from 'schemastery'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-core'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as uiStdio from '@deepseek-ai/dsh-stdio'
|
||||
|
||||
export const name = 'stdio-agent'
|
||||
export const name = 'stdio-demo'
|
||||
|
||||
/**
|
||||
* App config: the swappable per-demo values, each routed to where the app wires
|
||||
* it. `model`/`resumeSessionId` configure the pre-created `main` agent (through
|
||||
* {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is
|
||||
* {@link @deepseek-ai/dsh-agent-spine-demo}'s forwarded `agents` list); `persona` is
|
||||
* the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
|
||||
* is the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
||||
* fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions
|
||||
@@ -40,13 +40,13 @@ export interface Config {
|
||||
persona?: string
|
||||
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
|
||||
toolOrder?: string[]
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */
|
||||
tools?: ToolsConfig
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
||||
welcome?: string
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/** Model-facing bash tool config forwarded through agent-core. */
|
||||
toolBash?: NonNullable<agentCore.Config['toolBash']>
|
||||
@@ -80,7 +80,7 @@ export const Config: z<Config> = z.object({
|
||||
|
||||
/**
|
||||
* Compose the spine with the stdio front door. The console logger comes first
|
||||
* (infra), then the agent-core bundle pre-creating the `main` agent from this
|
||||
* (infra), then the agent-spine-demo bundle pre-creating the `main` agent from this
|
||||
* app's `model`/`resumeSessionId` with the deployment `persona`, then the JSONL
|
||||
* backend, then the readline UI bound to `main`. The `hmr` dev-reload plugin is
|
||||
* a leaf concern (see the module doc), so it is not mounted here.
|
||||
@@ -14,16 +14,16 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js')
|
||||
const stdioBin = join(repoRoot, 'packages/examples/stdio-demo/lib/bin.js')
|
||||
|
||||
// Symlink each required workspace package by package name so plain Node resolves its built `main`,
|
||||
// matching an installed dependency rather than tsconfig paths.
|
||||
const dshPackages = [
|
||||
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
|
||||
'bash/tool-bash', 'support/invariants', 'ui/app-boot',
|
||||
'session-persistence/session-persistence',
|
||||
'session-persistence/session-persistence-jsonl', 'ui/stdio-agent',
|
||||
'session-persistence/session-persistence-jsonl', 'examples/stdio-demo',
|
||||
'ui/stdio', 'ui/tool-ask-user', 'ui/user-interaction',
|
||||
]
|
||||
const vendorPackages = [
|
||||
@@ -71,7 +71,7 @@ async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promi
|
||||
'- id: bash',
|
||||
' name: \'@deepseek-ai/dsh-bash-local\'',
|
||||
'- id: stdio-agent',
|
||||
' name: \'@deepseek-ai/dsh-stdio-agent\'',
|
||||
' name: \'@deepseek-ai/dsh-stdio-demo\'',
|
||||
' config:',
|
||||
' model: mock-echo',
|
||||
' persona: \'demo\'',
|
||||
@@ -120,7 +120,7 @@ afterEach(async () => {
|
||||
consumer = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
describe.skipIf(!existsSync(stdioBin))('dsh-stdio-demo BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('boots the published bin, prints its banner, and runs the echo tool round-trip', async () => {
|
||||
consumer = await makeConsumer('BUILT-BIN-OK ready.')
|
||||
const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi')
|
||||
@@ -26,7 +26,7 @@ async function mount(config: stdioAgent.Config, withBash = false): Promise<Conte
|
||||
}
|
||||
|
||||
async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<stdioAgent.Config['skills']>> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-skills-'))
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-demo-skills-'))
|
||||
return {
|
||||
local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
|
||||
...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {},
|
||||
@@ -45,7 +45,7 @@ async function composePrefix(ctx: Context): Promise<Message[]> {
|
||||
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-default-skills-'))
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-demo-default-skills-'))
|
||||
process.env.DSH_HOME = join(home, '.dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(home, '.agents')
|
||||
try {
|
||||
@@ -64,9 +64,9 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-stdio-agent app', () => {
|
||||
describe('dsh-stdio-demo app', () => {
|
||||
it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig() })
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig() })
|
||||
// The spine services (brought up by the agent-core bundle) are all present.
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
@@ -112,7 +112,7 @@ describe('dsh-stdio-agent app', () => {
|
||||
const ctx = await mount({
|
||||
model: 'mock',
|
||||
persona: 'hi',
|
||||
persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
|
||||
persistenceRoot: '/tmp/dsh-stdio-demo-spec-resume',
|
||||
resumeSessionId: 'no-such-session',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
})
|
||||
@@ -141,7 +141,7 @@ describe('dsh-stdio-agent app', () => {
|
||||
})
|
||||
|
||||
it('exposes its name and Config schema', () => {
|
||||
expect(stdioAgent.name).toBe('stdio-agent')
|
||||
expect(stdioAgent.name).toBe('stdio-demo')
|
||||
expect(stdioAgent.Config).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('dsh-stdio-agent app', () => {
|
||||
const ctx = await mount({
|
||||
model: 'mock',
|
||||
toolOrder: ['zulu', TOOL_ORDER_REST],
|
||||
persistenceRoot: '/tmp/dsh-stdio-agent-spec-tool-order',
|
||||
persistenceRoot: '/tmp/dsh-stdio-demo-spec-tool-order',
|
||||
})
|
||||
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
|
||||
// this providerless mount, so register two plain tools to order.
|
||||
@@ -175,7 +175,7 @@ describe('dsh-stdio-agent app', () => {
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(stdioAgent) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(stdioAgent)
|
||||
expect(unwrapped.name).toBe('stdio-agent')
|
||||
expect(unwrapped.name).toBe('stdio-demo')
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
@@ -18,7 +18,7 @@
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../app-boot"
|
||||
"path": "../../ui/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/logger-console"
|
||||
@@ -30,16 +30,16 @@
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent-core"
|
||||
"path": "../agent-spine-demo"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
"path": "../../ui/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../stdio"
|
||||
"path": "../../ui/stdio"
|
||||
},
|
||||
{
|
||||
"path": "../tool-ask-user"
|
||||
"path": "../../ui/tool-ask-user"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
7
packages/mcp/README.md
Normal file
7
packages/mcp/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# MCP — Model Context Protocol
|
||||
|
||||
Packages bridging the harness to the MCP ecosystem.
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `mcp-client/` | MCP client bridge: connects to external MCP servers and registers their tools on `ctx.tools` |
|
||||
88
packages/mcp/mcp-client/README.md
Normal file
88
packages/mcp/mcp-client/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# @deepseek-ai/dsh-mcp-client
|
||||
|
||||
MCP client bridge plugin: connects to external [Model Context Protocol](https://modelcontextprotocol.io/) servers and registers their tools on `ctx.tools`, making them available to the model as native tools under server-qualified names (`mcp__<serverName>__<rawName>`).
|
||||
|
||||
## Usage
|
||||
|
||||
One plugin instance per MCP server in `cordis.yml`:
|
||||
|
||||
```yaml
|
||||
- id: mcp-github
|
||||
name: '@deepseek-ai/dsh-mcp-client'
|
||||
config:
|
||||
serverName: github
|
||||
transport: stdio
|
||||
command: npx
|
||||
args: ['-y', '@modelcontextprotocol/server-github']
|
||||
env:
|
||||
GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN
|
||||
|
||||
- id: mcp-web
|
||||
name: '@deepseek-ai/dsh-mcp-client'
|
||||
config:
|
||||
serverName: web
|
||||
transport: streamable-http
|
||||
url: http://localhost:3000/mcp
|
||||
headers:
|
||||
Authorization: !!js '`Bearer ${process.env.MCP_TOKEN}`'
|
||||
```
|
||||
|
||||
The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same server-qualified shape Claude Code and Codex use. HMR hot-swaps: editing the entry triggers disconnect + reconnect without process restart; an unchanged `serverName` reproduces identical tool names.
|
||||
|
||||
## Config
|
||||
|
||||
| Field | Transport | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `transport` | both | yes | `"stdio"` or `"streamable-http"` |
|
||||
| `serverName` | both | yes | Namespace for this server's model-facing tool names; `[A-Za-z0-9_-]{1,32}`, unique across live instances |
|
||||
| `command` | stdio | yes | Executable to spawn |
|
||||
| `args` | stdio | no | Arguments passed to the command |
|
||||
| `env` | stdio | no | Extra env vars merged on top of scrubbed ambient env |
|
||||
| `cwd` | stdio | no | Working directory for the child process |
|
||||
| `url` | http | yes | MCP server URL |
|
||||
| `headers` | http | no | Extra headers (e.g. auth tokens) |
|
||||
| `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) |
|
||||
|
||||
## Tool naming
|
||||
|
||||
Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`) and the public name `mcp__<serverName>__<rawName>` registered on `ctx.tools`. Public names are normalized to the DeepSeek function-name contract (64 chars, `[A-Za-z0-9_-]`); when replacement or truncation changes the name, a deterministic 12-hex-char hash of `(serverName, rawName)` is appended so distinct tools never collapse into one name. Names are pure functions of `(serverName, rawName)` — connection order, re-syncs, and other servers never rename a tool.
|
||||
|
||||
- Two servers publishing the same raw name (e.g. `search`) coexist under their namespaces.
|
||||
- A duplicate `serverName` across live instances fails the later plugin instance at load.
|
||||
- A server listing the same tool name twice is rejected as an invalid tool list.
|
||||
- A foreign registration squatting on this server's namespace rolls back the whole generation (never a partial set), with a loud error.
|
||||
|
||||
## Behavior
|
||||
|
||||
- On connect: `listTools()` → registers each tool via `ctx.tools.register()` under its public name.
|
||||
- Listens for `notifications/tools/list_changed` → re-syncs; a failed re-sync keeps the previous generation registered.
|
||||
- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support — the public name is never sent to the server.
|
||||
- Image content in results is discarded with a placeholder (the harness has no image block type).
|
||||
- On disconnect/crash: all tools are unregistered; no auto-reconnect.
|
||||
|
||||
## Services consumed
|
||||
|
||||
| Service | Usage |
|
||||
|---|---|
|
||||
| `ctx.tools` | Register/unregister MCP tools |
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Discovered MCP tools
|
||||
|
||||
**What the model sees**: After initial discovery succeeds, each advertised MCP tool appears as a native tool named `mcp__<serverName>__<rawName>` (or its deterministic normalized form), with the server-provided description and input schema. A successful re-sync replaces the generation; plugin disposal removes it.
|
||||
|
||||
**Token effect**: Data-dependent schema cost is paid on every request while the tools are registered. Re-sync replaces rather than accumulates schemas, and the server-qualified name adds tokens to every tool definition and call.
|
||||
|
||||
### Tool-call history and results
|
||||
|
||||
**What the model sees**: The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained text result; image, audio, resource, and unsupported blocks become short placeholders, and MCP `isError` results follow the registry's model-visible error path.
|
||||
|
||||
**Token effect**: Arguments and mapped text are retained until compaction. Binary and resource payloads are discarded rather than added to context.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Initial discovery is asynchronous** — plugin load does not wait for connection and `listTools()`, so a turn started immediately after boot or HMR can assemble before the MCP tools are registered.
|
||||
- **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred.
|
||||
- **Crash recovery is manual** — transport closure unregisters the server's tools, but reconnect requires an HMR reload or harness restart.
|
||||
- **Non-text results are lossy** — image, audio, and resource payloads are replaced with placeholders, and a structured-only result has no model-visible structured representation.
|
||||
41
packages/mcp/mcp-client/package.json
Normal file
41
packages/mcp/mcp-client/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-mcp-client",
|
||||
"description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@modelcontextprotocol/server-everything": "^2026.7.4",
|
||||
"@modelcontextprotocol/server-filesystem": "^2026.7.4",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
177
packages/mcp/mcp-client/src/index.ts
Normal file
177
packages/mcp/mcp-client/src/index.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* MCP client bridge plugin: connects to an external MCP server and registers
|
||||
* its tools on `ctx.tools` under server-qualified public names
|
||||
* (`mcp__<serverName>__<rawName>`). Each plugin instance connects to one MCP
|
||||
* server; load multiple instances in `cordis.yml` for multiple servers.
|
||||
*
|
||||
* Namespace plugin (named exports, no default export). Lifecycle is
|
||||
* effect-scoped: disposal disconnects from the server, unregisters all tools,
|
||||
* and releases the `serverName` namespace reservation. HMR hot-swaps by
|
||||
* disposing the old instance and creating a new one; identical `serverName`
|
||||
* reproduces identical public tool names.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-mcp-client
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { createTransport } from './transport.ts'
|
||||
import { syncTools } from './tools.ts'
|
||||
// Side-effect type import: declaration-merges `ctx.tools` onto Context.
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'mcp-client'
|
||||
|
||||
/** Services required by this plugin. */
|
||||
export const inject = ['tools']
|
||||
|
||||
/** Default timeout for individual MCP tool calls (ms). */
|
||||
const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000
|
||||
|
||||
/**
|
||||
* Valid `serverName`: 1–32 chars of `[A-Za-z0-9_-]`. Kept well under the
|
||||
* 64-char public-name budget so typical raw tool names survive unhashed.
|
||||
*/
|
||||
const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
|
||||
|
||||
/**
|
||||
* Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps
|
||||
* in one process — tests — must not see each other's names). A duplicate
|
||||
* namespace is a configuration error surfaced at plugin load, never silent
|
||||
* shadowing.
|
||||
*/
|
||||
const activeServerNames = new WeakMap<Context, Set<string>>()
|
||||
|
||||
// ---- Config ----
|
||||
|
||||
/** Config for connecting to an MCP server via a spawned child process over stdio. */
|
||||
export interface StdioConfig {
|
||||
/** Transport type: spawn a child process and communicate over stdio. */
|
||||
transport: 'stdio'
|
||||
/**
|
||||
* Stable local namespace for this server's model-facing tool names
|
||||
* (`mcp__<serverName>__<rawName>`). Must match `[A-Za-z0-9_-]{1,32}` and be
|
||||
* unique across live mcp-client instances.
|
||||
*/
|
||||
serverName: string
|
||||
/** Executable to spawn. */
|
||||
command: string
|
||||
/** Arguments passed to the command. */
|
||||
args: string[]
|
||||
/** Extra env vars merged on top of scrubbed ambient env. */
|
||||
env: Record<string, string>
|
||||
/** Working directory for the child process. */
|
||||
cwd: string
|
||||
/** Timeout per callTool invocation (ms). */
|
||||
toolCallTimeoutMs: number
|
||||
}
|
||||
|
||||
/** Config for connecting to an MCP server over Streamable HTTP (SSE). */
|
||||
export interface StreamableHttpConfig {
|
||||
/** Transport type: connect to an MCP server over Streamable HTTP (SSE). */
|
||||
transport: 'streamable-http'
|
||||
/**
|
||||
* Stable local namespace for this server's model-facing tool names
|
||||
* (`mcp__<serverName>__<rawName>`). Must match `[A-Za-z0-9_-]{1,32}` and be
|
||||
* unique across live mcp-client instances.
|
||||
*/
|
||||
serverName: string
|
||||
/** MCP server URL. */
|
||||
url: string
|
||||
/** Extra headers (e.g. auth tokens). */
|
||||
headers: Record<string, string>
|
||||
/** Timeout per callTool invocation (ms). */
|
||||
toolCallTimeoutMs: number
|
||||
}
|
||||
|
||||
/** Discriminated union of all supported MCP transport configurations. */
|
||||
export type Config = StdioConfig | StreamableHttpConfig
|
||||
|
||||
export const Config = z.union([
|
||||
z.object({
|
||||
transport: z.const('stdio'),
|
||||
serverName: z.string().required().pattern(SERVER_NAME_PATTERN),
|
||||
command: z.string().required(),
|
||||
args: z.array(String).default([]),
|
||||
env: z.dict(String).default({}),
|
||||
cwd: z.string().default(''),
|
||||
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
|
||||
}),
|
||||
z.object({
|
||||
transport: z.const('streamable-http'),
|
||||
serverName: z.string().required().pattern(SERVER_NAME_PATTERN),
|
||||
url: z.string().required(),
|
||||
headers: z.dict(String).default({}),
|
||||
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
|
||||
}),
|
||||
]) as unknown as z<Config>
|
||||
|
||||
// ---- Plugin apply ----
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// Reserve the namespace first: a duplicate `serverName` fails THIS instance
|
||||
// at load with an actionable error and leaves the earlier instance intact.
|
||||
ctx.effect(() => {
|
||||
let names = activeServerNames.get(ctx.root)
|
||||
if (!names) {
|
||||
names = new Set()
|
||||
activeServerNames.set(ctx.root, names)
|
||||
}
|
||||
if (names.has(config.serverName)) {
|
||||
throw new Error(
|
||||
`mcp-client: serverName "${config.serverName}" is already in use by another mcp-client instance — pick a unique serverName in cordis.yml`,
|
||||
)
|
||||
}
|
||||
names.add(config.serverName)
|
||||
return () => void names.delete(config.serverName)
|
||||
}, 'mcp-client.serverName')
|
||||
|
||||
const transport = createTransport(config)
|
||||
const client = new Client(
|
||||
{ name: 'dsh-mcp-client', version: '0.0.1' },
|
||||
{ capabilities: {} },
|
||||
)
|
||||
|
||||
const opts = {
|
||||
serverName: config.serverName,
|
||||
toolCallTimeoutMs: config.toolCallTimeoutMs,
|
||||
}
|
||||
|
||||
// Connect and set up tools. Errors during connect/first sync are logged,
|
||||
// not thrown (the plugin simply has no tools registered). `ready` resolves
|
||||
// to an accessor for the CURRENT disposer generation, so the effect
|
||||
// disposer below always unregisters the live set, not the first one.
|
||||
const ready = (async () => {
|
||||
await client.connect(transport)
|
||||
|
||||
let disposers = await syncTools(client, ctx, opts, new Map())
|
||||
|
||||
client.setNotificationHandler(
|
||||
ToolListChangedNotificationSchema,
|
||||
async () => {
|
||||
ctx.logger.info(`mcp-client(${config.serverName}): tool list changed, re-syncing`)
|
||||
try {
|
||||
disposers = await syncTools(client, ctx, opts, disposers)
|
||||
} catch (error) {
|
||||
// Fetch-phase failure: the previous generation is still registered
|
||||
// and `disposers` still owns it — keep serving the last good list.
|
||||
ctx.logger.error(`mcp-client(${config.serverName}): tool re-sync failed: ${String(error)}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return () => disposers
|
||||
})().catch((error: unknown) => {
|
||||
ctx.logger.error(`mcp-client(${config.serverName}): failed to connect: ${String(error)}`)
|
||||
return () => new Map<string, () => void>()
|
||||
})
|
||||
|
||||
ctx.effect(() => async () => {
|
||||
const live = await ready
|
||||
for (const dispose of live().values()) dispose()
|
||||
try { await client.close() } catch { /* transport already gone */ }
|
||||
}, 'mcp-client.connection')
|
||||
}
|
||||
230
packages/mcp/mcp-client/src/tools.ts
Normal file
230
packages/mcp/mcp-client/src/tools.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Tool bridge: discovers MCP tools, registers them on the harness ToolRegistry
|
||||
* under deterministic server-qualified public names, and handles re-sync when
|
||||
* the server's tool list changes.
|
||||
*
|
||||
* Naming contract (see the mcp-client RFC "Naming invariants"): every MCP tool
|
||||
* has the stable identity `(serverName, rawName)`; the model-facing public name
|
||||
* is `mcp__<serverName>__<rawName>`, normalized to the DeepSeek function-name
|
||||
* constraints. The raw name is only ever sent on the wire (`tools/call`); the
|
||||
* public name is never parsed to recover it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** Resolved options relevant to tool bridging. */
|
||||
export interface ToolBridgeOptions {
|
||||
serverName: string
|
||||
toolCallTimeoutMs: number
|
||||
}
|
||||
|
||||
/** State for one sync generation: the current set of disposers keyed by public name. */
|
||||
export type ToolDisposers = Map<string, () => void>
|
||||
|
||||
/**
|
||||
* DeepSeek function-name contract: at most 64 characters. Wire-protocol
|
||||
* constant, not configuration.
|
||||
*/
|
||||
const MAX_PUBLIC_NAME_LENGTH = 64
|
||||
|
||||
/** DeepSeek function-name contract: only `[A-Za-z0-9_-]` is allowed. */
|
||||
const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g
|
||||
|
||||
/** Hex chars of the SHA-256 identity hash appended on lossy normalization. */
|
||||
const HASH_LENGTH = 12
|
||||
|
||||
/**
|
||||
* Derive the model-facing public name for one MCP tool.
|
||||
*
|
||||
* Deterministic pure function of `(serverName, rawName)`: the clean case is
|
||||
* `mcp__<serverName>__<rawName>` verbatim. When character replacement or
|
||||
* truncation to the DeepSeek function-name contract (64 chars,
|
||||
* `[A-Za-z0-9_-]`) changes the name, a 12-hex-char SHA-256 hash of the
|
||||
* identity is appended so distinct MCP identities never collapse into the
|
||||
* same public name.
|
||||
*
|
||||
* @param serverName - Stable local namespace from plugin config.
|
||||
* @param rawName - The MCP server's own tool name.
|
||||
* @returns The globally unique, model-facing ToolRegistry name.
|
||||
*/
|
||||
export function publicToolName(serverName: string, rawName: string): string {
|
||||
const joined = `mcp__${serverName}__${rawName}`
|
||||
const normalized = joined.replace(INVALID_NAME_CHARS, '_')
|
||||
if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized
|
||||
const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, HASH_LENGTH)
|
||||
return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the MCP server's tool list into the harness ToolRegistry.
|
||||
*
|
||||
* Two phases keep the swap safe:
|
||||
*
|
||||
* 1. Fetch: drain `client.listTools()` pagination and build the full next
|
||||
* generation of `ToolDefinition`s under public names. Any failure here
|
||||
* (network error, duplicate raw name in the server's list) rejects and
|
||||
* leaves the previous generation registered untouched.
|
||||
* 2. Swap: dispose the previous generation, register the new one. A registry
|
||||
* conflict here can only mean a foreign registration squats on this
|
||||
* server's `mcp__<serverName>__` namespace — the partial generation is
|
||||
* rolled back (zero tools from this server), the error is logged, and an
|
||||
* empty map is returned.
|
||||
*
|
||||
* @param client - Connected MCP Client instance used to list and call tools.
|
||||
* @param ctx - Cordis context providing the `tools` service for registration.
|
||||
* @param opts - Bridge options: server namespace and per-call timeout.
|
||||
* @param previous - Disposer map from the prior sync generation; disposed
|
||||
* during the swap phase (only after the fetch phase succeeded).
|
||||
* @returns A map of registered public tool names to their unregister
|
||||
* disposers — the exact set of live registrations owned by this server.
|
||||
*/
|
||||
export async function syncTools(
|
||||
client: Client,
|
||||
ctx: Context,
|
||||
opts: ToolBridgeOptions,
|
||||
previous: ToolDisposers,
|
||||
): Promise<ToolDisposers> {
|
||||
// Phase 1: fetch and build the next generation without touching the registry.
|
||||
const definitions = new Map<string, ToolDefinition>()
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const response = await client.listTools(cursor ? { cursor } : undefined)
|
||||
for (const tool of response.tools) {
|
||||
const publicName = publicToolName(opts.serverName, tool.name)
|
||||
if (definitions.has(publicName)) {
|
||||
throw new Error(
|
||||
`mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`,
|
||||
)
|
||||
}
|
||||
definitions.set(publicName, {
|
||||
name: publicName,
|
||||
description: tool.description ?? '',
|
||||
parameters: tool.inputSchema,
|
||||
execute: createExecutor(client, tool.name, opts),
|
||||
})
|
||||
}
|
||||
cursor = response.nextCursor
|
||||
} while (cursor)
|
||||
|
||||
// Phase 2: swap generations.
|
||||
for (const dispose of previous.values()) dispose()
|
||||
const disposers: ToolDisposers = new Map()
|
||||
try {
|
||||
for (const [publicName, definition] of definitions) {
|
||||
disposers.set(publicName, ctx.tools.register(definition))
|
||||
}
|
||||
} catch (error) {
|
||||
// A conflict on an `mcp__<serverName>__`-qualified name means a foreign
|
||||
// registration occupies this server's namespace. Roll back so the model
|
||||
// sees either the full generation or none of it — never a partial set.
|
||||
for (const dispose of disposers.values()) dispose()
|
||||
ctx.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`)
|
||||
return new Map()
|
||||
}
|
||||
return disposers
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape we read from each MCP content block. Intentionally looser than the
|
||||
* SDK's `ContentBlock` type: we're at a network trust boundary (data arrives
|
||||
* from an external MCP server process via JSON-RPC), so fields that the SDK
|
||||
* declares required may be absent at runtime if the server is buggy.
|
||||
*/
|
||||
interface McpContentBlock {
|
||||
type: string
|
||||
text?: string
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an execute function for one MCP tool. The executor closes over the
|
||||
* raw MCP tool name and calls `client.callTool` with it (never the public
|
||||
* name), with abort signal and timeout, then maps the result to harness
|
||||
* ContentBlocks.
|
||||
*
|
||||
* When the MCP server returns `isError: true`, the executor throws so that
|
||||
* the ToolRegistry's catch path produces an `isError` result for the model.
|
||||
*/
|
||||
function createExecutor(
|
||||
client: Client,
|
||||
rawName: string,
|
||||
opts: ToolBridgeOptions,
|
||||
): ToolDefinition['execute'] {
|
||||
return async (args: unknown, exec: ToolExecution) => {
|
||||
// The agent loop passes `JSON.parse(model_arguments)` which is usually an
|
||||
// object, but can be any JSON value if the model misbehaves (outputs a bare
|
||||
// string/number/null). Fallback to {} lets the MCP server produce a
|
||||
// specific "missing required param" error the model can learn from.
|
||||
const argsObj = (typeof args === 'object' && args !== null ? args : {}) as Record<string, unknown>
|
||||
const result = await client.callTool(
|
||||
{ name: rawName, arguments: argsObj },
|
||||
undefined,
|
||||
{
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
timeout: opts.toolCallTimeoutMs,
|
||||
},
|
||||
)
|
||||
|
||||
// The SDK may return a legacy `toolResult` shape; normalize to content array.
|
||||
if (!('content' in result) || !Array.isArray(result.content)) {
|
||||
const text = 'toolResult' in result
|
||||
? JSON.stringify(result.toolResult)
|
||||
: '(no output)'
|
||||
return [{ type: 'text' as const, text }]
|
||||
}
|
||||
|
||||
// Trust boundary: the SDK's return type erases to `any[]` due to the
|
||||
// union of CallToolResult | CompatibilityCallToolResult. We process each
|
||||
// element defensively in extractText (reading only .type/.text/.mimeType
|
||||
// with optional fallbacks).
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const content: McpContentBlock[] = result.content
|
||||
const text = extractText(content, rawName)
|
||||
|
||||
// MCP isError → throw so ToolRegistry produces an isError result for the model.
|
||||
if ('isError' in result && result.isError === true) {
|
||||
throw new Error(text)
|
||||
}
|
||||
|
||||
return [{ type: 'text', text }]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from an MCP content array into a single string.
|
||||
* - text blocks: join with '\n'
|
||||
* - image/audio/resource blocks: replaced with a placeholder
|
||||
*
|
||||
* Defensive: fields that the MCP spec declares required (mimeType, text) are
|
||||
* guarded with fallbacks because this is a network trust boundary.
|
||||
*/
|
||||
function extractText(mcpContent: McpContentBlock[], toolName: string): string {
|
||||
const parts: string[] = []
|
||||
|
||||
for (const block of mcpContent) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
if (block.text !== undefined) parts.push(block.text)
|
||||
break
|
||||
case 'image':
|
||||
parts.push(`[image: ${block.mimeType ?? 'unknown'}, content discarded]`)
|
||||
break
|
||||
case 'audio':
|
||||
parts.push(`[audio: ${block.mimeType ?? 'unknown'}, content discarded]`)
|
||||
break
|
||||
case 'resource':
|
||||
case 'resource_link':
|
||||
parts.push('[resource: content discarded]')
|
||||
break
|
||||
default:
|
||||
parts.push(`[unsupported content type: ${block.type}]`)
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n') || `(${toolName} returned no text content)`
|
||||
}
|
||||
56
packages/mcp/mcp-client/src/transport.ts
Normal file
56
packages/mcp/mcp-client/src/transport.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Transport factory: creates the appropriate MCP transport based on the
|
||||
* plugin's resolved config. Stdio spawns a child process (with credential
|
||||
* scrubbing); Streamable HTTP connects to a URL.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
import type { Config } from './index.ts'
|
||||
|
||||
/**
|
||||
* Credential-shaped ambient env vars are NOT forwarded to the child by default
|
||||
* (the parent harness's own secrets must not leak into a spawned process
|
||||
* implicitly). Same pattern as `dsh-subagent-acp`.
|
||||
*/
|
||||
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
|
||||
|
||||
/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */
|
||||
function buildChildEnv(extra: Record<string, string>): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
|
||||
}
|
||||
return { ...env, ...extra }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an MCP transport from the resolved plugin config.
|
||||
*
|
||||
* @param config - Resolved plugin config discriminated on `transport`.
|
||||
* @returns A connected-ready MCP Transport (stdio or Streamable HTTP).
|
||||
*/
|
||||
export function createTransport(config: Config): Transport {
|
||||
switch (config.transport) {
|
||||
case 'stdio':
|
||||
return new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: buildChildEnv(config.env),
|
||||
cwd: config.cwd,
|
||||
})
|
||||
case 'streamable-http':
|
||||
// The MCP SDK's StreamableHTTPClientTransport has optional callback
|
||||
// properties typed without `| undefined` (exactOptionalPropertyTypes
|
||||
// mismatch with the Transport interface). The cast is safe — the SDK
|
||||
// constructed the object, it simply doesn't declare the optionals
|
||||
// strictly enough for our tsconfig.
|
||||
return new StreamableHTTPClientTransport(
|
||||
new URL(config.url),
|
||||
{ requestInit: { headers: config.headers } },
|
||||
) as Transport
|
||||
}
|
||||
}
|
||||
282
packages/mcp/mcp-client/tests/apply.spec.ts
Normal file
282
packages/mcp/mcp-client/tests/apply.spec.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Tests for the mcp-client plugin's `apply` lifecycle entry point.
|
||||
* Isolated file so vi.mock of the MCP SDK doesn't pollute other test suites.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { Config } from '@deepseek-ai/dsh-mcp-client'
|
||||
|
||||
// ---- Mock MCP SDK ----
|
||||
|
||||
// vi.mock factories are hoisted above every import/const, so the mock fns and
|
||||
// class must be created inside vi.hoisted to exist when the factories run.
|
||||
const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient } = vi.hoisted(() => {
|
||||
const mockConnect = vi.fn<() => Promise<void>>()
|
||||
const mockClose = vi.fn<() => Promise<void>>()
|
||||
const mockListTools = vi.fn()
|
||||
const mockCallTool = vi.fn()
|
||||
const mockSetNotificationHandler = vi.fn()
|
||||
class MockClient {
|
||||
connect = mockConnect
|
||||
close = mockClose
|
||||
listTools = mockListTools
|
||||
callTool = mockCallTool
|
||||
setNotificationHandler = mockSetNotificationHandler
|
||||
}
|
||||
return { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient }
|
||||
})
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
Client: MockClient,
|
||||
}))
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
StdioClientTransport: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
|
||||
StreamableHTTPClientTransport: vi.fn(),
|
||||
}))
|
||||
|
||||
// vi.mock is hoisted above static imports, so the module under test sees the
|
||||
// mocked SDK even through a static import.
|
||||
import { apply, name, inject, Config as ConfigSchema } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
async function mountRegistry(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
// Annotated binding (not withResolvers<void>()): the tests lint layer runs
|
||||
// no-invalid-void-type with default options, which rejects the explicit
|
||||
// type argument in call position but accepts the inferred form.
|
||||
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
setTimeout(gate.resolve, ms)
|
||||
return gate.promise
|
||||
}
|
||||
|
||||
const stdioConfig: Config = {
|
||||
transport: 'stdio',
|
||||
serverName: 'srv',
|
||||
command: 'echo',
|
||||
args: [],
|
||||
env: {},
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
describe('mcp-client plugin module exports', () => {
|
||||
it('exports name, inject, and Config', () => {
|
||||
expect(name).toBe('mcp-client')
|
||||
expect(inject).toEqual(['tools'])
|
||||
expect(ConfigSchema).toBeDefined()
|
||||
})
|
||||
|
||||
it('Config schema rejects a missing serverName', () => {
|
||||
expect(() => ConfigSchema({
|
||||
transport: 'stdio',
|
||||
command: 'echo',
|
||||
} as never)).toThrow()
|
||||
})
|
||||
|
||||
it('Config schema rejects an invalid serverName', () => {
|
||||
// schemastery unions wrap branch errors in a generic "expected ... but got"
|
||||
// message, so assert the throw, not the inner pattern text.
|
||||
expect(() => ConfigSchema({
|
||||
transport: 'stdio',
|
||||
serverName: 'bad name!',
|
||||
command: 'echo',
|
||||
} as never)).toThrow()
|
||||
expect(() => ConfigSchema({
|
||||
transport: 'stdio',
|
||||
serverName: 'x'.repeat(33),
|
||||
command: 'echo',
|
||||
} as never)).toThrow()
|
||||
})
|
||||
|
||||
it('Config schema accepts a valid serverName', () => {
|
||||
const resolved = ConfigSchema({
|
||||
transport: 'stdio',
|
||||
serverName: 'github-prod_1',
|
||||
command: 'echo',
|
||||
} as never)
|
||||
expect(resolved.serverName).toBe('github-prod_1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('apply (plugin lifecycle)', () => {
|
||||
let ctx: Context
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
mockConnect.mockResolvedValue(undefined)
|
||||
mockClose.mockResolvedValue(undefined)
|
||||
mockListTools.mockResolvedValue({
|
||||
tools: [{ name: 'remote', description: 'A remote tool', inputSchema: { type: 'object' } }],
|
||||
nextCursor: undefined,
|
||||
})
|
||||
mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] })
|
||||
ctx = await mountRegistry()
|
||||
})
|
||||
|
||||
it('connects, syncs tools under the namespace, and registers a notification handler', async () => {
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
|
||||
expect(mockConnect).toHaveBeenCalled()
|
||||
expect(mockListTools).toHaveBeenCalled()
|
||||
expect(mockSetNotificationHandler).toHaveBeenCalled()
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
expect(ctx.tools.get('remote')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a duplicate serverName at load and leaves the first instance intact', async () => {
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
|
||||
expect(() => { apply(ctx, stdioConfig) }).toThrow(/serverName "srv" is already in use/)
|
||||
// First instance unaffected.
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
})
|
||||
|
||||
it('releases the serverName reservation on dispose', async () => {
|
||||
const first = new Context()
|
||||
await first.plugin(SystemPrompt)
|
||||
await first.plugin(ToolRegistry)
|
||||
apply(first, stdioConfig)
|
||||
await sleep(50)
|
||||
|
||||
await first.fiber.dispose()
|
||||
await sleep(50)
|
||||
|
||||
// Same root would conflict; a fresh app root reuses the name freely,
|
||||
// and the disposed instance no longer holds the reservation on its root.
|
||||
const second = new Context()
|
||||
await second.plugin(SystemPrompt)
|
||||
await second.plugin(ToolRegistry)
|
||||
expect(() => { apply(second, stdioConfig) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('scopes serverName reservations per app root', async () => {
|
||||
const other = await mountRegistry()
|
||||
|
||||
apply(ctx, stdioConfig)
|
||||
// Same serverName on a DIFFERENT root is fine.
|
||||
expect(() => { apply(other, stdioConfig) }).not.toThrow()
|
||||
await sleep(50)
|
||||
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
expect(other.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
})
|
||||
|
||||
it('logs error and registers no tools when connect fails; dispose is a no-op', async () => {
|
||||
mockConnect.mockRejectedValue(new Error('connection refused'))
|
||||
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
|
||||
expect(mockListTools).not.toHaveBeenCalled()
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
|
||||
// Disposal exercises the empty fallback accessor: nothing to unregister,
|
||||
// close still attempted, no throw.
|
||||
await ctx.fiber.dispose()
|
||||
await sleep(50)
|
||||
expect(mockClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-syncs tools on ToolListChanged notification', async () => {
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
|
||||
// Simulate the notification handler being invoked with a new tool list.
|
||||
mockListTools.mockResolvedValue({
|
||||
tools: [{ name: 'updated', inputSchema: { type: 'object' } }],
|
||||
nextCursor: undefined,
|
||||
})
|
||||
|
||||
// Extract and call the notification handler.
|
||||
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
|
||||
await handler()
|
||||
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
expect(ctx.tools.get('mcp__srv__updated')).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the previous generation when a re-sync fails', async () => {
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
|
||||
mockListTools.mockRejectedValue(new Error('flaky server'))
|
||||
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
|
||||
// Must not reject (contained), and must keep the last good generation.
|
||||
await handler()
|
||||
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
})
|
||||
|
||||
it('effect disposer unregisters the CURRENT generation and closes client', async () => {
|
||||
// Load through ctx.plugin so ONLY the plugin's fiber is disposed — the
|
||||
// registry must survive to observe the unregistration.
|
||||
const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig)
|
||||
await sleep(50)
|
||||
|
||||
// Advance to a second generation first.
|
||||
mockListTools.mockResolvedValue({
|
||||
tools: [{ name: 'updated', inputSchema: { type: 'object' } }],
|
||||
nextCursor: undefined,
|
||||
})
|
||||
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
|
||||
await handler()
|
||||
expect(ctx.tools.get('mcp__srv__updated')).toBeDefined()
|
||||
|
||||
await fiber.dispose()
|
||||
await sleep(50)
|
||||
|
||||
expect(mockClose).toHaveBeenCalled()
|
||||
// The live (second) generation was unregistered, not just the first.
|
||||
expect(ctx.tools.get('mcp__srv__updated')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('effect disposer handles client.close failure gracefully', async () => {
|
||||
mockClose.mockRejectedValue(new Error('already closed'))
|
||||
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
|
||||
// Should not throw when dispose is triggered.
|
||||
await ctx.fiber.dispose()
|
||||
await sleep(50)
|
||||
|
||||
expect(mockClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses streamable-http config path', async () => {
|
||||
const httpConfig: Config = {
|
||||
transport: 'streamable-http',
|
||||
serverName: 'web',
|
||||
url: 'http://localhost:3000/mcp',
|
||||
headers: { Authorization: 'Bearer x' },
|
||||
toolCallTimeoutMs: 30_000,
|
||||
}
|
||||
|
||||
apply(ctx, httpConfig)
|
||||
await sleep(50)
|
||||
|
||||
expect(mockConnect).toHaveBeenCalled()
|
||||
expect(ctx.tools.get('mcp__web__remote')).toBeDefined()
|
||||
})
|
||||
})
|
||||
65
packages/mcp/mcp-client/tests/fixture-server.ts
Normal file
65
packages/mcp/mcp-client/tests/fixture-server.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Minimal MCP server over stdio for e2e testing of the dsh-mcp-client plugin.
|
||||
* Registers controlled tools with predictable behavior for asserting edge cases.
|
||||
*
|
||||
* Run: node --import tsx fixture-server.ts
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import { z } from 'zod'
|
||||
|
||||
const server = new McpServer(
|
||||
{ name: 'fixture-server', version: '1.0.0' },
|
||||
{ capabilities: { tools: { listChanged: true } } },
|
||||
)
|
||||
|
||||
server.registerTool('add', {
|
||||
title: 'Add Tool',
|
||||
description: 'Adds two numbers.',
|
||||
inputSchema: { a: z.number().describe('First number'), b: z.number().describe('Second number') },
|
||||
}, async args => ({
|
||||
content: [{ type: 'text', text: String(args.a + args.b) }],
|
||||
}))
|
||||
|
||||
server.registerTool('greet', {
|
||||
title: 'Greet Tool',
|
||||
description: 'Greets a person by name.',
|
||||
inputSchema: { name: z.string().describe('Name to greet') },
|
||||
}, async args => ({
|
||||
content: [{ type: 'text', text: `Hello, ${args.name}!` }],
|
||||
}))
|
||||
|
||||
server.registerTool('fail', {
|
||||
title: 'Fail Tool',
|
||||
description: 'Always returns an error.',
|
||||
inputSchema: {},
|
||||
}, async () => ({
|
||||
content: [{ type: 'text', text: 'Something went wrong' }],
|
||||
isError: true,
|
||||
}))
|
||||
|
||||
server.registerTool('image', {
|
||||
title: 'Image Tool',
|
||||
description: 'Returns an image content block.',
|
||||
inputSchema: {},
|
||||
}, async () => ({
|
||||
content: [
|
||||
{ type: 'text', text: 'Here is an image:' },
|
||||
{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' },
|
||||
{ type: 'text', text: 'End of image.' },
|
||||
],
|
||||
}))
|
||||
|
||||
// Dotted name: legal in MCP, illegal in the DeepSeek function-name contract.
|
||||
// Exercises the bridge's normalize-and-hash public-name path end to end.
|
||||
server.registerTool('admin.reset', {
|
||||
title: 'Admin Reset Tool',
|
||||
description: 'Tool with a dotted name (normalization test).',
|
||||
inputSchema: {},
|
||||
}, async () => ({
|
||||
content: [{ type: 'text', text: 'reset done' }],
|
||||
}))
|
||||
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
29
packages/mcp/mcp-client/tests/load-path.spec.ts
Normal file
29
packages/mcp/mcp-client/tests/load-path.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Real-load-path guard for @deepseek-ai/dsh-mcp-client. `mcp-client` is a
|
||||
* NAMESPACE plugin with `inject` — so a stray `export default apply` would
|
||||
* make the cordis Loader's `unwrapExports` (`exports.default ?? exports`)
|
||||
* collapse the module to the bare `apply` function, DROPPING `inject`. The
|
||||
* plugin would then read `ctx.tools` without having injected it and throw
|
||||
* `cannot get property … without inject` the moment it loads (postmortem 0001).
|
||||
*
|
||||
* This test unwraps the module through the REAL `Loader.prototype.unwrapExports`
|
||||
* and verifies the namespace shape is preserved.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import * as mcpClient from '@deepseek-ai/dsh-mcp-client'
|
||||
|
||||
describe('dsh-mcp-client real-load-path guard', () => {
|
||||
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
|
||||
expect('default' in mcpClient).toBe(false)
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(mcpClient) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(mcpClient)
|
||||
expect(unwrapped.name).toBe('mcp-client')
|
||||
expect(unwrapped.inject).toEqual(['tools'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
})
|
||||
})
|
||||
441
packages/mcp/mcp-client/tests/mcp-client.e2e.ts
Normal file
441
packages/mcp/mcp-client/tests/mcp-client.e2e.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* End-to-end tests for dsh-mcp-client. Exercises the REAL MCP protocol against:
|
||||
* 1. A self-written fixture server over stdio (controlled edge cases)
|
||||
* 2. @modelcontextprotocol/server-everything (official integration test server)
|
||||
* 3. @modelcontextprotocol/server-filesystem (real filesystem operations)
|
||||
* 4. An in-process StreamableHTTPServerTransport server over Streamable HTTP
|
||||
*
|
||||
* No API key needed — all servers are local/keyless.
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
||||
import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
||||
import { z } from 'zod'
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
|
||||
import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
|
||||
import type { Config } from '@deepseek-ai/dsh-mcp-client'
|
||||
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const fixtureServerPath = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
// Resolve package-local .bin for pnpm-hoisted MCP server binaries.
|
||||
const packageDir = fileURLToPath(new URL('..', import.meta.url))
|
||||
const localBin = join(packageDir, 'node_modules', '.bin')
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
async function mountRegistry(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Apply the MCP client plugin and wait for tools to be registered. */
|
||||
async function applyAndWait(ctx: Context, config: Config, timeoutMs = 20_000): Promise<void> {
|
||||
// Annotated bindings (not withResolvers<void>()): the tests lint layer runs
|
||||
// no-invalid-void-type with default options, which rejects the explicit
|
||||
// type argument in call position but accepts the inferred form.
|
||||
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
const timer = setTimeout(
|
||||
() => { gate.reject(new Error(`applyAndWait timed out after ${timeoutMs}ms — no tools/change event`)) },
|
||||
timeoutMs,
|
||||
)
|
||||
ctx.on('tools/change', () => { clearTimeout(timer); gate.resolve() })
|
||||
apply(ctx, config)
|
||||
await gate.promise
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
setTimeout(gate.resolve, ms)
|
||||
return gate.promise
|
||||
}
|
||||
|
||||
/** Narrow a result content block to its text, failing the test on any other shape. */
|
||||
function textOf(block: unknown): string {
|
||||
if (block && typeof block === 'object' && 'text' in block && typeof block.text === 'string') {
|
||||
return block.text
|
||||
}
|
||||
throw new Error(`expected a text content block, got ${JSON.stringify(block)}`)
|
||||
}
|
||||
|
||||
let callSeq = 0
|
||||
function nextCallId(): CallId {
|
||||
return CallId(`e2e-${++callSeq}`)
|
||||
}
|
||||
|
||||
// ---- Fixture server tests ----
|
||||
|
||||
describe('fixture server — controlled scenarios', () => {
|
||||
let ctx: Context
|
||||
|
||||
const fixtureConfig: Config = {
|
||||
transport: 'stdio',
|
||||
serverName: 'fixture',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, fixtureServerPath],
|
||||
env: { TSX_TSCONFIG_PATH: repoTsconfig },
|
||||
cwd: packageDir,
|
||||
toolCallTimeoutMs: 15_000,
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await mountRegistry()
|
||||
await applyAndWait(ctx, fixtureConfig)
|
||||
}, 30_000)
|
||||
|
||||
afterAll(async () => {
|
||||
if (ctx) await ctx.fiber.dispose()
|
||||
await sleep(200)
|
||||
})
|
||||
|
||||
it('discovers all fixture tools under the server namespace', () => {
|
||||
const schemas = ctx.tools.schemas()
|
||||
const names = schemas.map(s => s.name)
|
||||
expect(names).toContain('mcp__fixture__add')
|
||||
expect(names).toContain('mcp__fixture__greet')
|
||||
expect(names).toContain('mcp__fixture__fail')
|
||||
expect(names).toContain('mcp__fixture__image')
|
||||
// Raw names are not registered.
|
||||
expect(names).not.toContain('add')
|
||||
})
|
||||
|
||||
it('normalizes the dotted tool name with a deterministic hash suffix', () => {
|
||||
const publicName = publicToolName('fixture', 'admin.reset')
|
||||
expect(publicName).toMatch(/^mcp__fixture__admin_reset_[0-9a-f]{12}$/)
|
||||
expect(ctx.tools.get(publicName)).toBeDefined()
|
||||
})
|
||||
|
||||
it('executes the dotted tool via its normalized public name', async () => {
|
||||
const result = await ctx.tools.execute({
|
||||
callId: nextCallId(), name: publicToolName('fixture', 'admin.reset'), arguments: {},
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'reset done' })
|
||||
})
|
||||
|
||||
it('executes add(2, 3) → "5"', async () => {
|
||||
const result = await ctx.tools.execute({
|
||||
callId: nextCallId(), name: 'mcp__fixture__add', arguments: { a: 2, b: 3 },
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '5' })
|
||||
})
|
||||
|
||||
it('executes greet("World") → "Hello, World!"', async () => {
|
||||
const result = await ctx.tools.execute({
|
||||
callId: nextCallId(), name: 'mcp__fixture__greet', arguments: { name: 'World' },
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'Hello, World!' })
|
||||
})
|
||||
|
||||
it('executes fail() → isError result', async () => {
|
||||
const result = await ctx.tools.execute({
|
||||
callId: nextCallId(), name: 'mcp__fixture__fail', arguments: {},
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ type: 'text' })
|
||||
})
|
||||
|
||||
it('executes image() → image placeholder', async () => {
|
||||
const result = await ctx.tools.execute({
|
||||
callId: nextCallId(), name: 'mcp__fixture__image', arguments: {},
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
const text = textOf(result.content[0])
|
||||
expect(text).toContain('Here is an image:')
|
||||
expect(text).toContain('[image: image/png, content discarded]')
|
||||
expect(text).toContain('End of image.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fixture server — duplicate serverName', () => {
|
||||
it('rejects a second instance with the same serverName on one root', async () => {
|
||||
const ctx = await mountRegistry()
|
||||
const config: Config = {
|
||||
transport: 'stdio',
|
||||
serverName: 'dup',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, fixtureServerPath],
|
||||
env: { TSX_TSCONFIG_PATH: repoTsconfig },
|
||||
cwd: packageDir,
|
||||
toolCallTimeoutMs: 15_000,
|
||||
}
|
||||
await applyAndWait(ctx, config)
|
||||
|
||||
expect(() => { apply(ctx, config) }).toThrow(/serverName "dup" is already in use/)
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
await sleep(200)
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
describe('fixture server — disposal', () => {
|
||||
it('disposes cleanly without error', async () => {
|
||||
const ctx = await mountRegistry()
|
||||
await applyAndWait(ctx, {
|
||||
transport: 'stdio',
|
||||
serverName: 'fixture',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, fixtureServerPath],
|
||||
env: { TSX_TSCONFIG_PATH: repoTsconfig },
|
||||
cwd: packageDir,
|
||||
toolCallTimeoutMs: 15_000,
|
||||
})
|
||||
|
||||
// Tools are registered before dispose.
|
||||
expect(ctx.tools.get('mcp__fixture__add')).toBeDefined()
|
||||
expect(ctx.tools.schemas().length).toBeGreaterThanOrEqual(4)
|
||||
|
||||
// Dispose should complete without throwing.
|
||||
await ctx.fiber.dispose()
|
||||
await sleep(200)
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
// ---- @modelcontextprotocol/server-everything ----
|
||||
|
||||
describe('server-everything — official test server', () => {
|
||||
let ctx: Context
|
||||
|
||||
const config: Config = {
|
||||
transport: 'stdio',
|
||||
serverName: 'everything',
|
||||
command: join(localBin, 'mcp-server-everything'),
|
||||
args: ['stdio'],
|
||||
env: {},
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 30_000,
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await mountRegistry()
|
||||
await applyAndWait(ctx, config)
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
if (ctx) await ctx.fiber.dispose()
|
||||
await sleep(500)
|
||||
})
|
||||
|
||||
it('discovers tools from server-everything', () => {
|
||||
const schemas = ctx.tools.schemas()
|
||||
const names = schemas.map(s => s.name)
|
||||
expect(names).toContain('mcp__everything__echo')
|
||||
expect(names).toContain('mcp__everything__get-sum')
|
||||
expect(names).toContain('mcp__everything__get-tiny-image')
|
||||
expect(names.length).toBeGreaterThanOrEqual(8)
|
||||
})
|
||||
|
||||
it('executes echo({ message: "hello" }) → "Echo: hello"', async () => {
|
||||
const result = await ctx.tools.execute({
|
||||
callId: nextCallId(), name: 'mcp__everything__echo', arguments: { message: 'hello' },
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(textOf(result.content[0])).toBe('Echo: hello')
|
||||
})
|
||||
|
||||
it('executes get-sum({ a: 3, b: 7 }) → contains "10"', async () => {
|
||||
const result = await ctx.tools.execute({
|
||||
callId: nextCallId(), name: 'mcp__everything__get-sum', arguments: { a: 3, b: 7 },
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(textOf(result.content[0])).toContain('10')
|
||||
})
|
||||
|
||||
it('executes get-tiny-image → image placeholder', async () => {
|
||||
const result = await ctx.tools.execute({
|
||||
callId: nextCallId(), name: 'mcp__everything__get-tiny-image', arguments: {},
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(textOf(result.content[0])).toContain('[image: image/png, content discarded]')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- @modelcontextprotocol/server-filesystem ----
|
||||
|
||||
describe('server-filesystem — real filesystem operations', () => {
|
||||
let ctx: Context
|
||||
let tempDir: string
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'mcp-fs-e2e-'))
|
||||
|
||||
ctx = await mountRegistry()
|
||||
const config: Config = {
|
||||
transport: 'stdio',
|
||||
serverName: 'filesystem',
|
||||
command: join(localBin, 'mcp-server-filesystem'),
|
||||
args: [tempDir],
|
||||
env: {},
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 30_000,
|
||||
}
|
||||
await applyAndWait(ctx, config)
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
if (ctx) await ctx.fiber.dispose()
|
||||
await sleep(500)
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers filesystem tools', () => {
|
||||
const schemas = ctx.tools.schemas()
|
||||
const names = schemas.map(s => s.name)
|
||||
expect(names).toContain('mcp__filesystem__read_file')
|
||||
expect(names).toContain('mcp__filesystem__write_file')
|
||||
expect(names).toContain('mcp__filesystem__list_directory')
|
||||
})
|
||||
|
||||
it('write_file + read_file round-trip', async () => {
|
||||
const filePath = join(tempDir, 'test.txt')
|
||||
const content = 'Hello from MCP e2e test!'
|
||||
|
||||
// Write via MCP tool
|
||||
const writeResult = await ctx.tools.execute({
|
||||
callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content },
|
||||
})
|
||||
expect(writeResult.isError).toBe(false)
|
||||
|
||||
// Verify file was actually written (world verification)
|
||||
const onDisk = await readFile(filePath, 'utf8')
|
||||
expect(onDisk).toBe(content)
|
||||
|
||||
// Read back via MCP tool
|
||||
const readResult = await ctx.tools.execute({
|
||||
callId: nextCallId(), name: 'mcp__filesystem__read_file', arguments: { path: filePath },
|
||||
})
|
||||
expect(readResult.isError).toBe(false)
|
||||
expect(textOf(readResult.content[0])).toContain(content)
|
||||
})
|
||||
|
||||
it('list_directory shows written file', async () => {
|
||||
// Ensure a file exists
|
||||
await writeFile(join(tempDir, 'listed.txt'), 'listed')
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: nextCallId(), name: 'mcp__filesystem__list_directory', arguments: { path: tempDir },
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(textOf(result.content[0])).toContain('listed.txt')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Streamable HTTP transport ----
|
||||
|
||||
describe('streamable-http — in-process MCP server', () => {
|
||||
let ctx: Context
|
||||
let httpServer: Server
|
||||
let baseUrl: string
|
||||
/** Authorization header values observed by the HTTP server, in arrival order. */
|
||||
const seenAuth: Array<string | undefined> = []
|
||||
|
||||
/**
|
||||
* Stateless Streamable HTTP endpoint: a fresh McpServer + server transport
|
||||
* per request (the SDK's documented stateless pattern — no session id, no
|
||||
* SSE stream to keep). The tool set mirrors a minimal fixture server.
|
||||
*/
|
||||
async function handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
seenAuth.push(req.headers.authorization)
|
||||
const server = new McpServer(
|
||||
{ name: 'http-fixture', version: '1.0.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
)
|
||||
server.registerTool('ping', {
|
||||
description: 'Replies pong.',
|
||||
inputSchema: {},
|
||||
}, async () => ({
|
||||
content: [{ type: 'text', text: 'pong' }],
|
||||
}))
|
||||
server.registerTool('shout', {
|
||||
description: 'Upper-cases a message.',
|
||||
inputSchema: { message: z.string().describe('Message to upper-case') },
|
||||
}, async args => ({
|
||||
content: [{ type: 'text', text: args.message.toUpperCase() }],
|
||||
}))
|
||||
// Stateless mode: sessionIdGenerator ABSENT (the runtime treats absent and
|
||||
// explicit-undefined identically; exactOptionalPropertyTypes forbids the
|
||||
// SDK-documented explicit `sessionIdGenerator: undefined` spelling).
|
||||
const transport = new StreamableHTTPServerTransport({})
|
||||
res.on('close', () => { void transport.close(); void server.close() })
|
||||
// Same exactOptionalPropertyTypes mismatch the client transport factory
|
||||
// documents (src/transport.ts): the SDK types optional callbacks without
|
||||
// `| undefined`. The SDK constructed the object; the cast is safe.
|
||||
await server.connect(transport as Transport)
|
||||
await transport.handleRequest(req, res)
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
httpServer = createServer((req, res) => {
|
||||
handleMcpRequest(req, res).catch((error: unknown) => {
|
||||
res.writeHead(500).end(String(error))
|
||||
})
|
||||
})
|
||||
const listening: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
httpServer.listen(0, '127.0.0.1', listening.resolve)
|
||||
await listening.promise
|
||||
const address = httpServer.address()
|
||||
if (address === null || typeof address === 'string') throw new Error(`expected a TCP AddressInfo, got ${String(address)}`)
|
||||
baseUrl = `http://127.0.0.1:${address.port}/mcp`
|
||||
|
||||
ctx = await mountRegistry()
|
||||
const config: Config = {
|
||||
transport: 'streamable-http',
|
||||
serverName: 'web',
|
||||
url: baseUrl,
|
||||
headers: { Authorization: 'Bearer e2e-test-token' },
|
||||
toolCallTimeoutMs: 15_000,
|
||||
}
|
||||
await applyAndWait(ctx, config)
|
||||
}, 30_000)
|
||||
|
||||
afterAll(async () => {
|
||||
if (ctx) await ctx.fiber.dispose()
|
||||
await sleep(200)
|
||||
const closed: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
httpServer.close(() => { closed.resolve() })
|
||||
await closed.promise
|
||||
})
|
||||
|
||||
it('discovers tools under the server namespace over HTTP', () => {
|
||||
const names = ctx.tools.schemas().map(s => s.name)
|
||||
expect(names).toContain('mcp__web__ping')
|
||||
expect(names).toContain('mcp__web__shout')
|
||||
})
|
||||
|
||||
it('executes ping() → "pong" over HTTP', async () => {
|
||||
const result = await ctx.tools.execute({
|
||||
callId: nextCallId(), name: 'mcp__web__ping', arguments: {},
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'pong' })
|
||||
})
|
||||
|
||||
it('executes shout({ message }) with args over HTTP', async () => {
|
||||
const result = await ctx.tools.execute({
|
||||
callId: nextCallId(), name: 'mcp__web__shout', arguments: { message: 'quiet' },
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'QUIET' })
|
||||
})
|
||||
|
||||
it('sends configured headers on every HTTP request', () => {
|
||||
expect(seenAuth.length).toBeGreaterThan(0)
|
||||
for (const auth of seenAuth) expect(auth).toBe('Bearer e2e-test-token')
|
||||
})
|
||||
})
|
||||
602
packages/mcp/mcp-client/tests/mcp-client.spec.ts
Normal file
602
packages/mcp/mcp-client/tests/mcp-client.spec.ts
Normal file
@@ -0,0 +1,602 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
|
||||
import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts'
|
||||
import type { Config } from '@deepseek-ai/dsh-mcp-client'
|
||||
|
||||
// ---- Mock MCP Client ----
|
||||
|
||||
interface MockTool {
|
||||
name: string
|
||||
description?: string
|
||||
inputSchema: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface MockCallResult {
|
||||
content: Array<{ type: string; text?: string; mimeType?: string }>
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
function createMockClient(tools: MockTool[], callResult: MockCallResult = { content: [{ type: 'text', text: 'ok' }] }) {
|
||||
return {
|
||||
listTools: vi.fn().mockResolvedValue({ tools, nextCursor: undefined }),
|
||||
callTool: vi.fn().mockResolvedValue(callResult),
|
||||
setNotificationHandler: vi.fn(),
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Test harness helper ----
|
||||
|
||||
async function mountRegistry(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const defaultOpts: ToolBridgeOptions = {
|
||||
serverName: 'srv',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
describe('publicToolName', () => {
|
||||
it('joins clean names verbatim', () => {
|
||||
expect(publicToolName('github', 'create_issue')).toBe('mcp__github__create_issue')
|
||||
expect(publicToolName('everything', 'get-sum')).toBe('mcp__everything__get-sum')
|
||||
})
|
||||
|
||||
it('replaces invalid characters and appends an identity hash', () => {
|
||||
const name = publicToolName('srv', 'admin.reset')
|
||||
expect(name).toMatch(/^mcp__srv__admin_reset_[0-9a-f]{12}$/)
|
||||
expect(name.length).toBeLessThanOrEqual(64)
|
||||
})
|
||||
|
||||
it('truncates over-long names and appends an identity hash', () => {
|
||||
const rawName = 'a'.repeat(80)
|
||||
const name = publicToolName('srv', rawName)
|
||||
expect(name).toHaveLength(64)
|
||||
expect(name).toMatch(/_[0-9a-f]{12}$/)
|
||||
expect(name.startsWith('mcp__srv__aaa')).toBe(true)
|
||||
})
|
||||
|
||||
it('is deterministic and collision-free for distinct identities', () => {
|
||||
// Two raw names that normalize to the same base must not collapse.
|
||||
const a = publicToolName('srv', 'admin.reset')
|
||||
const b = publicToolName('srv', 'admin_reset')
|
||||
expect(a).toBe(publicToolName('srv', 'admin.reset'))
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncTools', () => {
|
||||
let ctx: Context
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await mountRegistry()
|
||||
})
|
||||
|
||||
it('registers tools under server-qualified public names', async () => {
|
||||
const client = createMockClient([
|
||||
{ name: 'greet', description: 'Say hello', inputSchema: { type: 'object', properties: { name: { type: 'string' } } } },
|
||||
{ name: 'add', description: 'Add numbers', inputSchema: { type: 'object', properties: {} } },
|
||||
])
|
||||
|
||||
const disposers = await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
|
||||
expect(disposers.size).toBe(2)
|
||||
expect(ctx.tools.get('mcp__srv__greet')).toBeDefined()
|
||||
expect(ctx.tools.get('mcp__srv__add')).toBeDefined()
|
||||
// Raw names are NOT registered.
|
||||
expect(ctx.tools.get('greet')).toBeUndefined()
|
||||
expect(ctx.tools.get('add')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lets two servers publish the same raw name side by side', async () => {
|
||||
const clientA = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }])
|
||||
const clientB = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }])
|
||||
|
||||
await syncTools(clientA as never, ctx, { ...defaultOpts, serverName: 'github' }, new Map())
|
||||
await syncTools(clientB as never, ctx, { ...defaultOpts, serverName: 'web' }, new Map())
|
||||
|
||||
expect(ctx.tools.get('mcp__github__search')).toBeDefined()
|
||||
expect(ctx.tools.get('mcp__web__search')).toBeDefined()
|
||||
})
|
||||
|
||||
it('coexists with a native tool of the same raw name', async () => {
|
||||
ctx.tools.register({
|
||||
name: 'search',
|
||||
description: 'Native search',
|
||||
parameters: { type: 'object' },
|
||||
execute: async () => [{ type: 'text', text: 'native' }],
|
||||
})
|
||||
const client = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }])
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
|
||||
expect(ctx.tools.get('search')).toBeDefined()
|
||||
expect(ctx.tools.get('mcp__srv__search')).toBeDefined()
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'search', arguments: {} })
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'native' })
|
||||
})
|
||||
|
||||
it('rejects a tool list where one raw name appears twice', async () => {
|
||||
const client = createMockClient([
|
||||
{ name: 'dup', inputSchema: { type: 'object' } },
|
||||
{ name: 'dup', inputSchema: { type: 'object' } },
|
||||
])
|
||||
|
||||
await expect(syncTools(client as never, ctx, defaultOpts, new Map()))
|
||||
.rejects.toThrow(/listed tool "dup" more than once/)
|
||||
// Nothing registered, previous generation untouched (it was empty).
|
||||
expect(ctx.tools.get('mcp__srv__dup')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the previous generation when the fetch phase fails', async () => {
|
||||
const client = createMockClient([{ name: 'stable', inputSchema: { type: 'object' } }])
|
||||
const first = await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
expect(ctx.tools.get('mcp__srv__stable')).toBeDefined()
|
||||
|
||||
client.listTools.mockRejectedValue(new Error('network down'))
|
||||
await expect(syncTools(client as never, ctx, defaultOpts, first)).rejects.toThrow('network down')
|
||||
|
||||
// The previous generation is still live.
|
||||
expect(ctx.tools.get('mcp__srv__stable')).toBeDefined()
|
||||
})
|
||||
|
||||
it('rolls back the whole generation when a foreign tool squats on the namespace', async () => {
|
||||
// A foreign registration occupies one of this server's public names.
|
||||
ctx.tools.register({
|
||||
name: 'mcp__srv__taken',
|
||||
description: 'Squatter',
|
||||
parameters: { type: 'object' },
|
||||
execute: async () => [{ type: 'text', text: 'squatter' }],
|
||||
})
|
||||
const client = createMockClient([
|
||||
{ name: 'free', inputSchema: { type: 'object' } },
|
||||
{ name: 'taken', inputSchema: { type: 'object' } },
|
||||
])
|
||||
|
||||
const disposers = await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
|
||||
// All-or-nothing: the non-conflicting tool is rolled back too.
|
||||
expect(disposers.size).toBe(0)
|
||||
expect(ctx.tools.get('mcp__srv__free')).toBeUndefined()
|
||||
// The squatter is untouched.
|
||||
expect(ctx.tools.get('mcp__srv__taken')).toBeDefined()
|
||||
})
|
||||
|
||||
it('unregisters previous tools before re-syncing', async () => {
|
||||
const client = createMockClient([
|
||||
{ name: 'old_tool', inputSchema: { type: 'object' } },
|
||||
])
|
||||
|
||||
const firstDisposers = await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
expect(ctx.tools.get('mcp__srv__old_tool')).toBeDefined()
|
||||
|
||||
// Second sync with different tools should remove old_tool.
|
||||
client.listTools.mockResolvedValue({ tools: [{ name: 'new_tool', inputSchema: { type: 'object' } }], nextCursor: undefined })
|
||||
const secondDisposers = await syncTools(client as never, ctx, defaultOpts, firstDisposers)
|
||||
|
||||
expect(ctx.tools.get('mcp__srv__old_tool')).toBeUndefined()
|
||||
expect(ctx.tools.get('mcp__srv__new_tool')).toBeDefined()
|
||||
expect(secondDisposers.size).toBe(1)
|
||||
})
|
||||
|
||||
it('drains paginated listTools responses', async () => {
|
||||
const client = createMockClient([])
|
||||
client.listTools
|
||||
.mockResolvedValueOnce({ tools: [{ name: 'page1', inputSchema: { type: 'object' } }], nextCursor: 'cursor1' })
|
||||
.mockResolvedValueOnce({ tools: [{ name: 'page2', inputSchema: { type: 'object' } }], nextCursor: undefined })
|
||||
|
||||
const disposers = await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
|
||||
expect(disposers.size).toBe(2)
|
||||
expect(ctx.tools.get('mcp__srv__page1')).toBeDefined()
|
||||
expect(ctx.tools.get('mcp__srv__page2')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool execution', () => {
|
||||
let ctx: Context
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await mountRegistry()
|
||||
})
|
||||
|
||||
it('calls MCP callTool with the RAW name and returns text content', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'echo', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'text', text: 'hello world' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } })
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'hello world' }])
|
||||
// The wire sees the raw MCP name, never the public name.
|
||||
expect(client.callTool).toHaveBeenCalledWith(
|
||||
{ name: 'echo', arguments: { msg: 'hi' } },
|
||||
undefined,
|
||||
expect.objectContaining({ timeout: 60_000 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('sends the raw name for normalized public names', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'admin.reset', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'text', text: 'reset done' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const publicName = publicToolName('srv', 'admin.reset')
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: publicName, arguments: {} })
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(client.callTool).toHaveBeenCalledWith(
|
||||
{ name: 'admin.reset', arguments: {} },
|
||||
undefined,
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('joins multiple text blocks with newline', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'multi', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'text', text: 'line1' }, { type: 'text', text: 'line2' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__multi', arguments: {} })
|
||||
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }])
|
||||
})
|
||||
|
||||
it('discards image content with placeholder', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'img', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'text', text: 'before' }, { type: 'image', mimeType: 'image/png' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' })
|
||||
})
|
||||
|
||||
it('maps isError to an error result via throw', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'fail', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'text', text: 'something went wrong' }], isError: true },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__fail', arguments: {} })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' })
|
||||
})
|
||||
|
||||
it('passes abort signal to callTool', async () => {
|
||||
const controller = new AbortController()
|
||||
const client = createMockClient(
|
||||
[{ name: 'slow', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'text', text: 'done' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__slow', arguments: {}, signal: controller.signal })
|
||||
|
||||
expect(client.callTool).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.objectContaining({ signal: controller.signal }),
|
||||
)
|
||||
})
|
||||
|
||||
it('handles legacy toolResult shape', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'legacy', inputSchema: { type: 'object' } }],
|
||||
)
|
||||
client.callTool.mockResolvedValue({ toolResult: { key: 'value' } })
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy', arguments: {} })
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool execution edge cases', () => {
|
||||
let ctx: Context
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await mountRegistry()
|
||||
})
|
||||
|
||||
it('handles audio content with placeholder', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'audio_tool', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'audio', mimeType: 'audio/mp3' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '[audio: audio/mp3, content discarded]' })
|
||||
})
|
||||
|
||||
it('handles resource content with placeholder', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'res_tool', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'resource' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
|
||||
})
|
||||
|
||||
it('handles resource_link content with placeholder', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'link_tool', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'resource_link' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
|
||||
})
|
||||
|
||||
it('handles unknown content types', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'unknown_tool', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'video' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported content type: video]' })
|
||||
})
|
||||
|
||||
it('handles image with missing mimeType (buggy server)', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'img2', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'image' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '[image: unknown, content discarded]' })
|
||||
})
|
||||
|
||||
it('handles audio with missing mimeType (buggy server)', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'audio_no_mime', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'audio' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '[audio: unknown, content discarded]' })
|
||||
})
|
||||
|
||||
it('handles text block with missing text (buggy server)', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'notext', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'text' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' })
|
||||
})
|
||||
|
||||
it('handles empty content array', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'empty_tool', inputSchema: { type: 'object' } }],
|
||||
{ content: [] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' })
|
||||
})
|
||||
|
||||
|
||||
it('handles legacy toolResult with undefined value', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'legacy2', inputSchema: { type: 'object' } }],
|
||||
)
|
||||
client.callTool.mockResolvedValue({})
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' })
|
||||
})
|
||||
|
||||
it('handles isError with non-text content (fallback error message)', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'err_notext', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'image', mimeType: 'image/png' }], isError: true },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'Error: [image: image/png, content discarded]' })
|
||||
})
|
||||
|
||||
|
||||
it('uses tool description when provided', async () => {
|
||||
const client = createMockClient([
|
||||
{ name: 'described', description: 'A described tool', inputSchema: { type: 'object' } },
|
||||
])
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const tool = ctx.tools.get('mcp__srv__described')
|
||||
expect(tool?.description).toBe('A described tool')
|
||||
})
|
||||
|
||||
it('uses empty description when tool has no description', async () => {
|
||||
const client = createMockClient([
|
||||
{ name: 'nodesc', inputSchema: { type: 'object' } },
|
||||
])
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const tool = ctx.tools.get('mcp__srv__nodesc')
|
||||
expect(tool?.description).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTransport', () => {
|
||||
it('creates StdioClientTransport for stdio config', () => {
|
||||
const config: Config = {
|
||||
transport: 'stdio',
|
||||
serverName: 'srv',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: {},
|
||||
cwd: '/tmp',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
}
|
||||
const transport = createTransport(config)
|
||||
expect(transport).toBeDefined()
|
||||
expect(transport).toHaveProperty('start')
|
||||
expect(transport).toHaveProperty('close')
|
||||
})
|
||||
|
||||
it('creates StreamableHTTPClientTransport for http config without headers', () => {
|
||||
const config: Config = {
|
||||
transport: 'streamable-http',
|
||||
serverName: 'srv',
|
||||
url: 'http://localhost:3000/mcp',
|
||||
headers: {},
|
||||
toolCallTimeoutMs: 60_000,
|
||||
}
|
||||
const transport = createTransport(config)
|
||||
expect(transport).toBeDefined()
|
||||
expect(transport).toHaveProperty('start')
|
||||
expect(transport).toHaveProperty('close')
|
||||
})
|
||||
|
||||
it('creates StreamableHTTPClientTransport for http config with headers', () => {
|
||||
const config: Config = {
|
||||
transport: 'streamable-http',
|
||||
serverName: 'srv',
|
||||
url: 'http://localhost:3000/mcp',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
toolCallTimeoutMs: 60_000,
|
||||
}
|
||||
const transport = createTransport(config)
|
||||
expect(transport).toBeDefined()
|
||||
expect(transport).toHaveProperty('start')
|
||||
expect(transport).toHaveProperty('close')
|
||||
})
|
||||
|
||||
it('scrubs sensitive env vars and forwards the rest', () => {
|
||||
const original = { ...process.env }
|
||||
try {
|
||||
process.env.SAFE_VAR = 'kept'
|
||||
process.env.MY_SECRET = 'hidden'
|
||||
process.env.API_KEY = 'hidden'
|
||||
process.env.AUTH_TOKEN = 'hidden'
|
||||
|
||||
const config: Config = {
|
||||
transport: 'stdio',
|
||||
serverName: 'srv',
|
||||
command: 'echo',
|
||||
args: [],
|
||||
env: { EXTRA: 'injected' },
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
}
|
||||
// createTransport internally calls buildChildEnv; we verify by inspecting
|
||||
// the constructed StdioClientTransport. Since we can't inspect private fields
|
||||
// easily, we at least confirm it doesn't throw and returns a transport.
|
||||
const transport = createTransport(config)
|
||||
expect(transport).toBeDefined()
|
||||
} finally {
|
||||
// Restore env
|
||||
delete process.env.SAFE_VAR
|
||||
delete process.env.MY_SECRET
|
||||
delete process.env.API_KEY
|
||||
delete process.env.AUTH_TOKEN
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in original)) Reflect.deleteProperty(process.env, key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('merges explicit env on top of scrubbed ambient env', () => {
|
||||
const config: Config = {
|
||||
transport: 'stdio',
|
||||
serverName: 'srv',
|
||||
command: 'echo',
|
||||
args: [],
|
||||
env: { CUSTOM: 'value' },
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
}
|
||||
const transport = createTransport(config)
|
||||
expect(transport).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool execution — non-object args fallback', () => {
|
||||
let ctx: Context
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await mountRegistry()
|
||||
})
|
||||
|
||||
it('coerces null args to empty object for callTool', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'coerce', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'text', text: 'ok' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
// Simulate model emitting `null` as tool arguments (malformed).
|
||||
await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null })
|
||||
|
||||
expect(client.callTool).toHaveBeenCalledWith(
|
||||
{ name: 'coerce', arguments: {} },
|
||||
undefined,
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('coerces primitive string args to empty object for callTool', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'coerce2', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'text', text: 'ok' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce2', arguments: 'bad' })
|
||||
|
||||
expect(client.callTool).toHaveBeenCalledWith(
|
||||
{ name: 'coerce2', arguments: {} },
|
||||
undefined,
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
15
packages/mcp/mcp-client/tsconfig.json
Normal file
15
packages/mcp/mcp-client/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/tools" }
|
||||
]
|
||||
}
|
||||
@@ -33,7 +33,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th
|
||||
config:
|
||||
providerName: acp
|
||||
command: node
|
||||
args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', '--config', './examples/acp-agent/cordis.yml']
|
||||
args: ['--import', 'tsx', './packages/examples/acp-demo/src/bin.ts', '--config', './examples/acp-agent/cordis.yml']
|
||||
permission: reject
|
||||
env:
|
||||
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
|
||||
|
||||
@@ -15,7 +15,7 @@ import * as acp from '../src/index.ts'
|
||||
*/
|
||||
|
||||
// The real acp-agent example: its bin + cordis.yml (the live DeepSeek config).
|
||||
const binScript = fileURLToPath(new URL('../../../ui/acp-agent/src/bin.ts', import.meta.url))
|
||||
const binScript = fileURLToPath(new URL('../../../examples/acp-demo/src/bin.ts', import.meta.url))
|
||||
const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cordis.yml', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
@@ -10,4 +10,4 @@ Packages that exist to serve development, testing, and the examples rather than
|
||||
| `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
|
||||
| `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) |
|
||||
|
||||
`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-core` bundle mounts it unconditionally. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.
|
||||
`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.
|
||||
|
||||
@@ -21,7 +21,7 @@ const SCENARIOS: Scenario[] = [
|
||||
|
||||
defineAcpSnapshotSuite({
|
||||
agent: { // absolute paths, resolved from the suite's own location
|
||||
binScript: fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)),
|
||||
binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
|
||||
configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
|
||||
tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
|
||||
},
|
||||
|
||||
@@ -37,7 +37,7 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
* them from its own `import.meta.url`.
|
||||
*/
|
||||
export interface AgentUnderTest {
|
||||
/** The agent bin entry (e.g. `packages/ui/acp-agent/src/bin.ts`), run unbuilt via tsx. */
|
||||
/** The agent bin entry (e.g. `packages/examples/acp-demo/src/bin.ts`), run unbuilt via tsx. */
|
||||
binScript: string
|
||||
/**
|
||||
* The example's live `cordis.yml`. Under `DSH_SNAPSHOT=replay` the bin swaps
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Runtime event-contract assertions intended for development diagnostics. This pure-listener plugin checks relationships among session events, agent states, scoped dispatches, and model requests; it does not own or change product behavior.
|
||||
|
||||
The plugin has no environment guard: it is active wherever it is registered. The default [`dsh-agent-core`](../../core/agent-core/README.md) bundle mounts it unconditionally; a custom composition can omit it when the runtime cost is undesirable. It doubles as executable documentation of the event taxonomy — the assertions *are* the contract.
|
||||
The plugin has no environment guard: it is active wherever it is registered. The default [`dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md) bundle mounts it unconditionally; a custom composition can omit it when the runtime cost is undesirable. It doubles as executable documentation of the event taxonomy — the assertions *are* the contract.
|
||||
|
||||
Session itself owns immutable log storage in every composition: it takes one lossless JSON snapshot of each accepted event, deep-freezes that record, and exposes the log through immutable array snapshots. The invariants plugin checks the cross-record and cross-seam rules that storage immutability cannot express.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Runtime listeners that fail loudly when cross-event contracts are broken:
|
||||
* turn and step nesting, scoped dispatch, status transitions, and request
|
||||
* reconstruction. The plugin has no environment guard and is active wherever
|
||||
* mounted, including the default `dsh-agent-core` bundle; custom compositions
|
||||
* mounted, including the default `dsh-agent-spine-demo` bundle; custom compositions
|
||||
* may omit it. Sessions still own event snapshots and freezing.
|
||||
* @module @deepseek-ai/dsh-invariants
|
||||
*/
|
||||
|
||||
@@ -6,4 +6,4 @@ The model-facing todo tool. A single **product** package — there is no interfa
|
||||
|---|---|---|
|
||||
| `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) |
|
||||
|
||||
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio app's readline UI](../ui/stdio-agent) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate.
|
||||
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio app's readline UI](../examples/stdio-demo) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate.
|
||||
|
||||
@@ -18,7 +18,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup
|
||||
|
||||
## Rendering
|
||||
|
||||
The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio app's readline UI](../../ui/stdio-agent) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires).
|
||||
The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio app's readline UI](../../examples/stdio-demo) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires).
|
||||
|
||||
## Export shape
|
||||
|
||||
|
||||
@@ -10,14 +10,11 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
|
||||
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
|
||||
| `stdio/` | Terminal readline channel over `ctx.agents`, `session/event`, and `ctx.userInteraction`; agent lifecycle stays with app/developer code | (drives `ctx.agents`) |
|
||||
| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
|
||||
| `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
|
||||
| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
|
||||
| `jsonrpc-agent/` | Bin-only SDK runtime app that boots an external `cordis.yml` | (`bin` only) |
|
||||
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
|
||||
|
||||
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) plugin is the unstructured readline analogue of the `acp` bridge; app bundles and SDK projects compose it explicitly with the services and tools their product profile selects.
|
||||
|
||||
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.
|
||||
|
||||
`stdio-agent` and `acp-agent` compose the [`agent-core`](../core/agent-core/README.md) spine with their front-door plugins and own their boot bins; a leaf `cordis.yml` supplies backends and optional tools. `jsonrpc-agent` is bin-only because its external config also chooses the serving `jsonrpc` plugin. Each lives in `ui/` as a user-facing front door whose artifact owns its stdout policy.
|
||||
The runnable app bundles that bake these bridges into boot bins — the stdio chat app, the ACP server app, and the JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`stdio-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# `@deepseek-ai/dsh-app-boot`
|
||||
|
||||
Shared boot glue for the app bins ([`dsh-stdio-agent`](../stdio-agent/README.md), [`dsh-acp-agent`](../acp-agent/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between two published artifacts.
|
||||
Shared boot glue for the app bins ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between two published artifacts.
|
||||
|
||||
| Export | Role |
|
||||
|---|---|
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Shared boot glue for the app bins (`dsh-stdio-agent`, `dsh-acp-agent`): load the gitignored
|
||||
* Shared boot glue for the app bins (`dsh-stdio-demo`, `dsh-acp-demo`): load the gitignored
|
||||
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), and
|
||||
* drive the cordis Loader against a leaf `cordis.yml` until the whole tree has settled.
|
||||
* @module @deepseek-ai/dsh-app-boot
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-jsonrpc
|
||||
|
||||
Stdio JSON-RPC plugin for out-of-process SDK clients such as Python `deepseek_harness`. [`HarnessSdkServer`](src/server.ts) handles `initialize` → `session/prompt` → `shutdown` plus session and subagent notifications over [`JsonRpcLineTransport`](src/transport.ts). This package owns the protocol; [`jsonrpc-agent`](../jsonrpc-agent/README.md) boots the external `cordis.yml` that chooses the surrounding runtime. See the [single-executable RFC](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) for the distribution design.
|
||||
Stdio JSON-RPC plugin for out-of-process SDK clients such as Python `deepseek_harness`. [`HarnessSdkServer`](src/server.ts) handles `initialize` → `session/prompt` → `shutdown` plus session and subagent notifications over [`JsonRpcLineTransport`](src/transport.ts). This package owns the protocol; [`jsonrpc-agent`](../../examples/jsonrpc-demo/README.md) boots the external `cordis.yml` that chooses the surrounding runtime. See the [single-executable RFC](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) for the distribution design.
|
||||
|
||||
## Wiring
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-core": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'
|
||||
import { PassThrough, Writable } from 'node:stream'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-core'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as jsonrpc from '../src/index.ts'
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AgentId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-core'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import SubagentService, { type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
Reference in New Issue
Block a user