Skip to content

Commit 86e8abb

Browse files
authored
mcp: improve tool prefixing behavior (#249929)
Closes #249281 Uses `mcp_servername_toolname` to form the tool.
1 parent d4c6686 commit 86e8abb

File tree

6 files changed

+58
-189
lines changed

6 files changed

+58
-189
lines changed

src/vs/workbench/contrib/mcp/common/mcpRegistry.ts

Lines changed: 2 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,11 @@
55

66
import { Codicon } from '../../../../base/common/codicons.js';
77
import { Emitter } from '../../../../base/common/event.js';
8-
import { StringSHA1 } from '../../../../base/common/hash.js';
98
import { MarkdownString } from '../../../../base/common/htmlContent.js';
109
import { Lazy } from '../../../../base/common/lazy.js';
1110
import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';
1211
import { derived, IObservable, observableValue } from '../../../../base/common/observable.js';
1312
import { basename } from '../../../../base/common/resources.js';
14-
import { indexOfPattern } from '../../../../base/common/strings.js';
1513
import { localize } from '../../../../nls.js';
1614
import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
1715
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
@@ -37,8 +35,6 @@ const createTrustMemento = observableMemento<Readonly<Record<string, boolean>>>(
3735
key: 'mcp.trustedCollections'
3836
});
3937

40-
const collectionPrefixLen = 3;
41-
4238
export class McpRegistry extends Disposable implements IMcpRegistry {
4339
declare public readonly _serviceBrand: undefined;
4440

@@ -54,41 +50,6 @@ export class McpRegistry extends Disposable implements IMcpRegistry {
5450
return this._collections.read(reader);
5551
});
5652

57-
private readonly _collectionToPrefixes = this._collections.map(c => {
58-
// This creates tool prefixes based on a hash of the collection ID. This is
59-
// a short prefix because tool names that are too long can cause errors (#243602).
60-
// So we take a hash (in order for tools to be stable, because randomized
61-
// names can cause hallicinations if present in history) and then adjust
62-
// them if there are any collisions.
63-
type CollectionHash = { view: number; hash: string; collection: McpCollectionDefinition };
64-
65-
const hashes = c.map((collection): CollectionHash => {
66-
const sha = new StringSHA1();
67-
sha.update(collection.id);
68-
const hash = sha.digest();
69-
// Gemini errors if the name starts with a number (microsoft/vscode-copilot-release#7152)
70-
return { view: indexOfPattern(hash, /[a-z]/i), hash, collection };
71-
});
72-
73-
const view = (h: CollectionHash) => h.hash.slice(h.view, h.view + collectionPrefixLen);
74-
75-
let collided = false;
76-
do {
77-
hashes.sort((a, b) => view(a).localeCompare(view(b)) || a.collection.id.localeCompare(b.collection.id));
78-
collided = false;
79-
for (let i = 1; i < hashes.length; i++) {
80-
const prev = hashes[i - 1];
81-
const curr = hashes[i];
82-
if (view(prev) === view(curr) && curr.view + collectionPrefixLen < curr.hash.length) {
83-
curr.view++;
84-
collided = true;
85-
}
86-
}
87-
} while (collided);
88-
89-
return Object.fromEntries(hashes.map(h => [h.collection.id, view(h) + '.']));
90-
});
91-
9253
private readonly _workspaceStorage = new Lazy(() => this._register(this._instantiationService.createInstance(McpRegistryInputStorage, StorageScope.WORKSPACE, StorageTarget.USER)));
9354
private readonly _profileStorage = new Lazy(() => this._register(this._instantiationService.createInstance(McpRegistryInputStorage, StorageScope.PROFILE, StorageTarget.USER)));
9455

@@ -150,7 +111,8 @@ export class McpRegistry extends Disposable implements IMcpRegistry {
150111
if (toReplace) {
151112
this._collections.set(currentCollections.map(c => c === toReplace ? collection : c), undefined);
152113
} else {
153-
this._collections.set([...currentCollections, collection], undefined);
114+
this._collections.set([...currentCollections, collection]
115+
.sort((a, b) => (a.presentation?.order || 0) - (b.presentation?.order || 0)), undefined);
154116
}
155117

156118
return {
@@ -169,10 +131,6 @@ export class McpRegistry extends Disposable implements IMcpRegistry {
169131
});
170132
}
171133

