Skip to content

Commit 5c28bf5

Browse files
authored
Merge pull request microsoft#249361 from microsoft/connor4312/mcp-resources-1
mcp: initial wiring for resources
2 parents 093e2b9 + e216d01 commit 5c28bf5

File tree

11 files changed

+847
-102
lines changed

11 files changed

+847
-102
lines changed

src/vs/base/common/iterator.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,14 @@ export namespace Iterable {
171171
for await (const item of iterable) {
172172
result.push(item);
173173
}
174-
return Promise.resolve(result);
174+
return result;
175+
}
176+
177+
export async function asyncToArrayFlat<T>(iterable: AsyncIterable<T[]>): Promise<T[]> {
178+
let result: T[] = [];
179+
for await (const item of iterable) {
180+
result = result.concat(item);
181+
}
182+
return result;
175183
}
176184
}

src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { IEditorService } from '../../../../services/editor/common/editorService
4040
import { IHistoryService } from '../../../../services/history/common/history.js';
4141
import { LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js';
4242
import { ISearchService } from '../../../../services/search/common/search.js';
43-
import { IMcpPrompt, IMcpPromptMessage, IMcpService } from '../../../mcp/common/mcpTypes.js';
43+
import { IMcpPrompt, IMcpPromptMessage, IMcpServer, IMcpService, McpResourceURI } from '../../../mcp/common/mcpTypes.js';
4444
import { searchFilesAndFolders } from '../../../search/browser/chatContributions.js';
4545
import { IChatAgentData, IChatAgentNameService, IChatAgentService, getFullyQualifiedId } from '../../common/chatAgents.js';
4646
import { IChatEditingService } from '../../common/chatEditingService.js';
@@ -229,7 +229,7 @@ class SlashCommandCompletions extends Disposable {
229229
command: {
230230
id: StartParameterizedPromptAction.ID,
231231
title: prompt.name,
232-
arguments: [model, prompt, `${label} `],
232+
arguments: [model, server, prompt, `${label} `],
233233
},
234234
insertText: `${label} `,
235235
range,
@@ -539,7 +539,7 @@ class StartParameterizedPromptAction extends Action2 {
539539
});
540540
}
541541

