mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
fix(mcp): OAuth-aware app connections, list proxy, unique result ids, laid-out iframes
Plumbs OAuth context into app follow-up requests. The app controllers now build a flowManager and tokenMethods and pass them through readResource, listResources, and appToolCall to getAppConnection and getConnection, so a cold-recreated connection (idle timeout, restart, reload) for an OAuth-backed server reuses the user's stored tokens instead of failing for lack of OAuth context. Backs the advertised serverResources capability with resource listing. Apps that feature-detect serverResources can call resources/list, which had no handler. A new listResources manager method, a POST /api/mcp/resources/list route, and an onlistresources bridge handler proxy listing the same way reads are proxied. Makes synthetic and embedded app resource ids unique per result snapshot. The id now mixes in the tool result content, _meta, and error state alongside the resourceUri, structuredContent, and arguments, so repeated calls that differ only in those fields no longer collide and overwrite earlier conversation resources. Keeps app iframes laid out while waiting for size. The frame is rendered transparent until a positive size event instead of display:none, with the loading state overlaid, so an app whose initial auto-resize reports zero is not stuck behind the spinner.
This commit is contained in:
parent
39a06f43f4
commit
ff39323fff
10 changed files with 274 additions and 29 deletions
|
|
@ -1,18 +1,25 @@
|
|||
const path = require('path');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Constants } = require('librechat-data-provider');
|
||||
const { CacheKeys, Constants } = require('librechat-data-provider');
|
||||
const { getUserMCPAuthMap } = require('@librechat/api');
|
||||
const { getMCPManager } = require('~/config');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { resolveConfigServers } = require('~/server/services/MCP');
|
||||
const { findPluginAuthsByKeys } = require('~/models');
|
||||
const {
|
||||
findPluginAuthsByKeys,
|
||||
findToken,
|
||||
createToken,
|
||||
updateToken,
|
||||
deleteTokens,
|
||||
} = require('~/models');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
// MCP SDK ErrorCode.InvalidRequest = -32600
|
||||
const MCP_INVALID_REQUEST = -32600;
|
||||
|
||||
/**
|
||||
* Resolves the request-scoped config and the user's custom variables for a server so app
|
||||
* follow-up requests can connect to config-sourced servers and re-resolve credentialed headers
|
||||
* even when the original tool-call connection is gone.
|
||||
* Resolves the request-scoped config, the user's custom variables, and the OAuth flow/token
|
||||
* context for a server so app follow-up requests can connect to config-sourced servers and
|
||||
* re-resolve credentialed or OAuth connections even when the original tool-call connection is gone.
|
||||
*/
|
||||
const resolveAppContext = async (req, serverName) => {
|
||||
const userId = req.user?.id;
|
||||
|
|
@ -25,7 +32,9 @@ const resolveAppContext = async (req, serverName) => {
|
|||
.catch(() => undefined),
|
||||
]);
|
||||
const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
|
||||
return { configServers, customUserVars };
|
||||
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
|
||||
const tokenMethods = { findToken, createToken, updateToken, deleteTokens };
|
||||
return { configServers, customUserVars, flowManager, tokenMethods };
|
||||
};
|
||||
|
||||
/** @route POST /api/mcp/resources/read */
|
||||
|
|
@ -48,7 +57,10 @@ const readMCPResource = async (req, res) => {
|
|||
}
|
||||
|
||||
const mcpManager = getMCPManager();
|
||||
const { configServers, customUserVars } = await resolveAppContext(req, serverName);
|
||||
const { configServers, customUserVars, flowManager, tokenMethods } = await resolveAppContext(
|
||||
req,
|
||||
serverName,
|
||||
);
|
||||
const result = await mcpManager.readResource({
|
||||
userId,
|
||||
serverName,
|
||||
|
|
@ -56,6 +68,8 @@ const readMCPResource = async (req, res) => {
|
|||
user: req.user,
|
||||
configServers,
|
||||
customUserVars,
|
||||
flowManager,
|
||||
tokenMethods,
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
|
|
@ -64,6 +78,44 @@ const readMCPResource = async (req, res) => {
|
|||
}
|
||||
};
|
||||
|
||||
/** @route POST /api/mcp/resources/list */
|
||||
const listMCPResources = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { serverName, cursor } = req.body;
|
||||
if (!serverName) {
|
||||
return res.status(400).json({ error: 'serverName is required' });
|
||||
}
|
||||
if (cursor !== undefined && typeof cursor !== 'string') {
|
||||
return res.status(400).json({ error: 'cursor must be a string' });
|
||||
}
|
||||
|
||||
const mcpManager = getMCPManager();
|
||||
const { configServers, customUserVars, flowManager, tokenMethods } = await resolveAppContext(
|
||||
req,
|
||||
serverName,
|
||||
);
|
||||
const result = await mcpManager.listResources({
|
||||
userId,
|
||||
serverName,
|
||||
user: req.user,
|
||||
cursor,
|
||||
configServers,
|
||||
customUserVars,
|
||||
flowManager,
|
||||
tokenMethods,
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('[listMCPResources] Error:', error);
|
||||
return res.status(500).json({ error: 'Failed to list resources' });
|
||||
}
|
||||
};
|
||||
|
||||
/** @route POST /api/mcp/app-tool-call */
|
||||
const appToolCall = async (req, res) => {
|
||||
try {
|
||||
|
|
@ -85,7 +137,10 @@ const appToolCall = async (req, res) => {
|
|||
}
|
||||
|
||||
const mcpManager = getMCPManager();
|
||||
const { configServers, customUserVars } = await resolveAppContext(req, serverName);
|
||||
const { configServers, customUserVars, flowManager, tokenMethods } = await resolveAppContext(
|
||||
req,
|
||||
serverName,
|
||||
);
|
||||
const result = await mcpManager.appToolCall({
|
||||
userId,
|
||||
serverName,
|
||||
|
|
@ -94,6 +149,8 @@ const appToolCall = async (req, res) => {
|
|||
user: req.user,
|
||||
configServers,
|
||||
customUserVars,
|
||||
flowManager,
|
||||
tokenMethods,
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
|
|
@ -153,4 +210,4 @@ const serveMCPSandbox = async (_req, res) => {
|
|||
}
|
||||
};
|
||||
|
||||
module.exports = { readMCPResource, appToolCall, serveMCPSandbox };
|
||||
module.exports = { readMCPResource, listMCPResources, appToolCall, serveMCPSandbox };
|
||||
|
|
|
|||
|
|
@ -31,7 +31,12 @@ const {
|
|||
getMCPServerById,
|
||||
getMCPTools,
|
||||
} = require('~/server/controllers/mcp');
|
||||
const { readMCPResource, appToolCall, serveMCPSandbox } = require('~/server/controllers/mcpApps');
|
||||
const {
|
||||
readMCPResource,
|
||||
listMCPResources,
|
||||
appToolCall,
|
||||
serveMCPSandbox,
|
||||
} = require('~/server/controllers/mcpApps');
|
||||
const mcpAppToolCallLimiter = require('~/server/middleware/limiters/mcpAppToolCallLimiter');
|
||||
const {
|
||||
getOAuthReconnectionManager,
|
||||
|
|
@ -988,6 +993,12 @@ router.delete(
|
|||
*/
|
||||
router.post('/resources/read', requireJwtAuth, checkMCPUsePermissions, readMCPResource);
|
||||
|
||||
/**
|
||||
* List resources available on an MCP server
|
||||
* @route POST /api/mcp/resources/list
|
||||
*/
|
||||
router.post('/resources/list', requireJwtAuth, checkMCPUsePermissions, listMCPResources);
|
||||
|
||||
/**
|
||||
* Proxy tool calls from MCP App iframe to MCP server
|
||||
* @route POST /api/mcp/app-tool-call
|
||||
|
|
|
|||
|
|
@ -76,9 +76,9 @@ const MCPAppView = React.memo(function MCPAppView({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="my-2" style={height ? { height } : { minHeight: 100 }}>
|
||||
<div className="relative my-2" style={height ? { height } : { minHeight: 100 }}>
|
||||
{!loaded && !timedOut && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border-light bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
|
||||
<div className="absolute inset-0 flex items-center gap-2 rounded-lg border border-border-light bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
|
||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
|
|
@ -98,7 +98,7 @@ const MCPAppView = React.memo(function MCPAppView({
|
|||
</div>
|
||||
)}
|
||||
{timedOut && !loaded && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border-light bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
|
||||
<div className="absolute inset-0 flex items-center gap-2 rounded-lg border border-border-light bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
|
||||
{localize('com_ui_mcp_app_failed_to_load')}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -110,7 +110,7 @@ const MCPAppView = React.memo(function MCPAppView({
|
|||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
display: loaded ? 'block' : 'none',
|
||||
opacity: loaded ? 1 : 0,
|
||||
}}
|
||||
title={`MCP App: ${app.toolName ?? ''}`}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ function MCPAppCard({
|
|||
return (
|
||||
<>
|
||||
{!loaded && (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border border-border-light bg-surface-secondary text-sm text-text-secondary">
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg border border-border-light bg-surface-secondary text-sm text-text-secondary">
|
||||
{localize('com_ui_loading_interactive_view')}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -56,7 +56,7 @@ function MCPAppCard({
|
|||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
display: loaded ? 'block' : 'none',
|
||||
opacity: loaded ? 1 : 0,
|
||||
}}
|
||||
title={`MCP App: ${resource.toolName ?? ''}`}
|
||||
/>
|
||||
|
|
@ -181,7 +181,7 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
|
|||
animationDelay: `${index * 100}ms`,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="relative flex h-full flex-col">
|
||||
<MCPAppCard
|
||||
resource={uiResource}
|
||||
onHeightChange={(h) => handleCardHeightChange(index, h)}
|
||||
|
|
|
|||
|
|
@ -62,11 +62,11 @@ export function MCPUIResource(props: MCPUIResourceProps) {
|
|||
if (uiResource.toolName && uiResource.serverName) {
|
||||
return (
|
||||
<span
|
||||
className="mx-1 inline-block w-full align-middle"
|
||||
className="relative mx-1 inline-block w-full align-middle"
|
||||
style={height ? { height } : { minHeight: '200px' }}
|
||||
>
|
||||
{!loaded && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border-light bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
|
||||
<div className="absolute inset-0 flex items-center gap-2 rounded-lg border border-border-light bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
|
||||
{localize('com_ui_loading_interactive_view')}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -78,7 +78,7 @@ export function MCPUIResource(props: MCPUIResourceProps) {
|
|||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
display: loaded ? 'block' : 'none',
|
||||
opacity: loaded ? 1 : 0,
|
||||
}}
|
||||
title={`MCP App: ${uiResource.toolName ?? ''}`}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@ import {
|
|||
} from '@modelcontextprotocol/ext-apps/app-bridge';
|
||||
import type { UIResource } from 'librechat-data-provider';
|
||||
import type { AppToolResult } from '~/utils/mcpApps';
|
||||
import { callMCPAppTool, fetchMCPResourceHtml, readMCPResource } from '~/utils/mcpApps';
|
||||
import {
|
||||
callMCPAppTool,
|
||||
fetchMCPResourceHtml,
|
||||
readMCPResource,
|
||||
listMCPResources,
|
||||
} from '~/utils/mcpApps';
|
||||
import { useOptionalMessagesOperations } from '~/Providers';
|
||||
import { logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
|
@ -108,6 +113,9 @@ export function useAppBridge(
|
|||
bridge.onreadresource = async (params) =>
|
||||
readMCPResource(resource.serverName as string, params.uri, user?.id) as never;
|
||||
|
||||
bridge.onlistresources = async (params) =>
|
||||
listMCPResources(resource.serverName as string, params?.cursor) as never;
|
||||
|
||||
bridge.onmessage = async ({ content }) => {
|
||||
const text = (content as MessageContentBlock[])
|
||||
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
||||
|
|
|
|||
|
|
@ -102,6 +102,10 @@ export async function readMCPResource(
|
|||
return promise;
|
||||
}
|
||||
|
||||
export async function listMCPResources(serverName: string, cursor?: string) {
|
||||
return request.post(`${apiBaseUrl()}/api/mcp/resources/list`, { serverName, cursor });
|
||||
}
|
||||
|
||||
type ResourceUiMeta = {
|
||||
csp?: {
|
||||
connectDomains?: string[];
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
import {
|
||||
CallToolResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ErrorCode,
|
||||
McpError,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
|
@ -721,12 +722,16 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
user,
|
||||
configServers,
|
||||
customUserVars,
|
||||
flowManager,
|
||||
tokenMethods,
|
||||
}: {
|
||||
serverName: string;
|
||||
userId: string;
|
||||
user?: IUser;
|
||||
configServers?: Record<string, t.ParsedServerConfig>;
|
||||
customUserVars?: Record<string, string>;
|
||||
flowManager?: FlowStateManager<MCPOAuthTokens | null>;
|
||||
tokenMethods?: TokenMethods;
|
||||
}): Promise<MCPConnection> {
|
||||
const logPrefix = `[MCP][User: ${userId}][${serverName}]`;
|
||||
const rawConfig = await MCPServersRegistry.getInstance().getServerConfig(
|
||||
|
|
@ -762,6 +767,8 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
user,
|
||||
serverConfig: rawConfig ?? undefined,
|
||||
customUserVars,
|
||||
flowManager,
|
||||
tokenMethods,
|
||||
});
|
||||
|
||||
// Refresh headers when the config can be fully resolved: env-var-only configs always, and
|
||||
|
|
@ -791,6 +798,8 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
user,
|
||||
configServers,
|
||||
customUserVars,
|
||||
flowManager,
|
||||
tokenMethods,
|
||||
}: {
|
||||
userId: string;
|
||||
serverName: string;
|
||||
|
|
@ -798,6 +807,8 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
user?: import('@librechat/data-schemas').IUser;
|
||||
configServers?: Record<string, t.ParsedServerConfig>;
|
||||
customUserVars?: Record<string, string>;
|
||||
flowManager?: FlowStateManager<MCPOAuthTokens | null>;
|
||||
tokenMethods?: TokenMethods;
|
||||
}): Promise<unknown> {
|
||||
const logPrefix = `[MCP][User: ${userId}][${serverName}]`;
|
||||
if (userId && user) this.updateUserLastActivity(userId);
|
||||
|
|
@ -807,6 +818,8 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
user,
|
||||
configServers,
|
||||
customUserVars,
|
||||
flowManager,
|
||||
tokenMethods,
|
||||
});
|
||||
|
||||
if (!(await connection.isConnected())) {
|
||||
|
|
@ -828,6 +841,60 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxies an MCP App resources/list request to the server. Paired with readResource so the
|
||||
* advertised serverResources capability is fully backed (resource-browser apps need listing).
|
||||
*/
|
||||
async listResources({
|
||||
userId,
|
||||
serverName,
|
||||
user,
|
||||
cursor,
|
||||
configServers,
|
||||
customUserVars,
|
||||
flowManager,
|
||||
tokenMethods,
|
||||
}: {
|
||||
userId: string;
|
||||
serverName: string;
|
||||
user?: import('@librechat/data-schemas').IUser;
|
||||
cursor?: string;
|
||||
configServers?: Record<string, t.ParsedServerConfig>;
|
||||
customUserVars?: Record<string, string>;
|
||||
flowManager?: FlowStateManager<MCPOAuthTokens | null>;
|
||||
tokenMethods?: TokenMethods;
|
||||
}): Promise<unknown> {
|
||||
const logPrefix = `[MCP][User: ${userId}][${serverName}]`;
|
||||
if (userId && user) this.updateUserLastActivity(userId);
|
||||
const connection = await this.getAppConnection({
|
||||
serverName,
|
||||
userId,
|
||||
user,
|
||||
configServers,
|
||||
customUserVars,
|
||||
flowManager,
|
||||
tokenMethods,
|
||||
});
|
||||
|
||||
if (!(await connection.isConnected())) {
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`${logPrefix} Connection is not active. Cannot list resources.`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await connection.client.request(
|
||||
{
|
||||
method: 'resources/list',
|
||||
params: cursor != null ? { cursor } : {},
|
||||
},
|
||||
ListResourcesResultSchema,
|
||||
{ timeout: connection.timeout },
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxies a tool call from an MCP App iframe to the MCP server.
|
||||
* Unlike callTool, this is a lightweight proxy without provider formatting.
|
||||
|
|
@ -840,6 +907,8 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
user,
|
||||
configServers,
|
||||
customUserVars,
|
||||
flowManager,
|
||||
tokenMethods,
|
||||
}: {
|
||||
userId: string;
|
||||
serverName: string;
|
||||
|
|
@ -848,6 +917,8 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
user?: import('@librechat/data-schemas').IUser;
|
||||
configServers?: Record<string, t.ParsedServerConfig>;
|
||||
customUserVars?: Record<string, string>;
|
||||
flowManager?: FlowStateManager<MCPOAuthTokens | null>;
|
||||
tokenMethods?: TokenMethods;
|
||||
}): Promise<unknown> {
|
||||
const logPrefix = `[MCP][User: ${userId}][${serverName}]`;
|
||||
if (userId && user) this.updateUserLastActivity(userId);
|
||||
|
|
@ -857,6 +928,8 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
user,
|
||||
configServers,
|
||||
customUserVars,
|
||||
flowManager,
|
||||
tokenMethods,
|
||||
});
|
||||
|
||||
if (!(await connection.isConnected())) {
|
||||
|
|
|
|||
|
|
@ -1276,6 +1276,85 @@ describe('MCPManager', () => {
|
|||
Authorization: 'Bearer secret',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards configServers, flowManager, and tokenMethods to getConnection', async () => {
|
||||
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({
|
||||
source: 'yaml',
|
||||
type: 'sse',
|
||||
url: 'https://example.com/mcp',
|
||||
});
|
||||
|
||||
const mockConnection = {
|
||||
isConnected: jest.fn().mockResolvedValue(true),
|
||||
setRequestHeaders: jest.fn(),
|
||||
fetchTools: jest.fn().mockResolvedValue([{ name: 'do_thing', _meta: {} }]),
|
||||
timeout: 30000,
|
||||
client: { request: jest.fn().mockResolvedValue({ content: [] }) },
|
||||
} as unknown as MCPConnection;
|
||||
|
||||
const manager = await MCPManager.createInstance(newMCPServersConfig());
|
||||
const getConnectionSpy = jest
|
||||
.spyOn(manager, 'getConnection')
|
||||
.mockResolvedValue(mockConnection);
|
||||
|
||||
const flowManager = {} as never;
|
||||
const tokenMethods = {} as never;
|
||||
const configServers = { 'cfg-server': { type: 'sse', url: 'https://x' } } as never;
|
||||
|
||||
await manager.appToolCall({
|
||||
userId: 'user-123',
|
||||
serverName: 'cfg-server',
|
||||
toolName: 'do_thing',
|
||||
toolArguments: {},
|
||||
user: mockUser as IUser,
|
||||
configServers,
|
||||
flowManager,
|
||||
tokenMethods,
|
||||
});
|
||||
|
||||
expect(mockRegistryInstance.getServerConfig).toHaveBeenCalledWith(
|
||||
'cfg-server',
|
||||
'user-123',
|
||||
configServers,
|
||||
);
|
||||
expect(getConnectionSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ flowManager, tokenMethods }),
|
||||
);
|
||||
});
|
||||
|
||||
it('proxies resources/list through the app connection', async () => {
|
||||
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({
|
||||
source: 'yaml',
|
||||
type: 'sse',
|
||||
url: 'https://example.com/mcp',
|
||||
});
|
||||
|
||||
const request = jest.fn().mockResolvedValue({ resources: [{ uri: 'file://a' }] });
|
||||
const mockConnection = {
|
||||
isConnected: jest.fn().mockResolvedValue(true),
|
||||
setRequestHeaders: jest.fn(),
|
||||
fetchTools: jest.fn().mockResolvedValue([]),
|
||||
timeout: 30000,
|
||||
client: { request },
|
||||
} as unknown as MCPConnection;
|
||||
|
||||
const manager = await MCPManager.createInstance(newMCPServersConfig());
|
||||
jest.spyOn(manager, 'getConnection').mockResolvedValue(mockConnection);
|
||||
|
||||
const result = await manager.listResources({
|
||||
userId: 'user-123',
|
||||
serverName: 'srv',
|
||||
user: mockUser as IUser,
|
||||
cursor: 'next',
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'resources/list', params: { cursor: 'next' } }),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result).toEqual({ resources: [{ uri: 'file://a' }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConnection', () => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,24 @@ function generateResourceId(text: string): string {
|
|||
return crypto.createHash('sha256').update(text).digest('hex').substring(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a UI resource ID that is unique per result snapshot. The frontend indexes conversation
|
||||
* resources by ID, so two calls that share a base (resourceUri/text) and args but differ in
|
||||
* structuredContent, text content, _meta, or error state must not collide and overwrite each other.
|
||||
*/
|
||||
function deriveResourceId(base: string, result: t.MCPToolCallResponse, toolArgs: unknown): string {
|
||||
const meta = (result as { _meta?: unknown } | undefined)?._meta;
|
||||
const parts = [
|
||||
base,
|
||||
result?.structuredContent != null ? JSON.stringify(result.structuredContent) : '',
|
||||
result?.content != null ? JSON.stringify(result.content) : '',
|
||||
meta != null ? JSON.stringify(meta) : '',
|
||||
result?.isError === true ? '1' : '',
|
||||
toolArgs != null ? JSON.stringify(toolArgs) : '',
|
||||
];
|
||||
return generateResourceId(parts.join('\x00'));
|
||||
}
|
||||
|
||||
function getMCPImageDataMaxBytes(): number {
|
||||
const raw = process.env.MCP_IMAGE_DATA_MAX_BYTES;
|
||||
if (!raw) {
|
||||
|
|
@ -198,10 +216,7 @@ export function formatToolContent(
|
|||
'text' in item.resource && item.resource.text && typeof item.resource.text === 'string'
|
||||
? item.resource.text
|
||||
: item.resource.uri;
|
||||
const scKey =
|
||||
result?.structuredContent != null ? JSON.stringify(result.structuredContent) : '';
|
||||
const argsKey = metadata?.toolArgs != null ? JSON.stringify(metadata.toolArgs) : '';
|
||||
const resourceId = generateResourceId(baseHash + '\x00' + scKey + '\x00' + argsKey);
|
||||
const resourceId = deriveResourceId(baseHash, result, metadata?.toolArgs);
|
||||
const itemUi = (item.resource._meta as { ui?: Record<string, unknown> } | undefined)?.ui as
|
||||
| { csp?: UIResource['csp']; permissions?: UIResource['permissions'] }
|
||||
| undefined;
|
||||
|
|
@ -256,9 +271,7 @@ export function formatToolContent(
|
|||
metadata.serverName &&
|
||||
metadata.toolName
|
||||
) {
|
||||
const scKey = result?.structuredContent != null ? JSON.stringify(result.structuredContent) : '';
|
||||
const argsKey = metadata.toolArgs != null ? JSON.stringify(metadata.toolArgs) : '';
|
||||
const resourceId = generateResourceId(metadata.resourceUri + '\x00' + scKey + '\x00' + argsKey);
|
||||
const resourceId = deriveResourceId(metadata.resourceUri, result, metadata.toolArgs);
|
||||
uiResources.push({
|
||||
resourceId,
|
||||
uri: metadata.resourceUri,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue