mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🧭 fix: Fail Closed When Expected MCP Tools Are Unavailable (#14646)
* fix: fail closed when expected mcp tools are unavailable * test: strengthen MCP handoff coverage * fix: clarify unavailable MCP tool guidance * fix: preserve MCP discovery for empty catalogs
This commit is contained in:
parent
7775f25b0d
commit
489bc02d4a
22 changed files with 1112 additions and 44 deletions
|
|
@ -196,6 +196,8 @@ jest.mock('~/cache', () => ({
|
|||
jest.mock('~/server/services/ToolService', () => ({
|
||||
loadAgentTools: jest.fn().mockResolvedValue([]),
|
||||
loadToolsForExecution: jest.fn().mockResolvedValue([]),
|
||||
isExpectedMCPToolsUnavailableError: (error) =>
|
||||
error?.code === 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
||||
}));
|
||||
|
||||
const mockGetMultiplier = jest.fn().mockReturnValue(1);
|
||||
|
|
@ -393,6 +395,31 @@ describe('OpenAIChatCompletionController', () => {
|
|||
expect.objectContaining({ agentResourceType: ResourceType.REMOTE_AGENT }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 503 when an agent expects MCP tools but resolves none', async () => {
|
||||
const { initializeAgent } = require('@librechat/api');
|
||||
const { loadAgentTools } = require('~/server/services/ToolService');
|
||||
const toolError = Object.assign(new Error('Expected MCP tools are unavailable'), {
|
||||
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
||||
status: 503,
|
||||
statusCode: 503,
|
||||
});
|
||||
loadAgentTools.mockRejectedValueOnce(toolError);
|
||||
initializeAgent.mockImplementationOnce(async ({ req, res, loadTools, agent }) => {
|
||||
await loadTools({
|
||||
req,
|
||||
res,
|
||||
tools: ['run_query_mcp_warehouse'],
|
||||
model: agent.model,
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
});
|
||||
});
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execution envelope', () => {
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@ jest.mock('@librechat/api', () => ({
|
|||
jest.mock('~/server/services/ToolService', () => ({
|
||||
loadAgentTools: jest.fn().mockResolvedValue([]),
|
||||
loadToolsForExecution: jest.fn().mockResolvedValue([]),
|
||||
isExpectedMCPToolsUnavailableError: (error) =>
|
||||
error?.code === 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
||||
}));
|
||||
|
||||
const mockGetMultiplier = jest.fn().mockReturnValue(1);
|
||||
|
|
@ -343,6 +345,36 @@ describe('createResponse controller', () => {
|
|||
};
|
||||
});
|
||||
|
||||
it('returns 503 when an agent expects MCP tools but resolves none', async () => {
|
||||
const { initializeAgent, sendResponsesErrorResponse } = require('@librechat/api');
|
||||
const { loadAgentTools } = require('~/server/services/ToolService');
|
||||
const toolError = Object.assign(new Error('Expected MCP tools are unavailable'), {
|
||||
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
||||
status: 503,
|
||||
statusCode: 503,
|
||||
});
|
||||
loadAgentTools.mockRejectedValueOnce(toolError);
|
||||
initializeAgent.mockImplementationOnce(async ({ req, res, loadTools, agent }) => {
|
||||
await loadTools({
|
||||
req,
|
||||
res,
|
||||
tools: ['run_query_mcp_warehouse'],
|
||||
model: agent.model,
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
});
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
503,
|
||||
'Expected MCP tools are unavailable',
|
||||
'server_error',
|
||||
);
|
||||
});
|
||||
|
||||
describe('execution envelope', () => {
|
||||
it('creates the portable run input before agent initialization', async () => {
|
||||
req.user = {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const {
|
|||
loadAgentTools,
|
||||
loadToolsForExecution,
|
||||
getAccessibleMcpServerNames,
|
||||
isExpectedMCPToolsUnavailableError,
|
||||
} = require('~/server/services/ToolService');
|
||||
const {
|
||||
findAccessibleResources,
|
||||
|
|
@ -103,6 +104,9 @@ function createToolLoader(signal, definitionsOnly = true) {
|
|||
});
|
||||
} catch (error) {
|
||||
logger.error('Error loading tools for agent ' + agentId, error);
|
||||
if (isExpectedMCPToolsUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,11 @@ const {
|
|||
createToolEndCallback,
|
||||
agentLogHandlerObj,
|
||||
} = require('~/server/controllers/agents/callbacks');
|
||||
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
|
||||
const {
|
||||
loadAgentTools,
|
||||
loadToolsForExecution,
|
||||
isExpectedMCPToolsUnavailableError,
|
||||
} = require('~/server/services/ToolService');
|
||||
const {
|
||||
findAccessibleResources,
|
||||
getEffectivePermissions,
|
||||
|
|
@ -113,6 +117,9 @@ function createToolLoader(signal, definitionsOnly = true) {
|
|||
});
|
||||
} catch (error) {
|
||||
logger.error('Error loading tools for agent ' + agentId, error);
|
||||
if (isExpectedMCPToolsUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const { isEphemeralAgentId } = require('librechat-data-provider');
|
|||
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
|
||||
const { getMCPServerTools } = require('~/server/services/Config');
|
||||
const { getAccessibleMcpServerNames } = require('~/server/services/MCP');
|
||||
const { isExpectedMCPToolsUnavailableError } = require('~/server/services/ToolService');
|
||||
const { getSkillDbMethods, canAuthorSkillFiles } = require('./skillDeps');
|
||||
const db = require('~/models');
|
||||
|
||||
|
|
@ -226,6 +227,9 @@ const processAddedConvo = async ({
|
|||
|
||||
return { userMCPAuthMap };
|
||||
} catch (err) {
|
||||
if (isExpectedMCPToolsUnavailableError(err)) {
|
||||
throw err;
|
||||
}
|
||||
logger.error('[processAddedConvo] Error processing addedConvo for parallel agent', err);
|
||||
return { userMCPAuthMap };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ jest.mock('~/server/services/MCP', () => ({
|
|||
getAccessibleMcpServerNames: jest.fn(async () => []),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/ToolService', () => ({
|
||||
isExpectedMCPToolsUnavailableError: (error) =>
|
||||
error?.code === 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
||||
}));
|
||||
|
||||
jest.mock('./skillDeps', () => ({
|
||||
canAuthorSkillFiles: (...args) => mockCanAuthorSkillFiles(...args),
|
||||
getSkillDbMethods: () => mockGetSkillDbMethods(),
|
||||
|
|
@ -138,6 +143,16 @@ describe('processAddedConvo', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('propagates an expected-MCP-tools failure from an added parallel agent', async () => {
|
||||
const toolError = Object.assign(new Error('Added agent expected MCP tools are unavailable'), {
|
||||
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
||||
statusCode: 503,
|
||||
});
|
||||
mockInitializeAgent.mockRejectedValueOnce(toolError);
|
||||
|
||||
await expect(processAddedConvo(baseParams())).rejects.toBe(toolError);
|
||||
});
|
||||
|
||||
it('keeps deployment-aware skill metadata on a persisted added-agent config', async () => {
|
||||
const deploymentSkillId = { toString: () => 'deployment-skill' };
|
||||
const agentConfigs = new Map();
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const {
|
|||
loadAgentTools,
|
||||
loadToolsForExecution,
|
||||
getAccessibleMcpServerNames,
|
||||
isExpectedMCPToolsUnavailableError,
|
||||
} = require('~/server/services/ToolService');
|
||||
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
|
||||
const {
|
||||
|
|
@ -109,6 +110,9 @@ function createToolLoader(signal, streamId = null, definitionsOnly = false, jobC
|
|||
});
|
||||
} catch (error) {
|
||||
logger.error('Error loading tools for agent ' + agentId, error);
|
||||
if (isExpectedMCPToolsUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -800,6 +804,9 @@ const initializeClient = async ({
|
|||
agentToolContexts.set(agentId, buildAgentToolContext({ agent, config }));
|
||||
return config;
|
||||
} catch (err) {
|
||||
if (isExpectedMCPToolsUnavailableError(err)) {
|
||||
throw err;
|
||||
}
|
||||
logger.error(`[processAgent] Error processing subagent ${agentId}:`, err);
|
||||
skippedAgentIds.add(agentId);
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ const mockLoadToolsForExecution = jest.fn();
|
|||
jest.mock('~/server/services/ToolService', () => ({
|
||||
loadAgentTools: jest.fn(),
|
||||
loadToolsForExecution: (...args) => mockLoadToolsForExecution(...args),
|
||||
isExpectedMCPToolsUnavailableError: (error) =>
|
||||
error?.code === 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
||||
}));
|
||||
|
||||
jest.mock('~/server/controllers/ModelController', () => ({
|
||||
|
|
@ -82,6 +84,7 @@ jest.mock('~/cache', () => ({
|
|||
|
||||
const { initializeClient } = require('./initialize');
|
||||
const { getSkillDbMethods, getSkillToolDeps } = require('./skillDeps');
|
||||
const { loadAgentTools } = require('~/server/services/ToolService');
|
||||
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { User, AclEntry } = require('~/db/models');
|
||||
|
|
@ -199,6 +202,83 @@ describe('initializeClient — processAgent ACL gate', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('propagates an expected-MCP-tools failure from the runtime tool loader', async () => {
|
||||
const toolError = Object.assign(new Error('Expected MCP tools are unavailable'), {
|
||||
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
||||
statusCode: 503,
|
||||
});
|
||||
loadAgentTools.mockRejectedValueOnce(toolError);
|
||||
mockInitializeAgent.mockImplementationOnce(async ({ req, res, loadTools, agent }) => {
|
||||
await loadTools({
|
||||
req,
|
||||
res,
|
||||
tools: ['run_query_mcp_warehouse'],
|
||||
model: agent.model,
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
});
|
||||
return makePrimaryConfig([]);
|
||||
});
|
||||
|
||||
await expect(
|
||||
initializeClient({
|
||||
req: makeReq(),
|
||||
res: {},
|
||||
signal: new AbortController().signal,
|
||||
endpointOption: makeEndpointOption(),
|
||||
}),
|
||||
).rejects.toBe(toolError);
|
||||
});
|
||||
|
||||
it('aborts the run when a handoff target resolves none of its expected MCP tools', async () => {
|
||||
const target = await createAgent({
|
||||
id: AUTHORIZED_ID,
|
||||
name: 'Target Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(),
|
||||
tools: ['run_query_mcp_warehouse'],
|
||||
});
|
||||
await AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: testUser._id,
|
||||
principalModel: PrincipalModel.USER,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: target._id,
|
||||
permBits: PermissionBits.VIEW,
|
||||
grantedBy: testUser._id,
|
||||
});
|
||||
|
||||
const toolError = Object.assign(new Error('Target Agent expected MCP tools are unavailable'), {
|
||||
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
||||
statusCode: 503,
|
||||
});
|
||||
loadAgentTools.mockRejectedValueOnce(toolError);
|
||||
mockInitializeAgent
|
||||
.mockResolvedValueOnce(
|
||||
makePrimaryConfig([{ from: PRIMARY_ID, to: AUTHORIZED_ID, edgeType: 'handoff' }]),
|
||||
)
|
||||
.mockImplementationOnce(async ({ req, res, loadTools, agent }) => {
|
||||
await loadTools({
|
||||
req,
|
||||
res,
|
||||
tools: agent.tools,
|
||||
model: agent.model,
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
initializeClient({
|
||||
req: makeReq(),
|
||||
res: {},
|
||||
signal: new AbortController().signal,
|
||||
endpointOption: makeEndpointOption(),
|
||||
}),
|
||||
).rejects.toBe(toolError);
|
||||
});
|
||||
|
||||
it('should skip handoff agent and filter its edge when user lacks VIEW access', async () => {
|
||||
await createAgent({
|
||||
id: TARGET_ID,
|
||||
|
|
@ -588,6 +668,49 @@ describe('initializeClient — subagent loading', () => {
|
|||
return agent;
|
||||
};
|
||||
|
||||
it('aborts the run when a pure subagent resolves none of its expected MCP tools', async () => {
|
||||
const subAgent = await createAgent({
|
||||
id: SUBAGENT_ID,
|
||||
name: 'Data Subagent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(),
|
||||
tools: ['run_query_mcp_warehouse'],
|
||||
});
|
||||
await grantView(subAgent);
|
||||
|
||||
const toolError = Object.assign(new Error('Subagent expected MCP tools are unavailable'), {
|
||||
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
||||
statusCode: 503,
|
||||
});
|
||||
loadAgentTools.mockRejectedValueOnce(toolError);
|
||||
mockInitializeAgent
|
||||
.mockResolvedValueOnce(
|
||||
makePrimaryConfig({
|
||||
subagents: { enabled: true, allowSelf: true, agent_ids: [SUBAGENT_ID] },
|
||||
}),
|
||||
)
|
||||
.mockImplementationOnce(async ({ req, res, loadTools, agent }) => {
|
||||
await loadTools({
|
||||
req,
|
||||
res,
|
||||
tools: agent.tools,
|
||||
model: agent.model,
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
initializeClient({
|
||||
req: makeSubagentReq(),
|
||||
res: {},
|
||||
signal: new AbortController().signal,
|
||||
endpointOption: makeEndpointOption(),
|
||||
}),
|
||||
).rejects.toBe(toolError);
|
||||
});
|
||||
|
||||
it('loads a configured subagent, populates `subagentAgentConfigs`, and keeps it out of `agentConfigs`', async () => {
|
||||
const subAgent = await createAgent({
|
||||
id: SUBAGENT_ID,
|
||||
|
|
|
|||
|
|
@ -524,6 +524,29 @@ const nativeTools = new Set([
|
|||
Tools.memory,
|
||||
]);
|
||||
|
||||
const mcpServerPinPrefix = `${Constants.mcp_server}${Constants.mcp_delimiter}`;
|
||||
const isExpectedMCPTool = (toolName) =>
|
||||
toolName?.includes(Constants.mcp_delimiter) &&
|
||||
!toolName.startsWith(mcpServerPinPrefix) &&
|
||||
!isActionTool(toolName);
|
||||
const expectedMCPToolsUnavailableCode = 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE';
|
||||
const isExpectedMCPToolsUnavailableError = (error) =>
|
||||
error?.code === expectedMCPToolsUnavailableCode;
|
||||
const createExpectedMCPToolsUnavailableError = (agentName, cause) => {
|
||||
const subject = agentName ? `Agent "${agentName}"` : 'The agent';
|
||||
const error = new Error(
|
||||
`${subject} is configured to use MCP tools, but none are available. Verify that the MCP server is connected and this agent can access its selected tools, then try again.`,
|
||||
);
|
||||
error.name = 'AgentToolInitializationError';
|
||||
error.code = expectedMCPToolsUnavailableCode;
|
||||
error.status = 503;
|
||||
error.statusCode = 503;
|
||||
if (cause != null) {
|
||||
error.cause = cause;
|
||||
}
|
||||
return error;
|
||||
};
|
||||
|
||||
/** Checks if a tool name is a known built-in tool */
|
||||
const isBuiltInTool = (toolName) =>
|
||||
Boolean(
|
||||
|
|
@ -573,6 +596,7 @@ async function loadToolDefinitionsWrapper({
|
|||
}
|
||||
|
||||
const appConfig = req.config;
|
||||
const hasExpectedMCPTools = agent.tools.some(isExpectedMCPTool);
|
||||
const enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent.id);
|
||||
|
||||
const checkCapability = (capability) => enabledCapabilities.has(capability);
|
||||
|
|
@ -616,6 +640,9 @@ async function loadToolDefinitionsWrapper({
|
|||
});
|
||||
|
||||
if (!filteredTools || filteredTools.length === 0) {
|
||||
if (hasExpectedMCPTools) {
|
||||
throw createExpectedMCPToolsUnavailableError(agent.name);
|
||||
}
|
||||
return { toolDefinitions: [] };
|
||||
}
|
||||
|
||||
|
|
@ -893,6 +920,33 @@ async function loadToolDefinitionsWrapper({
|
|||
return result?.availableTools || null;
|
||||
};
|
||||
|
||||
const refreshMCPServerTools = async (_userId, serverName) => {
|
||||
if (pendingOAuthServers.has(serverName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const oauthStart = async (authURL, options) => {
|
||||
pendingOAuthServers.add(serverName);
|
||||
if (typeof authURL === 'string' && authURL.length > 0) {
|
||||
pendingOAuthStarts.set(serverName, { authURL, options });
|
||||
}
|
||||
};
|
||||
const result = await reinitMCPServer({
|
||||
user: req.user,
|
||||
forceNew: true,
|
||||
oauthStart,
|
||||
flowManager,
|
||||
serverName,
|
||||
configServers,
|
||||
userMCPAuthMap,
|
||||
requestBody: req.body,
|
||||
requestScopedConnections,
|
||||
});
|
||||
|
||||
rememberMCPAvailableTools(serverName, result?.availableTools);
|
||||
return result?.availableTools || null;
|
||||
};
|
||||
|
||||
const getActionToolDefinitions = async (agentId, actionToolNames) => {
|
||||
const actionSets = (await loadActionSets({ agent_id: agentId })) ?? [];
|
||||
if (actionSets.length === 0) {
|
||||
|
|
@ -952,26 +1006,28 @@ async function loadToolDefinitionsWrapper({
|
|||
return definitions;
|
||||
};
|
||||
|
||||
let { toolDefinitions, toolRegistry, hasDeferredTools } = await loadToolDefinitions(
|
||||
{
|
||||
userId: req.user.id,
|
||||
agentId: agent.id,
|
||||
tools: defsFilteredTools,
|
||||
toolOptions: agent.tool_options,
|
||||
deferredToolsEnabled,
|
||||
programmaticToolsEnabled,
|
||||
codeExecutionEnabled,
|
||||
provider: agent.provider,
|
||||
mcpServerNames,
|
||||
rawServerNames: mcpRawServerNames,
|
||||
accessibleServerNames: defsAccessibleServerNames,
|
||||
},
|
||||
{
|
||||
isBuiltInTool,
|
||||
getOrFetchMCPServerTools,
|
||||
getActionToolDefinitions,
|
||||
},
|
||||
);
|
||||
let { toolDefinitions, toolRegistry, hasDeferredTools, mcpResolution } =
|
||||
await loadToolDefinitions(
|
||||
{
|
||||
userId: req.user.id,
|
||||
agentId: agent.id,
|
||||
tools: defsFilteredTools,
|
||||
toolOptions: agent.tool_options,
|
||||
deferredToolsEnabled,
|
||||
programmaticToolsEnabled,
|
||||
codeExecutionEnabled,
|
||||
provider: agent.provider,
|
||||
mcpServerNames,
|
||||
rawServerNames: mcpRawServerNames,
|
||||
accessibleServerNames: defsAccessibleServerNames,
|
||||
},
|
||||
{
|
||||
isBuiltInTool,
|
||||
getOrFetchMCPServerTools,
|
||||
refreshMCPServerTools,
|
||||
getActionToolDefinitions,
|
||||
},
|
||||
);
|
||||
|
||||
/** OAuth discovery must not reconnect (or prompt for) a server whose
|
||||
* definitions the collision filter deliberately rejected. */
|
||||
|
|
@ -1056,15 +1112,21 @@ async function loadToolDefinitionsWrapper({
|
|||
{
|
||||
isBuiltInTool,
|
||||
getOrFetchMCPServerTools,
|
||||
refreshMCPServerTools,
|
||||
getActionToolDefinitions,
|
||||
},
|
||||
);
|
||||
toolDefinitions = reloadResult.toolDefinitions;
|
||||
toolRegistry = reloadResult.toolRegistry;
|
||||
hasDeferredTools = reloadResult.hasDeferredTools;
|
||||
mcpResolution = reloadResult.mcpResolution;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasExpectedMCPTools && mcpResolution?.resolvedToolCount === 0) {
|
||||
throw createExpectedMCPToolsUnavailableError(agent.name);
|
||||
}
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const toolContextMap = {};
|
||||
/** @type {Record<string, string>} */
|
||||
|
|
@ -1195,16 +1257,23 @@ async function loadAgentTools({
|
|||
accessibleMcpServerNames,
|
||||
}) {
|
||||
if (definitionsOnly) {
|
||||
return loadToolDefinitionsWrapper({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
agentResourceType,
|
||||
streamId,
|
||||
jobCreatedAt,
|
||||
tool_resources,
|
||||
accessibleMcpServerNames,
|
||||
});
|
||||
try {
|
||||
return await loadToolDefinitionsWrapper({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
agentResourceType,
|
||||
streamId,
|
||||
jobCreatedAt,
|
||||
tool_resources,
|
||||
accessibleMcpServerNames,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isExpectedMCPToolsUnavailableError(error) || !agent.tools?.some(isExpectedMCPTool)) {
|
||||
throw error;
|
||||
}
|
||||
throw createExpectedMCPToolsUnavailableError(agent.name, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!agent.tools || agent.tools.length === 0) {
|
||||
|
|
@ -1971,6 +2040,7 @@ module.exports = {
|
|||
loadToolsForExecution,
|
||||
processRequiredActions,
|
||||
resolveAgentCapabilities,
|
||||
isExpectedMCPToolsUnavailableError,
|
||||
/** Re-exported for controllers that already depend on (and mock) this
|
||||
* module, avoiding a fresh heavy `services/MCP` require chain there. */
|
||||
getAccessibleMcpServerNames,
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ async function reinitMCPServer({
|
|||
tools = await connection.fetchTools();
|
||||
}
|
||||
|
||||
if (tools && tools.length > 0) {
|
||||
if (tools) {
|
||||
availableTools = await updateMCPServerTools({
|
||||
userId: user.id,
|
||||
serverName,
|
||||
|
|
|
|||
|
|
@ -102,6 +102,24 @@ describe('reinitMCPServer — customUserVars gating (issue #10969)', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('updates the cache with an empty catalog after a successful connection', async () => {
|
||||
mockGetConnection.mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]) });
|
||||
|
||||
await reinitMCPServer({
|
||||
user,
|
||||
serverName,
|
||||
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
||||
userMCPAuthMap: undefined,
|
||||
});
|
||||
|
||||
expect(mockUpdateMCPServerTools).toHaveBeenCalledWith({
|
||||
userId: user.id,
|
||||
serverName,
|
||||
tools: [],
|
||||
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes request body and Graph resolver into connection creation', async () => {
|
||||
mockGetConnection.mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]) });
|
||||
const requestBody = { conversationId: 'conv-123', messageId: 'msg-123' };
|
||||
|
|
|
|||
|
|
@ -359,6 +359,113 @@ describe('ToolService - Action Capability Gating', () => {
|
|||
expect(callArgs.tools).toContain(regularTool);
|
||||
});
|
||||
|
||||
it('fails initialization when an explicitly selected MCP tool cannot be resolved', async () => {
|
||||
const mcpTool = `search${Constants.mcp_delimiter}warehouse`;
|
||||
const capabilities = [AgentCapabilities.tools];
|
||||
const req = createMockReq(capabilities);
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
mockResolveConfigServers.mockResolvedValue({
|
||||
warehouse: {
|
||||
type: 'streamable-http',
|
||||
url: 'https://mcp.example.com/warehouse',
|
||||
},
|
||||
});
|
||||
mockLoadToolDefinitions.mockResolvedValueOnce({
|
||||
toolDefinitions: [],
|
||||
toolRegistry: new Map(),
|
||||
hasDeferredTools: false,
|
||||
mcpResolution: { expectedToolCount: 1, resolvedToolCount: 0 },
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadAgentTools({
|
||||
req,
|
||||
res: {},
|
||||
agent: { id: 'agent_123', name: 'Target Agent', tools: [mcpTool] },
|
||||
definitionsOnly: true,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
||||
statusCode: 503,
|
||||
message: expect.stringContaining('can access its selected tools'),
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when MCP definition loading throws before resolution completes', async () => {
|
||||
const capabilities = [AgentCapabilities.tools];
|
||||
const req = createMockReq(capabilities);
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
mockLoadToolDefinitions.mockRejectedValueOnce(new Error('MCP registry unavailable'));
|
||||
|
||||
await expect(
|
||||
loadAgentTools({
|
||||
req,
|
||||
res: {},
|
||||
agent: {
|
||||
id: 'agent_123',
|
||||
name: 'Target Agent',
|
||||
tools: [`run_query${Constants.mcp_delimiter}warehouse`],
|
||||
},
|
||||
definitionsOnly: true,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
||||
statusCode: 503,
|
||||
cause: expect.objectContaining({ message: 'MCP registry unavailable' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('allows a server pin with no explicitly selected MCP tools', async () => {
|
||||
const serverPin = `${Constants.mcp_server}${Constants.mcp_delimiter}warehouse`;
|
||||
const capabilities = [AgentCapabilities.tools];
|
||||
const req = createMockReq(capabilities);
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
mockLoadToolDefinitions.mockResolvedValueOnce({
|
||||
toolDefinitions: [],
|
||||
toolRegistry: new Map(),
|
||||
hasDeferredTools: false,
|
||||
mcpResolution: { expectedToolCount: 0, resolvedToolCount: 0 },
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadAgentTools({
|
||||
req,
|
||||
res: {},
|
||||
agent: { id: 'agent_123', tools: [serverPin] },
|
||||
definitionsOnly: true,
|
||||
}),
|
||||
).resolves.toMatchObject({ toolDefinitions: [] });
|
||||
});
|
||||
|
||||
it('allows partial MCP resolution when at least one expected tool is available', async () => {
|
||||
const capabilities = [AgentCapabilities.tools];
|
||||
const req = createMockReq(capabilities);
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
mockLoadToolDefinitions.mockResolvedValueOnce({
|
||||
toolDefinitions: [{ name: 'list_sources_mcp_warehouse', toolType: 'mcp' }],
|
||||
toolRegistry: new Map(),
|
||||
hasDeferredTools: false,
|
||||
mcpResolution: { expectedToolCount: 2, resolvedToolCount: 1 },
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadAgentTools({
|
||||
req,
|
||||
res: {},
|
||||
agent: {
|
||||
id: 'agent_123',
|
||||
tools: [
|
||||
`list_sources${Constants.mcp_delimiter}warehouse`,
|
||||
`run_query${Constants.mcp_delimiter}warehouse`,
|
||||
],
|
||||
},
|
||||
definitionsOnly: true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
toolDefinitions: [expect.objectContaining({ name: 'list_sources_mcp_warehouse' })],
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter MCP tool definitions when user lacks MCP server use permission', async () => {
|
||||
const { userCanUseMCPServers } = require('~/server/services/MCP');
|
||||
userCanUseMCPServers.mockResolvedValueOnce(false);
|
||||
|
|
@ -381,6 +488,31 @@ describe('ToolService - Action Capability Gating', () => {
|
|||
expect(callArgs.tools).not.toContain(mcpTool);
|
||||
});
|
||||
|
||||
it('fails explicitly when MCP permission filtering removes every expected tool', async () => {
|
||||
const { userCanUseMCPServers } = require('~/server/services/MCP');
|
||||
userCanUseMCPServers.mockResolvedValueOnce(false);
|
||||
|
||||
const capabilities = [AgentCapabilities.tools];
|
||||
const req = createMockReq(capabilities);
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
|
||||
await expect(
|
||||
loadAgentTools({
|
||||
req,
|
||||
res: {},
|
||||
agent: {
|
||||
id: 'agent_123',
|
||||
tools: [`run_query${Constants.mcp_delimiter}warehouse`],
|
||||
},
|
||||
definitionsOnly: true,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
||||
statusCode: 503,
|
||||
});
|
||||
expect(mockLoadToolDefinitions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return actionsEnabled in the result', async () => {
|
||||
const capabilities = [AgentCapabilities.tools];
|
||||
const req = createMockReq(capabilities);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue