fix(mcp): shorten tool names to stay under 64 character limit

Claude Desktop enforces a 64 character limit on MCP tool names.
- Remove 'tpmjs-tools-' and 'tpmjs-' prefixes from package names
- Remove 'Tool' suffix from tool names
- Add fallback truncation if still too long
- Update parser to try all possible package/tool name combinations
This commit is contained in:
Ajax Davis 2026-01-16 05:12:10 +10:00
parent 3324034e6f
commit 46b212e65a
2 changed files with 72 additions and 30 deletions

View file

@ -169,22 +169,19 @@ export async function handleToolsCall(
'Database query timed out'
);
// Try both scoped (@scope/name) and literal (name-with-hyphens) package name interpretations
// This handles cases like:
// - @tpmjs/hello (scoped) → sanitized as tpmjs-hello → parsed back as @tpmjs/hello
// - firecrawl-aisdk (not scoped) → sanitized as firecrawl-aisdk → should match as-is
let collectionTool = collection?.tools.find(
(ct) =>
ct.tool.package.npmPackageName === parsed.packageName && ct.tool.name === parsed.toolName
);
// Try all possible package name and tool name combinations
// This handles both old format (full package name) and new shortened format
type CollectionTool = NonNullable<typeof collection>['tools'][number];
let collectionTool: CollectionTool | undefined;
// If not found with scoped name, try the literal package name
if (!collectionTool && parsed.literalPackageName !== parsed.packageName) {
collectionTool = collection?.tools.find(
(ct) =>
ct.tool.package.npmPackageName === parsed.literalPackageName &&
ct.tool.name === parsed.toolName
);
for (const pkgName of parsed.possiblePackages) {
for (const toolName of parsed.possibleToolNames) {
collectionTool = collection?.tools.find(
(ct) => ct.tool.package.npmPackageName === pkgName && ct.tool.name === toolName
);
if (collectionTool) break;
}
if (collectionTool) break;
}
if (!collectionTool) {

View file

@ -19,13 +19,37 @@ export interface BridgeTool {
/**
* Sanitize package name and tool name into a valid MCP tool name.
* MCP tool names must match ^[a-zA-Z0-9_-]+
* MCP tool names must match ^[a-zA-Z0-9_-]+ and be at most 64 characters.
*
* Example: @tpmjs/hello + helloWorldTool tpmjs-hello--helloWorldTool
* Example: @tpmjs/tools-sprites-foo + spritesBarTool sprites-foo--spritesBar
*/
export function sanitizeMcpName(packageName: string, toolName: string): string {
const sanitizedPkg = packageName.replace(/^@/, '').replace(/\//g, '-');
return `${sanitizedPkg}--${toolName}`;
// Remove @ and convert / to -
let sanitizedPkg = packageName.replace(/^@/, '').replace(/\//g, '-');
// Remove common prefixes to shorten
sanitizedPkg = sanitizedPkg.replace(/^tpmjs-tools-/, '');
sanitizedPkg = sanitizedPkg.replace(/^tpmjs-/, '');
// Remove 'Tool' suffix from tool name
const sanitizedTool = toolName.replace(/Tool$/, '');
const fullName = `${sanitizedPkg}--${sanitizedTool}`;
// Ensure we stay under 64 character limit
if (fullName.length <= 64) {
return fullName;
}
// If still too long, truncate package name to fit
const maxPkgLen = 64 - sanitizedTool.length - 2; // 2 for '--'
if (maxPkgLen > 10) {
return `${sanitizedPkg.slice(0, maxPkgLen)}--${sanitizedTool}`;
}
// Last resort: truncate both
return fullName.slice(0, 64);
}
/**
@ -57,7 +81,14 @@ export function convertToMcpTool(tool: Tool & { package: Package }): McpToolDefi
* Parsed tool name result - either a registry tool or a bridge tool
*/
export type ParsedToolName =
| { type: 'registry'; packageName: string; literalPackageName: string; toolName: string }
| {
type: 'registry';
packageName: string;
literalPackageName: string;
toolName: string;
possiblePackages: string[];
possibleToolNames: string[];
}
| { type: 'bridge'; serverId: string; toolName: string };
/**
@ -65,6 +96,7 @@ export type ParsedToolName =
* Handles both registry tools and bridge tools.
*
* Registry: tpmjs-hello--helloWorldTool { type: 'registry', packageName: "@tpmjs/hello", toolName: "helloWorldTool" }
* Shortened: sprites-get--spritesGet tries @tpmjs/tools-sprites-get, @tpmjs/sprites-get, sprites-get
* Bridge: bridge--chrome-devtools--screenshot { type: 'bridge', serverId: "chrome-devtools", toolName: "screenshot" }
*/
export function parseToolName(mcpName: string): ParsedToolName | null {
@ -83,21 +115,34 @@ export function parseToolName(mcpName: string): ParsedToolName | null {
if (!match || !match[1] || !match[2]) return null;
const pkg = match[1];
const toolName = match[2];
let toolName = match[2];
// Try to reconstruct @scope/name format if it looks scoped
// tpmjs-hello → @tpmjs/hello (first dash becomes @scope/)
// But also keep the original for non-scoped packages like firecrawl-aisdk
const scopedPackageName = pkg.includes('-') ? `@${pkg.replace('-', '/')}` : pkg;
const literalPackageName = pkg;
// Add back 'Tool' suffix if it was removed (try both with and without)
// The handler will try to find the tool with both variants
const toolNameWithSuffix = toolName.endsWith('Tool') ? toolName : `${toolName}Tool`;
// Return both possible interpretations - the handler will try both
// Generate possible package names to try:
// 1. @tpmjs/tools-{pkg} (shortened tpmjs-tools- prefix)
// 2. @tpmjs/{pkg} (shortened tpmjs- prefix)
// 3. @{scope}/{name} (standard scoped, first dash becomes /)
// 4. {pkg} as literal (non-scoped packages)
const possiblePackages = [
`@tpmjs/tools-${pkg}`,
`@tpmjs/${pkg}`,
pkg.includes('-') ? `@${pkg.replace('-', '/')}` : `@${pkg}`,
pkg,
];
// Return the first scoped interpretation as primary, with literal as fallback
return {
type: 'registry',
packageName: scopedPackageName,
literalPackageName,
toolName,
};
packageName: possiblePackages[0]!, // @tpmjs/tools-{pkg}
literalPackageName: pkg,
toolName: toolNameWithSuffix,
// Additional candidates for the handler to try
possiblePackages,
possibleToolNames: [toolNameWithSuffix, toolName],
} as ParsedToolName;
}
/**