Skip to content

Commit 11aa194

Browse files
committed
feat: add MCP tool name handling with API-safe names
1 parent 9609d26 commit 11aa194

2 files changed

Lines changed: 132 additions & 3 deletions

File tree

src/mcp/mcp-manager.ts

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import { createHash } from "crypto";
12
import { McpClient, type McpToolDefinition, type McpPromptDefinition, type McpResourceDefinition } from "./mcp-client";
23
import type { McpServerConfig } from "../settings";
34

45
const MCP_STARTUP_TIMEOUT_MS = process.env.DEEPCODE_MCP_TIMEOUT
56
? parseInt(process.env.DEEPCODE_MCP_TIMEOUT, 10)
67
: 30_000;
78
const MCP_CALL_TOOL_TIMEOUT_MS = 60_000;
9+
const API_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
10+
const API_TOOL_NAME_MAX_LENGTH = 64;
811

912
type McpToolEntry = {
1013
serverName: string;
@@ -27,6 +30,32 @@ export type McpServerStatus = {
2730
resources: string[];
2831
};
2932

33+
function buildMcpNamespacedName(
34+
serverName: string,
35+
toolName: string,
36+
usedNames: ReadonlySet<string> = new Set()
37+
): string {
38+
const rawName = buildRawMcpNamespacedName(serverName, toolName);
39+
const sanitizedName = `mcp__${sanitizeApiToolNamePart(serverName)}__${sanitizeApiToolNamePart(toolName)}`;
40+
let candidate = fitApiToolName(sanitizedName, rawName);
41+
if (!usedNames.has(candidate)) {
42+
return candidate;
43+
}
44+
45+
const hash = hashToolName(rawName);
46+
candidate = fitApiToolNameWithSuffix(sanitizedName, `_${hash}`);
47+
if (!usedNames.has(candidate)) {
48+
return candidate;
49+
}
50+
51+
for (let index = 2; ; index += 1) {
52+
candidate = fitApiToolNameWithSuffix(sanitizedName, `_${hash}_${index}`);
53+
if (!usedNames.has(candidate)) {
54+
return candidate;
55+
}
56+
}
57+
}
58+
3059
export class McpManager {
3160
private clients: McpClient[] = [];
3261
private tools: McpToolEntry[] = [];
@@ -151,8 +180,10 @@ export class McpManager {
151180
const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS);
152181
if (this.disposed) return;
153182
const toolNamespacedNames: string[] = [];
183+
const usedToolNames = new Set(this.tools.map((tool) => tool.namespacedName));
154184
for (const tool of serverTools) {
155-
const namespacedName = `mcp__${name}__${tool.name}`;
185+
const namespacedName = buildMcpNamespacedName(name, tool.name, usedToolNames);
186+
usedToolNames.add(namespacedName);
156187
this.tools.push({
157188
serverName: name,
158189
originalName: tool.name,
@@ -289,7 +320,7 @@ export class McpManager {
289320
type: "function" as const,
290321
function: {
291322
name: t.namespacedName,
292-
description: t.definition.description ?? `${t.serverName}: ${t.originalName}`,
323+
description: this.buildMcpToolDescription(t),
293324
parameters: {
294325
type: "object" as const,
295326
properties: t.definition.inputSchema.properties,
@@ -413,8 +444,10 @@ export class McpManager {
413444
const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS);
414445
this.tools = this.tools.filter((t) => t.serverName !== serverName);
415446
const toolNamespacedNames: string[] = [];
447+
const usedToolNames = new Set(this.tools.map((tool) => tool.namespacedName));
416448
for (const tool of serverTools) {
417-
const namespacedName = `mcp__${serverName}__${tool.name}`;
449+
const namespacedName = buildMcpNamespacedName(serverName, tool.name, usedToolNames);
450+
usedToolNames.add(namespacedName);
418451
this.tools.push({
419452
serverName,
420453
originalName: tool.name,
@@ -450,4 +483,42 @@ export class McpManager {
450483
}
451484
this.onStatusChanged?.();
452485
}
486+
487+
private buildMcpToolDescription(tool: McpToolEntry): string {
488+
const description = tool.definition.description?.trim();
489+
const source = `${tool.serverName}: ${tool.originalName}`;
490+
if (!description) {
491+
return source;
492+
}
493+
if (tool.namespacedName === buildRawMcpNamespacedName(tool.serverName, tool.originalName)) {
494+
return description;
495+
}
496+
return `${description}\nMCP source: ${source}`;
497+
}
498+
}
499+
500+
function buildRawMcpNamespacedName(serverName: string, toolName: string): string {
501+
return `mcp__${serverName}__${toolName}`;
502+
}
503+
504+
function sanitizeApiToolNamePart(value: string): string {
505+
const sanitized = value.replace(/[^a-zA-Z0-9_-]/g, "_");
506+
return sanitized || "unnamed";
507+
}
508+
509+
function fitApiToolName(name: string, rawName: string): string {
510+
if (API_TOOL_NAME_PATTERN.test(name) && name.length <= API_TOOL_NAME_MAX_LENGTH) {
511+
return name;
512+
}
513+
return fitApiToolNameWithSuffix(name, `_${hashToolName(rawName)}`);
514+
}
515+
516+
function fitApiToolNameWithSuffix(name: string, suffix: string): string {
517+
const maxPrefixLength = API_TOOL_NAME_MAX_LENGTH - suffix.length;
518+
const prefix = name.slice(0, Math.max(1, maxPrefixLength));
519+
return `${prefix}${suffix}`;
520+
}
521+
522+
function hashToolName(value: string): string {
523+
return createHash("sha256").update(value).digest("hex").slice(0, 8);
453524
}

src/tests/session.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,64 @@ rl.on("line", (line) => {
542542
assert.deepEqual(manager.getMcpStatus(), []);
543543
});
544544

545+
test("SessionManager exposes MCP tools with API-safe names and preserves original dispatch names", async () => {
546+
const workspace = createTempDir("deepcode-mcp-safe-name-workspace-");
547+
const serverPath = path.join(workspace, "mcp-invalid-name-server.cjs");
548+
fs.writeFileSync(
549+
serverPath,
550+
`
551+
const readline = require("readline");
552+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
553+
function send(message) {
554+
process.stdout.write(JSON.stringify(message) + "\\n");
555+
}
556+
rl.on("line", (line) => {
557+
const request = JSON.parse(line);
558+
if (!("id" in request)) {
559+
return;
560+
}
561+
if (request.method === "initialize") {
562+
send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} } } });
563+
return;
564+
}
565+
if (request.method === "tools/list") {
566+
send({ jsonrpc: "2.0", id: request.id, result: { tools: [
567+
{ name: "speak.text", description: "Speak text", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } },
568+
{ name: "speak/text", description: "Speak text using a slash name", inputSchema: { type: "object", properties: {} } }
569+
] } });
570+
return;
571+
}
572+
if (request.method === "tools/call") {
573+
send({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: request.params.name + ":" + (request.params.arguments.text || "") }] } });
574+
return;
575+
}
576+
send({ jsonrpc: "2.0", id: request.id, result: { content: [] } });
577+
});
578+
`,
579+
"utf8"
580+
);
581+
582+
const manager = createSessionManager(workspace, "machine-id-mcp-safe-name");
583+
await manager.initMcpServers({ "voice.box": { command: process.execPath, args: [serverPath] } });
584+
585+
const status = manager.getMcpStatus()[0];
586+
assert.equal(status?.status, "ready");
587+
assert.deepEqual(status?.tools, ["mcp__voice_box__speak_text", "mcp__voice_box__speak_text_59a610ad"]);
588+
589+
const mcpManager = (manager as any).mcpManager;
590+
const definitions = mcpManager.getMcpToolDefinitions();
591+
assert.equal(definitions[0].function.name, "mcp__voice_box__speak_text");
592+
assert.match(definitions[0].function.name, /^[a-zA-Z0-9_-]+$/);
593+
assert.match(definitions[0].function.description, /MCP source: voice\.box: speak\.text/);
594+
assert.deepEqual(await mcpManager.executeMcpTool("mcp__voice_box__speak_text", { text: "ok" }), {
595+
ok: true,
596+
name: "mcp__voice_box__speak_text",
597+
output: "speak.text:ok",
598+
});
599+
600+
manager.dispose();
601+
});
602+
545603
test("SessionManager dispose kills live processes without timeout controls", (t) => {
546604
if (process.platform === "win32") {
547605
t.skip("process group kill assertion is non-Windows specific");

0 commit comments

Comments
 (0)