@theia/ai-registry 1.73.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +40 -0
  2. package/lib/browser/ai-registry-frontend-module.d.ts +5 -0
  3. package/lib/browser/ai-registry-frontend-module.d.ts.map +1 -0
  4. package/lib/browser/ai-registry-frontend-module.js +45 -0
  5. package/lib/browser/ai-registry-frontend-module.js.map +1 -0
  6. package/lib/browser/ai-registry-toolbar-contribution.d.ts +9 -0
  7. package/lib/browser/ai-registry-toolbar-contribution.d.ts.map +1 -0
  8. package/lib/browser/ai-registry-toolbar-contribution.js +53 -0
  9. package/lib/browser/ai-registry-toolbar-contribution.js.map +1 -0
  10. package/lib/browser/mcp/mcp-entries.d.ts +40 -0
  11. package/lib/browser/mcp/mcp-entries.d.ts.map +1 -0
  12. package/lib/browser/mcp/mcp-entries.js +143 -0
  13. package/lib/browser/mcp/mcp-entries.js.map +1 -0
  14. package/lib/browser/mcp/mcp-extensions-contribution.d.ts +45 -0
  15. package/lib/browser/mcp/mcp-extensions-contribution.d.ts.map +1 -0
  16. package/lib/browser/mcp/mcp-extensions-contribution.js +198 -0
  17. package/lib/browser/mcp/mcp-extensions-contribution.js.map +1 -0
  18. package/lib/browser/mcp/mcp-extensions-contribution.spec.d.ts +2 -0
  19. package/lib/browser/mcp/mcp-extensions-contribution.spec.d.ts.map +1 -0
  20. package/lib/browser/mcp/mcp-extensions-contribution.spec.js +266 -0
  21. package/lib/browser/mcp/mcp-extensions-contribution.spec.js.map +1 -0
  22. package/lib/browser/mcp/mcp-install-service.d.ts +72 -0
  23. package/lib/browser/mcp/mcp-install-service.d.ts.map +1 -0
  24. package/lib/browser/mcp/mcp-install-service.js +255 -0
  25. package/lib/browser/mcp/mcp-install-service.js.map +1 -0
  26. package/lib/browser/mcp/mcp-install-service.spec.d.ts +2 -0
  27. package/lib/browser/mcp/mcp-install-service.spec.d.ts.map +1 -0
  28. package/lib/browser/mcp/mcp-install-service.spec.js +604 -0
  29. package/lib/browser/mcp/mcp-install-service.spec.js.map +1 -0
  30. package/lib/browser/mcp/mcp-registry-ui-bridge-impl.d.ts +27 -0
  31. package/lib/browser/mcp/mcp-registry-ui-bridge-impl.d.ts.map +1 -0
  32. package/lib/browser/mcp/mcp-registry-ui-bridge-impl.js +136 -0
  33. package/lib/browser/mcp/mcp-registry-ui-bridge-impl.js.map +1 -0
  34. package/lib/common/ai-registry-configuration.d.ts +28 -0
  35. package/lib/common/ai-registry-configuration.d.ts.map +1 -0
  36. package/lib/common/ai-registry-configuration.js +56 -0
  37. package/lib/common/ai-registry-configuration.js.map +1 -0
  38. package/lib/common/mcp/mcp-registry-entry-resolver.d.ts +12 -0
  39. package/lib/common/mcp/mcp-registry-entry-resolver.d.ts.map +1 -0
  40. package/lib/common/mcp/mcp-registry-entry-resolver.js +68 -0
  41. package/lib/common/mcp/mcp-registry-entry-resolver.js.map +1 -0
  42. package/lib/common/mcp/mcp-registry-entry-resolver.spec.d.ts +2 -0
  43. package/lib/common/mcp/mcp-registry-entry-resolver.spec.d.ts.map +1 -0
  44. package/lib/common/mcp/mcp-registry-entry-resolver.spec.js +230 -0
  45. package/lib/common/mcp/mcp-registry-entry-resolver.spec.js.map +1 -0
  46. package/lib/common/mcp/mcp-registry-types.d.ts +105 -0
  47. package/lib/common/mcp/mcp-registry-types.d.ts.map +1 -0
  48. package/lib/common/mcp/mcp-registry-types.js +18 -0
  49. package/lib/common/mcp/mcp-registry-types.js.map +1 -0
  50. package/lib/common/registry-fetch-service.d.ts +24 -0
  51. package/lib/common/registry-fetch-service.d.ts.map +1 -0
  52. package/lib/common/registry-fetch-service.js +72 -0
  53. package/lib/common/registry-fetch-service.js.map +1 -0
  54. package/lib/common/registry-fetch-service.spec.d.ts +2 -0
  55. package/lib/common/registry-fetch-service.spec.d.ts.map +1 -0
  56. package/lib/common/registry-fetch-service.spec.js +129 -0
  57. package/lib/common/registry-fetch-service.spec.js.map +1 -0
  58. package/lib/package.spec.d.ts +1 -0
  59. package/lib/package.spec.d.ts.map +1 -0
  60. package/lib/package.spec.js +26 -0
  61. package/lib/package.spec.js.map +1 -0
  62. package/package.json +50 -0
  63. package/src/browser/ai-registry-frontend-module.ts +48 -0
  64. package/src/browser/ai-registry-toolbar-contribution.ts +51 -0
  65. package/src/browser/mcp/mcp-entries.tsx +288 -0
  66. package/src/browser/mcp/mcp-extensions-contribution.spec.ts +294 -0
  67. package/src/browser/mcp/mcp-extensions-contribution.ts +199 -0
  68. package/src/browser/mcp/mcp-install-service.spec.ts +673 -0
  69. package/src/browser/mcp/mcp-install-service.ts +300 -0
  70. package/src/browser/mcp/mcp-registry-ui-bridge-impl.ts +130 -0
  71. package/src/browser/style/mcp-entries.css +56 -0
  72. package/src/common/ai-registry-configuration.ts +52 -0
  73. package/src/common/mcp/mcp-registry-entry-resolver.spec.ts +248 -0
  74. package/src/common/mcp/mcp-registry-entry-resolver.ts +68 -0
  75. package/src/common/mcp/mcp-registry-types.ts +119 -0
  76. package/src/common/registry-fetch-service.spec.ts +136 -0
  77. package/src/common/registry-fetch-service.ts +78 -0
  78. package/src/package.spec.ts +28 -0
