From 857b22f6f1f1edac1c518abe2ec29cc1ece81435 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 26 Jun 2026 10:26:35 +0800 Subject: [PATCH 1/3] feat(web-shell): browse MCP server resources in the /mcp dialog Port the TUI's MCP resource browser (#5544/#5635) to the Web Shell. The /mcp dialog now shows per-server resource and prompt counts plus an expandable resource browser (URI, MIME type, size, description, and the @server:uri chat reference), reaching parity with the terminal UI. Resources and prompts were never serialized past the in-process core registries, so this wires the data end to end: per-server resourceCount and promptCount ride the existing /workspace/mcp status, and a new qwen/status/workspace/mcp/resources ext-method (mirroring the tools drill-down) carries the resource list through the daemon, SDK, and webui hook into the Web Shell McpDialog. All additions are protocol-additive; older daemons 404 the new route and the client degrades gracefully. --- packages/acp-bridge/src/bridge.ts | 22 ++ packages/acp-bridge/src/bridgeTypes.ts | 11 + packages/acp-bridge/src/status.ts | 51 +++++ .../cli/src/acp-integration/acpAgent.test.ts | 54 +++++ packages/cli/src/acp-integration/acpAgent.ts | 158 +++++++++++++++ packages/cli/src/serve/acp-http/dispatch.ts | 16 ++ packages/cli/src/serve/acp-http/index.ts | 1 + .../cli/src/serve/acp-http/transport.test.ts | 33 +++ packages/cli/src/serve/server.test.ts | 56 +++++ packages/cli/src/serve/server.ts | 27 +++ packages/sdk-typescript/scripts/build.js | 4 +- .../sdk-typescript/src/daemon/DaemonClient.ts | 19 ++ .../src/daemon/acpRouteTable.ts | 9 + packages/sdk-typescript/src/daemon/index.ts | 2 + packages/sdk-typescript/src/daemon/types.ts | 43 ++++ packages/web-shell/client/App.tsx | 37 +++- .../components/dialogs/McpDialog.module.css | 46 +++++ .../client/components/dialogs/McpDialog.tsx | 191 +++++++++++++++++- .../components/messages/McpStatusMessage.tsx | 7 + packages/web-shell/client/i18n.tsx | 20 ++ packages/webui/src/daemon-react-sdk.ts | 4 + packages/webui/src/daemon/index.ts | 2 + .../DaemonWorkspaceProvider.test.tsx | 86 ++++++++ .../webui/src/daemon/workspace/actions.ts | 30 +++ .../daemon/workspace/hooks/useDaemonMcp.ts | 1 + packages/webui/src/daemon/workspace/types.ts | 4 + 26 files changed, 925 insertions(+), 9 deletions(-) diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index b7509b49449..3842887c0d7 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -3745,6 +3745,28 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); }, + async getWorkspaceMcpResourcesStatus(serverName) { + return requestWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceMcpResources, + () => ({ + v: STATUS_SCHEMA_VERSION, + workspaceCwd: boundWorkspace, + serverName, + initialized: false, + acpChannelLive: false, + resources: [], + errors: [ + { + kind: 'mcp_resources', + status: 'not_started' as const, + hint: 'spawn a session to populate', + }, + ], + }), + { serverName }, + ); + }, + async getWorkspaceToolsStatus() { return requestWorkspaceStatus( SERVE_STATUS_EXT_METHODS.workspaceTools, diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 280b26b32fd..4b29098ae77 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -26,6 +26,7 @@ import type { ServeWorkspaceExtensionsStatus, ServeWorkspaceHooksStatus, ServeWorkspaceMcpToolsStatus, + ServeWorkspaceMcpResourcesStatus, ServeWorkspaceToolsStatus, ServeSessionContextUsageStatus, ServeSessionStatsStatus, @@ -457,6 +458,16 @@ export interface AcpSessionBridge { serverName: string, ): Promise; + /** + * Read discovered MCP resources (`resources/list`) for one server from + * the live ACP registry. Drill-down companion to + * `getWorkspaceMcpToolsStatus`; the per-server `resourceCount` rides + * the base `/workspace/mcp` status. + */ + getWorkspaceMcpResourcesStatus( + serverName: string, + ): Promise; + /** * Read the live built-in tool registry for the bound workspace. * (New in upstream — kept in bridge pending workspace service migration.) diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 8ad7649c5cd..af2c6fd5f5a 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -97,6 +97,7 @@ export class MissingCliEntryError extends Error { export const SERVE_STATUS_EXT_METHODS = { workspaceMcp: 'qwen/status/workspace/mcp', workspaceMcpTools: 'qwen/status/workspace/mcp/tools', + workspaceMcpResources: 'qwen/status/workspace/mcp/resources', workspaceSkills: 'qwen/status/workspace/skills', workspaceTools: 'qwen/status/workspace/tools', workspaceProviders: 'qwen/status/workspace/providers', @@ -196,6 +197,24 @@ export interface ServeWorkspaceMcpServerStatus extends ServeStatusCell { }; description?: string; extensionName?: string; + /** + * Count of MCP resources (`resources/list`) this server advertises, + * from the workspace `ResourceRegistry`. Rides the existing status + * payload so dashboards can show a "Resources: N" line and gate a + * resource-browser affordance without a separate fetch. Absent on + * older daemons; present (including `0`) on newer daemons for + * non-disabled servers. The full list is fetched lazily via + * `qwen/status/workspace/mcp/resources`. + */ + resourceCount?: number; + /** + * Count of MCP prompts (`prompts/list`) this server advertises, from + * the workspace `PromptRegistry`. Inline-only (there is no prompt + * drill-down endpoint — prompts surface as slash commands), so this + * count is the sole signal a dashboard has. Absent on older daemons; + * present (including `0`) on newer daemons for non-disabled servers. + */ + promptCount?: number; /** * Why this server is not live, when known. Distinguishes * operator-disabled (`disabled: true` from `disabledMcpServers` @@ -324,6 +343,38 @@ export interface ServeWorkspaceMcpToolsStatus { errors?: ServeStatusCell[]; } +/** + * One resource advertised by an MCP server (`resources/list`). Mirrors + * the `MCPResourceDisplayInfo` the TUI `/mcp` dialog renders: metadata + * only (no content). The content is read on demand in-chat via the + * `@:` reference, which the frontend reconstructs from + * `serverName` (the parent `ServeWorkspaceMcpResourcesStatus`) + `uri`. + */ +export interface ServeWorkspaceMcpResourceStatus { + uri: string; + name?: string; + title?: string; + description?: string; + mimeType?: string; + size?: number; +} + +/** + * Drill-down payload for `GET /workspace/mcp/:server/resources`. Mirrors + * `ServeWorkspaceMcpToolsStatus` — resources are a per-server drill-down + * exactly like tools, kept off the base `/workspace/mcp` status so that + * frequently-polled payload stays lean. + */ +export interface ServeWorkspaceMcpResourcesStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + serverName: string; + initialized: boolean; + acpChannelLive: boolean; + resources: ServeWorkspaceMcpResourceStatus[]; + errors?: ServeStatusCell[]; +} + export type ServeSkillLevel = 'project' | 'user' | 'extension' | 'bundled'; export interface ServeWorkspaceSkillStatus extends ServeStatusCell { diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 76aa0c81413..51f3441a1e8 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -1560,6 +1560,27 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ]), getActiveRuntimeModelSnapshot: vi.fn().mockReturnValue(undefined), getModel: vi.fn().mockReturnValue('qwen-plus'), + getResourceRegistry: vi.fn().mockReturnValue({ + getResourcesByServer: (name: string) => + name === 'docs' + ? [ + { + uri: 'file:///docs/intro.md', + name: 'Intro', + title: 'Introduction', + description: 'Getting started', + mimeType: 'text/markdown', + size: 1024, + serverName: 'docs', + }, + { uri: 'file:///docs/api.md', serverName: 'docs' }, + ] + : [], + }), + getPromptRegistry: vi.fn().mockReturnValue({ + getPromptsByServer: (name: string) => + name === 'docs' ? [{ name: 'summarize', serverName: 'docs' }] : [], + }), } as unknown as Config; const agentPromise = runAcpAgent( @@ -1579,6 +1600,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => { SERVE_STATUS_EXT_METHODS.workspaceMcp, {}, ); + const mcpResources = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.workspaceMcpResources, + { serverName: 'docs' }, + ); const skills = await agent.extMethod( SERVE_STATUS_EXT_METHODS.workspaceSkills, {}, @@ -1603,6 +1628,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { disabled: false, description: 'Docs server', extensionName: 'docs-ext', + resourceCount: 2, + promptCount: 1, }, { kind: 'mcp_server', @@ -1611,6 +1638,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { mcpStatus: 'connected', transport: 'http', disabled: false, + resourceCount: 0, + promptCount: 0, }, { kind: 'mcp_server', @@ -1634,6 +1663,31 @@ describe('QwenAgent MCP SSE/HTTP support', () => { expect(JSON.stringify(mcp)).not.toContain('Authorization'); expect(JSON.stringify(mcp)).not.toContain('bad-ext'); + // The resources drill-down returns metadata-only entries (serverName is + // implied by the request, not echoed into each item). + expect(mcpResources).toMatchObject({ + v: 1, + workspaceCwd: '/work/status', + serverName: 'docs', + initialized: true, + acpChannelLive: true, + resources: [ + { + uri: 'file:///docs/intro.md', + name: 'Intro', + title: 'Introduction', + description: 'Getting started', + mimeType: 'text/markdown', + size: 1024, + }, + { uri: 'file:///docs/api.md' }, + ], + }); + expect( + (mcpResources as { resources: Array> }) + .resources[1], + ).not.toHaveProperty('serverName'); + expect(skills).toMatchObject({ v: 1, workspaceCwd: '/work/status', diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 6a087ef02ff..1fb7dae2020 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -75,6 +75,8 @@ import type { ProviderModelConfig, ProviderSetupInputs, ResumedSessionData, + DiscoveredMCPResource, + DiscoveredMCPPrompt, } from '@qwen-code/qwen-code-core'; import { AgentSideConnection, @@ -188,6 +190,8 @@ import { type ServeMcpTransport, type ServeWorkspaceMcpToolStatus, type ServeWorkspaceMcpToolsStatus, + type ServeWorkspaceMcpResourceStatus, + type ServeWorkspaceMcpResourcesStatus, type ServePreflightCell, type ServePreflightKind, type ServeSessionContextStatus, @@ -3392,6 +3396,21 @@ class QwenAgent implements Agent { status: this.mcpStatus(e.status), })); } + // Resource / prompt counts ride the base status so the /mcp + // dialog can render "Resources: N" / "Prompts: N" and gate the + // resource-browser affordance without a separate round-trip. + // Disabled servers are not discovered, so leave their counts + // absent — mirrors the TUI ServerDetailStep gating. + if (!disabled) { + out.resourceCount = this.resolveServerMcpResources( + config, + name, + ).length; + out.promptCount = this.resolveServerMcpPrompts( + config, + name, + ).length; + } return out; }), ), @@ -3527,6 +3546,132 @@ class QwenAgent implements Agent { } } + /** + * Resolve the resources discovered for one server, with the same + * pool-mode fallback `buildWorkspaceMcpToolsStatus` uses for tools: the + * workspace `Config`'s `ResourceRegistry` is authoritative in + * single-session mode, but in pool mode resources are registered into + * per-session registries (`SessionMcpView.applyResources`), leaving the + * workspace registry empty. Fall back to the first active session that + * has this server's resources. + */ + private resolveServerMcpResources( + config: Config, + serverName: string, + ): DiscoveredMCPResource[] { + // Defensive optional-call mirrors useAtCompletion.ts: a partial Config + // (older snapshot or a test stub) may not expose the registry accessor, + // and a missing registry must degrade to "no resources" rather than + // throwing and collapsing the whole /mcp status into an error cell. + const resources = + config.getResourceRegistry?.()?.getResourcesByServer(serverName) ?? []; + if (resources.length > 0) { + return resources; + } + for (const session of this.getActiveSessions()) { + const sessionResources = + session + .getConfig() + .getResourceRegistry?.() + ?.getResourcesByServer(serverName) ?? []; + if (sessionResources.length > 0) { + return sessionResources; + } + } + return resources; + } + + /** + * Resolve the prompts discovered for one server, mirroring + * {@link resolveServerMcpResources}. Used only for the per-server + * `promptCount` on the base status — prompts have no drill-down + * endpoint (they surface as slash commands). + */ + private resolveServerMcpPrompts( + config: Config, + serverName: string, + ): DiscoveredMCPPrompt[] { + // Defensive optional-call — see resolveServerMcpResources. + const prompts = + config.getPromptRegistry?.()?.getPromptsByServer(serverName) ?? []; + if (prompts.length > 0) { + return prompts; + } + for (const session of this.getActiveSessions()) { + const sessionPrompts = + session + .getConfig() + .getPromptRegistry?.() + ?.getPromptsByServer(serverName) ?? []; + if (sessionPrompts.length > 0) { + return sessionPrompts; + } + } + return prompts; + } + + private buildWorkspaceMcpResourcesStatus( + config: Config, + serverName: string, + ): ServeWorkspaceMcpResourcesStatus { + const workspaceCwd = this.safeWorkspaceCwd(config); + try { + const servers = config.getMcpServers() ?? {}; + if (!Object.prototype.hasOwnProperty.call(servers, serverName)) { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + serverName, + initialized: true, + acpChannelLive: true, + resources: [], + errors: [ + { + kind: 'mcp_resources', + status: 'error', + error: `MCP server not configured: ${serverName}`, + }, + ], + }; + } + + const resources: ServeWorkspaceMcpResourceStatus[] = + this.resolveServerMcpResources(config, serverName).map((resource) => ({ + uri: resource.uri, + ...(typeof resource.name === 'string' ? { name: resource.name } : {}), + ...(typeof resource.title === 'string' + ? { title: resource.title } + : {}), + ...(typeof resource.description === 'string' + ? { description: resource.description } + : {}), + ...(typeof resource.mimeType === 'string' + ? { mimeType: resource.mimeType } + : {}), + ...(typeof resource.size === 'number' ? { size: resource.size } : {}), + })); + + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + serverName, + initialized: true, + acpChannelLive: true, + resources, + }; + } catch (error) { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + serverName, + initialized: true, + acpChannelLive: true, + resources: [], + errors: [this.errorCell('mcp_resources', error)], + }; + } + } + /** * Build the MCP budget status cells exposed on `GET /workspace/mcp`. * @@ -4963,6 +5108,19 @@ class QwenAgent implements Agent { serverName, ) as unknown as Record; } + case SERVE_STATUS_EXT_METHODS.workspaceMcpResources: { + const serverName = params['serverName']; + if (typeof serverName !== 'string' || serverName.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing serverName', + ); + } + return this.buildWorkspaceMcpResourcesStatus( + this.config, + serverName, + ) as unknown as Record; + } case SERVE_STATUS_EXT_METHODS.workspaceSkills: return (await this.buildWorkspaceSkillsStatus( this.config, diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 4c9c8fc4d95..ca62b724764 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -145,6 +145,7 @@ const ALL_QWEN_VENDOR_METHODS: readonly string[] = [ // Wave 1: remaining workspace `${QWEN_METHOD_NS}workspace/tools`, `${QWEN_METHOD_NS}workspace/mcp/tools`, + `${QWEN_METHOD_NS}workspace/mcp/resources`, `${QWEN_METHOD_NS}workspace/mcp/servers/add`, `${QWEN_METHOD_NS}workspace/mcp/servers/remove`, `${QWEN_METHOD_NS}sessions/delete`, @@ -2278,6 +2279,21 @@ export class AcpDispatcher { return; } + case `${QWEN_METHOD_NS}workspace/mcp/resources`: { + const serverName = String(params['serverName'] ?? ''); + if (!serverName) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`serverName` required'), + ); + return; + } + const result = + await this.bridge.getWorkspaceMcpResourcesStatus(serverName); + this.replyConn(conn, id, result as unknown); + return; + } + case `${QWEN_METHOD_NS}workspace/mcp/servers/add`: { const name = String(params['name'] ?? ''); if (!name || name.length > MAX_NAME_LENGTH) { diff --git a/packages/cli/src/serve/acp-http/index.ts b/packages/cli/src/serve/acp-http/index.ts index 6a6532eee86..336b5fba9ec 100644 --- a/packages/cli/src/serve/acp-http/index.ts +++ b/packages/cli/src/serve/acp-http/index.ts @@ -97,6 +97,7 @@ const WS_READ_METHODS = new Set([ '_qwen/workspace/voice', '_qwen/workspace/tools', '_qwen/workspace/mcp/tools', + '_qwen/workspace/mcp/resources', '_qwen/workspace/agents/list', '_qwen/workspace/agents/get', '_qwen/workspace/memory', diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index fd2180a44a7..f864e0cfa74 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -322,6 +322,9 @@ class FakeBridge { async getWorkspaceMcpToolsStatus(serverName: string) { return { v: 1, serverName, tools: [] }; } + async getWorkspaceMcpResourcesStatus(serverName: string) { + return { v: 1, serverName, resources: [] }; + } async addRuntimeMcpServer(name: string) { return { name, @@ -3105,6 +3108,36 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); + it('_qwen/workspace/mcp/resources rejects missing serverName', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 64, + method: '_qwen/workspace/mcp/resources', + params: {}, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32602 } }); + }); + + it('_qwen/workspace/mcp/resources returns resources', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 65, + method: '_qwen/workspace/mcp/resources', + params: { serverName: 'fs' }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ + result: { serverName: 'fs', resources: [] }, + }); + }); + it('_qwen/workspace/mcp/servers/add rejects missing name', async () => { const connId = await initialize(); const streamRes = openStream(connId); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 2631882e7ce..16e98ca1c51 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -96,6 +96,7 @@ import type { ServeWorkspaceHooksStatus, ServeWorkspaceMcpStatus, ServeWorkspaceMcpToolsStatus, + ServeWorkspaceMcpResourcesStatus, ServeWorkspacePreflightStatus, ServeWorkspaceProvidersStatus, ServeWorkspaceSkillsStatus, @@ -387,6 +388,9 @@ interface FakeBridgeOpts { workspaceMcpToolsImpl?: ( serverName: string, ) => Promise; + workspaceMcpResourcesImpl?: ( + serverName: string, + ) => Promise; workspaceSkillsImpl?: () => Promise; workspaceToolsImpl?: () => Promise; workspaceProvidersImpl?: () => Promise; @@ -562,6 +566,7 @@ interface FakeBridge extends AcpSessionBridge { summaryCalls: string[]; workspaceMcpCalls: number; workspaceMcpToolsCalls: string[]; + workspaceMcpResourcesCalls: string[]; workspaceSkillsCalls: number; workspaceToolsCalls: number; workspaceProvidersCalls: number; @@ -688,6 +693,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const summaryCalls: string[] = []; let workspaceMcpCalls = 0; const workspaceMcpToolsCalls: string[] = []; + const workspaceMcpResourcesCalls: string[] = []; let workspaceSkillsCalls = 0; let workspaceToolsCalls = 0; let workspaceProvidersCalls = 0; @@ -785,6 +791,16 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { acpChannelLive: false, tools: [], })); + const workspaceMcpResourcesImpl = + opts.workspaceMcpResourcesImpl ?? + (async (serverName: string) => ({ + v: 1 as const, + workspaceCwd: WS_BOUND, + serverName, + initialized: true, + acpChannelLive: false, + resources: [], + })); const workspaceProvidersImpl = opts.workspaceProvidersImpl ?? (async () => ({ @@ -1087,6 +1103,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { listCalls, summaryCalls, workspaceMcpToolsCalls, + workspaceMcpResourcesCalls, extensionEvents, sessionContextCalls, sessionContextUsageCalls, @@ -1239,6 +1256,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { workspaceMcpToolsCalls.push(serverName); return workspaceMcpToolsImpl(serverName); }, + async getWorkspaceMcpResourcesStatus(serverName) { + workspaceMcpResourcesCalls.push(serverName); + return workspaceMcpResourcesImpl(serverName); + }, async getWorkspaceSkillsStatus() { workspaceSkillsCalls += 1; return workspaceSkillsImpl(); @@ -12418,6 +12439,41 @@ describe('GET /workspace/mcp/:server/tools', () => { }); }); +describe('GET /workspace/mcp/:server/resources', () => { + it('returns resources for a valid server name', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .get('/workspace/mcp/my-server/resources') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ serverName: 'my-server', resources: [] }); + expect(bridge.workspaceMcpResourcesCalls).toEqual(['my-server']); + }); + + it('decodes URL-encoded server names', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .get('/workspace/mcp/my%20server/resources') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + expect(bridge.workspaceMcpResourcesCalls).toEqual(['my server']); + }); + + it('400 when server name exceeds length limit', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const longName = 'a'.repeat(300); + const res = await request(app) + .get(`/workspace/mcp/${longName}/resources`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_server_name'); + expect(bridge.workspaceMcpResourcesCalls).toHaveLength(0); + }); +}); + describe('POST /workspace/mcp/:server/restart — entryIndex validation', () => { const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; const auth = (req: request.Test): request.Test => diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 95f1401c549..496349d82dd 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -2198,6 +2198,33 @@ export function createServeApp( } }); + app.get('/workspace/mcp/:server/resources', async (req, res) => { + const serverName = req.params['server']; + if (!serverName || typeof serverName !== 'string') { + res.status(400).json({ + error: 'Server name path parameter is required', + code: 'invalid_server_name', + }); + return; + } + if (serverName.length > MAX_SERVER_NAME_LENGTH) { + res.status(400).json({ + error: `Server name exceeds ${MAX_SERVER_NAME_LENGTH}-character limit`, + code: 'invalid_server_name', + }); + return; + } + try { + res + .status(200) + .json(await bridge.getWorkspaceMcpResourcesStatus(serverName)); + } catch (err) { + sendBridgeError(res, err, { + route: 'GET /workspace/mcp/:server/resources', + }); + } + }); + app.get('/workspace/skills', async (req, res) => { try { const ctx = buildWorkspaceCtx(req, 'GET /workspace/skills'); diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 409c9cc3ff7..ecf6201a06d 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -34,7 +34,9 @@ const rootDir = join(__dirname, '..'); // Bumped from 126KB to 127KB for prompt clientId self-heal. // Bumped from 127KB to 130KB for daemon workspace voice, trust, permissions, // session LSP helper APIs, and the full daemon route table. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 130 * 1024; +// Bumped from 130KB to 131KB for the workspace MCP resources drill-down +// (workspaceMcpResources client method + route + resource status types). +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 131 * 1024; rmSync(join(rootDir, 'dist'), { recursive: true, force: true }); mkdirSync(join(rootDir, 'dist'), { recursive: true }); diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 5eaf302fbd0..57cb9a0f5ae 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -50,6 +50,7 @@ import type { DaemonWorkspaceEnvStatus, DaemonWorkspaceMcpStatus, DaemonWorkspaceMcpToolsStatus, + DaemonWorkspaceMcpResourcesStatus, DaemonWorkspaceMemoryStatus, DaemonWorkspacePreflightStatus, DaemonWorkspaceProvidersStatus, @@ -681,6 +682,24 @@ export class DaemonClient { ); } + async workspaceMcpResources( + serverName: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/workspace/mcp/${encodeURIComponent(serverName)}/resources`, + { headers: this.headers() }, + async (res) => { + if (!res.ok) { + throw await this.failOnError( + res, + 'GET /workspace/mcp/:server/resources', + ); + } + return (await res.json()) as DaemonWorkspaceMcpResourcesStatus; + }, + ); + } + async workspaceSkills(): Promise { return await this.fetchWithTimeout( `${this.baseUrl}/workspace/skills`, diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index 3b4252f9baa..0778cd1b7a1 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -500,6 +500,15 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ extractParams: (segs) => ({ serverName: segs[0] }), }, }, + // GET /workspace/mcp/:server/resources → _qwen/workspace/mcp/resources + { + httpMethod: 'GET', + pattern: /^\/workspace\/mcp\/([^/]+)\/resources\/?$/, + mapping: { + method: '_qwen/workspace/mcp/resources', + extractParams: (segs) => ({ serverName: segs[0] }), + }, + }, // POST /workspace/mcp/servers → _qwen/workspace/mcp/servers/add { httpMethod: 'POST', diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 5ffad8ae53c..4d3ba9b03bd 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -419,6 +419,8 @@ export type { DaemonWorkspaceMcpStatus, DaemonWorkspaceMcpToolStatus, DaemonWorkspaceMcpToolsStatus, + DaemonWorkspaceMcpResourceStatus, + DaemonWorkspaceMcpResourcesStatus, DaemonWorkspaceMemoryFile, DaemonWorkspaceMemoryStatus, DaemonWorkspacePreflightStatus, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 375ae010e3a..863e41d5c0f 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -298,6 +298,21 @@ export interface DaemonWorkspaceMcpServerStatus extends DaemonStatusCell { }; description?: string; extensionName?: string; + /** + * Count of MCP resources (`resources/list`) this server advertises. + * Rides the base status so a client can show "Resources: N" and gate a + * resource browser without a separate fetch. Absent on older daemons; + * present (including `0`) on newer daemons for non-disabled servers. + * The full list is fetched lazily via `workspaceMcpResources()`. + */ + resourceCount?: number; + /** + * Count of MCP prompts (`prompts/list`) this server advertises. + * Inline-only — prompts have no drill-down endpoint (they surface as + * slash commands). Absent on older daemons; present (including `0`) on + * newer daemons for non-disabled servers. + */ + promptCount?: number; /** * Why this server is not live, when known. * `'config'` -- operator-disabled via `disabledMcpServers`. @@ -382,6 +397,34 @@ export interface DaemonWorkspaceMcpToolsStatus { errors?: DaemonStatusCell[]; } +/** + * One resource advertised by an MCP server (`resources/list`). Metadata + * only — content is read on demand in-chat via the `@:` + * reference reconstructed from the parent `serverName` + this `uri`. + */ +export interface DaemonWorkspaceMcpResourceStatus { + uri: string; + name?: string; + title?: string; + description?: string; + mimeType?: string; + size?: number; +} + +/** + * Drill-down payload returned by `workspaceMcpResources(serverName)`. + * Mirrors `DaemonWorkspaceMcpToolsStatus`. + */ +export interface DaemonWorkspaceMcpResourcesStatus { + v: 1; + workspaceCwd: string; + serverName: string; + initialized: boolean; + acpChannelLive: boolean; + resources: DaemonWorkspaceMcpResourceStatus[]; + errors?: DaemonStatusCell[]; +} + export type DaemonSkillLevel = 'project' | 'user' | 'extension' | 'bundled'; export interface DaemonWorkspaceSkillStatus extends DaemonStatusCell { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 453c2cef9eb..43b5f934bb3 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -2472,19 +2472,44 @@ export function App({ string, Awaited> > = {}; + const resourcesByServer: Record< + string, + Awaited> + > = {}; await Promise.all( (status?.servers ?? []).map(async (server) => { - try { - toolsByServer[server.name] = - await workspaceActions.loadMcpTools(server.name); - } catch { - // Allow partial failure — other servers still render - } + // Tools and resources load in parallel; a failure in one + // must not hide the other, and per-server failures still + // let sibling servers render. + await Promise.all([ + (async () => { + try { + toolsByServer[server.name] = + await workspaceActions.loadMcpTools(server.name); + } catch { + // Allow partial failure — other servers still render + } + })(), + (async () => { + // Skip the round-trip for servers that advertise no + // resources (or older daemons that omit the count). + if (!server.resourceCount) return; + try { + resourcesByServer[server.name] = + await workspaceActions.loadMcpResources( + server.name, + ); + } catch { + // Allow partial failure — other servers still render + } + })(), + ]); }), ); setMcpDialogMessage({ status, toolsByServer, + resourcesByServer, showDescriptions: mcpArg === 'desc', showSchema: mcpArg === 'schema', showTips: !mcpArg, diff --git a/packages/web-shell/client/components/dialogs/McpDialog.module.css b/packages/web-shell/client/components/dialogs/McpDialog.module.css index 13c28103d52..a920d2a531b 100644 --- a/packages/web-shell/client/components/dialogs/McpDialog.module.css +++ b/packages/web-shell/client/components/dialogs/McpDialog.module.css @@ -187,6 +187,12 @@ text-align: right; } +.resourceCount, +.promptCount { + margin-left: 6px; + text-align: right; +} + .success { color: var(--success-color); } @@ -283,6 +289,46 @@ flex-direction: column; } +.resources { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid var(--border-color, var(--bg-secondary)); +} + +.resources > .sectionTitle { + padding: 2px 8px; + color: var(--text-secondary); +} + +.resourceUri { + font-family: + ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.resourceLabel { + flex: 0 1 auto; + margin-left: 8px; + color: var(--text-secondary); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.resourceRef { + font-family: + ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; + font-size: 12px; + color: var(--accent-color, var(--text-primary)); + word-break: break-all; +} + .toolDetail { display: flex; flex-direction: column; diff --git a/packages/web-shell/client/components/dialogs/McpDialog.tsx b/packages/web-shell/client/components/dialogs/McpDialog.tsx index 9a7df01b08b..a267e46ad4e 100644 --- a/packages/web-shell/client/components/dialogs/McpDialog.tsx +++ b/packages/web-shell/client/components/dialogs/McpDialog.tsx @@ -4,6 +4,8 @@ import type { DaemonWorkspaceMcpServerStatus, DaemonWorkspaceMcpToolStatus, DaemonWorkspaceMcpToolsStatus, + DaemonWorkspaceMcpResourceStatus, + DaemonWorkspaceMcpResourcesStatus, } from '@qwen-code/webui/daemon-react-sdk'; import { useMcp } from '@qwen-code/webui/daemon-react-sdk'; import { useI18n } from '../../i18n'; @@ -109,6 +111,21 @@ function toolKey(serverName: string, toolName: string): string { return `${serverName}:${toolName}`; } +// A resource URI may contain ':', so join with NUL (mirrors core's +// ResourceRegistry key) to keep the expand-state key collision-proof. +function resourceKey(serverName: string, uri: string): string { + return `${serverName}\u0000${uri}`; +} + +/** + * Build the in-chat reference for an MCP resource. Mirrors the TUI's + * `buildMcpResourceRef`: the stored ref is `:`; the `@` + * is the input trigger character, prepended only for display. + */ +function buildMcpResourceRef(serverName: string, uri: string): string { + return `${serverName}:${uri}`; +} + const detailLabel = trimDialogLabel; function ChevronIcon({ expanded }: { expanded: boolean }) { @@ -252,6 +269,64 @@ function ToolDetail({ tool, t }: { tool: DaemonWorkspaceMcpToolStatus; t: T }) { ); } +function ResourceDetail({ + resource, + serverName, + t, +}: { + resource: DaemonWorkspaceMcpResourceStatus; + serverName: string; + t: T; +}) { + // Only surface the friendly name when it adds information beyond the URI + // that is already shown — mirrors the TUI ResourceDetailStep. + const friendlyName = resource.title || resource.name || ''; + const showName = friendlyName !== '' && friendlyName !== resource.uri; + return ( +
+ + {showName ? ( + + ) : null} + {resource.mimeType ? ( + + ) : null} + {typeof resource.size === 'number' ? ( + + ) : null} + {resource.description ? ( +
+
+ {detailLabel(t('mcp.description'))} +
+
+ {resource.description.trim()} +
+
+ ) : null} + {/* How to reference: typing @server:uri in chat injects the content. */} +
+
{t('mcp.resource.reference')}
+ + @{buildMcpResourceRef(serverName, resource.uri)} + +
+
+ ); +} + export function McpDialog({ message }: McpDialogProps) { const { t } = useI18n(); const mcp = useMcp({ autoLoad: false }); @@ -261,11 +336,17 @@ export function McpDialog({ message }: McpDialogProps) { const [toolsByServer, setToolsByServer] = useState< Record >(message.toolsByServer); + const [resourcesByServer, setResourcesByServer] = useState< + Record + >(message.resourcesByServer ?? {}); const servers = useMemo(() => status.servers ?? [], [status.servers]); const [expandedServers, setExpandedServers] = useState>( new Set(), ); const [expandedTools, setExpandedTools] = useState>(new Set()); + const [expandedResources, setExpandedResources] = useState>( + new Set(), + ); const [actionMessage, setActionMessage] = useState<{ serverName: string; text: string; @@ -289,6 +370,7 @@ export function McpDialog({ message }: McpDialogProps) { items: Array<{ server: DaemonWorkspaceMcpServerStatus; tools: DaemonWorkspaceMcpToolStatus[]; + resources: DaemonWorkspaceMcpResourceStatus[]; }>; }> = []; for (const server of servers) { @@ -296,13 +378,14 @@ export function McpDialog({ message }: McpDialogProps) { const item = { server, tools: toolsByServer[server.name]?.tools ?? [], + resources: resourcesByServer[server.name]?.resources ?? [], }; const group = groups.find((candidate) => candidate.label === label); if (group) group.items.push(item); else groups.push({ label, items: [item] }); } return groups; - }, [servers, t, toolsByServer]); + }, [servers, t, toolsByServer, resourcesByServer]); const reloadServer = useCallback( async (serverName: string) => { @@ -318,6 +401,23 @@ export function McpDialog({ message }: McpDialogProps) { ...current, [nextServer.name]: nextTools, })); + // Keep the resource list in sync after reconnect/enable. Only fetch + // when the refreshed status still advertises resources; otherwise + // drop any now-stale list so the section disappears. + if (nextServer.resourceCount) { + const nextResources = await mcp.loadResources(nextServer.name); + setResourcesByServer((current) => ({ + ...current, + [nextServer.name]: nextResources, + })); + } else { + setResourcesByServer((current) => { + if (!(nextServer.name in current)) return current; + const next = { ...current }; + delete next[nextServer.name]; + return next; + }); + } }, [mcp], ); @@ -377,6 +477,7 @@ export function McpDialog({ message }: McpDialogProps) { return current.has(serverName) ? new Set() : new Set([serverName]); }); setExpandedTools(new Set()); + setExpandedResources(new Set()); }, []); const toggleTool = useCallback((serverName: string, toolName: string) => { @@ -386,6 +487,13 @@ export function McpDialog({ message }: McpDialogProps) { }); }, []); + const toggleResource = useCallback((serverName: string, uri: string) => { + const key = resourceKey(serverName, uri); + setExpandedResources((current) => { + return current.has(key) ? new Set() : new Set([key]); + }); + }, []); + return (
{connectingCount > 0 ? ( @@ -404,11 +512,13 @@ export function McpDialog({ message }: McpDialogProps) { {group.label !== t('mcp.extensionMcp') ? (
{group.label}
) : null} - {group.items.map(({ server, tools }) => { + {group.items.map(({ server, tools, resources }) => { const display = statusDisplay(server, t); const expanded = expandedServers.has(server.name); const actions = serverActions(server, t); const toolCount = toolsByServer[server.name]?.tools.length ?? 0; + const resourceCount = server.resourceCount ?? 0; + const promptCount = server.promptCount ?? 0; return (
+ {resourceCount > 0 ? ( + + {t('mcp.resourceCount', { count: resourceCount })} + + ) : null} + {promptCount > 0 ? ( + + {t('mcp.promptCount', { count: promptCount })} + + ) : null} @@ -517,6 +641,69 @@ export function McpDialog({ message }: McpDialogProps) { })}
)} + {resources.length > 0 ? ( +
+
+ {t('mcp.resources')} +
+ {resources.map((resource) => { + const key = resourceKey( + server.name, + resource.uri, + ); + const resourceExpanded = + expandedResources.has(key); + const label = + resource.title || resource.name || ''; + return ( +
+ + {resourceExpanded ? ( + + ) : null} +
+ ); + })} +
+ ) : null}
) : null}
diff --git a/packages/web-shell/client/components/messages/McpStatusMessage.tsx b/packages/web-shell/client/components/messages/McpStatusMessage.tsx index e13129f59f7..a3a58701559 100644 --- a/packages/web-shell/client/components/messages/McpStatusMessage.tsx +++ b/packages/web-shell/client/components/messages/McpStatusMessage.tsx @@ -4,6 +4,7 @@ import type { DaemonWorkspaceMcpServerStatus, DaemonWorkspaceMcpToolStatus, DaemonWorkspaceMcpToolsStatus, + DaemonWorkspaceMcpResourcesStatus, } from '@qwen-code/webui/daemon-react-sdk'; import { useMcp } from '@qwen-code/webui/daemon-react-sdk'; import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; @@ -36,6 +37,12 @@ type McpPanelActiveEvent = CustomEvent<{ id: string; active: boolean }>; interface SerializedMcpStatusMessage { status: DaemonWorkspaceMcpStatus; toolsByServer: Record; + /** + * Per-server MCP resources, preloaded alongside tools so the dialog can + * browse them offline. Keyed by server name. May be absent on messages + * serialized by older clients — read defensively (`?? {}`). + */ + resourcesByServer: Record; showDescriptions: boolean; showSchema: boolean; showTips: boolean; diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index d794f0e9abe..375f13cfaef 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -729,6 +729,17 @@ const EN: Messages = { 'mcp.manageServers': 'Manage MCP servers', 'mcp.parameters': 'Parameters:', 'mcp.required': ' required', + 'mcp.resources': 'Resources', + 'mcp.resourceCount': (v) => + `${v?.count ?? 0} ${v?.count === 1 ? 'resource' : 'resources'}`, + 'mcp.promptCount': (v) => + `${v?.count ?? 0} ${v?.count === 1 ? 'prompt' : 'prompts'}`, + 'mcp.resource.uriLabel': 'URI:', + 'mcp.resource.nameLabel': 'Name:', + 'mcp.resource.mimeTypeLabel': 'MIME Type:', + 'mcp.resource.sizeLabel': 'Size:', + 'mcp.resource.bytes': (v) => `${v?.count ?? 0} bytes`, + 'mcp.resource.reference': 'Reference in chat', 'mcp.scrollPosition': (v) => `${v?.current ?? 0}/${v?.total ?? 0}`, 'mcp.shortcut.back': 'Esc to back', 'mcp.shortcut.close': 'Esc to close', @@ -1858,6 +1869,15 @@ const ZH: Messages = { 'mcp.manageServers': '管理 MCP servers', 'mcp.parameters': '参数:', 'mcp.required': ' 必填', + 'mcp.resources': '资源', + 'mcp.resourceCount': (v) => `${v?.count ?? 0} 个资源`, + 'mcp.promptCount': (v) => `${v?.count ?? 0} 个提示词`, + 'mcp.resource.uriLabel': 'URI:', + 'mcp.resource.nameLabel': '名称:', + 'mcp.resource.mimeTypeLabel': 'MIME 类型:', + 'mcp.resource.sizeLabel': '大小:', + 'mcp.resource.bytes': (v) => `${v?.count ?? 0} 字节`, + 'mcp.resource.reference': '在对话中引用', 'mcp.scrollPosition': (v) => `${v?.current ?? 0}/${v?.total ?? 0}`, 'mcp.shortcut.back': 'Esc 返回', 'mcp.shortcut.close': 'Esc 关闭', diff --git a/packages/webui/src/daemon-react-sdk.ts b/packages/webui/src/daemon-react-sdk.ts index 35b94767a21..37416bd04c4 100644 --- a/packages/webui/src/daemon-react-sdk.ts +++ b/packages/webui/src/daemon-react-sdk.ts @@ -271,6 +271,10 @@ export type { DaemonWorkspaceMcpToolStatus, /** All tools from a single MCP server. */ DaemonWorkspaceMcpToolsStatus, + /** Single MCP resource: uri, name, title, mime type, size, description. */ + DaemonWorkspaceMcpResourceStatus, + /** All resources from a single MCP server. */ + DaemonWorkspaceMcpResourcesStatus, /** Memory file entry: path, scope, byte size. */ DaemonWorkspaceMemoryFile, /** Skill status: name, description, level, model-invocable flag. */ diff --git a/packages/webui/src/daemon/index.ts b/packages/webui/src/daemon/index.ts index 62a26ba0606..d6924e8d549 100644 --- a/packages/webui/src/daemon/index.ts +++ b/packages/webui/src/daemon/index.ts @@ -125,6 +125,8 @@ export type { DaemonWorkspaceMcpServerStatus, DaemonWorkspaceMcpToolStatus, DaemonWorkspaceMcpToolsStatus, + DaemonWorkspaceMcpResourceStatus, + DaemonWorkspaceMcpResourcesStatus, DaemonWorkspaceMemoryFile, DaemonWorkspaceSkillStatus, DaemonWorkspaceToolStatus, diff --git a/packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.test.tsx b/packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.test.tsx index 8fa677122a6..a6a9e51153d 100644 --- a/packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.test.tsx +++ b/packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.test.tsx @@ -21,6 +21,7 @@ const sdkMocks = vi.hoisted(() => { const capabilities = vi.fn(); const workspaceMcp = vi.fn(); const workspaceMcpTools = vi.fn(); + const workspaceMcpResources = vi.fn(); const restartMcpServer = vi.fn(); const workspaceSkills = vi.fn(); const workspaceTools = vi.fn(); @@ -42,6 +43,7 @@ const sdkMocks = vi.hoisted(() => { capabilities = capabilities; workspaceMcp = workspaceMcp; workspaceMcpTools = workspaceMcpTools; + workspaceMcpResources = workspaceMcpResources; restartMcpServer = restartMcpServer; workspaceSkills = workspaceSkills; workspaceTools = workspaceTools; @@ -64,6 +66,7 @@ const sdkMocks = vi.hoisted(() => { capabilities, workspaceMcp, workspaceMcpTools, + workspaceMcpResources, restartMcpServer, workspaceSkills, workspaceTools, @@ -97,6 +100,12 @@ const sdkMocks = vi.hoisted(() => { serverName: 'mock', tools: [], }); + workspaceMcpResources.mockReset(); + workspaceMcpResources.mockResolvedValue({ + v: 1, + serverName: 'mock', + resources: [], + }); restartMcpServer.mockReset(); restartMcpServer.mockResolvedValue({ restarted: true }); workspaceSkills.mockReset(); @@ -332,6 +341,83 @@ describe('DaemonWorkspaceProvider', () => { }); }); + it('loads MCP resources for a server', async () => { + sdkMocks.workspaceMcpResources.mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/mock-workspace', + serverName: 'docs', + initialized: true, + acpChannelLive: true, + resources: [{ uri: 'file:///docs/intro.md', name: 'Intro' }], + }); + let actions: DaemonWorkspaceActions | undefined; + + function Harness() { + const workspace = useOptionalDaemonWorkspace(); + actions = workspace?.actions; + return null; + } + + await renderWithProvider(); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + if (!actions) throw new Error('actions not defined'); + const workspaceActions = actions; + + await act(async () => { + await expect( + workspaceActions.loadMcpResources('docs'), + ).resolves.toMatchObject({ + serverName: 'docs', + resources: [{ uri: 'file:///docs/intro.md', name: 'Intro' }], + }); + }); + expect(sdkMocks.workspaceMcpResources).toHaveBeenCalledWith('docs'); + }); + + it('returns MCP resources fallback for older daemons', async () => { + sdkMocks.workspaceMcpResources.mockRejectedValueOnce( + new Error('missing route'), + ); + let actions: DaemonWorkspaceActions | undefined; + + function Harness() { + const workspace = useOptionalDaemonWorkspace(); + actions = workspace?.actions; + return null; + } + + await renderWithProvider(); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + if (!actions) throw new Error('actions not defined'); + const workspaceActions = actions; + + await act(async () => { + await expect( + workspaceActions.loadMcpResources('server-a'), + ).resolves.toEqual({ + v: 1, + workspaceCwd: '', + serverName: 'server-a', + initialized: false, + acpChannelLive: false, + resources: [], + errors: [ + { + kind: 'mcp_resources', + status: 'error', + error: 'The connected daemon does not expose MCP resource details.', + }, + ], + }); + }); + }); + it('loads workspace glob matches', async () => { const fetchMock = vi.fn( async ( diff --git a/packages/webui/src/daemon/workspace/actions.ts b/packages/webui/src/daemon/workspace/actions.ts index 6d3e351b98f..9cd6d05f2a9 100644 --- a/packages/webui/src/daemon/workspace/actions.ts +++ b/packages/webui/src/daemon/workspace/actions.ts @@ -92,6 +92,36 @@ export function createDaemonWorkspaceActions({ } }, + async loadMcpResources(serverName) { + const client = requireClient(getClient, 'Load MCP resources failed'); + try { + return await withActionTimeout( + client.workspaceMcpResources(serverName), + 'Load MCP resources timed out', + ); + } catch { + // Older daemons lack the resources route. Degrade gracefully so a + // mixed-version client still renders the rest of the /mcp dialog — + // mirrors the loadMcpTools fallback. + return { + v: 1 as const, + workspaceCwd: '', + serverName, + initialized: false, + acpChannelLive: false, + resources: [], + errors: [ + { + kind: 'mcp_resources' as const, + status: 'error' as const, + error: + 'The connected daemon does not expose MCP resource details.', + }, + ], + }; + } + }, + async restartMcpServer(serverName) { const client = requireClient(getClient, 'Restart MCP server failed'); return withActionTimeout( diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonMcp.ts b/packages/webui/src/daemon/workspace/hooks/useDaemonMcp.ts index 441dcb2a5e1..7c4716c0419 100644 --- a/packages/webui/src/daemon/workspace/hooks/useDaemonMcp.ts +++ b/packages/webui/src/daemon/workspace/hooks/useDaemonMcp.ts @@ -28,6 +28,7 @@ export function useDaemonMcp(options: DaemonResourceOptions = {}) { ...result, status: result.data, loadTools: workspaceActions.loadMcpTools, + loadResources: workspaceActions.loadMcpResources, restartServer: workspaceActions.restartMcpServer, manageServer: workspaceActions.manageMcpServer, }; diff --git a/packages/webui/src/daemon/workspace/types.ts b/packages/webui/src/daemon/workspace/types.ts index 2d80e90446d..606a2b27a35 100644 --- a/packages/webui/src/daemon/workspace/types.ts +++ b/packages/webui/src/daemon/workspace/types.ts @@ -42,6 +42,7 @@ import type { DaemonWorkspaceFileWriteResult, DaemonWorkspaceMcpStatus, DaemonWorkspaceMcpToolsStatus, + DaemonWorkspaceMcpResourcesStatus, DaemonWorkspaceMemoryStatus, DaemonWorkspacePreflightStatus, DaemonWorkspaceProvidersStatus, @@ -152,6 +153,9 @@ export interface DaemonWorkspaceActions { // MCP loadMcpStatus(): Promise; loadMcpTools(serverName: string): Promise; + loadMcpResources( + serverName: string, + ): Promise; restartMcpServer(serverName: string): Promise; manageMcpServer( serverName: string, From 41e56d05b0173bf52c0dc9604b0ac4a157a66cfa Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 26 Jun 2026 11:39:25 +0800 Subject: [PATCH 2/3] fix(web-shell): make resourcesByServer optional and pluralize 1-byte size Address review on #5879: - SerializedMcpStatusMessage.resourcesByServer is now optional, matching its JSDoc ("older clients omit it") so TypeScript enforces the `?? {}` defensive read at every consumer. - mcp.resource.bytes now pluralizes ("1 byte" vs "2 bytes"), consistent with the mcp.resourceCount / mcp.promptCount strings in this PR. --- .../client/components/messages/McpStatusMessage.tsx | 7 ++++--- packages/web-shell/client/i18n.tsx | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/web-shell/client/components/messages/McpStatusMessage.tsx b/packages/web-shell/client/components/messages/McpStatusMessage.tsx index a3a58701559..a08fd0d3ea5 100644 --- a/packages/web-shell/client/components/messages/McpStatusMessage.tsx +++ b/packages/web-shell/client/components/messages/McpStatusMessage.tsx @@ -39,10 +39,11 @@ interface SerializedMcpStatusMessage { toolsByServer: Record; /** * Per-server MCP resources, preloaded alongside tools so the dialog can - * browse them offline. Keyed by server name. May be absent on messages - * serialized by older clients — read defensively (`?? {}`). + * browse them offline. Keyed by server name. Optional because messages + * serialized by older clients omit it — consumers must read defensively + * (`?? {}`), and the optional type forces that at compile time. */ - resourcesByServer: Record; + resourcesByServer?: Record; showDescriptions: boolean; showSchema: boolean; showTips: boolean; diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 375f13cfaef..2db6c3f11ca 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -738,7 +738,8 @@ const EN: Messages = { 'mcp.resource.nameLabel': 'Name:', 'mcp.resource.mimeTypeLabel': 'MIME Type:', 'mcp.resource.sizeLabel': 'Size:', - 'mcp.resource.bytes': (v) => `${v?.count ?? 0} bytes`, + 'mcp.resource.bytes': (v) => + `${v?.count ?? 0} ${v?.count === 1 ? 'byte' : 'bytes'}`, 'mcp.resource.reference': 'Reference in chat', 'mcp.scrollPosition': (v) => `${v?.current ?? 0}/${v?.total ?? 0}`, 'mcp.shortcut.back': 'Esc to back', From 2abb167eb37ef6ff53a7ce64a3cbeb734116cab1 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 26 Jun 2026 18:11:29 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(web-shell):=20address=20MCP=20resources?= =?UTF-8?q?=20review=20=E2=80=94=20remove=20non-functional=20@ref,=20harde?= =?UTF-8?q?n=20fallbacks,=20add=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on #5879: - [Critical] Remove the "@server:uri" chat-reference UI from the resource browser: the Web Shell submits prompts as a plain text block and the daemon forwards it verbatim (the TUI's atCommandProcessor resolution is TUI-client-side only), so the reference never injected resource content. The browser stays as read-only metadata; @-reference injection is a follow-up that needs the resolver wired into the Web Shell prompt path. - [Critical] reloadServer now isolates the resource refetch in its own try/catch so a failed resource load can't report a successful reconnect/enable as failed. - [Critical] resolveServerMcpResources/Prompts skip a throwing session in the pool-mode fallback so one degraded session can't blank the base /workspace/mcp status (which now carries the counts). - [Suggestion] Resource section renders an "unavailable" state when a server advertises a count but the drill-down list is empty. - [Suggestion] Add SDK client URL-encoding test (workspaceMcpResources), route-table matching test, and an unconfigured-server error-branch test. --- .../cli/src/acp-integration/acpAgent.test.ts | 16 +++++++ packages/cli/src/acp-integration/acpAgent.ts | 38 ++++++++++------ .../test/unit/DaemonClient.test.ts | 19 ++++++++ .../test/unit/acpRouteTable.test.ts | 12 +++++ .../components/dialogs/McpDialog.module.css | 8 ---- .../client/components/dialogs/McpDialog.tsx | 45 ++++++++----------- packages/web-shell/client/i18n.tsx | 4 +- 7 files changed, 92 insertions(+), 50 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 51f3441a1e8..8facc009f36 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -1604,6 +1604,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => { SERVE_STATUS_EXT_METHODS.workspaceMcpResources, { serverName: 'docs' }, ); + const mcpResourcesMissing = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.workspaceMcpResources, + { serverName: 'not-configured' }, + ); const skills = await agent.extMethod( SERVE_STATUS_EXT_METHODS.workspaceSkills, {}, @@ -1688,6 +1692,18 @@ describe('QwenAgent MCP SSE/HTTP support', () => { .resources[1], ).not.toHaveProperty('serverName'); + // An unconfigured server name returns an error cell and does NOT fall + // back to scanning other servers/sessions. + expect(mcpResourcesMissing).toMatchObject({ + v: 1, + workspaceCwd: '/work/status', + serverName: 'not-configured', + initialized: true, + acpChannelLive: true, + resources: [], + errors: [{ kind: 'mcp_resources', status: 'error' }], + }); + expect(skills).toMatchObject({ v: 1, workspaceCwd: '/work/status', diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 1fb7dae2020..3ef3e4b0a2b 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -3569,13 +3569,19 @@ class QwenAgent implements Agent { return resources; } for (const session of this.getActiveSessions()) { - const sessionResources = - session - .getConfig() - .getResourceRegistry?.() - ?.getResourcesByServer(serverName) ?? []; - if (sessionResources.length > 0) { - return sessionResources; + try { + const sessionResources = + session + .getConfig() + .getResourceRegistry?.() + ?.getResourcesByServer(serverName) ?? []; + if (sessionResources.length > 0) { + return sessionResources; + } + } catch { + // A degraded session must not collapse the base /workspace/mcp + // status — skip it and keep scanning. (The counts ride that status, + // so one bad session shouldn't blank out every server's row.) } } return resources; @@ -3598,13 +3604,17 @@ class QwenAgent implements Agent { return prompts; } for (const session of this.getActiveSessions()) { - const sessionPrompts = - session - .getConfig() - .getPromptRegistry?.() - ?.getPromptsByServer(serverName) ?? []; - if (sessionPrompts.length > 0) { - return sessionPrompts; + try { + const sessionPrompts = + session + .getConfig() + .getPromptRegistry?.() + ?.getPromptsByServer(serverName) ?? []; + if (sessionPrompts.length > 0) { + return sessionPrompts; + } + } catch { + // See resolveServerMcpResources — skip a degraded session. } } return prompts; diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index c75ee125fd6..3c2c8028ec3 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -495,6 +495,25 @@ describe('DaemonClient', () => { ]); }); + it('GETs /workspace/mcp/:server/resources with URL encoding', async () => { + const resourcesStatus = { + v: 1, + serverName: 'my server', + resources: [{ uri: 'file:///intro.md', name: 'Intro' }], + }; + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, resourcesStatus), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect(client.workspaceMcpResources('my server')).resolves.toEqual( + resourcesStatus, + ); + expect(calls.map((c) => [c.method, c.url])).toEqual([ + ['GET', 'http://daemon/workspace/mcp/my%20server/resources'], + ]); + }); + it('GETs /workspace/tools and returns the tools envelope', async () => { const toolsStatus = { v: 1, diff --git a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts index bd150248146..c99b3e703dc 100644 --- a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts +++ b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts @@ -469,6 +469,18 @@ describe('acpRouteTable – matchRoute', () => { expect(params).toEqual({ serverName: 'fs' }); }); + it('GET /workspace/mcp/:server/resources maps to _qwen/workspace/mcp/resources', () => { + const result = matchRoute('/workspace/mcp/fs/resources', 'GET'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/workspace/mcp/resources'); + const params = result!.mapping.extractParams( + result!.segments, + undefined, + 'GET', + ); + expect(params).toEqual({ serverName: 'fs' }); + }); + it('POST /workspace/mcp/servers maps to _qwen/workspace/mcp/servers/add', () => { const result = matchRoute('/workspace/mcp/servers', 'POST'); expect(result).not.toBeNull(); diff --git a/packages/web-shell/client/components/dialogs/McpDialog.module.css b/packages/web-shell/client/components/dialogs/McpDialog.module.css index a920d2a531b..ef671b518d0 100644 --- a/packages/web-shell/client/components/dialogs/McpDialog.module.css +++ b/packages/web-shell/client/components/dialogs/McpDialog.module.css @@ -321,14 +321,6 @@ white-space: nowrap; } -.resourceRef { - font-family: - ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; - font-size: 12px; - color: var(--accent-color, var(--text-primary)); - word-break: break-all; -} - .toolDetail { display: flex; flex-direction: column; diff --git a/packages/web-shell/client/components/dialogs/McpDialog.tsx b/packages/web-shell/client/components/dialogs/McpDialog.tsx index a267e46ad4e..c21693f425e 100644 --- a/packages/web-shell/client/components/dialogs/McpDialog.tsx +++ b/packages/web-shell/client/components/dialogs/McpDialog.tsx @@ -117,15 +117,6 @@ function resourceKey(serverName: string, uri: string): string { return `${serverName}\u0000${uri}`; } -/** - * Build the in-chat reference for an MCP resource. Mirrors the TUI's - * `buildMcpResourceRef`: the stored ref is `:`; the `@` - * is the input trigger character, prepended only for display. - */ -function buildMcpResourceRef(serverName: string, uri: string): string { - return `${serverName}:${uri}`; -} - const detailLabel = trimDialogLabel; function ChevronIcon({ expanded }: { expanded: boolean }) { @@ -271,11 +262,9 @@ function ToolDetail({ tool, t }: { tool: DaemonWorkspaceMcpToolStatus; t: T }) { function ResourceDetail({ resource, - serverName, t, }: { resource: DaemonWorkspaceMcpResourceStatus; - serverName: string; t: T; }) { // Only surface the friendly name when it adds information beyond the URI @@ -316,13 +305,6 @@ function ResourceDetail({ ) : null} - {/* How to reference: typing @server:uri in chat injects the content. */} -
-
{t('mcp.resource.reference')}
- - @{buildMcpResourceRef(serverName, resource.uri)} - -
); } @@ -403,13 +385,20 @@ export function McpDialog({ message }: McpDialogProps) { })); // Keep the resource list in sync after reconnect/enable. Only fetch // when the refreshed status still advertises resources; otherwise - // drop any now-stale list so the section disappears. + // drop any now-stale list so the section disappears. The fetch is + // isolated in its own try/catch so a failed resource refresh never + // turns a successful reconnect/enable into a reported failure. if (nextServer.resourceCount) { - const nextResources = await mcp.loadResources(nextServer.name); - setResourcesByServer((current) => ({ - ...current, - [nextServer.name]: nextResources, - })); + try { + const nextResources = await mcp.loadResources(nextServer.name); + setResourcesByServer((current) => ({ + ...current, + [nextServer.name]: nextResources, + })); + } catch { + // Leave the prior resource list in place; the count badge still + // reflects the refreshed status. + } } else { setResourcesByServer((current) => { if (!(nextServer.name in current)) return current; @@ -641,11 +630,16 @@ export function McpDialog({ message }: McpDialogProps) { })} )} - {resources.length > 0 ? ( + {resourceCount > 0 ? (
{t('mcp.resources')}
+ {resources.length === 0 ? ( +
+ {t('mcp.resourcesUnavailable')} +
+ ) : null} {resources.map((resource) => { const key = resourceKey( server.name, @@ -695,7 +689,6 @@ export function McpDialog({ message }: McpDialogProps) { {resourceExpanded ? ( ) : null} diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 2db6c3f11ca..581976fdaee 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -730,6 +730,7 @@ const EN: Messages = { 'mcp.parameters': 'Parameters:', 'mcp.required': ' required', 'mcp.resources': 'Resources', + 'mcp.resourcesUnavailable': 'Resource details unavailable.', 'mcp.resourceCount': (v) => `${v?.count ?? 0} ${v?.count === 1 ? 'resource' : 'resources'}`, 'mcp.promptCount': (v) => @@ -740,7 +741,6 @@ const EN: Messages = { 'mcp.resource.sizeLabel': 'Size:', 'mcp.resource.bytes': (v) => `${v?.count ?? 0} ${v?.count === 1 ? 'byte' : 'bytes'}`, - 'mcp.resource.reference': 'Reference in chat', 'mcp.scrollPosition': (v) => `${v?.current ?? 0}/${v?.total ?? 0}`, 'mcp.shortcut.back': 'Esc to back', 'mcp.shortcut.close': 'Esc to close', @@ -1871,6 +1871,7 @@ const ZH: Messages = { 'mcp.parameters': '参数:', 'mcp.required': ' 必填', 'mcp.resources': '资源', + 'mcp.resourcesUnavailable': '无法加载资源详情。', 'mcp.resourceCount': (v) => `${v?.count ?? 0} 个资源`, 'mcp.promptCount': (v) => `${v?.count ?? 0} 个提示词`, 'mcp.resource.uriLabel': 'URI:', @@ -1878,7 +1879,6 @@ const ZH: Messages = { 'mcp.resource.mimeTypeLabel': 'MIME 类型:', 'mcp.resource.sizeLabel': '大小:', 'mcp.resource.bytes': (v) => `${v?.count ?? 0} 字节`, - 'mcp.resource.reference': '在对话中引用', 'mcp.scrollPosition': (v) => `${v?.current ?? 0}/${v?.total ?? 0}`, 'mcp.shortcut.back': 'Esc 返回', 'mcp.shortcut.close': 'Esc 关闭',