fix: keep duplicate indexes on registry fallback and harden the oauth split

Duplication blanked mcpServerNames when the registry was unavailable, because
filterAuthorizedTools grandfathers the source's tools without resolving them -
the copy kept tools it could no longer resolve. Source names now carry forward
for the tools that still point at them.

splitToolCallName also treated any oauth_mcp_ prefix as a synthetic OAuth
call, so a genuine upstream tool by that name resolved to the wrong server. A
configured server name now decides when one matches, since a real key always
ends in its server, and the prefix only breaks ties for unconfigured servers.
This commit is contained in:
Danny Avila 2026-07-27 08:49:24 -04:00
parent 056152fe35
commit 07404c0cf9
3 changed files with 49 additions and 4 deletions

View file

@ -948,6 +948,22 @@ const duplicateAgentHandler = async (req, res) => {
configServers,
resolvedServerNames,
});
/** When the registry is unavailable, `filterAuthorizedTools` grandfathers the
* source's tools without resolving them, so carry forward the source names those
* retained tools still point at rather than blanking the index. */
const sourceNames = agent.mcpServerNames ?? [];
if (sourceNames.length > 0) {
const sourceNameSet = new Set(sourceNames);
for (const tool of newAgentData.tools ?? []) {
if (typeof tool !== 'string' || !tool.includes(Constants.mcp_delimiter)) {
continue;
}
const [, retainedName] = splitMCPToolKey(tool, sourceNames);
if (retainedName && sourceNameSet.has(retainedName)) {
resolvedServerNames.add(retainedName);
}
}
}
newAgentData.mcpServerNames = Array.from(resolvedServerNames);
}

View file

@ -2809,15 +2809,22 @@ export function splitMCPToolKey(
* Splits a tool-call name for display, where the key may be a synthetic MCP OAuth
* call (`oauth${mcp_delimiter}${serverName}`) rather than a real tool key.
*
* The OAuth form is unambiguous because its tool half is always exactly `oauth`,
* so everything after the first delimiter is the server even when the server name
* itself contains one. Real tool keys go through `splitMCPToolKey`, whose tool half
* is untrusted and may carry the delimiter.
* A configured server name is authoritative when one matches, because a real tool key
* always ends in its server. Only when none matches does the `oauth` prefix decide,
* which keeps a genuine upstream tool named `oauth${mcp_delimiter}...` from being read
* as a synthetic call while still resolving OAuth prompts for unconfigured servers.
*/
export function splitToolCallName(
toolCallName: string,
knownServerNames?: readonly string[],
): [string, string | undefined] {
if (knownServerNames?.length) {
const [toolName, serverName] = splitMCPToolKey(toolCallName, knownServerNames);
if (serverName != null && knownServerNames.includes(serverName)) {
return [toolName, serverName];
}
}
const oauthPrefix = `oauth${Constants.mcp_delimiter}`;
if (toolCallName.startsWith(oauthPrefix)) {
return ['oauth', toolCallName.slice(oauthPrefix.length)];

View file

@ -69,3 +69,25 @@ describe('splitToolCallName', () => {
]);
});
});
describe('splitToolCallName with configured server names', () => {
const d = Constants.mcp_delimiter;
it('reads a real tool whose own name starts with the oauth prefix', () => {
expect(splitToolCallName(`oauth${d}reset${d}github`, ['github'])).toEqual([
`oauth${d}reset`,
'github',
]);
});
it('still resolves a synthetic OAuth call for a configured server', () => {
expect(splitToolCallName(`oauth${d}github`, ['github'])).toEqual(['oauth', 'github']);
});
it('resolves a synthetic OAuth call for a delimiter-bearing configured server', () => {
expect(splitToolCallName(`oauth${d}foo${d}bar`, [`foo${d}bar`])).toEqual([
'oauth',
`foo${d}bar`,
]);
});
});