@@ -0,0 +1,294 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
18
+ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
19
+ // Entries render against the shared ExtensionCard, which pulls in browser-side modules,
20
+ // so a DOM is required at import time.
21
+ const disableJSDOM = enableJSDOM();
22
+ try {
23
+ FrontendApplicationConfigProvider.get();
24
+ } catch {
25
+ FrontendApplicationConfigProvider.set({});
26
+ }
27
+
28
+ import { expect } from 'chai';
29
+ import { Container } from '@theia/core/shared/inversify';
30
+ import { Emitter, MessageService, PreferenceService } from '@theia/core';
31
+ import { HoverService } from '@theia/core/lib/browser';
32
+ import { MCP_SERVERS_PREF } from '@theia/ai-mcp/lib/common/mcp-preferences';
33
+ import { MCPFrontendService } from '@theia/ai-mcp/lib/common/mcp-server-manager';
34
+ import { MCPServerEditor, MCPServerEditorImpl, MCPServerEditDialogFactory } from '@theia/ai-mcp/lib/browser/mcp-server-editor';
35
+ import { MCPServerInstallDialogFactory } from '@theia/ai-mcp/lib/browser/mcp-server-install-dialog';
36
+ import { RegistryFetchService } from '../../common/registry-fetch-service';
37
+ import { ResolvedRegistryEntry } from '../../common/mcp/mcp-registry-types';
38
+ import { MCPRegistryEntryResolver, MCPRegistryEntryResolverImpl } from '../../common/mcp/mcp-registry-entry-resolver';
39
+ import { MCPInstallService, MCPInstallServiceImpl } from './mcp-install-service';
40
+ import { MCPExtensionsContribution } from './mcp-extensions-contribution';
41
+ import { MCPInstalledEntry, MCPSearchResultEntry } from './mcp-entries';
42
+
43
+ after(() => disableJSDOM());
44
+
45
+ class FakePreferenceService {
46
+ private readonly store = new Map<string, unknown>();
47
+ private readonly listeners: ((change: { preferenceName: string }) => void)[] = [];
48
+ get<T>(key: string, defaultValue?: T): T | undefined {
49
+ return (this.store.has(key) ? this.store.get(key) : defaultValue) as T | undefined;
50
+ }
51
+ async set(key: string, value: unknown): Promise<void> {
52
+ this.store.set(key, value);
53
+ for (const l of this.listeners) {
54
+ l({ preferenceName: key });
55
+ }
56
+ }
57
+ onPreferenceChanged(listener: (change: { preferenceName: string }) => void): { dispose(): void } {
58
+ this.listeners.push(listener);
59
+ return { dispose: () => { /* no-op for tests */ } };
60
+ }
61
+ }
62
+
63
+ class StubRegistryFetchService {
64
+ protected readonly onDidChangeEmitter = new Emitter<void>();
65
+ readonly onDidChange = this.onDidChangeEmitter.event;
66
+ constructor(public entries: ResolvedRegistryEntry[] = []) { }
67
+ async getEntries(): Promise<ResolvedRegistryEntry[]> {
68
+ return this.entries;
69
+ }
70
+ }
71
+
72
+ function buildContainer(prefs: FakePreferenceService, fetch: StubRegistryFetchService): Container {
73
+ const container = new Container();
74
+ container.bind(PreferenceService).toConstantValue(prefs);
75
+ container.bind(RegistryFetchService).toConstantValue(fetch as unknown as RegistryFetchService);
76
+ container.bind(MCPRegistryEntryResolverImpl).toSelf().inSingletonScope();
77
+ container.bind(MCPRegistryEntryResolver).toService(MCPRegistryEntryResolverImpl);
78
+ // Editor dependencies — the contribution doesn't invoke the install path here, but
79
+ // MCPInstallService now injects MCPServerEditor so we stub its required services.
80
+ container.bind(MessageService).toConstantValue({ error: () => undefined } as unknown as MessageService);
81
+ container.bind(MCPFrontendService).toConstantValue({} as unknown as MCPFrontendService);
82
+ container.bind(HoverService).toConstantValue({ requestHover: () => undefined } as unknown as HoverService);
83
+ // The contribution doesn't open dialogs in these tests; bind factories that fail loudly if used.
84
+ container.bind(MCPServerEditDialogFactory).toConstantValue(() => {
85
+ throw new Error('MCPServerEditDialogFactory should not be invoked in these tests');
86
+ });
87
+ container.bind(MCPServerInstallDialogFactory).toConstantValue(() => {
88
+ throw new Error('MCPServerInstallDialogFactory should not be invoked in these tests');
89
+ });
90
+ container.bind(MCPServerEditorImpl).toSelf().inSingletonScope();
91
+ container.bind(MCPServerEditor).toService(MCPServerEditorImpl);
92
+ container.bind(MCPInstallServiceImpl).toSelf().inSingletonScope();
93
+ container.bind(MCPInstallService).toService(MCPInstallServiceImpl);
94
+ container.bind(MCPExtensionsContribution).toSelf().inSingletonScope();
95
+ return container;
96
+ }
97
+
98
+ const exampleRegistryEntry: ResolvedRegistryEntry = {
99
+ serverId: 'io.github.example/example-mcp',
100
+ name: 'Example',
101
+ description: 'Example MCP server',
102
+ localName: 'example',
103
+ config: { command: 'npx', args: ['-y', 'example-mcp'] },
104
+ version: '^1.0.0',
105
+ configHash: 'hash-v1',
106
+ mcpRegistryVerified: true
107
+ };
108
+
109
+ describe('MCPExtensionsContribution.resolveInstalled', () => {
110
+
111
+ it('only surfaces servers tied to a registry entry; user-added entries are filtered out', async () => {
112
+ const prefs = new FakePreferenceService();
113
+ await prefs.set(MCP_SERVERS_PREF, {
114
+ // Linked: matches a registry entry by serverId.
115
+ example: {
116
+ command: 'npx',
117
+ args: ['-y', 'example-mcp'],
118
+ registryMetadata: {
119
+ serverId: exampleRegistryEntry.serverId,
120
+ version: exampleRegistryEntry.version,
121
+ configHash: exampleRegistryEntry.configHash
122
+ }
123
+ },
124
+ // User-added: no registry counterpart at all.
125
+ standalone: { command: 'node', args: ['srv.js'] }
126
+ });
127
+ const fetch = new StubRegistryFetchService([exampleRegistryEntry]);
128
+ const contribution = buildContainer(prefs, fetch).get(MCPExtensionsContribution);
129
+
130
+ const entries = [...await contribution.resolveInstalled()] as MCPInstalledEntry[];
131
+
132
+ expect(entries.map(e => e.local.name)).to.deep.equal(['example']);
133
+ expect(entries[0].state).to.deep.equal({ kind: 'installed-from-registry', updateAvailable: false });
134
+ });
135
+
136
+ it('surfaces installed-link-stale entries so the view can render the warning + Unlink/Uninstall actions', async () => {
137
+ const prefs = new FakePreferenceService();
138
+ await prefs.set(MCP_SERVERS_PREF, {
139
+ stale: {
140
+ command: 'npx',
141
+ args: ['-y', 'gone-mcp'],
142
+ registryMetadata: {
143
+ serverId: 'io.github.example/gone-server',
144
+ version: '^1.0.0'
145
+ }
146
+ }
147
+ });
148
+ const fetch = new StubRegistryFetchService([exampleRegistryEntry]);
149
+ const contribution = buildContainer(prefs, fetch).get(MCPExtensionsContribution);
150
+
151
+ const entries = [...await contribution.resolveInstalled()] as MCPInstalledEntry[];
152
+
153
+ expect(entries.map(e => e.local.name)).to.deep.equal(['stale']);
154
+ expect(entries[0].state).to.deep.equal({ kind: 'installed-link-stale' });
155
+ });
156
+
157
+ it('shows installed-link-stale (Unlink + Uninstall) when the local is linked to a missing id, even if the key still matches a registry entry', async () => {
158
+ // PR scenario: user installs from the registry, then manually rewrites
159
+ // `registryMetadata.serverId` to a value that no longer exists. The local key
160
+ // still matches a registry `localName`, but the broken id linkage must take
161
+ // precedence - the entry has to surface as link-stale so the user sees the
162
+ // Unlink + Uninstall affordances and the warning, not Link (which would suggest
163
+ // the entry is merely unlinked).
164
+ const prefs = new FakePreferenceService();
165
+ await prefs.set(MCP_SERVERS_PREF, {
166
+ example: {
167
+ command: 'npx',
168
+ args: ['-y', 'example-mcp'],
169
+ registryMetadata: {
170
+ serverId: 'io.example/gone',
171
+ version: '^1.0.0',
172
+ configHash: 'hash-v1'
173
+ }
174
+ }
175
+ });
176
+ const fetch = new StubRegistryFetchService([exampleRegistryEntry]);
177
+ const contribution = buildContainer(prefs, fetch).get(MCPExtensionsContribution);
178
+
179
+ const entries = [...await contribution.resolveInstalled()] as MCPInstalledEntry[];
180
+
181
+ expect(entries).to.have.length(1);
182
+ expect(entries[0].state).to.deep.equal({ kind: 'installed-link-stale' });
183
+ });
184
+
185
+ it('skips malformed stored entries whose `command` is not a string — mere key presence is not enough', async () => {
186
+ const prefs = new FakePreferenceService();
187
+ await prefs.set(MCP_SERVERS_PREF, {
188
+ // `command` is present but typed wrong; the shared MCPServersPreference.isValue
189
+ // guard must reject this so it doesn't get cast to MCPServerDescription.
190
+ broken: { command: 42, args: ['-y', 'whatever'] },
191
+ example: {
192
+ command: 'npx',
193
+ args: ['-y', 'example-mcp'],
194
+ registryMetadata: {
195
+ serverId: exampleRegistryEntry.serverId,
196
+ version: exampleRegistryEntry.version,
197
+ configHash: exampleRegistryEntry.configHash
198
+ }
199
+ }
200
+ });
201
+ const fetch = new StubRegistryFetchService([exampleRegistryEntry]);
202
+ const contribution = buildContainer(prefs, fetch).get(MCPExtensionsContribution);
203
+
204
+ const entries = [...await contribution.resolveInstalled()] as MCPInstalledEntry[];
205
+
206
+ expect(entries.map(e => e.local.name)).to.deep.equal(['example']);
207
+ });
208
+ });
209
+
210
+ describe('MCPExtensionsContribution.resolveSearchResults', () => {
211
+
212
+ const githubEntry: ResolvedRegistryEntry = {
213
+ serverId: 'io.github.github/github-mcp-server',
214
+ name: 'GitHub',
215
+ description: 'Connect AI assistants to GitHub repositories',
216
+ localName: 'github',
217
+ config: { command: 'docker', args: ['run', '-i', '--rm'] },
218
+ version: '^1.0.0',
219
+ configHash: 'hash-github',
220
+ mcpRegistryVerified: true
221
+ };
222
+
223
+ it('returns nothing for an empty or whitespace-only query', async () => {
224
+ const prefs = new FakePreferenceService();
225
+ const fetch = new StubRegistryFetchService([exampleRegistryEntry, githubEntry]);
226
+ const contribution = buildContainer(prefs, fetch).get(MCPExtensionsContribution);
227
+
228
+ const empty = [...await contribution.resolveSearchResults('', { verifiedOnly: false })];
229
+ const whitespace = [...await contribution.resolveSearchResults(' ', { verifiedOnly: false })];
230
+
231
+ expect(empty).to.be.empty;
232
+ expect(whitespace).to.be.empty;
233
+ });
234
+
235
+ it('returns all entries with searchableText covering name, serverId and description so the global fuzzy ranker can match any of them', async () => {
236
+ const prefs = new FakePreferenceService();
237
+ const fetch = new StubRegistryFetchService([exampleRegistryEntry, githubEntry]);
238
+ const contribution = buildContainer(prefs, fetch).get(MCPExtensionsContribution);
239
+
240
+ // The contribution intentionally does not filter by the query string: that's the
241
+ // view's job (`VSXExtensionsSource.collectSearchResults` runs `FuzzySearch.filter`
242
+ // across results from every contribution). The contribution's only responsibility
243
+ // is to supply candidates with rich `searchableText`.
244
+ const results = [...await contribution.resolveSearchResults('REPOSITORIES', { verifiedOnly: false })];
245
+
246
+ expect(results).to.have.length(2);
247
+ const githubResult = results.find(r => (r.element as MCPSearchResultEntry).entry.serverId === githubEntry.serverId)!;
248
+ expect(githubResult.searchableText).to.contain(githubEntry.name);
249
+ expect(githubResult.searchableText).to.contain(githubEntry.serverId);
250
+ expect(githubResult.searchableText).to.contain(githubEntry.description);
251
+ });
252
+
253
+ it('classifies a registry entry as installed-link-stale when the matching-key local is linked to a server id missing from the registry', async () => {
254
+ // PR scenario: user installed `example` from the registry, then manually pointed
255
+ // its `registryMetadata.serverId` at an id not in the registry. In Search results
256
+ // the matching registry entry must show the **Unlink / Uninstall** affordances -
257
+ // mirroring what the Installed view shows - instead of **Link** (which would
258
+ // imply the local is merely unlinked rather than stale-linked).
259
+ const prefs = new FakePreferenceService();
260
+ await prefs.set(MCP_SERVERS_PREF, {
261
+ example: {
262
+ command: 'npx',
263
+ args: ['-y', 'example-mcp'],
264
+ registryMetadata: {
265
+ serverId: 'io.example/gone',
266
+ version: '^1.0.0',
267
+ configHash: 'hash-v1'
268
+ }
269
+ }
270
+ });
271
+ const fetch = new StubRegistryFetchService([exampleRegistryEntry]);
272
+ const contribution = buildContainer(prefs, fetch).get(MCPExtensionsContribution);
273
+
274
+ const results = [...await contribution.resolveSearchResults('example', { verifiedOnly: false })];
275
+ const exampleResult = results.find(r => (r.element as MCPSearchResultEntry).entry.serverId === exampleRegistryEntry.serverId);
276
+
277
+ expect(exampleResult, 'registry entry must surface in search results').to.not.be.undefined;
278
+ expect((exampleResult!.element as MCPSearchResultEntry).state).to.deep.equal({ kind: 'installed-link-stale' });
279
+ });
280
+
281
+ it('omits unverified entries when verifiedOnly is true and keeps verified ones', async () => {
282
+ const unverified: ResolvedRegistryEntry = { ...exampleRegistryEntry, mcpRegistryVerified: false };
283
+ const prefs = new FakePreferenceService();
284
+ const fetch = new StubRegistryFetchService([unverified, githubEntry]);
285
+ const contribution = buildContainer(prefs, fetch).get(MCPExtensionsContribution);
286
+
287
+ const all = [...await contribution.resolveSearchResults('example', { verifiedOnly: false })];
288
+ const verifiedOnly = [...await contribution.resolveSearchResults('example', { verifiedOnly: true })];
289
+
290
+ expect(all).to.have.length(2);
291
+ expect(verifiedOnly).to.have.length(1);
292
+ expect((verifiedOnly[0].element as MCPSearchResultEntry).entry.serverId).to.equal(githubEntry.serverId);
293
+ });
294
+ });
@@ -0,0 +1,199 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
18
+ import { Disposable, DisposableCollection, Emitter, Event, nls, PreferenceChange, PreferenceService } from '@theia/core';
19
+ import { HoverService } from '@theia/core/lib/browser';
20
+ import { TreeElement } from '@theia/core/lib/browser/source-tree';
21
+ import { ExtensionsSourceContribution, SearchContext, SearchResult } from '@theia/vsx-registry/lib/browser/extensions-source-contribution';
22
+ import { MCP_SERVERS_PREF } from '@theia/ai-mcp/lib/common/mcp-preferences';
23
+ import { MCPServerDescription } from '@theia/ai-mcp/lib/common/mcp-server-manager';
24
+ import { MCPServersPreference, MCPServersPreferenceValue } from '@theia/ai-mcp/lib/common/mcp-servers-preference';
25
+ import { MCPServerInstallDialogFactory } from '@theia/ai-mcp/lib/browser/mcp-server-install-dialog';
26
+ import { RegistryFetchService } from '../../common/registry-fetch-service';
27
+ import { ResolvedRegistryEntry } from '../../common/mcp/mcp-registry-types';
28
+ import { MCPInstallService } from './mcp-install-service';
29
+ import { MCPEntryHandlers, MCPInstalledEntry, MCPSearchResultEntry } from './mcp-entries';
30
+
31
+ type StoredServer = MCPServersPreferenceValue;
32
+
33
+ @injectable()
34
+ export class MCPExtensionsContribution implements ExtensionsSourceContribution, Disposable {
35
+
36
+ readonly type = 'mcp-server';
37
+ readonly displayName = nls.localizeByDefault('MCP Servers');
38
+ readonly priority = 100;
39
+
40
+ @inject(PreferenceService)
41
+ protected readonly preferenceService: PreferenceService;
42
+
43
+ @inject(MCPInstallService)
44
+ protected readonly installService: MCPInstallService;
45
+
46
+ @inject(RegistryFetchService)
47
+ protected readonly fetchService: RegistryFetchService;
48
+
49
+ @inject(HoverService)
50
+ protected readonly hoverService: HoverService;
51
+
52
+ @inject(MCPServerInstallDialogFactory)
53
+ protected readonly installDialogFactory: MCPServerInstallDialogFactory;
54
+
55
+ protected readonly onDidChangeEmitter = new Emitter<void>();
56
+ readonly onDidChange: Event<void> = this.onDidChangeEmitter.event;
57
+
58
+ protected readonly toDispose = new DisposableCollection(this.onDidChangeEmitter);
59
+
60
+ protected handlers: MCPEntryHandlers;
61
+
62
+ @postConstruct()
63
+ protected init(): void {
64
+ this.handlers = {
65
+ install: entry => this.confirmAndInstall(entry),
66
+ uninstall: serverKey => this.installService.uninstall(serverKey),
67
+ unlink: serverKey => this.installService.unlink(serverKey),
68
+ update: entry => this.installService.update(entry),
69
+ link: entry => this.installService.link(entry),
70
+ fixConfig: entry => this.installService.fixConfig(entry)
71
+ };
72
+ this.toDispose.push(this.preferenceService.onPreferenceChanged((change: PreferenceChange) => {
73
+ if (change.preferenceName === MCP_SERVERS_PREF) {
74
+ this.onDidChangeEmitter.fire();
75
+ }
76
+ }));
77
+ this.toDispose.push(this.fetchService.onDidChange(() => this.onDidChangeEmitter.fire()));
78
+ }
79
+
80
+ dispose(): void {
81
+ this.toDispose.dispose();
82
+ }
83
+
84
+ async resolveInstalled(): Promise<Iterable<TreeElement>> {
85
+ const stored = this.readStoredServers();
86
+ const registryEntries = await this.safeGetRegistryEntries();
87
+ const byServerId = new Map(registryEntries.map(e => [e.serverId, e]));
88
+ const byName = new Map(registryEntries.map(e => [e.localName, e]));
89
+ const result: TreeElement[] = [];
90
+ for (const [name, config] of Object.entries(stored)) {
91
+ const local = this.toServerDescription(name, config);
92
+ if (!local) {
93
+ continue;
94
+ }
95
+ const state = this.installService.classifyLocalServer(local, registryEntries);
96
+ // Hand-added local servers belong to the MCP configuration widget, not this view.
97
+ // Stale-linked entries (registryMetadata.serverId set but registry no longer lists
98
+ // it) do belong here so we can surface the warning and offer Unlink / Uninstall.
99
+ if (state.kind === 'installed-user-added') {
100
+ continue;
101
+ }
102
+ const linkedId = local.registryMetadata?.serverId;
103
+ const matchedEntry = (linkedId && byServerId.get(linkedId)) || byName.get(local.name);
104
+ result.push(new MCPInstalledEntry(local, matchedEntry, state, this.handlers, this.hoverService));
105
+ }
106
+ return result;
107
+ }
108
+
109
+ async resolveSearchResults(query: string, context: SearchContext): Promise<Iterable<SearchResult>> {
110
+ if (!query.trim()) {
111
+ return [];
112
+ }
113
+ const registryEntries = await this.safeGetRegistryEntries();
114
+ const localDescriptions: MCPServerDescription[] = [];
115
+ for (const [name, cfg] of Object.entries(this.readStoredServers())) {
116
+ const local = this.toServerDescription(name, cfg);
117
+ if (local) {
118
+ localDescriptions.push(local);
119
+ }
120
+ }
121
+ const result: SearchResult[] = [];
122
+ for (const entry of registryEntries) {
123
+ // `verifiedOnly` comes from the OVSX-named `extensions.onlyShowVerifiedExtensions`
124
+ // preference. In this contribution "verified" maps to `mcpRegistryVerified`
125
+ // (i.e. approved in the AI registry), piggy-backing on the same toggle.
126
+ if (context.verifiedOnly && !entry.mcpRegistryVerified) {
127
+ continue;
128
+ }
129
+ // Hand all candidates to the view's global fuzzy ranker (`FuzzySearch.filter`
130
+ // in `VSXExtensionsSource.collectSearchResults`). It already discards entries
131
+ // that don't match the query, and a substring pre-filter would drop legitimate
132
+ // fuzzy matches such as "ChroDevTo" → "Chrome DevTools".
133
+ const searchableText = `${entry.name} ${entry.serverId} ${entry.description}`;
134
+ const state = this.installService.classifyRegistryEntry(entry, localDescriptions, registryEntries);
135
+ result.push({
136
+ element: new MCPSearchResultEntry(entry, state, this.handlers, this.hoverService),
137
+ searchableText
138
+ });
139
+ }
140
+ return result;
141
+ }
142
+
143
+ async refresh(): Promise<void> {
144
+ await this.fetchService.getEntries(true);
145
+ }
146
+
147
+ /**
148
+ * Returns the raw (untyped) servers preference; per-entry validation happens in
149
+ * `toServerDescription`, which uses the shared `MCPServersPreference.isValue` guard
150
+ * so this view and `McpFrontendApplicationContribution` apply the same value checks.
151
+ */
152
+ protected readStoredServers(): Record<string, unknown> {
153
+ return this.preferenceService.get<Record<string, unknown>>(MCP_SERVERS_PREF, {}) ?? {};
154
+ }
155
+
156
+ protected async safeGetRegistryEntries(): Promise<ResolvedRegistryEntry[]> {
157
+ try {
158
+ return await this.fetchService.getEntries();
159
+ } catch (error) {
160
+ // Without entries, locally-installed servers classify as user-added and the
161
+ // MCP section shows nothing; users can still manage servers from the AI
162
+ // configuration widget directly.
163
+ console.warn('AI registry fetch failed; MCP entries unavailable.', error);
164
+ return [];
165
+ }
166
+ }
167
+
168
+ protected toServerDescription(name: string, stored: unknown): MCPServerDescription | undefined {
169
+ // Reuse the same value guard the MCP frontend contribution applies to the
170
+ // preference, so a `command: 42` (or any non-string) entry is rejected here too
171
+ // instead of being cast straight to `MCPServerDescription`.
172
+ if (!MCPServersPreference.isValue(stored)) {
173
+ console.warn(`Ignoring malformed MCP server "${name}": value does not match the MCP servers preference schema.`);
174
+ return undefined;
175
+ }
176
+ return { name, ...(stored as StoredServer) } as MCPServerDescription;
177
+ }
178
+
179
+ /**
180
+ * Prompts the user for parameters the registry can't decide for them (autostart,
181
+ * auth token) before writing the entry. Cancelling the dialog aborts the install.
182
+ * The dialog is created through an injected factory so this contribution doesn't
183
+ * import the DOM-touching `ReactDialog` chain directly.
184
+ */
185
+ protected async confirmAndInstall(entry: ResolvedRegistryEntry): Promise<void> {
186
+ const dialog = this.installDialogFactory({
187
+ name: entry.localName,
188
+ autostart: true,
189
+ // The registry sets `serverAuthToken` to mark auth as part of the connection
190
+ // contract, even with no default value — so we check key presence, not value.
191
+ requireAuthToken: 'serverAuthToken' in entry.config
192
+ });
193
+ const result = await dialog.open();
194
+ if (!result) {
195
+ return;
196
+ }
197
+ await this.installService.install(entry, result);
198
+ }
199
+ }