fix(mcp): keep resource-listing diagnostics off ACP stdout

The MCP SDK answers resources/list and resources/templates/list for a
server without the resources capability with an empty page, but first
writes a console.debug line to stdout. Under the ACP profile that line
lands in the JSON-RPC stream whenever the model lists resources of a
client-supplied, tools-only MCP server. Return the same empty pages
before calling the SDK, and cover it with a unit assertion and an ACP
stdout regression test.
This commit is contained in:
Yichen Jiang committed 2026-10-24 22:25:48 +08:00
1 parent 1f81c047fc
commit cc4446eecc
9 files changed
+91 -14

No files matched your search

@@ -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<string, unknown>[] {
const frames = launched.rawStdout().trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
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')
}
})
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+13 -6
View File
@@ -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 */
@@ -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,
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -117,7 +117,7 @@ kind: "package-reference"
资源访问由显式调用按需发起。
- 不具备 MCP `resources` 能力的已配置服务器仍会出现在服务器名称提示词中,并保持共享资源工具可用。SDK 返回空的资源列表与模板列表;不受支持的读取会失败。
- 不具备 MCP `resources` 能力的已配置服务器仍会出现在服务器名称提示词中,并保持共享资源工具可用。MCP 客户端直接返回空的资源列表与模板列表,不联系服务器;不受支持的读取会失败。
- `tools.restrict()` 在注册过滤条件时检查全局或祖先作用域提供的名称。引用这些作用域中不存在的资源工具名称会报未知工具错误。注册在调用方自身作用域中的资源工具不受 allow/deny 掩码过滤。
- 不支持资源订阅与更新通知;再次调用列表或读取工具以获取当前内容。
- 二进制资源不会投影为原生图片或音频。程序化调用方保留其规范 base64 值。