diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index a7f41350da..a214a2dc3d 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -404,7 +404,7 @@ export default function ToolCall({ {!hideAttachments && attachments && attachments.length > 0 && ( )} - {hasOutput && + {mcpApps.length > 0 && mcpApps.map((app) => )} ); diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index 5230b2e996..4e4ecb33c9 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -977,19 +977,25 @@ Please follow these instructions when using tools from the respective MCP server const uris = new Set(); let cursor: string | undefined; - for (let page = 0; page < MCPManager.RESOURCE_LIST_MAX_PAGES; page++) { - const result: ListResourcesResult = await connection.client.request( - { method: 'resources/list', params: cursor != null ? { cursor } : {} }, - ListResourcesResultSchema, - { timeout: connection.timeout }, - ); - for (const resource of result.resources) { - uris.add(resource.uri); + // A template-only server may not implement resources/list; treat its failure as an empty + // concrete list so advertised templates below are still collected and can authorize reads. + try { + for (let page = 0; page < MCPManager.RESOURCE_LIST_MAX_PAGES; page++) { + const result: ListResourcesResult = await connection.client.request( + { method: 'resources/list', params: cursor != null ? { cursor } : {} }, + ListResourcesResultSchema, + { timeout: connection.timeout }, + ); + for (const resource of result.resources) { + uris.add(resource.uri); + } + if (result.nextCursor == null) { + break; + } + cursor = result.nextCursor; } - if (result.nextCursor == null) { - break; - } - cursor = result.nextCursor; + } catch (error) { + logger.debug(`[MCP][${cacheKey}] resources/list unavailable; using templates only.`, error); } const templates: RegExp[] = []; diff --git a/packages/api/src/mcp/__tests__/MCPManager.test.ts b/packages/api/src/mcp/__tests__/MCPManager.test.ts index 83a2f102b8..539b0bfb2d 100644 --- a/packages/api/src/mcp/__tests__/MCPManager.test.ts +++ b/packages/api/src/mcp/__tests__/MCPManager.test.ts @@ -1561,6 +1561,30 @@ describe('MCPManager', () => { const methods = request.mock.calls.map((c) => (c[0] as { method: string }).method); expect(methods).toContain('resources/read'); }); + + it('authorizes a template match when the server does not implement resources/list', async () => { + const request = jest.fn().mockImplementation((req: { method: string }) => { + if (req.method === 'resources/list') { + return Promise.reject(new Error('Method not found')); + } + if (req.method === 'resources/templates/list') { + return Promise.resolve({ resourceTemplates: [{ uriTemplate: 'db://items/{id}' }] }); + } + return Promise.resolve({ contents: [] }); + }); + const manager = await MCPManager.createInstance(newMCPServersConfig()); + jest.spyOn(manager, 'getConnection').mockResolvedValue(buildConnection(request)); + + await manager.readResource({ + userId: 'user-123', + serverName: 'srv', + uri: 'db://items/42', + user: mockUser as IUser, + }); + + const methods = request.mock.calls.map((c) => (c[0] as { method: string }).method); + expect(methods).toContain('resources/read'); + }); }); describe('getConnection', () => { diff --git a/packages/api/src/mcp/__tests__/parsers.test.ts b/packages/api/src/mcp/__tests__/parsers.test.ts index 8339dd4572..6d8ee26b7f 100644 --- a/packages/api/src/mcp/__tests__/parsers.test.ts +++ b/packages/api/src/mcp/__tests__/parsers.test.ts @@ -357,6 +357,51 @@ describe('formatToolContent', () => { expect(uiResourceArtifact?.resultMeta).toBeUndefined(); }); + it('still synthesizes the tool-declared app when the result returns a different ui:// resource', () => { + const result: t.MCPToolCallResponse = { + content: [ + { + type: 'resource', + resource: { + uri: 'ui://chart', + mimeType: 'text/html;profile=mcp-app', + text: '

c

', + }, + }, + ], + }; + + const [, artifacts] = formatToolContent(result, 'openai', { + serverName: 'srv', + toolName: 'do_thing', + resourceUri: 'ui://app', + }); + + const uris = (artifacts?.ui_resources?.data ?? []).map((r) => r.uri); + expect(uris).toContain('ui://chart'); + expect(uris).toContain('ui://app'); + }); + + it('does not double-synthesize when the returned resource is the declared app', () => { + const result: t.MCPToolCallResponse = { + content: [ + { + type: 'resource', + resource: { uri: 'ui://app', mimeType: 'text/html;profile=mcp-app', text: '

a

' }, + }, + ], + }; + + const [, artifacts] = formatToolContent(result, 'openai', { + serverName: 'srv', + toolName: 'do_thing', + resourceUri: 'ui://app', + }); + + const uris = (artifacts?.ui_resources?.data ?? []).map((r) => r.uri); + expect(uris).toEqual(['ui://app']); + }); + it('suppresses embedded ui:// resources when apps are disabled for the scope', () => { const result: t.MCPToolCallResponse = { content: [ diff --git a/packages/api/src/mcp/parsers.ts b/packages/api/src/mcp/parsers.ts index 637f19592e..c54f0849b2 100644 --- a/packages/api/src/mcp/parsers.ts +++ b/packages/api/src/mcp/parsers.ts @@ -316,13 +316,16 @@ export function formatToolContent( } } - // MCP Apps: if the tool declares a ui:// resourceUri but didn't include a resource - // content item, create a synthetic UIResource so the frontend renders AppRenderer. + // MCP Apps: the tool-declared ui:// resourceUri is the app the host renders for the call, so + // synthesize it unless the result already returned that exact resource. A secondary ui:// + // resource in the result must not suppress the declared app. + const declaredAppAlreadyReturned = + metadata?.resourceUri != null && uiResources.some((r) => r.uri === metadata.resourceUri); if ( - uiResources.length === 0 && metadata?.resourceUri && metadata.serverName && - metadata.toolName + metadata.toolName && + !declaredAppAlreadyReturned ) { const resourceId = deriveResourceId( metadata.resourceUri,