From 89f5053c15b2d04b24c3d8119fff5392cdba662f Mon Sep 17 00:00:00 2001 From: zm2231 <25645999+zm2231@users.noreply.github.com> Date: Wed, 13 May 2026 21:01:25 -0400 Subject: [PATCH] fix: disambiguate bridged tool names --- src/serve.ts | 30 ++++++++++++++++++++---------- tests/serve.test.ts | 21 ++++++++++++++++++++- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/serve.ts b/src/serve.ts index 0215140..82a0710 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -163,26 +163,36 @@ export function selectServedServers( } export function encodeToolName(server: string, tool: string): string { - return `${server}${TOOL_SEPARATOR}${tool}`; + return `${encodeToolNamePart(server)}${TOOL_SEPARATOR}${encodeToolNamePart(tool)}`; } export function decodeToolName( name: string, servedServers: readonly Pick[] ): { server: string; tool: string } | undefined { - const sorted = [...servedServers].toSorted((a, b) => b.name.length - a.name.length); - for (const server of sorted) { - const prefix = `${server.name}${TOOL_SEPARATOR}`; - if (name.startsWith(prefix)) { - const tool = name.slice(prefix.length); - if (tool.length > 0) { - return { server: server.name, tool }; - } - } + const separatorIndex = name.indexOf(TOOL_SEPARATOR); + if (separatorIndex === -1) { + return undefined; + } + const server = decodeToolNamePart(name.slice(0, separatorIndex)); + const tool = decodeToolNamePart(name.slice(separatorIndex + TOOL_SEPARATOR.length)); + if (tool.length === 0) { + return undefined; + } + if (servedServers.some((served) => served.name === server)) { + return { server, tool }; } return undefined; } +function encodeToolNamePart(value: string): string { + return value.replaceAll('%', '%25').replaceAll('_', '%5F'); +} + +function decodeToolNamePart(value: string): string { + return value.replaceAll('%5F', '_').replaceAll('%25', '%'); +} + function describeTool(server: string, description: string | undefined): string | undefined { if (!description) { return `Tool from MCPorter server '${server}'.`; diff --git a/tests/serve.test.ts b/tests/serve.test.ts index 0a2d881..dc109c3 100644 --- a/tests/serve.test.ts +++ b/tests/serve.test.ts @@ -39,12 +39,31 @@ describe('mcporter serve bridge', () => { it('encodes and decodes namespaced tool names with longest-prefix matching', () => { expect(encodeToolName('alpha', 'ping')).toBe('alpha__ping'); - expect(decodeToolName('alpha-long__tool__with__separator', [{ name: 'alpha' }, { name: 'alpha-long' }])).toEqual({ + expect( + decodeToolName('alpha-long__tool%5F%5Fwith%5F%5Fseparator', [{ name: 'alpha' }, { name: 'alpha-long' }]) + ).toEqual({ server: 'alpha-long', tool: 'tool__with__separator', }); }); + it('escapes namespaced tool parts to avoid server/tool collisions', () => { + const first = encodeToolName('alpha', 'beta__ping'); + const second = encodeToolName('alpha__beta', 'ping'); + + expect(first).toBe('alpha__beta%5F%5Fping'); + expect(second).toBe('alpha%5F%5Fbeta__ping'); + expect(first).not.toBe(second); + expect(decodeToolName(first, [{ name: 'alpha' }, { name: 'alpha__beta' }])).toEqual({ + server: 'alpha', + tool: 'beta__ping', + }); + expect(decodeToolName(second, [{ name: 'alpha' }, { name: 'alpha__beta' }])).toEqual({ + server: 'alpha__beta', + tool: 'ping', + }); + }); + it('exposes daemon tools through a single MCP server', async () => { const runtime = { listTools: vi.fn().mockImplementation(async (server: string) => [