diff --git a/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts new file mode 100644 index 0000000000..41ebdbe1f7 --- /dev/null +++ b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts @@ -0,0 +1,66 @@ +/** MCP resource-listing diagnostics preserve the shipped ACP profile's JSON-RPC stream. */ +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' +import { launchAcpTestAgent, type LaunchedAcpTestAgent } from '@deepseek-ai/dsh-session-snapshot' +import { expect, it } from 'vitest' +import { cleanupAcpExampleTest } from './cleanup.ts' + +const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url)) +const binScript = join(repoRoot, 'apps/cli/src/bin.ts') +const tsconfigPath = join(repoRoot, 'tsconfig.json') + +/** Parse every stdout line as a JSON-RPC frame; a diagnostic line fails the parse with its leading text. */ +function jsonRpcFrames(launched: LaunchedAcpTestAgent): Record[] { + const frames = launched.rawStdout().trim().split('\n').map(line => JSON.parse(line) as Record) + for (const frame of frames) expect(frame).toMatchObject({ jsonrpc: '2.0' }) + return frames +} + +it('keeps MCP resource-listing diagnostics off stdout for a tools-only client server', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-stdout-')) + const llm = await startMockLlmServer({ + sequence: ['tool_call_success', 'success'], + toolName: 'list_mcp_resources', + toolArguments: JSON.stringify({ server: 'fixture' }), + }) + let launched: LaunchedAcpTestAgent | undefined + try { + const configPath = join(cwd, 'mcp.patch.yml') + await writeFile(configPath, JSON.stringify([ + { id: 'llm-deepseek', config: { baseURL: llm.baseURL, models: [{ id: 'fixture-api-key-model' }] } }, + { id: 'acp', config: { provider: 'deepseek-official', model: 'fixture-api-key-model' } }, + { id: 'session-persistence-jsonl', config: { root: join(cwd, 'sessions'), compression: 'none' } }, + ]) + '\n') + launched = launchAcpTestAgent({ + agent: { binScript, configPath, profile: 'acp', tsconfigPath }, + cwd, + env: { DEEPSEEK_API_KEY: 'acp-fixture-api-key', DEEPSEEK_BASE_URL: llm.baseURL, DSH_TELEMETRY_DISABLED: '1' }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + // The fixture server advertises only the tools capability. + const mcpServer = join(repoRoot, 'packages/mcp/mcp-client/tests/fixture-server.ts') + const created = await launched.client.newSession({ + cwd, mcpServers: [{ name: 'fixture', command: process.execPath, args: [mcpServer], env: [] }], + }) + await expect(launched.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'list the fixture resources' }], + })).resolves.toEqual({ stopReason: 'end_turn' }) + expect(launched.updates).toContainEqual(expect.objectContaining({ sessionUpdate: 'tool_call_update', status: 'completed' })) + await launched.client.closeSession({ sessionId: created.sessionId }) + await launched.close() + + expect(launched.child.exitCode).toBe(0) + expect(llm.requests.map(request => request.behavior)).toEqual(['tool_call_success', 'success']) + expect(jsonRpcFrames(launched).length).toBeGreaterThanOrEqual(5) + } finally { + const cleanup = await Promise.allSettled([cleanupAcpExampleTest(launched, cwd), llm.close()]) + const failures: unknown[] = [] + for (const result of cleanup) if (result.status === 'rejected') failures.push(result.reason) + if (failures.length > 0) throw new AggregateError(failures, 'ACP MCP test cleanup failed') + } +}) diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 9a88686b46..8503666317 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-client/README.i18n.yaml @@ -42,8 +42,8 @@ en: ae9029b48b2a2e4e zh: 048f08361b799667 /deepseek-ai-dsh-mcp-client/understand-the-implementation/tool-execution-internals: - en: b4333df1bd3f027c - zh: 6bb4be691452c27a + en: bb161208cc002746 + zh: 3ab0588ca71effe5 /deepseek-ai-dsh-mcp-client/understand-the-implementation/environment-scrubbing-stdio: en: fef482a6f8cc789f zh: 08f4c594ec83b4a1 diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index 950b9b8c85..be3f250ebf 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -130,7 +130,7 @@ The SDK receives tool-list changes through legacy notifications or a modern subs ### Tool execution internals -A tool call uses the SDK with the raw name, complete tool definition, JSON arguments, abort signal, and configured timeout. The SDK owns protocol validation, advertised output-schema validation, and modern request headers. Canonical success is `{ content: JsonValue[], structuredContent? }`, preserving valid MCP JSON blocks for programmatic and PTC mode callers. An MCP `isError` result throws before image persistence. The bridge validates each image batch before saving it; a refusal projects every image as diagnostic text. +A tool call uses the SDK with the raw name, complete tool definition, JSON arguments, abort signal, and configured timeout. The SDK owns protocol validation, advertised output-schema validation, and modern request headers. Canonical success is `{ content: JsonValue[], structuredContent? }`, preserving valid MCP JSON blocks for programmatic and PTC mode callers. An MCP `isError` result throws before image persistence. The bridge validates each image batch before saving it; a refusal projects every image as diagnostic text. For a server that does not advertise `resources`, resource and template lists return empty pages without calling the SDK, whose fallback writes a diagnostic to stdout, where the ACP profile carries JSON-RPC. ### Environment scrubbing (stdio) diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index 3ba2038418..00612777e4 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -130,7 +130,7 @@ SDK 通过旧版通知或现代协议订阅接收工具列表变化。监督器 ### 工具执行内部细节 -工具调用向 SDK 提供原始名称、完整工具定义、JSON 参数、取消信号及配置的超时。SDK 负责协议校验、已声明输出 schema 的校验和现代协议请求 header。成功结果规范值为 `{ content: JsonValue[], structuredContent? }`,为编程调用方及 PTC 模式保留有效的 MCP JSON 块。MCP `isError` 结果会在图片持久化前抛出。桥接器在保存前校验整批图片;拒绝时将每张图片投影为诊断文本。 +工具调用向 SDK 提供原始名称、完整工具定义、JSON 参数、取消信号及配置的超时。SDK 负责协议校验、已声明输出 schema 的校验和现代协议请求 header。成功结果规范值为 `{ content: JsonValue[], structuredContent? }`,为编程调用方及 PTC 模式保留有效的 MCP JSON 块。MCP `isError` 结果会在图片持久化前抛出。桥接器在保存前校验整批图片;拒绝时将每张图片投影为诊断文本。对未声明 `resources` 能力的服务器,资源列表与模板列表直接返回空页而不调用 SDK,因为 SDK 的兜底逻辑会把诊断写到 stdout,而 ACP 配置档在 stdout 上传输 JSON-RPC。 ### 环境清洗(stdio) diff --git a/packages/mcp/mcp-client/src/connection.ts b/packages/mcp/mcp-client/src/connection.ts index 9a4ec9123a..310925f184 100644 --- a/packages/mcp/mcp-client/src/connection.ts +++ b/packages/mcp/mcp-client/src/connection.ts @@ -368,15 +368,22 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe const generation = client if (!generation || connectedAt === undefined) throw new Error(`${label}: server is disconnected`) const options = { signal: exec.signal, timeout: config.toolCallTimeoutMs } + // The SDK answers an unadvertised capability with the same empty page, but + // writes a console.debug line to stdout, which the ACP profile reserves for JSON-RPC. + const listable = generation.getServerCapabilities()?.resources !== undefined switch (request.method) { case 'resources/list': - return await generation.listResources( - request.cursor === undefined ? undefined : { cursor: request.cursor }, options, - ) as JsonValue + return listable + ? await generation.listResources( + request.cursor === undefined ? undefined : { cursor: request.cursor }, options, + ) as JsonValue + : { resources: [] } case 'resources/templates/list': - return await generation.listResourceTemplates( - request.cursor === undefined ? undefined : { cursor: request.cursor }, options, - ) as JsonValue + return listable + ? await generation.listResourceTemplates( + request.cursor === undefined ? undefined : { cursor: request.cursor }, options, + ) as JsonValue + : { resourceTemplates: [] } case 'resources/read': return await generation.readResource({ uri: request.uri }, options) as JsonValue /* v8 ignore next 2 -- resource requests are the closed, typed tool operation union */ diff --git a/packages/mcp/mcp-client/tests/protocol.spec.ts b/packages/mcp/mcp-client/tests/protocol.spec.ts index 6124752c08..5ec78b6573 100644 --- a/packages/mcp/mcp-client/tests/protocol.spec.ts +++ b/packages/mcp/mcp-client/tests/protocol.spec.ts @@ -62,6 +62,8 @@ describe('modern MCP connections', () => { expect(names.toSorted()).toEqual([ 'list_mcp_resource_templates', 'list_mcp_resources', 'mcp__fixture__ping', 'read_mcp_resource', ]) + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}) + onTestFinished(() => { debug.mockRestore() }) for (const [name, expected] of [ ['list_mcp_resources', { resources: [] }], ['list_mcp_resource_templates', { resourceTemplates: [] }], @@ -72,6 +74,8 @@ describe('modern MCP connections', () => { }) expect(result).toMatchObject({ isError: false, value: expected }) } + // console.debug writes stdout, which the ACP profile reserves for JSON-RPC. + expect(debug).not.toHaveBeenCalled() const read = await ctx.tools.execute({ name: 'read_mcp_resource', arguments: { server: 'fixture', uri: 'memo://readme' }, callId: ToolCallId('unsupported-resource-read'), signal: new AbortController().signal, diff --git a/packages/mcp/mcp-resources/README.i18n.yaml b/packages/mcp/mcp-resources/README.i18n.yaml index 67fdbaba79..97cb1f5a8b 100644 --- a/packages/mcp/mcp-resources/README.i18n.yaml +++ b/packages/mcp/mcp-resources/README.i18n.yaml @@ -57,8 +57,8 @@ en: ea0dfc069bf175c0 zh: 3e1465e56ff79764 /deepseek-ai-dsh-mcp-resources/known-limitations-and-deferred-work: - en: 58d13dc99946c11d - zh: 4099c46a1ca60512 + en: 73f1775fd13a7e98 + zh: 8501425c7dd12aee /deepseek-ai-dsh-mcp-resources/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/mcp/mcp-resources/README.md b/packages/mcp/mcp-resources/README.md index 1380095a38..281a8feffb 100644 --- a/packages/mcp/mcp-resources/README.md +++ b/packages/mcp/mcp-resources/README.md @@ -117,7 +117,7 @@ Each result appends to history without rewriting earlier results. Later reads ca Resource access is explicit and on demand. -- A configured server without the MCP `resources` capability still appears in the server-name prompt and keeps shared resource tools available. The SDK returns empty resource and template lists; unsupported reads fail. +- A configured server without the MCP `resources` capability still appears in the server-name prompt and keeps shared resource tools available. The MCP client returns empty resource and template lists without contacting the server; unsupported reads fail. - `tools.restrict()` checks names supplied by global or ancestor scopes when the filter is registered. Naming a resource tool absent from those scopes fails as an unknown tool. Resource tools registered in the caller's own scope are outside allow/deny masks. - Resource subscriptions and update notifications are unsupported; call the list or read tools again to obtain current content. - Binary resources are not projected as native images or audio. Programmatic callers retain their canonical base64 values. diff --git a/packages/mcp/mcp-resources/README.zh.md b/packages/mcp/mcp-resources/README.zh.md index c9a5f2d4af..d6baa73bcc 100644 --- a/packages/mcp/mcp-resources/README.zh.md +++ b/packages/mcp/mcp-resources/README.zh.md @@ -117,7 +117,7 @@ kind: "package-reference" 资源访问由显式调用按需发起。 -- 不具备 MCP `resources` 能力的已配置服务器仍会出现在服务器名称提示词中,并保持共享资源工具可用。SDK 返回空的资源列表与模板列表;不受支持的读取会失败。 +- 不具备 MCP `resources` 能力的已配置服务器仍会出现在服务器名称提示词中,并保持共享资源工具可用。MCP 客户端直接返回空的资源列表与模板列表,不联系服务器;不受支持的读取会失败。 - `tools.restrict()` 在注册过滤条件时检查全局或祖先作用域提供的名称。引用这些作用域中不存在的资源工具名称会报未知工具错误。注册在调用方自身作用域中的资源工具不受 allow/deny 掩码过滤。 - 不支持资源订阅与更新通知;再次调用列表或读取工具以获取当前内容。 - 二进制资源不会投影为原生图片或音频。程序化调用方保留其规范 base64 值。