diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d4227da4..46139d9cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `nodemailer` to `^9.0.1`. [#1356](https://github.com/sourcebot-dev/sourcebot/pull/1356) - Upgraded `@opentelemetry/core` to `^2.8.0`. [#1413](https://github.com/sourcebot-dev/sourcebot/pull/1413) - [EE] Fixed connector setup dialogs to add scrolling when connector setup content goes out of view. +- [EE] Fixed Ask connector MCP tools with provider-invalid names failing to run by sanitizing model-facing tool names while preserving raw names in the UI. ## [5.0.4] - 2026-06-18 diff --git a/packages/web/src/ee/features/chat/agent.test.ts b/packages/web/src/ee/features/chat/agent.test.ts index 9787212c6..a0fbbf857 100644 --- a/packages/web/src/ee/features/chat/agent.test.ts +++ b/packages/web/src/ee/features/chat/agent.test.ts @@ -242,6 +242,62 @@ beforeEach(() => { }); describe('createMessageStream approval continuation', () => { + test('streams raw MCP tool names for client display', async () => { + const { getConnectedMcpClients } = await import('@/ee/features/chat/mcp/mcpClientFactory'); + const { getMcpTools } = await import('@/ee/features/chat/mcp/mcpToolSets'); + vi.mocked(getConnectedMcpClients).mockResolvedValueOnce([ + { serverId: 'server-backstage', serverName: 'Backstage' }, + ] as never); + vi.mocked(getMcpTools).mockResolvedValueOnce({ + tools: {}, + failedServers: [], + serverFaviconUrls: { + backstage: 'https://backstage.example.com/favicon.ico', + }, + toolDisplayNames: { + 'mcp_backstage__catalog_query-catalog-entities': 'catalog.query-catalog-entities', + }, + cleanup: vi.fn(), + }); + mockAi.streamText.mockReturnValue(createFakeStreamResult()); + + await createMessageStream({ + chatId: 'chat-id', + messages: [createUserMessage()], + selectedRepos: [], + disabledMcpServerIds: [], + prisma: {}, + model: {}, + modelName: 'test-model', + promptCacheStrategy: noopStrategy, + onFinish: vi.fn(), + onError: () => 'error', + userId: 'user-id', + orgId: 1, + } as unknown as Parameters[0]); + + const execute = mockAi.latestCreateUIMessageStreamOptions?.execute; + if (!execute) { + throw new Error('Expected createUIMessageStream to capture execute callback.'); + } + + const write = vi.fn(); + await execute({ + writer: { + merge: vi.fn(), + write, + }, + }); + + expect(write).toHaveBeenCalledWith({ + type: 'data-mcp-tool', + data: { + modelToolName: 'mcp_backstage__catalog_query-catalog-entities', + rawToolName: 'catalog.query-catalog-entities', + }, + }); + }); + test.each([ ['dynamic', dynamicApprovalRespondedPart], ['static', staticApprovalRespondedPart], diff --git a/packages/web/src/ee/features/chat/agent.ts b/packages/web/src/ee/features/chat/agent.ts index 9819d5202..9a951c290 100644 --- a/packages/web/src/ee/features/chat/agent.ts +++ b/packages/web/src/ee/features/chat/agent.ts @@ -338,6 +338,12 @@ export const createMessageStream = async ({ data: { sanitizedName, faviconUrl }, }); }, + onMcpToolDiscovered: (modelToolName, rawToolName) => { + writer.write({ + type: 'data-mcp-tool', + data: { modelToolName, rawToolName }, + }); + }, onMcpServerFailed: (serverName) => { writer.write({ type: 'data-mcp-failed-server', @@ -470,6 +476,7 @@ interface AgentOptions { inputSources: Source[]; onWriteSource: (source: Source) => void; onMcpServerDiscovered: (sanitizedName: string, faviconUrl: string) => void; + onMcpToolDiscovered: (modelToolName: string, rawToolName: string) => void; onMcpServerFailed: (serverName: string) => void; traceId: string; chatId: string; @@ -489,6 +496,7 @@ const createAgentStream = async ({ disabledMcpServerIds, onWriteSource, onMcpServerDiscovered, + onMcpToolDiscovered, onMcpServerFailed, traceId, chatId, @@ -525,7 +533,7 @@ const createAgentStream = async ({ })) ).filter((source) => source !== undefined); - let mcpToolSetsObj: McpToolsResult = { tools: {}, failedServers: [], serverFaviconUrls: {}, cleanup: async () => {} }; + let mcpToolSetsObj: McpToolsResult = { tools: {}, failedServers: [], serverFaviconUrls: {}, toolDisplayNames: {}, cleanup: async () => {} }; if (userId && orgId && await hasEntitlement('ask') && disabledMcpServerIds !== undefined) { try { const allMcpClients = await getConnectedMcpClients(prisma, userId, orgId); @@ -539,6 +547,9 @@ const createAgentStream = async ({ for (const [sanitizedName, faviconUrl] of Object.entries(mcpToolSetsObj.serverFaviconUrls)) { onMcpServerDiscovered(sanitizedName, faviconUrl); } + for (const [modelToolName, rawToolName] of Object.entries(mcpToolSetsObj.toolDisplayNames)) { + onMcpToolDiscovered(modelToolName, rawToolName); + } if (mcpClients.length > 0) { logger.info(`Connected to ${mcpClients.length} external MCP server(s): ${mcpClients.map(c => c.serverName).join(', ')}`); diff --git a/packages/web/src/ee/features/chat/components/chatThread/chatThread.tsx b/packages/web/src/ee/features/chat/components/chatThread/chatThread.tsx index ddfd0a151..e99b6874a 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/chatThread.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/chatThread.tsx @@ -28,7 +28,7 @@ import { duplicateChat } from '@/features/chat/actions'; import { generateAndUpdateChatNameFromMessage } from '@/ee/features/chat/actions'; import { isServiceError } from '@/lib/utils'; import { NotConfiguredErrorBanner } from '@/features/chat/components/notConfiguredErrorBanner'; -import { McpServerIconContext, McpServerIconMap } from '../../mcpServerIconContext'; +import { McpServerIconContext, McpServerIconMap, McpToolNameContext, McpToolNameMap } from '../../mcpServerIconContext'; import { ToolApprovalProvider } from '../../toolApprovalContext'; import useCaptureEvent from '@/hooks/useCaptureEvent'; import { SignInPromptBanner } from './signInPromptBanner'; @@ -104,6 +104,18 @@ export const ChatThread = ({ return map; }); + const [mcpToolNameMap, setMcpToolNameMap] = useState(() => { + const map: McpToolNameMap = {}; + initialMessages?.forEach((message) => { + message.parts + .filter((part) => part.type === 'data-mcp-tool') + .forEach((part) => { + map[part.data.modelToolName] = part.data.rawToolName; + }); + }); + return map; + }); + const [failedMcpServers, setFailedMcpServers] = useState(() => { const names: string[] = []; initialMessages?.forEach((message) => { @@ -173,6 +185,12 @@ export const ChatThread = ({ [dataPart.data.sanitizedName]: dataPart.data.faviconUrl, })); } + if (dataPart.type === 'data-mcp-tool') { + setMcpToolNameMap((prev) => ({ + ...prev, + [dataPart.data.modelToolName]: dataPart.data.rawToolName, + })); + } if (dataPart.type === 'data-mcp-failed-server') { setFailedMcpServers((prev) => { if (prev.includes(dataPart.data.serverName)) { @@ -385,6 +403,7 @@ export const ChatThread = ({ return ( + chatBoxRef.current?.addFiles(files)} @@ -532,6 +551,7 @@ export const ChatThread = ({ )} + ); diff --git a/packages/web/src/ee/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/ee/features/chat/components/chatThread/detailsCard.tsx index c705b6be5..c172cc9bc 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/detailsCard.tsx @@ -536,6 +536,7 @@ export const StepPartRenderer = ({ part, toolTokenUsageMap }: { part: SBChatMess return null; case 'data-source': case 'data-mcp-server': + case 'data-mcp-tool': case 'data-mcp-failed-server': case 'data-attachment': case 'file': diff --git a/packages/web/src/ee/features/chat/components/chatThread/toolApprovalBanner.tsx b/packages/web/src/ee/features/chat/components/chatThread/toolApprovalBanner.tsx index ed0ccdecc..0460ae93c 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/toolApprovalBanner.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/toolApprovalBanner.tsx @@ -2,14 +2,14 @@ import { Button } from "@/components/ui/button"; import { McpFavicon } from "@/ee/features/chat/mcp/components/mcpFavicon"; -import { useMcpServerIconMap } from "@/ee/features/chat/mcpServerIconContext"; +import { McpToolNameMap, useMcpServerIconMap, useMcpToolNameMap } from "@/ee/features/chat/mcpServerIconContext"; import { useToolApproval } from "@/ee/features/chat/toolApprovalContext"; import { SBChatToolPart } from "@/features/chat/utils"; import { cn } from "@/lib/utils"; import { getToolName } from "ai"; import { ChevronRight } from "lucide-react"; import { useCallback, useState } from "react"; -import { parseMcpToolName } from "./tools/mcpToolComponent"; +import { getMcpToolDisplayParts } from "./tools/mcpToolComponent"; import { JsonHighlighter } from "./tools/jsonHighlighter"; export type ApprovalRequestedToolPart = SBChatToolPart & { @@ -23,6 +23,7 @@ interface ToolApprovalBannerProps { export const ToolApprovalBanner = ({ parts }: ToolApprovalBannerProps) => { const addToolApprovalResponse = useToolApproval(); const iconMap = useMcpServerIconMap(); + const rawToolNames = useMcpToolNameMap(); if (parts.length === 0) { return null; @@ -36,6 +37,7 @@ export const ToolApprovalBanner = ({ parts }: ToolApprovalBannerProps) => { part={part} addToolApprovalResponse={addToolApprovalResponse} iconMap={iconMap} + rawToolNames={rawToolNames} /> ))} @@ -46,17 +48,17 @@ const ToolApprovalItem = ({ part, addToolApprovalResponse, iconMap, + rawToolNames, }: { part: ApprovalRequestedToolPart; addToolApprovalResponse: ReturnType; iconMap: Record; + rawToolNames: McpToolNameMap; }) => { const [isExpanded, setIsExpanded] = useState(false); const partToolName = getToolName(part); - const parsed = parseMcpToolName(partToolName); - const serverName = parsed?.serverName ?? partToolName; - const toolName = parsed?.toolName ?? partToolName; - const faviconUrl = parsed ? iconMap[parsed.serverName] : undefined; + const display = getMcpToolDisplayParts(partToolName, rawToolNames); + const faviconUrl = display.serverName ? iconMap[display.serverName] : undefined; const requestText = JSON.stringify(part.input, null, 2); @@ -83,13 +85,13 @@ const ToolApprovalItem = ({ > - {parsed ? ( + {display.serverName ? ( <> - Agent wants to use {toolName} from {serverName} + Agent wants to use {display.toolName} from {display.serverName} ) : ( <> - Agent wants to use {toolName} + Agent wants to use {display.toolName} )} diff --git a/packages/web/src/ee/features/chat/components/chatThread/tools/mcpToolComponent.test.tsx b/packages/web/src/ee/features/chat/components/chatThread/tools/mcpToolComponent.test.tsx new file mode 100644 index 000000000..8d7675136 --- /dev/null +++ b/packages/web/src/ee/features/chat/components/chatThread/tools/mcpToolComponent.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react'; +import type { DynamicToolUIPart } from 'ai'; +import { describe, expect, test } from 'vitest'; +import { McpToolNameContext } from '@/ee/features/chat/mcpServerIconContext'; +import { getMcpToolDisplayParts, McpToolComponent } from './mcpToolComponent'; + +describe('getMcpToolDisplayParts', () => { + test('maps provider-safe MCP tool names back to raw tool names for display', () => { + expect(getMcpToolDisplayParts( + 'mcp_backstage__catalog_query-catalog-entities', + { + 'mcp_backstage__catalog_query-catalog-entities': 'catalog.query-catalog-entities', + }, + )).toEqual({ + serverName: 'backstage', + toolName: 'catalog.query-catalog-entities', + displayName: 'backstage: catalog.query-catalog-entities', + }); + }); + + test('falls back to the provider-safe name for older messages without metadata', () => { + expect(getMcpToolDisplayParts('mcp_backstage__catalog_query-catalog-entities')).toEqual({ + serverName: 'backstage', + toolName: 'catalog_query-catalog-entities', + displayName: 'backstage: catalog_query-catalog-entities', + }); + }); +}); + +describe('McpToolComponent', () => { + test('renders the raw MCP tool name when display metadata is available', () => { + const part = { + type: 'dynamic-tool', + toolName: 'mcp_backstage__catalog_query-catalog-entities', + toolCallId: 'tool-call-1', + state: 'approval-requested', + input: { filter: 'kind=component' }, + } as DynamicToolUIPart; + + render( + + + + ); + + expect(screen.getByText('backstage: catalog.query-catalog-entities')).toBeTruthy(); + expect(screen.getByText('Request (backstage: catalog.query-catalog-entities)')).toBeTruthy(); + expect(screen.queryByText('backstage: catalog_query-catalog-entities')).toBeNull(); + }); +}); diff --git a/packages/web/src/ee/features/chat/components/chatThread/tools/mcpToolComponent.tsx b/packages/web/src/ee/features/chat/components/chatThread/tools/mcpToolComponent.tsx index 2b4cc840f..bdad91c46 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/tools/mcpToolComponent.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/tools/mcpToolComponent.tsx @@ -2,7 +2,7 @@ import { CopyIconButton } from "@/app/(app)/components/copyIconButton"; import { McpFavicon } from "@/ee/features/chat/mcp/components/mcpFavicon"; -import { useMcpServerIconMap } from "@/ee/features/chat/mcpServerIconContext"; +import { McpToolNameMap, useMcpServerIconMap, useMcpToolNameMap } from "@/ee/features/chat/mcpServerIconContext"; import { cn } from "@/lib/utils"; import { Separator } from "@/components/ui/separator"; import { DynamicToolUIPart } from "ai"; @@ -26,17 +26,34 @@ export function parseMcpToolName(toolName: string): { serverName: string; toolNa }; } +export function getMcpToolDisplayParts( + modelToolName: string, + rawToolNames: McpToolNameMap = {}, +): { serverName?: string; toolName: string; displayName: string } { + const parsed = parseMcpToolName(modelToolName); + const toolName = rawToolNames[modelToolName] ?? parsed?.toolName ?? modelToolName; + + return parsed + ? { + serverName: parsed.serverName, + toolName, + displayName: `${parsed.serverName}: ${toolName}`, + } + : { + toolName, + displayName: toolName, + }; +} + export const McpToolComponent = ({ part, estimatedOutputTokens }: { part: DynamicToolUIPart, estimatedOutputTokens?: number }) => { const needsApproval = part.state === 'approval-requested'; const [isExpanded, setIsExpanded] = useState(needsApproval); const onToggle = useCallback(() => setIsExpanded(v => !v), []); const iconMap = useMcpServerIconMap(); - const parsed = parseMcpToolName(part.toolName); - const displayName = parsed - ? `${parsed.serverName}: ${parsed.toolName}` - : part.toolName; - const faviconUrl = parsed ? iconMap[parsed.serverName] : undefined; + const rawToolNames = useMcpToolNameMap(); + const display = getMcpToolDisplayParts(part.toolName, rawToolNames); + const faviconUrl = display.serverName ? iconMap[display.serverName] : undefined; const hasInput = part.state !== 'input-streaming'; @@ -76,7 +93,7 @@ export const McpToolComponent = ({ part, estimatedOutputTokens }: { part: Dynami return ( - {displayName} failed: {part.errorText} + {display.displayName} failed: {part.errorText} ); } @@ -85,7 +102,7 @@ export const McpToolComponent = ({ part, estimatedOutputTokens }: { part: Dynami - {displayName} — denied + {display.displayName} — denied ); } @@ -93,7 +110,7 @@ export const McpToolComponent = ({ part, estimatedOutputTokens }: { part: Dynami return ( - {displayName} + {display.displayName} ); } @@ -103,7 +120,7 @@ export const McpToolComponent = ({ part, estimatedOutputTokens }: { part: Dynami {approved ? : } - {displayName}{approved ? '...' : ' — denied'} + {display.displayName}{approved ? '...' : ' — denied'} ); } @@ -111,7 +128,7 @@ export const McpToolComponent = ({ part, estimatedOutputTokens }: { part: Dynami return ( - {displayName} + {display.displayName} ); } @@ -119,7 +136,7 @@ export const McpToolComponent = ({ part, estimatedOutputTokens }: { part: Dynami return ( - {displayName}... + {display.displayName}... ); }; @@ -148,7 +165,7 @@ export const McpToolComponent = ({ part, estimatedOutputTokens }: { part: Dynami {hasInput && isExpanded && (
- + {responseText !== undefined && ( diff --git a/packages/web/src/ee/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx b/packages/web/src/ee/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx index 36e1e2002..ad23eb24a 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/tools/toolSearchToolComponent.tsx @@ -6,6 +6,8 @@ import { ChevronRight } from "lucide-react"; import { useState } from "react"; import { cn } from "@/lib/utils"; import { ToolTokenBadge } from "./toolTokenBadge"; +import { useMcpToolNameMap } from "@/ee/features/chat/mcpServerIconContext"; +import { getMcpToolDisplayParts } from "./mcpToolComponent"; interface ToolSearchResult { name: string; @@ -20,13 +22,15 @@ interface ToolSearchToolComponentProps { export const ToolSearchToolComponent = ({ query, results, estimatedOutputTokens }: ToolSearchToolComponentProps) => { const [isOpen, setIsOpen] = useState(false); + const rawToolNames = useMcpToolNameMap(); + const displayQuery = getMcpToolDisplayParts(query, rawToolNames).displayName; return (
- Searched connector tools: {query} + Searched connector tools: {displayQuery} {results.length} result{results.length === 1 ? '' : 's'} {estimatedOutputTokens !== undefined && ( @@ -40,17 +44,20 @@ export const ToolSearchToolComponent = ({ query, results, estimatedOutputTokens
- {results.map((result) => ( -
- {result.name} - {result.description && ( - <> - - - {result.description} - - )} -
- ))} + {results.map((result) => { + const displayResult = getMcpToolDisplayParts(result.name, rawToolNames); + return ( +
+ {displayResult.displayName} + {result.description && ( + <> + - + {result.description} + + )} +
+ ); + })} {results.length === 0 && ( No tools found )} diff --git a/packages/web/src/ee/features/chat/mcp/mcpToolSets.test.ts b/packages/web/src/ee/features/chat/mcp/mcpToolSets.test.ts index da6261ef4..bf2ff01af 100644 --- a/packages/web/src/ee/features/chat/mcp/mcpToolSets.test.ts +++ b/packages/web/src/ee/features/chat/mcp/mcpToolSets.test.ts @@ -101,7 +101,7 @@ function createMockClient(overrides: Partial & { serverName: string // --- Tests --- // Import after mocks are set up -const { getMcpTools } = await import('./mcpToolSets'); +const { getMcpTools, sanitizeMcpToolNameForModel } = await import('./mcpToolSets'); beforeEach(() => { vi.clearAllMocks(); @@ -114,6 +114,26 @@ beforeEach(() => { mockRedisSet.mockResolvedValue('OK'); }); +describe('sanitizeMcpToolNameForModel', () => { + test('replaces provider-invalid characters with underscores', () => { + expect(sanitizeMcpToolNameForModel('mcp_backstage__catalog.query-catalog-entities')) + .toBe('mcp_backstage__catalog_query-catalog-entities'); + }); + + test('returns an underscore for an empty name', () => { + expect(sanitizeMcpToolNameForModel('')).toBe('_'); + }); + + test('caps long names at the provider limit', () => { + const sanitized = sanitizeMcpToolNameForModel( + 'mcp_atlassian_confluence__get_space_details_by_key_or_id_with_extra_context' + ); + + expect(sanitized).toMatch(/^[a-zA-Z0-9_-]{1,64}$/); + expect(sanitized.length).toBeLessThanOrEqual(64); + }); +}); + describe('getMcpTools', () => { test('single server with single tool produces correctly namespaced key', async () => { const mockClient = createMockMcpClient([ @@ -129,6 +149,91 @@ describe('getMcpTools', () => { expect(result.failedServers).toEqual([]); }); + test('sanitizes model-facing keys for MCP tools with punctuation', async () => { + const mockClient = createMockMcpClient([ + { name: 'catalog.query-catalog-entities', description: 'Query catalog entities' }, + ]); + mockCreateMCPClient.mockResolvedValue(mockClient); + + const result = await getMcpTools([ + createMockClient({ serverId: 'server-backstage', serverName: 'Backstage' }), + ]); + + expect(Object.keys(result.tools)).toEqual([ + 'mcp_backstage__catalog_query-catalog-entities', + ]); + expect(result.toolDisplayNames).toEqual({ + 'mcp_backstage__catalog_query-catalog-entities': 'catalog.query-catalog-entities', + }); + + const tool = result.tools['mcp_backstage__catalog_query-catalog-entities']; + await expect( + tool.execute({ filter: 'kind=component' }, { messages: [], toolCallId: 'test' }) + ).resolves.toEqual({ content: [{ type: 'text', text: 'result' }] }); + + expect(mockServerToolUpsert).toHaveBeenCalledWith(expect.objectContaining({ + where: { + mcpServerId_toolName: { + mcpServerId: 'server-backstage', + toolName: 'catalog.query-catalog-entities', + }, + }, + })); + expect(mockCaptureEvent).toHaveBeenCalledWith('ask_mcp_tool_call_completed', expect.objectContaining({ + serverId: 'server-backstage', + toolName: 'catalog.query-catalog-entities', + success: true, + })); + }); + + test('caps long model-facing keys while preserving the raw MCP tool name', async () => { + const rawToolName = 'get_space_details_by_key_or_id_with_extra_context_for_backstage_catalog_entities'; + const mockClient = createMockMcpClient([ + { name: rawToolName, description: 'Get space details' }, + ]); + mockCreateMCPClient.mockResolvedValue(mockClient); + + const result = await getMcpTools([ + createMockClient({ serverId: 'server-backstage', serverName: 'Backstage' }), + ]); + + const [modelToolName] = Object.keys(result.tools); + expect(modelToolName).toMatch(/^[a-zA-Z0-9_-]{1,64}$/); + expect(modelToolName.length).toBeLessThanOrEqual(64); + expect(result.toolDisplayNames[modelToolName]).toBe(rawToolName); + + const tool = result.tools[modelToolName]; + await expect( + tool.execute({}, { messages: [], toolCallId: 'test' }) + ).resolves.toEqual({ content: [{ type: 'text', text: 'result' }] }); + + expect(mockServerToolUpsert).toHaveBeenCalledWith(expect.objectContaining({ + where: { + mcpServerId_toolName: { + mcpServerId: 'server-backstage', + toolName: rawToolName, + }, + }, + })); + }); + + test('keeps valid hyphenated tool names distinct from underscored names', async () => { + const mockClient = createMockMcpClient([ + { name: 'foo-bar', description: 'Hyphenated tool' }, + { name: 'foo_bar', description: 'Underscored tool' }, + ]); + mockCreateMCPClient.mockResolvedValue(mockClient); + + const result = await getMcpTools([ + createMockClient({ serverName: 'Backstage' }), + ]); + + expect(Object.keys(result.tools)).toEqual([ + 'mcp_backstage__foo-bar', + 'mcp_backstage__foo_bar', + ]); + }); + test('multiple servers produce tools with distinct prefixes', async () => { const linearClient = createMockMcpClient([ { name: 'list_issues', description: 'List issues' }, @@ -382,6 +487,7 @@ describe('getMcpTools', () => { expect(result.tools).toEqual({}); expect(result.failedServers).toEqual([]); expect(result.serverFaviconUrls).toEqual({}); + expect(result.toolDisplayNames).toEqual({}); expect(typeof result.cleanup).toBe('function'); }); diff --git a/packages/web/src/ee/features/chat/mcp/mcpToolSets.ts b/packages/web/src/ee/features/chat/mcp/mcpToolSets.ts index 193c08ff2..2e37bd5b8 100644 --- a/packages/web/src/ee/features/chat/mcp/mcpToolSets.ts +++ b/packages/web/src/ee/features/chat/mcp/mcpToolSets.ts @@ -21,6 +21,7 @@ import { const logger = createLogger('mcp-tool-sets'); const ajv = new Ajv({ allErrors: true, strict: false }); const MCP_LIST_TOOLS_CACHE_TTL_SECONDS = 60 * 60; +const MODEL_TOOL_NAME_MAX_LENGTH = 64; type ListToolsResult = Awaited>; class McpToolTimeoutError extends Error { @@ -71,6 +72,7 @@ export interface McpToolsResult { tools: Record>[string]>; failedServers: string[]; serverFaviconUrls: Record; + toolDisplayNames: Record; cleanup: () => Promise; } @@ -113,6 +115,16 @@ function getOAuthScopeHash(oauthScopes: string[]): string { .slice(0, 16); } +/** + * Provider APIs such as OpenAI Responses enforce tight charset and length + * limits for tool names. MCP tool names are server-controlled, so normalize the + * fully qualified name before exposing it to the model. + */ +export function sanitizeMcpToolNameForModel(name: string): string { + const sanitized = name.replace(/[^A-Za-z0-9_-]/g, '_') || '_'; + return sanitized.slice(0, MODEL_TOOL_NAME_MAX_LENGTH); +} + function getMcpListToolsCacheKey(client: McpToolSet): string { return [ 'mcp:list-tools:v1', @@ -182,6 +194,7 @@ export async function getMcpTools(clients: McpToolSet[], analyticsContext?: McpT const allTools: McpToolsResult['tools'] = {}; const failedServers: string[] = []; const serverFaviconUrls: Record = {}; + const toolDisplayNames: Record = {}; const mcpClients: MCPClient[] = []; const connectionTimeoutMs = env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS; @@ -258,8 +271,10 @@ export async function getMcpTools(clients: McpToolSet[], analyticsContext?: McpT }); const originalExecute = tool.execute; - const qualifiedName = `${prefix}__${toolName}`; + const rawQualifiedName = `${prefix}__${toolName}`; + const qualifiedName = sanitizeMcpToolNameForModel(rawQualifiedName); const timeoutMs = env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS; + toolDisplayNames[qualifiedName] = toolName; const executeWithTimeout = (async (input: unknown, options: ToolExecutionOptions) => { const startTime = Date.now(); @@ -354,5 +369,5 @@ export async function getMcpTools(clients: McpToolSet[], analyticsContext?: McpT ); }; - return { tools: allTools, failedServers, serverFaviconUrls, cleanup }; + return { tools: allTools, failedServers, serverFaviconUrls, toolDisplayNames, cleanup }; } diff --git a/packages/web/src/ee/features/chat/mcpServerIconContext.tsx b/packages/web/src/ee/features/chat/mcpServerIconContext.tsx index 94628f4a5..4e5977636 100644 --- a/packages/web/src/ee/features/chat/mcpServerIconContext.tsx +++ b/packages/web/src/ee/features/chat/mcpServerIconContext.tsx @@ -8,3 +8,10 @@ export type McpServerIconMap = Record; export const McpServerIconContext = createContext({}); export const useMcpServerIconMap = () => useContext(McpServerIconContext); + +// Maps provider-safe model tool names back to raw MCP tool names for display. +export type McpToolNameMap = Record; + +export const McpToolNameContext = createContext({}); + +export const useMcpToolNameMap = () => useContext(McpToolNameContext); diff --git a/packages/web/src/features/chat/types.ts b/packages/web/src/features/chat/types.ts index 4f09fbc57..897a81548 100644 --- a/packages/web/src/features/chat/types.ts +++ b/packages/web/src/features/chat/types.ts @@ -143,6 +143,9 @@ export type SBChatMessageDataParts = { // The `mcp-server` data type carries favicon metadata for connected MCP servers, // keyed by sanitized server name (e.g. "linear"). "mcp-server": { sanitizedName: string; faviconUrl: string }, + // The `mcp-tool` data type maps the provider-safe model tool name back to + // the raw MCP tool name for display. + "mcp-tool": { modelToolName: string; rawToolName: string }, // The `mcp-failed-server` data type surfaces MCP servers that failed to load their tools. "mcp-failed-server": { serverName: string }, // A user-provided file attachment included with the message.