Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions packages/mcp-utils/src/aggregate/gateway-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
27 changes: 24 additions & 3 deletions packages/mcp-utils/src/aggregate/gateway-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__<server>__`) 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.
Expand All @@ -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;
Expand Down Expand Up @@ -117,6 +135,7 @@ export interface GatewayClientOptions {
export class GatewayClient extends Client {
private readonly clients: Record<string, ClientEntry>;
private readonly slugToKey = new Map<string, string>();
private readonly keyToSlug = new Map<string, string>();

/** Cache of resolved client promises keyed by client key. */
private readonly resolvedClients = new Map<string, Promise<IClient>>();
Expand All @@ -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);
}
}

Expand All @@ -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}`;
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/mcp-utils/src/aggregate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {
GatewayClient,
getGatewayClientId,
slugify,
capSlug,
stripToolNamespace,
displayToolName,
type ClientOrFactory,
Expand Down
Loading