From 6160129fd265b2c33b3add2db0fc38c01850a71b Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Fri, 10 Apr 2026 16:20:23 -0300 Subject: [PATCH] fix(mcp-utils): cap gateway tool name slugs to prevent 128-char API limit MCP clients like Claude Code prepend their own prefix (e.g. mcp__cms__) to tool names. Combined with long connection ID slugs, this exceeded the 128-character tool name limit enforced by Anthropic's API. Cap the slug portion to 32 characters so the full namespaced name stays within limits. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/aggregate/gateway-client.test.ts | 54 +++++++++++++++++++ .../mcp-utils/src/aggregate/gateway-client.ts | 27 ++++++++-- packages/mcp-utils/src/aggregate/index.ts | 1 + 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/packages/mcp-utils/src/aggregate/gateway-client.test.ts b/packages/mcp-utils/src/aggregate/gateway-client.test.ts index f46ee9634b..eb56baaba4 100644 --- a/packages/mcp-utils/src/aggregate/gateway-client.test.ts +++ b/packages/mcp-utils/src/aggregate/gateway-client.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, mock } from "bun:test"; import type { IClient } from "../client-like.ts"; import { GatewayClient, + capSlug, displayToolName, slugify, stripToolNamespace, @@ -72,6 +73,24 @@ describe("slugify", () => { }); }); +describe("capSlug", () => { + it("returns short slugs unchanged", () => { + expect(capSlug("my-server")).toBe("my-server"); + expect(capSlug("conn-abc123")).toBe("conn-abc123"); + }); + + it("truncates slugs exceeding 32 characters", () => { + const long = "a".repeat(40); + expect(capSlug(long)).toBe("a".repeat(32)); + }); + + it("removes trailing hyphen after truncation", () => { + // 31 chars + hyphen at position 32 → truncated to 32 → trailing hyphen removed + const slug = "a".repeat(31) + "-bbb"; + expect(capSlug(slug)).toBe("a".repeat(31)); + }); +}); + describe("stripToolNamespace", () => { it("strips clientId prefix", () => { expect(stripToolNamespace("my-conn_SOME_TOOL", "my-conn")).toBe( @@ -97,6 +116,13 @@ describe("stripToolNamespace", () => { ), ).toBe("hello_world"); }); + + it("strips truncated slug prefix for long connection IDs", () => { + const longId = "conn-" + "a".repeat(40); + const truncatedSlug = capSlug(slugify(longId)); + const namespaced = `${truncatedSlug}_MY_TOOL`; + expect(stripToolNamespace(namespaced, longId)).toBe("MY_TOOL"); + }); }); describe("displayToolName", () => { @@ -160,6 +186,34 @@ describe("GatewayClient", () => { }), ).toThrow(/duplicate slug/); }); + + it("truncates long connection ID slugs to fit within limits", async () => { + const longId = "conn-" + "x".repeat(40); // 45 chars, exceeds 32 max + const client = createMockClient([{ name: "SEARCH" }]); + + const gw = new GatewayClient({ [longId]: { client } }); + const result = await gw.listTools(); + + const toolName = result.tools[0].name; + // Slug should be truncated, tool name intact + expect(toolName.endsWith("_SEARCH")).toBe(true); + expect(toolName.length).toBeLessThanOrEqual(32 + 1 + "SEARCH".length); + }); + + it("routes tool calls correctly with truncated slugs", async () => { + const longId = "conn-" + "y".repeat(40); + const client = createMockClient([{ name: "DO_STUFF" }]); + + const gw = new GatewayClient({ [longId]: { client } }); + const { tools } = await gw.listTools(); + + await gw.callTool({ name: tools[0].name, arguments: { x: 1 } }); + expect(client.callTool).toHaveBeenCalledWith( + { name: "DO_STUFF", arguments: { x: 1 } }, + undefined, + undefined, + ); + }); }); describe("prompt namespacing", () => { diff --git a/packages/mcp-utils/src/aggregate/gateway-client.ts b/packages/mcp-utils/src/aggregate/gateway-client.ts index b90703953e..824a21bd8a 100644 --- a/packages/mcp-utils/src/aggregate/gateway-client.ts +++ b/packages/mcp-utils/src/aggregate/gateway-client.ts @@ -63,6 +63,24 @@ export function slugify(input: string): string { .replace(/^-|-$/g, ""); } +/** + * Maximum length for the slug portion of a namespaced tool/prompt name. + * + * MCP clients such as Claude Code prepend their own prefix to tool names + * (e.g. `mcp____`) and AI providers enforce a 128-character limit. + * Capping the slug at 32 characters keeps the full namespaced name short + * enough to stay within 128 even after client-side prefixing. + */ +const MAX_SLUG_LENGTH = 32; + +/** + * Truncate a slug to {@link MAX_SLUG_LENGTH}, removing any trailing hyphen. + */ +export function capSlug(slug: string): string { + if (slug.length <= MAX_SLUG_LENGTH) return slug; + return slug.slice(0, MAX_SLUG_LENGTH).replace(/-$/, ""); +} + /** * Extract `gatewayClientId` from an item's `_meta` object. * Returns `undefined` when the field is absent or not a string. @@ -89,7 +107,7 @@ export function stripToolNamespace( clientId?: string, ): string { if (!clientId) return namespacedName; - const prefix = `${slugify(clientId)}_`; + const prefix = `${capSlug(slugify(clientId))}_`; return namespacedName.startsWith(prefix) ? namespacedName.slice(prefix.length) : namespacedName; @@ -117,6 +135,7 @@ export interface GatewayClientOptions { export class GatewayClient extends Client { private readonly clients: Record; private readonly slugToKey = new Map(); + private readonly keyToSlug = new Map(); /** Cache of resolved client promises keyed by client key. */ private readonly resolvedClients = new Map>(); @@ -140,13 +159,14 @@ export class GatewayClient extends Client { }); this.clients = clients; for (const key of Object.keys(clients)) { - const slug = slugify(key); + const slug = capSlug(slugify(key)); if (this.slugToKey.has(slug)) { throw new Error( `GatewayClient: duplicate slug "${slug}" from keys "${this.slugToKey.get(slug)}" and "${key}"`, ); } this.slugToKey.set(slug, key); + this.keyToSlug.set(key, slug); } } @@ -155,7 +175,8 @@ export class GatewayClient extends Client { // --------------------------------------------------------------------------- private namespace(clientKey: string, name: string): string { - return `${slugify(clientKey)}_${name}`; + const slug = this.keyToSlug.get(clientKey) ?? capSlug(slugify(clientKey)); + return `${slug}_${name}`; } /** diff --git a/packages/mcp-utils/src/aggregate/index.ts b/packages/mcp-utils/src/aggregate/index.ts index cf886030e6..56a0820ea5 100644 --- a/packages/mcp-utils/src/aggregate/index.ts +++ b/packages/mcp-utils/src/aggregate/index.ts @@ -2,6 +2,7 @@ export { GatewayClient, getGatewayClientId, slugify, + capSlug, stripToolNamespace, displayToolName, type ClientOrFactory,