542-
async run(accessor: ServicesAccessor, model: ITextModel, prompt: IMcpPrompt, textToReplace: string) {
542+
async run(accessor: ServicesAccessor, model: ITextModel, server: IMcpServer, prompt: IMcpPrompt, textToReplace: string) {
543543
if (!model || !prompt) {
544544
return;
545545
}
@@ -614,11 +614,14 @@ class StartParameterizedPromptAction extends Action2 {
614614
const toAttach: IChatRequestVariableEntry[] = [];
615615
const attachBlob = async (mimeType: string | undefined, contents: string, uriStr?: string, isText = false) => {
616616
let validURI: URI | undefined;
617-
try {
618-
const uri = uriStr && URI.parse(uriStr);
619-
validURI = uri && await fileService.exists(uri) ? uri : undefined;
620-
} catch {
621-
// ignored
617+
if (uriStr) {
618+
for (const uri of [URI.parse(uriStr), McpResourceURI.fromServer(server.definition, uriStr)]) {
619+
try {
620+
validURI ||= await fileService.exists(uri) ? uri : undefined;
621+
} catch {
622+
// ignored
623+
}
624+
}
622625
}
623626

624627
if (isText) {
@@ -632,14 +635,12 @@ class StartParameterizedPromptAction extends Action2 {
632635
} else {
633636
toAttach.push({
634637
id: generateUuid(),
635-
kind: 'generic', // TODO: once we support proper MCP resources, use that
638+
kind: 'generic',
636639
value: contents,
637640
name: localize('mcp.prompt.resource', 'Prompt Resource'),
638641
});
639642
}
640-
}
641-
642-
if (mimeType && getAttachableImageExtension(mimeType)) {
643+
} else if (mimeType && getAttachableImageExtension(mimeType)) {
643644
chatWidget.attachmentModel.addContext({
644645
id: generateUuid(),
645646
name: localize('mcp.prompt.image', 'Prompt Image'),
@@ -656,8 +657,7 @@ class StartParameterizedPromptAction extends Action2 {
656657
name: basename(validURI),
657658
});
658659
} else {
659-
// todo: generic binary data attachment?
660-
// or just reference the MCP resource once we support them
660+
// not a valid resource/resource URI
661661
}
662662
};
663663

src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { McpContextKeysController } from '../common/mcpContextKeys.js';
2828
import { IMcpDevModeDebugging, McpDevModeDebugging } from '../common/mcpDevMode.js';
2929
import { McpRegistry } from '../common/mcpRegistry.js';
3030
import { IMcpRegistry } from '../common/mcpRegistryTypes.js';
31+
import { McpResourceFilesystem } from '../common/mcpResourceFilesystem.js';
3132
import { McpService } from '../common/mcpService.js';
3233
import { HasInstalledMcpServersContext, IMcpService, IMcpWorkbenchService, InstalledMcpServersViewId, McpServersGalleryEnabledContext } from '../common/mcpTypes.js';
3334
import { AddConfigurationAction, EditStoredInput, InstallFromActivation, ListMcpServerCommand, McpBrowseCommand, MCPServerActionRendering, McpServerOptionsCommand, RemoveStoredInput, ResetMcpCachedTools, ResetMcpTrustCommand, RestartServer, ShowConfiguration, ShowOutput, StartServer, StopServer } from './mcpCommands.js';
@@ -45,7 +46,6 @@ registerSingleton(IMcpWorkbenchService, McpWorkbenchService, InstantiationType.E
4546
registerSingleton(IMcpConfigPathsService, McpConfigPathsService, InstantiationType.Delayed);
4647
registerSingleton(IMcpDevModeDebugging, McpDevModeDebugging, InstantiationType.Delayed);
4748

48-
4949
mcpDiscoveryRegistry.register(new SyncDescriptor(RemoteNativeMpcDiscovery));
5050
mcpDiscoveryRegistry.register(new SyncDescriptor(ConfigMcpDiscovery));
5151
mcpDiscoveryRegistry.register(new SyncDescriptor(ExtensionMcpDiscovery));
@@ -55,6 +55,7 @@ registerWorkbenchContribution2('mcpDiscovery', McpDiscovery, WorkbenchPhase.Afte
5555
registerWorkbenchContribution2('mcpContextKeys', McpContextKeysController, WorkbenchPhase.BlockRestore);
5656
registerWorkbenchContribution2('mcpLanguageFeatures', McpLanguageFeatures, WorkbenchPhase.Eventually);
5757
registerWorkbenchContribution2('mcpUrlHandler', McpUrlHandler, WorkbenchPhase.BlockRestore);
58+
registerWorkbenchContribution2('mcpResourceFilesystem', McpResourceFilesystem, WorkbenchPhase.BlockRestore);
5859

5960
registerAction2(ListMcpServerCommand);
6061
registerAction2(McpServerOptionsCommand);
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { sumBy } from '../../../../base/common/arrays.js';
7+
import { decodeBase64, VSBuffer } from '../../../../base/common/buffer.js';
8+
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
9+
import { Emitter, Event } from '../../../../base/common/event.js';
10+
import { Lazy } from '../../../../base/common/lazy.js';
11+
import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
12+
import { autorun } from '../../../../base/common/observable.js';
13+
import { newWriteableStream, ReadableStreamEvents } from '../../../../base/common/stream.js';
14+
import { equalsIgnoreCase } from '../../../../base/common/strings.js';
15+
import { URI } from '../../../../base/common/uri.js';
16+
import { createFileSystemProviderError, FileChangeType, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileChange, IFileDeleteOptions, IFileOverwriteOptions, IFileReadStreamOptions, IFileService, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileWriteOptions, IStat, IWatchOptions } from '../../../../platform/files/common/files.js';
17+
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
18+
import { IWorkbenchContribution } from '../../../common/contributions.js';
19+
import { McpServer } from './mcpServer.js';
20+
import { McpServerRequestHandler } from './mcpServerRequestHandler.js';
21+
import { IMcpService, McpResourceURI } from './mcpTypes.js';
22+
import { MCP } from './modelContextProtocol.js';
23+
24+
export class McpResourceFilesystem extends Disposable implements IWorkbenchContribution,
25+
IFileSystemProviderWithFileReadWriteCapability,
26+
IFileSystemProviderWithFileAtomicReadCapability,
27+
IFileSystemProviderWithFileReadStreamCapability {
28+
/** Defer getting the MCP service since this is a BlockRestore and no need to make it unnecessarily. */
29+
private readonly _mcpServiceLazy = new Lazy(() => this._instantiationService.invokeFunction(a => a.get(IMcpService)));
30+
31+
private get _mcpService() {
32+
return this._mcpServiceLazy.value;
33+
}
34+
35+
public readonly onDidChangeCapabilities = Event.None;
36+
37+
private readonly _onDidChangeFile = this._register(new Emitter<readonly IFileChange[]>());
38+
public readonly onDidChangeFile = this._onDidChangeFile.event;
39+
40+
public readonly capabilities: FileSystemProviderCapabilities = FileSystemProviderCapabilities.None
41+
| FileSystemProviderCapabilities.Readonly
42+
| FileSystemProviderCapabilities.PathCaseSensitive
43+
| FileSystemProviderCapabilities.FileReadStream
44+
| FileSystemProviderCapabilities.FileAtomicRead;
45+
46+
constructor(
47+
@IInstantiationService private readonly _instantiationService: IInstantiationService,
48+
@IFileService private readonly _fileService: IFileService,
49+
) {
50+
super();
51+
this._register(this._fileService.registerProvider(McpResourceURI.scheme, this));
52+
}
53+
54+
//#region Filesystem API
55+
56+
public async readFile(resource: URI): Promise<Uint8Array> {
57+
return this._readFile(resource);
58+
}
59+
60+
public readFileStream(resource: URI, opts: IFileReadStreamOptions, token: CancellationToken): ReadableStreamEvents<Uint8Array> {
61+
const stream = newWriteableStream<Uint8Array>(data => VSBuffer.concat(data.map(data => VSBuffer.wrap(data))).buffer);
62+
63+
this._readFile(resource, token).then(
64+
data => {
65+
if (opts.position) {
66+
data = data.slice(opts.position);
67+
}
68+
69+
if (opts.length) {
70+
data = data.slice(0, opts.length);
71+
}
72+
73+
stream.end(data);
74+
},
75+
err => stream.error(err),
76+
);
77+
78+
return stream;
79+
}
80+
81+
public watch(uri: URI, _opts: IWatchOptions): IDisposable {
82+
const { resourceURI, server } = this._decodeURI(uri);
83+
84+
server.start();
85+
86+
const store = new DisposableStore();
87+
let watchedOnHandler: McpServerRequestHandler | undefined;
88+
const watchListener = store.add(new MutableDisposable());
89+
const callCts = store.add(new MutableDisposable<CancellationTokenSource>());
90+
store.add(autorun(reader => {
91+
const connection = server.connection.read(reader);
92+
if (!connection) {
93+
return;
94+
}
95+
96+
const handler = connection.handler.read(reader);
97+
if (!handler || watchedOnHandler === handler) {
98+
return;
99+
}
100+
101+
callCts.value?.dispose(true);
102+
callCts.value = new CancellationTokenSource();
103+
watchedOnHandler = handler;
104+
105+
const token = callCts.value.token;
106+
handler.subscribe({ uri: resourceURI.toString(true) }, token).then(
107+
() => {
108+
if (!token.isCancellationRequested) {
109+
watchListener.value = handler.onDidUpdateResource(e => {
110+
if (equalsUriPath(e.params.uri, resourceURI)) {
111+
this._onDidChangeFile.fire([{ resource: uri, type: FileChangeType.UPDATED }]);
112+
}
113+
});
114+
}
115+
}, err => {
116+
handler.logger.warn(`Failed to subscribe to resource changes for ${resourceURI}: ${err}`);
117+
watchedOnHandler = undefined;
118+
},
119+
);
120+
}));
121+
122+
return store;
123+
}
124+
125+
public async stat(resource: URI): Promise<IStat> {
126+
const { forSameURI, contents } = await this._readURI(resource);
127+
if (!contents.length) {
128+
throw createFileSystemProviderError(`File not found`, FileSystemProviderErrorCode.FileNotFound);
129+
}
130+
131+
return {
132+
ctime: 0,
133+
mtime: 0,
134+
size: sumBy(contents, c => contentToBuffer(c).byteLength),
135+
type: forSameURI.length ? FileType.File : FileType.Directory,
136+
};
137+
}
138+
139+
public async readdir(resource: URI): Promise<[string, FileType][]> {
140+
const { forSameURI, contents, resourceURI } = await this._readURI(resource);
141+
if (forSameURI.length > 0) {
142+
throw createFileSystemProviderError(`File is not a directory`, FileSystemProviderErrorCode.FileNotADirectory);
143+
}
144+
145+
const resourcePathParts = resourceURI.path.split('/');
146+
147+
const output = new Map<string, FileType>();
148+
for (const content of contents) {
149+
const contentURI = URI.parse(content.uri);
150+
const contentPathParts = contentURI.path.split('/');
151+
152+
// Skip contents that are not in the same directory
153+
if (contentPathParts.length <= resourcePathParts.length || !resourcePathParts.every((part, index) => equalsIgnoreCase(part, contentPathParts[index]))) {
154+
continue;
155+
}
156+
157+
// nested resource in a directory, just emit a directory to output
158+
else if (contentPathParts.length > resourcePathParts.length + 1) {
159+
output.set(contentPathParts[resourcePathParts.length], FileType.Directory);
160+
}
161+
162+
else {
163+
// resource in the same directory, emit the file
164+
const name = contentPathParts[contentPathParts.length - 1];
165+
output.set(name, contentToBuffer(content).byteLength > 0 ? FileType.File : FileType.Directory);
166+
}
167+
}
168+
169+
return [...output];
170+
}
171+
172+
public mkdir(resource: URI): Promise<void> {
173+
throw createFileSystemProviderError('write is not supported', FileSystemProviderErrorCode.NoPermissions);
174+
}
175+
public writeFile(resource: URI, content: Uint8Array, opts: IFileWriteOptions): Promise<void> {
176+
throw createFileSystemProviderError('write is not supported', FileSystemProviderErrorCode.NoPermissions);
177+
}
178+
public delete(resource: URI, opts: IFileDeleteOptions): Promise<void> {
179+
throw createFileSystemProviderError('delete is not supported', FileSystemProviderErrorCode.NoPermissions);
180+
}
181+
public rename(from: URI, to: URI, opts: IFileOverwriteOptions): Promise<void> {
182+
throw createFileSystemProviderError('rename is not supported', FileSystemProviderErrorCode.NoPermissions);
183+
}
184+
185+
//#endregion
186+
187+
private async _readFile(resource: URI, token?: CancellationToken): Promise<Uint8Array> {
188+
const { forSameURI, contents } = await this._readURI(resource);
189+
190+
// MCP does not distinguish between files and directories, and says that
191+
// servers should just return multiple when 'reading' a directory.
192+
if (!forSameURI.length) {
193+
if (!contents.length) {
194+
throw createFileSystemProviderError(`File not found`, FileSystemProviderErrorCode.FileNotFound);
195+
} else {
196+
throw createFileSystemProviderError(`File is a directory`, FileSystemProviderErrorCode.FileIsADirectory);
197+
}
198+
}
199+
200+
return contentToBuffer(forSameURI[0]);
201+
}
202+
203+
private _decodeURI(uri: URI) {
204+
let definitionId: string;
205+
let resourceURI: URI;
206+
try {
207+
({ definitionId, resourceURI } = McpResourceURI.toServer(uri));
208+
} catch (e) {
209+
throw createFileSystemProviderError(String(e), FileSystemProviderErrorCode.FileNotFound);
210+
}
211+
212+
if (resourceURI.path.endsWith('/')) {
213+
resourceURI = resourceURI.with({ path: resourceURI.path.slice(0, -1) });
214+
}
215+
216+
const server = this._mcpService.servers.get().find(s => s.definition.id === definitionId);
217+
if (!server) {
218+
throw createFileSystemProviderError(`MCP server with definition ID ${definitionId} not found`, FileSystemProviderErrorCode.FileNotFound);
219+
}
220+
221+
return { definitionId, resourceURI, server };
222+
}
223+
224+
private async _readURI(uri: URI, token?: CancellationToken) {
225+
const { resourceURI, server } = this._decodeURI(uri);
226+
const res = await McpServer.callOn(server, r => r.readResource({ uri: resourceURI.toString(true) }, token), token);
227+
228+
return {
229+
contents: res.contents,
230+
resourceURI,
231+
forSameURI: res.contents.filter(c => equalsUriPath(c.uri, resourceURI)),
232+
};
233+
}
234+
}
235+
236+
function equalsUriPath(a: string, b: URI): boolean {
237+
// MCP doesn't specify either way, but underlying systems may can be case-sensitive.
238+
// It's better to treat case-sensitive paths as case-insensitive than vise-versa.
239+
return equalsIgnoreCase(URI.parse(a).path, b.path);
240+
}
241+
242+
function contentToBuffer(content: MCP.TextResourceContents | MCP.BlobResourceContents): Uint8Array {
243+
if ('text' in content) {
244+
return VSBuffer.fromString(content.text).buffer;
245+
} else if ('blob' in content) {
246+
return decodeBase64(content.blob).buffer;
247+
} else {
248+
throw createFileSystemProviderError('Unknown content type', FileSystemProviderErrorCode.Unknown);
249+
}
250+
}

0 commit comments

Comments
 (0)