diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 8acf4c9292..7d7caa6a51 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -38,9 +38,10 @@ function createToolLoader(signal) { * @param {string} params.model * @param {AgentToolResources} params.tool_resources * @returns {Promise<{ - * tools: StructuredTool[], - * toolContextMap: Record, - * userMCPAuthMap?: Record> + * tools: StructuredTool[], + * toolContextMap: Record, + * userMCPAuthMap?: Record>, + * toolRegistry?: import('@librechat/agents').LCToolRegistry * } | undefined>} */ return async function loadTools({ req, res, agentId, tools, provider, model, tool_resources }) { diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 57fa07110a..01ab41e945 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -429,6 +429,7 @@ function createToolInstance({ res, toolName, serverName, toolDefinition, provide }); toolInstance.mcp = true; toolInstance.mcpRawServerName = serverName; + toolInstance.mcpJsonSchema = parameters; return toolInstance; } diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 352f573aaa..f1edf67fc3 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -6,6 +6,7 @@ const { hasCustomUserVars, getUserMCPAuthMap, isActionDomainAllowed, + buildToolClassification, } = require('@librechat/api'); const { Tools, @@ -36,6 +37,7 @@ const { recordUsage } = require('~/server/services/Threads'); const { loadTools } = require('~/app/clients/tools/util'); const { redactMessage } = require('~/config/parsers'); const { findPluginAuthsByKeys } = require('~/models'); +const { loadAuthValues } = require('~/server/services/Tools/credentials'); /** * Processes the required actions by calling the appropriate tools and returning the outputs. * @param {OpenAIClient} client - OpenAI or StreamRunManager Client. @@ -367,7 +369,12 @@ async function processRequiredActions(client, requiredActions) { * @param {AbortSignal} params.signal * @param {Pick> }>} The agent tools. + * @returns {Promise<{ + * tools?: StructuredTool[]; + * toolContextMap?: Record; + * userMCPAuthMap?: Record>; + * toolRegistry?: Map; + * }>} The agent tools and registry. */ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIApiKey }) { if (!agent.tools || agent.tools.length === 0) { @@ -502,11 +509,21 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA return map; }, {}); + /** Build tool registry from MCP tools and create PTC/tool search tools if configured */ + const { toolRegistry, additionalTools } = await buildToolClassification({ + loadedTools, + userId: req.user.id, + agentId: agent.id, + loadAuthValues, + }); + agentTools.push(...additionalTools); + if (!checkCapability(AgentCapabilities.actions)) { return { tools: agentTools, userMCPAuthMap, toolContextMap, + toolRegistry, }; } @@ -519,6 +536,7 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA tools: agentTools, userMCPAuthMap, toolContextMap, + toolRegistry, }; } @@ -645,6 +663,7 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA tools: agentTools, toolContextMap, userMCPAuthMap, + toolRegistry, }; } diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index a37ddf4848..532a0326c2 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -15,9 +15,9 @@ import type { Agent, TUser, } from 'librechat-data-provider'; +import type { GenericTool, LCToolRegistry, ToolMap } from '@librechat/agents'; import type { Response as ServerResponse } from 'express'; import type { IMongoFile } from '@librechat/data-schemas'; -import type { GenericTool } from '@librechat/agents'; import type { InitializeResultBase, ServerRequest, EndpointDbMethods } from '~/types'; import { getModelMaxTokens, extractLibreChatParams, optionalChainWithEmptyCheck } from '~/utils'; import { filterFilesByEndpointConfig } from '~/files'; @@ -36,6 +36,10 @@ export type InitializedAgent = Agent & { useLegacyContent: boolean; resendFiles: boolean; userMCPAuthMap?: Record>; + /** Tool map for ToolNode to use when executing tools (required for PTC) */ + toolMap?: ToolMap; + /** Tool registry for PTC and tool search (only present when MCP tools with env classification exist) */ + toolRegistry?: LCToolRegistry; }; /** @@ -66,6 +70,7 @@ export interface InitializeAgentParams { tools: GenericTool[]; toolContextMap: Record; userMCPAuthMap?: Record>; + toolRegistry?: LCToolRegistry; } | null>; /** Endpoint option (contains model_parameters and endpoint info) */ endpointOption?: Partial; @@ -195,6 +200,7 @@ export async function initializeAgent( tools: structuredTools, toolContextMap, userMCPAuthMap, + toolRegistry, } = (await loadTools?.({ req, res, @@ -203,7 +209,7 @@ export async function initializeAgent( tools: agent.tools ?? [], model: agent.model, tool_resources, - })) ?? { tools: [], toolContextMap: {}, userMCPAuthMap: undefined }; + })) ?? { tools: [], toolContextMap: {}, userMCPAuthMap: undefined, toolRegistry: undefined }; const { getOptions, overrideProvider } = getProviderConfig({ provider, @@ -306,6 +312,7 @@ export async function initializeAgent( attachments: finalAttachments, resendFiles, userMCPAuthMap, + toolRegistry, toolContextMap: toolContextMap ?? {}, useLegacyContent: !!options.useLegacyContent, maxContextTokens: Math.round((agentMaxContextNum - maxOutputTokensNum) * 0.9), diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index ae083253f9..ba692f7794 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -4,6 +4,7 @@ import type { MultiAgentGraphConfig, OpenAIClientOptions, StandardGraphConfig, + LCToolRegistry, AgentInputs, GenericTool, RunConfig, @@ -48,6 +49,7 @@ type RunAgent = Omit & { maxContextTokens?: number; useLegacyContent?: boolean; toolContextMap?: Record; + toolRegistry?: LCToolRegistry; }; /** @@ -143,6 +145,7 @@ export async function createRun({ tools: agent.tools, clientOptions: llmConfig, instructions: systemContent, + toolRegistry: agent.toolRegistry, maxContextTokens: agent.maxContextTokens, useLegacyContent: agent.useLegacyContent ?? false, }; diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index 63a0153cad..3b0d31b83b 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -17,7 +17,8 @@ import type { Tool, } from '@modelcontextprotocol/sdk/types.js'; import type { SearchResultData, UIResource, TPlugin } from 'librechat-data-provider'; -import type { TokenMethods, JsonSchemaType, IUser } from '@librechat/data-schemas'; +import type { TokenMethods, IUser } from '@librechat/data-schemas'; +import type { LCTool } from '@librechat/agents'; import type { FlowStateManager } from '~/flow/manager'; import type { RequestBody } from '~/types/http'; import type * as o from '~/mcp/oauth/types'; @@ -42,11 +43,6 @@ export interface MCPResource { description?: string; mimeType?: string; } -export interface LCTool { - name: string; - description?: string; - parameters: JsonSchemaType; -} export interface LCFunctionTool { type: 'function'; diff --git a/packages/api/src/tools/classification.ts b/packages/api/src/tools/classification.ts new file mode 100644 index 0000000000..4dccb61a04 --- /dev/null +++ b/packages/api/src/tools/classification.ts @@ -0,0 +1,414 @@ +/** + * @fileoverview Utility functions for building tool registries from environment variables. + * This is a temporary solution for tool classification until UI-based configuration is available. + * + * Environment Variables: + * - TOOL_PROGRAMMATIC_ONLY: Comma-separated tool names or server patterns (sys__all__sys_mcp_ServerName) + * - TOOL_PROGRAMMATIC_ONLY_EXCLUDE: Comma-separated tool names to exclude from programmatic only + * - TOOL_DUAL_CONTEXT: Comma-separated tool names or server patterns callable BOTH by LLM and PTC + * - TOOL_DUAL_CONTEXT_EXCLUDE: Comma-separated tool names to exclude from dual context + * - TOOL_DEFERRED: Comma-separated tool names or server patterns for deferred tools + * - TOOL_DEFERRED_EXCLUDE: Comma-separated tool names to exclude from deferred + * - TOOL_CLASSIFICATION_AGENT_IDS: Optional comma-separated agent IDs to restrict classification features + * + * Server patterns: Use `sys__all__sys_mcp_ServerName` to match all tools from an MCP server. + * Example: `sys__all__sys_mcp_Google-Workspace` matches all Google Workspace tools. + * + * Agent restriction: If TOOL_CLASSIFICATION_AGENT_IDS is set, only those agents will get + * PTC and tool search tools. If not set, all agents with matching tools get them. + * + * Smart enablement: PTC/tool search are only created if the agent has tools that actually + * match the classification patterns. An agent with no programmatic/deferred tools won't + * get PTC/tool search even if the env vars are set. + * + * @module packages/api/src/tools/classification + */ + +import { + EnvVar, + createProgrammaticToolCallingTool, + createToolSearchRegexTool, +} from '@librechat/agents'; +import type { + LCTool, + LCToolRegistry, + AllowedCaller, + JsonSchemaType, + GenericTool, +} from '@librechat/agents'; +import { Constants } from 'librechat-data-provider'; +import { logger } from '@librechat/data-schemas'; + +export type { LCTool, LCToolRegistry, AllowedCaller, JsonSchemaType }; + +/** Pattern prefix for matching all tools from an MCP server */ +const MCP_ALL_PATTERN = `${Constants.mcp_all}${Constants.mcp_delimiter}`; + +export interface ToolDefinition { + name: string; + description?: string; + parameters?: JsonSchemaType; +} + +/** + * Parses a comma-separated tool list from an environment variable. + * @param envValue - The environment variable value + * @returns Set of tool names or server patterns + */ +export function parseToolList(envValue: string | undefined): Set { + if (!envValue || envValue.trim() === '') { + return new Set(); + } + return new Set( + envValue + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0), + ); +} + +/** + * Extracts the MCP server name from a tool name. + * Tool names follow the pattern: toolName_mcp_ServerName + * @param toolName - The full tool name + * @returns The server name or undefined if not an MCP tool + */ +export function getServerNameFromTool(toolName: string): string | undefined { + const parts = toolName.split(Constants.mcp_delimiter); + if (parts.length >= 2) { + return parts[parts.length - 1]; + } + return undefined; +} + +/** + * Checks if a tool matches a set of patterns (tool names or server patterns). + * Supports both exact tool name matches and server-wide patterns like `mcp_all_mcp_ServerName`. + * + * @param toolName - The tool name to check + * @param patterns - Set of patterns (tool names or mcp_all_mcp_ServerName patterns) + * @param excludes - Set of tool names to exclude (takes precedence over patterns) + * @returns Whether the tool matches any pattern and is not excluded + */ +export function toolMatchesPatterns( + toolName: string, + patterns: Set, + excludes: Set, +): boolean { + if (excludes.has(toolName)) { + return false; + } + + if (patterns.has(toolName)) { + return true; + } + + const serverName = getServerNameFromTool(toolName); + if (serverName) { + const serverPattern = `${MCP_ALL_PATTERN}${serverName}`; + if (patterns.has(serverPattern)) { + return true; + } + } + + return false; +} + +/** + * Builds a tool registry from environment variables for the given tools. + * This is a temporary solution while UI-based configuration is being developed. + * + * Supports server-wide patterns using `mcp_all_mcp_ServerName` syntax. + * Exclusion env vars take precedence over inclusion patterns. + * + * Default behavior (if tool not listed in any env var): + * - allowed_callers: ['direct'] + * - defer_loading: false + * + * @param tools - Array of tool definitions + * @returns Map of tool name to tool definition with classification + * + * @example + * // Environment for server-wide configuration: + * // TOOL_PROGRAMMATIC_ONLY=mcp_all_mcp_Google-Workspace + * // TOOL_DEFERRED=mcp_all_mcp_Google-Workspace + * // TOOL_DEFERRED_EXCLUDE=list_spreadsheets_mcp_Google-Workspace,read_sheet_values_mcp_Google-Workspace + * + * @example + * // Environment for individual tools: + * // TOOL_PROGRAMMATIC_ONLY=get_expenses,get_team_members + * // TOOL_DUAL_CONTEXT=get_weather + * // TOOL_DEFERRED=generate_report + */ +export function buildToolRegistryFromEnv(tools: ToolDefinition[]): LCToolRegistry { + const programmaticOnly = parseToolList(process.env.TOOL_PROGRAMMATIC_ONLY); + const programmaticOnlyExclude = parseToolList(process.env.TOOL_PROGRAMMATIC_ONLY_EXCLUDE); + const dualContext = parseToolList(process.env.TOOL_DUAL_CONTEXT); + const dualContextExclude = parseToolList(process.env.TOOL_DUAL_CONTEXT_EXCLUDE); + const deferred = parseToolList(process.env.TOOL_DEFERRED); + const deferredExclude = parseToolList(process.env.TOOL_DEFERRED_EXCLUDE); + + const registry: LCToolRegistry = new Map(); + + for (const tool of tools) { + const { name, description, parameters } = tool; + + let allowed_callers: AllowedCaller[]; + + if (toolMatchesPatterns(name, programmaticOnly, programmaticOnlyExclude)) { + allowed_callers = ['code_execution']; + } else if (toolMatchesPatterns(name, dualContext, dualContextExclude)) { + allowed_callers = ['direct', 'code_execution']; + } else { + // Default: direct only (LLM can call, PTC cannot) + allowed_callers = ['direct']; + } + + const toolDef: LCTool = { + name, + allowed_callers, + defer_loading: toolMatchesPatterns(name, deferred, deferredExclude), + }; + + // Include description and parameters if available (needed for tool search and PTC stub generation) + if (description) { + toolDef.description = description; + } + if (parameters) { + toolDef.parameters = parameters; + } + + registry.set(name, toolDef); + } + + return registry; +} + +/** + * Checks if PTC (Programmatic Tool Calling) should be enabled based on environment configuration. + * PTC is enabled if any tools or server patterns are configured for programmatic calling. + * @returns Whether PTC should be enabled + */ +export function shouldEnablePTC(): boolean { + const programmaticOnly = parseToolList(process.env.TOOL_PROGRAMMATIC_ONLY); + const dualContext = parseToolList(process.env.TOOL_DUAL_CONTEXT); + return programmaticOnly.size > 0 || dualContext.size > 0; +} + +/** + * Checks if tool search should be enabled based on environment configuration. + * Tool search is enabled if any tools or server patterns are configured as deferred. + * @returns Whether tool search should be enabled + */ +export function shouldEnableToolSearch(): boolean { + const deferred = parseToolList(process.env.TOOL_DEFERRED); + return deferred.size > 0; +} + +interface MCPToolInstance { + name: string; + description?: string; + mcp?: boolean; + /** Original JSON schema attached at MCP tool creation time */ + mcpJsonSchema?: JsonSchemaType; +} + +/** + * Extracts MCP tool definition from a loaded tool instance. + * MCP tools have the original JSON schema attached as `mcpJsonSchema` property. + * + * @param tool - The loaded tool instance + * @returns Tool definition + */ +export function extractMCPToolDefinition(tool: MCPToolInstance): ToolDefinition { + const def: ToolDefinition = { name: tool.name }; + + if (tool.description) { + def.description = tool.description; + } + + if (tool.mcpJsonSchema) { + def.parameters = tool.mcpJsonSchema; + } + + return def; +} + +/** + * Checks if a tool is an MCP tool based on its properties. + * @param tool - The tool to check (can be any object with potential mcp property) + * @returns Whether the tool is an MCP tool + */ +export function isMCPTool(tool: unknown): tool is MCPToolInstance { + return typeof tool === 'object' && tool !== null && (tool as MCPToolInstance).mcp === true; +} + +/** + * Cleans up the temporary mcpJsonSchema property from MCP tools after registry is populated. + * This property is only needed during registry building and can be safely removed afterward. + * + * @param tools - Array of tools to clean up + */ +export function cleanupMCPToolSchemas(tools: MCPToolInstance[]): void { + for (const tool of tools) { + if (tool.mcpJsonSchema !== undefined) { + delete tool.mcpJsonSchema; + } + } +} + +/** Parameters for building tool classification and creating PTC/tool search tools */ +export interface BuildToolClassificationParams { + /** All loaded tools (will be filtered for MCP tools) */ + loadedTools: GenericTool[]; + /** User ID for auth lookup */ + userId: string; + /** Agent ID (used to check if this agent should have classification features) */ + agentId?: string; + /** Function to load auth values (dependency injection) */ + loadAuthValues: (params: { + userId: string; + authFields: string[]; + }) => Promise>; +} + +/** Result from building tool classification */ +export interface BuildToolClassificationResult { + /** Tool registry built from MCP tools (undefined if no MCP tools) */ + toolRegistry?: LCToolRegistry; + /** Additional tools created (PTC and/or tool search) */ + additionalTools: GenericTool[]; +} + +/** + * Checks if an agent is allowed to have classification features based on TOOL_CLASSIFICATION_AGENT_IDS. + * @param agentId - The agent ID to check + * @returns Whether the agent is allowed (true if no restriction set, or agent is in the list) + */ +export function isAgentAllowedForClassification(agentId?: string): boolean { + const allowedAgentIds = parseToolList(process.env.TOOL_CLASSIFICATION_AGENT_IDS); + if (allowedAgentIds.size === 0) { + return true; + } + if (!agentId) { + return false; + } + return allowedAgentIds.has(agentId); +} + +/** + * Checks if an agent's tools have any that match PTC patterns (programmatic only or dual context). + * @param toolRegistry - The tool registry to check + * @returns Whether any tools are configured for programmatic calling + */ +export function agentHasProgrammaticTools(toolRegistry: LCToolRegistry): boolean { + for (const toolDef of toolRegistry.values()) { + if (toolDef.allowed_callers?.includes('code_execution')) { + return true; + } + } + return false; +} + +/** + * Checks if an agent's tools have any that are deferred. + * @param toolRegistry - The tool registry to check + * @returns Whether any tools are configured as deferred + */ +export function agentHasDeferredTools(toolRegistry: LCToolRegistry): boolean { + for (const toolDef of toolRegistry.values()) { + if (toolDef.defer_loading === true) { + return true; + } + } + return false; +} + +/** + * Builds the tool registry from MCP tools and conditionally creates PTC and tool search tools. + * + * This function: + * 1. Checks if the agent is allowed for classification features (via TOOL_CLASSIFICATION_AGENT_IDS) + * 2. Filters loaded tools for MCP tools + * 3. Extracts tool definitions and builds the registry from env vars + * 4. Cleans up temporary mcpJsonSchema properties + * 5. Creates PTC tool only if agent has tools configured for programmatic calling + * 6. Creates tool search tool only if agent has deferred tools + * + * @param params - Parameters including loaded tools, userId, agentId, and dependencies + * @returns Tool registry and any additional tools created + */ +export async function buildToolClassification( + params: BuildToolClassificationParams, +): Promise { + const { loadedTools, userId, agentId, loadAuthValues } = params; + const additionalTools: GenericTool[] = []; + + const mcpTools = loadedTools.filter(isMCPTool); + if (mcpTools.length === 0) { + return { toolRegistry: undefined, additionalTools }; + } + + const mcpToolDefs = mcpTools.map(extractMCPToolDefinition); + const toolRegistry = buildToolRegistryFromEnv(mcpToolDefs); + + /** Clean up temporary mcpJsonSchema property from tools now that registry is populated */ + cleanupMCPToolSchemas(mcpTools); + + /** Check if this agent is allowed to have classification features */ + if (!isAgentAllowedForClassification(agentId)) { + logger.debug( + `[buildToolClassification] Agent ${agentId} not in TOOL_CLASSIFICATION_AGENT_IDS, skipping PTC/ToolSearch`, + ); + return { toolRegistry, additionalTools }; + } + + /** + * Check if this agent actually has tools that match the patterns. + * Only enable PTC if the agent has programmatic tools. + * Only enable tool search if the agent has deferred tools. + */ + const hasProgrammaticTools = agentHasProgrammaticTools(toolRegistry); + const hasDeferredTools = agentHasDeferredTools(toolRegistry); + + if (!hasProgrammaticTools && !hasDeferredTools) { + logger.debug( + `[buildToolClassification] Agent ${agentId} has no programmatic or deferred tools, skipping PTC/ToolSearch`, + ); + return { toolRegistry, additionalTools }; + } + + try { + const authValues = await loadAuthValues({ + userId, + authFields: [EnvVar.CODE_API_KEY], + }); + const codeApiKey = authValues[EnvVar.CODE_API_KEY]; + + if (!codeApiKey) { + logger.warn( + '[buildToolClassification] PTC/ToolSearch configured but CODE_API_KEY not available', + ); + return { toolRegistry, additionalTools }; + } + + if (hasProgrammaticTools) { + const ptcTool = createProgrammaticToolCallingTool({ apiKey: codeApiKey }); + additionalTools.push(ptcTool); + logger.debug(`[buildToolClassification] PTC tool enabled for agent ${agentId}`); + } + + if (hasDeferredTools) { + const toolSearchTool = createToolSearchRegexTool({ + apiKey: codeApiKey, + toolRegistry, + }); + additionalTools.push(toolSearchTool); + logger.debug(`[buildToolClassification] Tool Search enabled for agent ${agentId}`); + } + } catch (error) { + logger.error('[buildToolClassification] Error creating PTC/ToolSearch tools:', error); + } + + return { toolRegistry, additionalTools }; +} diff --git a/packages/api/src/tools/index.ts b/packages/api/src/tools/index.ts index eb375902f1..4dd8065c05 100644 --- a/packages/api/src/tools/index.ts +++ b/packages/api/src/tools/index.ts @@ -1,2 +1,3 @@ export * from './format'; export * from './toolkits'; +export * from './classification';