172-
public collectionToolPrefix(collection: McpCollectionReference): IObservable<string> {
173-
return this._collectionToPrefixes.map(p => p[collection.id] ?? '');
174-
}
175-
176134
public async discoverCollections(): Promise<McpCollectionDefinition[]> {
177135
const toDiscover = this._collections.get().filter(c => c.lazy && !c.lazy.isCached);
178136

src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,6 @@ export interface IMcpRegistry {
5555
/** Whether there are new collections that can be resolved with a discover() call */
5656
readonly lazyCollectionState: IObservable<LazyCollectionState>;
5757

58-
/** Gets the prefix that should be applied to a collection's tools in order to avoid ID conflicts */
59-
collectionToolPrefix(collection: McpCollectionReference): IObservable<string>;
60-
6158
/** Helper function to observe a definition by its reference. */
6259
getServerDefinition(collectionRef: McpDefinitionReference, definitionRef: McpDefinitionReference): IObservable<{ server: McpServerDefinition | undefined; collection: McpCollectionDefinition | undefined }>;
6360

src/vs/workbench/contrib/mcp/common/mcpServer.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { mcpActivationEvent } from './mcpConfiguration.js';
2929
import { McpDevModeServerAttache } from './mcpDevMode.js';
3030
import { IMcpRegistry } from './mcpRegistryTypes.js';
3131
import { McpServerRequestHandler } from './mcpServerRequestHandler.js';
32-
import { extensionMcpCollectionPrefix, IMcpPrompt, IMcpPromptMessage, IMcpResource, IMcpServer, IMcpServerConnection, IMcpServerStartOpts, IMcpTool, McpCollectionDefinition, McpCollectionReference, McpConnectionFailedError, McpConnectionState, McpDefinitionReference, McpResourceURI, McpServerDefinition, McpServerCacheState, McpServerTransportType, McpCapability, IMcpResourceTemplate } from './mcpTypes.js';
32+
import { extensionMcpCollectionPrefix, IMcpPrompt, IMcpPromptMessage, IMcpResource, IMcpServer, IMcpServerConnection, IMcpServerStartOpts, IMcpTool, McpCollectionDefinition, McpCollectionReference, McpConnectionFailedError, McpConnectionState, McpDefinitionReference, McpResourceURI, McpServerDefinition, McpServerCacheState, McpServerTransportType, McpCapability, IMcpResourceTemplate, McpToolName } from './mcpTypes.js';
3333
import { MCP } from './modelContextProtocol.js';
3434
import { UriTemplate } from './uriTemplate.js';
3535

@@ -266,7 +266,7 @@ export class McpServer extends Disposable implements IMcpServer {
266266

267267
const fromServer = this._tools.fromServerPromise.read(reader);
268268
const connectionState = this.connectionState.read(reader);
269-
const isIdle = McpConnectionState.canBeStarted(connectionState.state) && !fromServer;
269+
const isIdle = McpConnectionState.canBeStarted(connectionState.state) || !fromServer;
270270
if (isIdle) {
271271
return stateWhenServingFromCache();
272272
}
@@ -297,6 +297,7 @@ export class McpServer extends Disposable implements IMcpServer {
297297
explicitRoots: URI[] | undefined,
298298
private readonly _requiresExtensionActivation: boolean | undefined,
299299
private readonly _primitiveCache: McpServerMetadataCache,
300+
toolPrefix: string,
300301
@IMcpRegistry private readonly _mcpRegistry: IMcpRegistry,
301302
@IWorkspaceContextService workspacesService: IWorkspaceContextService,
302303
@IExtensionService private readonly _extensionService: IExtensionService,
@@ -354,15 +355,11 @@ export class McpServer extends Disposable implements IMcpServer {
354355
}));
355356

356357
// 3. Publish tools
357-
const toolPrefix = this._mcpRegistry.collectionToolPrefix(this.collection);
358358
this._tools = new CachedPrimitive<IMcpTool, IValidatedMcpTool>(
359359
this.definition.id,
360360
this._primitiveCache,
361361
(entry) => entry.tools,
362-
(entry, reader) => {
363-
const prefix = toolPrefix.read(reader);
364-
return entry.map(def => new McpTool(this, prefix, def)).sort((a, b) => a.compare(b));
365-
},
362+
(entry) => entry.map(def => new McpTool(this, toolPrefix, def)).sort((a, b) => a.compare(b)),
366363
);
367364

368365
// 4. Publish promtps
@@ -708,7 +705,7 @@ export class McpTool implements IMcpTool {
708705
idPrefix: string,
709706
private readonly _definition: IValidatedMcpTool,
710707
) {
711-
this.id = (idPrefix + _definition.name).replaceAll('.', '_');
708+
this.id = (idPrefix + _definition.name).replaceAll('.', '_').slice(0, McpToolName.MaxLength);
712709
}
713710

714711
call(params: Record<string, unknown>, token?: CancellationToken): Promise<MCP.CallToolResult> {

src/vs/workbench/contrib/mcp/common/mcpService.ts

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocatio
2020
import { McpCommandIds } from './mcpCommandIds.js';
2121
import { IMcpRegistry } from './mcpRegistryTypes.js';
2222
import { McpServer, McpServerMetadataCache } from './mcpServer.js';
23-
import { IMcpServer, IMcpService, IMcpTool, McpCollectionDefinition, McpServerCacheState, McpServerDefinition } from './mcpTypes.js';
23+
import { IMcpServer, IMcpService, IMcpTool, McpCollectionDefinition, McpServerCacheState, McpServerDefinition, McpToolName } from './mcpTypes.js';
2424

2525
interface ISyncedToolData {
2626
toolData: IToolData;
2727
store: DisposableStore;
2828
}
2929

30-
type IMcpServerRec = IReference<IMcpServer>;
30+
type IMcpServerRec = IReference<IMcpServer> & { toolPrefix: string };
3131

3232
export class McpService extends Disposable implements IMcpService {
3333

@@ -94,6 +94,15 @@ export class McpService extends Disposable implements IMcpService {
9494

9595
store.add(autorun(reader => {
9696
const toDelete = new Set(tools.keys());
97+
98+
// toRegister is deferred until deleting tools that moving a tool between
99+
// servers (or deleting one instance of a multi-instance server) doesn't cause an error.
100+
const toRegister: (() => void)[] = [];
101+
const registerTool = (tool: IMcpTool, toolData: IToolData, store: DisposableStore) => {
102+
store.add(this._toolsService.registerToolData(toolData));
103+
store.add(this._toolsService.registerToolImplementation(tool.id, this._instantiationService.createInstance(McpToolImplementation, tool, server)));
104+
};
105+
97106
for (const tool of server.tools.read(reader)) {
98107
const existing = tools.get(tool.id);
99108
const collection = this._mcpRegistry.collections.get().find(c => c.id === server.collection.id);
@@ -102,7 +111,7 @@ export class McpService extends Disposable implements IMcpService {
102111
source: { type: 'mcp', label: server.definition.label, collectionId: server.collection.id, definitionId: server.definition.id },
103112
icon: Codicon.tools,
104113
displayName: tool.definition.annotations?.title || tool.definition.name,
105-
toolReferenceName: tool.definition.name,
114+
toolReferenceName: tool.id,
106115
modelDescription: tool.definition.description ?? '',
107116
userDescription: tool.definition.description ?? '',
108117
inputSchema: tool.definition.inputSchema,
@@ -112,26 +121,20 @@ export class McpService extends Disposable implements IMcpService {
112121
tags: ['mcp'],
113122
};
114123

115-
const registerTool = (store: DisposableStore) => {
116-
store.add(this._toolsService.registerToolData(toolData));
117-
store.add(this._toolsService.registerToolImplementation(tool.id, this._instantiationService.createInstance(McpToolImplementation, tool, server)));
118-
};
119-
120124
if (existing) {
121125
if (!equals(existing.toolData, toolData)) {
122126
existing.toolData = toolData;
123127
existing.store.clear();
124128
// We need to re-register both the data and implementation, as the
125129
// implementation is discarded when the data is removed (#245921)
126-
registerTool(store);
130+
registerTool(tool, toolData, store);
127131
}
128132
toDelete.delete(tool.id);
129133
} else {
130134
const store = new DisposableStore();
131-
registerTool(store);
135+
toRegister.push(() => registerTool(tool, toolData, store));
132136
tools.set(tool.id, { toolData, store });
133137
}
134-
135138
}
136139

137140
for (const id of toDelete) {
@@ -141,6 +144,10 @@ export class McpService extends Disposable implements IMcpService {
141144
tools.delete(id);
142145
}
143146
}
147+
148+
for (const fn of toRegister) {
149+
fn();
150+
}
144151
}));
145152

146153
store.add(toDisposable(() => {
@@ -151,11 +158,12 @@ export class McpService extends Disposable implements IMcpService {
151158
}
152159

153160
public updateCollectedServers() {
161+
const prefixGenerator = new McpPrefixGenerator();
154162
const definitions = this._mcpRegistry.collections.get().flatMap(collectionDefinition =>
155-
collectionDefinition.serverDefinitions.get().map(serverDefinition => ({
156-
serverDefinition,
157-
collectionDefinition,
158-
}))
163+
collectionDefinition.serverDefinitions.get().map(serverDefinition => {
164+
const toolPrefix = prefixGenerator.generate(serverDefinition.label);
165+
return { serverDefinition, collectionDefinition, toolPrefix };
166+
})
159167
);
160168

161169
const nextDefinitions = new Set(definitions);
@@ -174,7 +182,7 @@ export class McpService extends Disposable implements IMcpService {
174182

175183
// Transfer over any servers that are still valid.
176184
for (const server of currentServers) {
177-
const match = definitions.find(d => defsEqual(server.object, d));
185+
const match = definitions.find(d => defsEqual(server.object, d) && server.toolPrefix === d.toolPrefix);
178186
if (match) {
179187
pushMatch(match, server);
180188
} else {
@@ -192,12 +200,13 @@ export class McpService extends Disposable implements IMcpService {
192200
def.serverDefinition.roots,
193201
!!def.collectionDefinition.lazy,
194202
def.collectionDefinition.scope === StorageScope.WORKSPACE ? this.workspaceCache : this.userCache,
203+
def.toolPrefix,
195204
);
196205

197206
store.add(object);
198207
this._syncTools(object, store);
199208

200-
nextServers.push({ object, dispose: () => store.dispose() });
209+
nextServers.push({ object, dispose: () => store.dispose(), toolPrefix: def.toolPrefix });
201210
}
202211

203212
transaction(tx => {
@@ -292,3 +301,18 @@ class McpToolImplementation implements IToolImpl {
292301
return result;
293302
}
294303
}
304+
305+
// Helper class for generating unique MCP tool prefixes
306+
class McpPrefixGenerator {
307+
private readonly seenPrefixes = new Set<string>();
308+
309+
generate(label: string): string {
310+
const baseToolPrefix = McpToolName.Prefix + label.toLowerCase().replace(/[^a-z0-9_.-]+/g, '_').slice(0, McpToolName.MaxPrefixLen - McpToolName.Prefix.length - 1);
311+
let toolPrefix = baseToolPrefix + '_';
312+
for (let i = 2; this.seenPrefixes.has(toolPrefix); i++) {
313+
toolPrefix = baseToolPrefix + i + '_';
314+
}
315+
this.seenPrefixes.add(toolPrefix);
316+
return toolPrefix;
317+
}
318+
}

src/vs/workbench/contrib/mcp/common/mcpTypes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,3 +639,9 @@ export const enum McpCapability {
639639
Tools = 1 << 7,
640640
ToolsListChanged = 1 << 8,
641641
}
642+
643+
export const enum McpToolName {
644+
Prefix = 'mcp_',
645+
MaxPrefixLen = 18,
646+
MaxLength = 64,
647+
}

0 commit comments

Comments
 (0)