Skip to content

Commit edd372a

Browse files
committed
Sanitize MCP tool names for model providers
1 parent 9c4780d commit edd372a

2 files changed

Lines changed: 138 additions & 2 deletions

File tree

packages/web/src/ee/features/chat/mcp/mcpToolSets.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ function createMockClient(overrides: Partial<McpToolSet> & { serverName: string
101101
// --- Tests ---
102102

103103
// Import after mocks are set up
104-
const { getMcpTools } = await import('./mcpToolSets');
104+
const { getMcpTools, sanitizeMcpToolNameForModel } = await import('./mcpToolSets');
105105

106106
beforeEach(() => {
107107
vi.clearAllMocks();
@@ -114,6 +114,17 @@ beforeEach(() => {
114114
mockRedisSet.mockResolvedValue('OK');
115115
});
116116

117+
describe('sanitizeMcpToolNameForModel', () => {
118+
test('replaces provider-invalid characters with underscores', () => {
119+
expect(sanitizeMcpToolNameForModel('mcp_backstage__catalog.query-catalog-entities'))
120+
.toBe('mcp_backstage__catalog_query_catalog_entities');
121+
});
122+
123+
test('returns an underscore for an empty name', () => {
124+
expect(sanitizeMcpToolNameForModel('')).toBe('_');
125+
});
126+
});
127+
117128
describe('getMcpTools', () => {
118129
test('single server with single tool produces correctly namespaced key', async () => {
119130
const mockClient = createMockMcpClient([
@@ -129,6 +140,58 @@ describe('getMcpTools', () => {
129140
expect(result.failedServers).toEqual([]);
130141
});
131142

143+
test('sanitizes model-facing keys for MCP tools with punctuation', async () => {
144+
const mockClient = createMockMcpClient([
145+
{ name: 'catalog.query-catalog-entities', description: 'Query catalog entities' },
146+
]);
147+
mockCreateMCPClient.mockResolvedValue(mockClient);
148+
149+
const result = await getMcpTools([
150+
createMockClient({ serverId: 'server-backstage', serverName: 'Backstage' }),
151+
]);
152+
153+
expect(Object.keys(result.tools)).toEqual([
154+
'mcp_backstage__catalog_query_catalog_entities',
155+
]);
156+
157+
const tool = result.tools['mcp_backstage__catalog_query_catalog_entities'];
158+
await expect(
159+
tool.execute({ filter: 'kind=component' }, { messages: [], toolCallId: 'test' })
160+
).resolves.toEqual({ content: [{ type: 'text', text: 'result' }] });
161+
162+
expect(mockServerToolUpsert).toHaveBeenCalledWith(expect.objectContaining({
163+
where: {
164+
mcpServerId_toolName: {
165+
mcpServerId: 'server-backstage',
166+
toolName: 'catalog.query-catalog-entities',
167+
},
168+
},
169+
}));
170+
expect(mockCaptureEvent).toHaveBeenCalledWith('ask_mcp_tool_call_completed', expect.objectContaining({
171+
serverId: 'server-backstage',
172+
toolName: 'catalog.query-catalog-entities',
173+
success: true,
174+
}));
175+
});
176+
177+
test('adds stable suffixes when sanitized tool names collide', async () => {
178+
const mockClient = createMockMcpClient([
179+
{ name: 'catalog.query', description: 'Query catalog' },
180+
{ name: 'catalog_query', description: 'Query catalog with underscore' },
181+
]);
182+
mockCreateMCPClient.mockResolvedValue(mockClient);
183+
184+
const result = await getMcpTools([
185+
createMockClient({ serverName: 'Backstage' }),
186+
]);
187+
188+
const toolNames = Object.keys(result.tools);
189+
expect(toolNames).toHaveLength(2);
190+
expect(new Set(toolNames).size).toBe(2);
191+
expect(toolNames).not.toContain('mcp_backstage__catalog_query');
192+
expect(toolNames.every((toolName) => /^mcp_backstage__catalog_query_[0-9a-f]{8}$/.test(toolName))).toBe(true);
193+
});
194+
132195
test('multiple servers produce tools with distinct prefixes', async () => {
133196
const linearClient = createMockMcpClient([
134197
{ name: 'list_issues', description: 'List issues' },

packages/web/src/ee/features/chat/mcp/mcpToolSets.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,77 @@ function getOAuthScopeHash(oauthScopes: string[]): string {
113113
.slice(0, 16);
114114
}
115115

116+
/**
117+
* Provider APIs such as OpenAI Responses reject dots and other punctuation in
118+
* tool names. MCP tool names are server-controlled, so normalize the fully
119+
* qualified name before exposing it to the model.
120+
*/
121+
export function sanitizeMcpToolNameForModel(name: string): string {
122+
let sanitized = '';
123+
124+
for (const character of name) {
125+
if (
126+
(character >= 'a' && character <= 'z') ||
127+
(character >= 'A' && character <= 'Z') ||
128+
(character >= '0' && character <= '9') ||
129+
character === '_'
130+
) {
131+
sanitized += character;
132+
} else {
133+
sanitized += '_';
134+
}
135+
}
136+
137+
return sanitized || '_';
138+
}
139+
140+
function getMcpToolNameCollisionSuffix(qualifiedName: string): string {
141+
return createHash('sha256')
142+
.update(qualifiedName)
143+
.digest('hex')
144+
.slice(0, 8);
145+
}
146+
147+
function buildModelToolNameMap(prefix: string, toolNames: string[]): Map<string, string> {
148+
const entries = toolNames.map((toolName) => {
149+
const qualifiedName = `${prefix}__${toolName}`;
150+
return {
151+
toolName,
152+
qualifiedName,
153+
sanitizedName: sanitizeMcpToolNameForModel(qualifiedName),
154+
};
155+
});
156+
157+
const sanitizedNameCounts = new Map<string, number>();
158+
for (const entry of entries) {
159+
sanitizedNameCounts.set(
160+
entry.sanitizedName,
161+
(sanitizedNameCounts.get(entry.sanitizedName) ?? 0) + 1,
162+
);
163+
}
164+
165+
const modelToolNames = new Map<string, string>();
166+
const usedModelToolNames = new Set<string>();
167+
for (const entry of [...entries].sort((a, b) => a.qualifiedName.localeCompare(b.qualifiedName))) {
168+
let modelToolName = entry.sanitizedName;
169+
170+
if ((sanitizedNameCounts.get(entry.sanitizedName) ?? 0) > 1) {
171+
modelToolName = `${entry.sanitizedName}_${getMcpToolNameCollisionSuffix(entry.qualifiedName)}`;
172+
}
173+
174+
let collisionIndex = 0;
175+
while (usedModelToolNames.has(modelToolName)) {
176+
collisionIndex += 1;
177+
modelToolName = `${entry.sanitizedName}_${getMcpToolNameCollisionSuffix(`${entry.qualifiedName}\0${collisionIndex}`)}`;
178+
}
179+
180+
usedModelToolNames.add(modelToolName);
181+
modelToolNames.set(entry.toolName, modelToolName);
182+
}
183+
184+
return modelToolNames;
185+
}
186+
116187
function getMcpListToolsCacheKey(client: McpToolSet): string {
117188
return [
118189
'mcp:list-tools:v1',
@@ -200,6 +271,7 @@ export async function getMcpTools(clients: McpToolSet[], analyticsContext?: McpT
200271
const toolDefinitions = await getListToolsResult(mcpClient, client, connectionTimeoutMs);
201272
const tools = mcpClient.toolsFromDefinitions(toolDefinitions);
202273
const prefix = `mcp_${sanitizedName}`;
274+
const modelToolNames = buildModelToolNameMap(prefix, Object.keys(tools));
203275
await createMissingMcpServerToolRows({
204276
serverId,
205277
tools: toolDefinitions.tools.map((tool) => {
@@ -258,7 +330,8 @@ export async function getMcpTools(clients: McpToolSet[], analyticsContext?: McpT
258330
});
259331

260332
const originalExecute = tool.execute;
261-
const qualifiedName = `${prefix}__${toolName}`;
333+
const rawQualifiedName = `${prefix}__${toolName}`;
334+
const qualifiedName = modelToolNames.get(toolName) ?? sanitizeMcpToolNameForModel(rawQualifiedName);
262335
const timeoutMs = env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS;
263336

264337
const executeWithTimeout = (async (input: unknown, options: ToolExecutionOptions) => {

0 commit comments

Comments
 (0)