mcp: support sse (#243621)

* mcp: support sse

Didn't seem like Claude Desktop configs have SSE support yet, but I did
the obvious of having an object with a `url`. I also added a `type`
(optional for stdio) so we can better disambiguate types of configs.

Example .vscode/mcp.json:

```
{
  "servers": {
    "everything": {
      "type": "sse",
      "url": "http://localhost:3001/sse"
    }
	}
}
```

Closes #243242

* update layer check
This commit is contained in:
Connor Peet
2025-03-14 23:32:00 -07:00
committed by GitHub
parent 580daf039a
commit 248739282e
19 changed files with 269 additions and 56 deletions
+1
View File
@@ -83,6 +83,7 @@ const CORE_TYPES = [
'Crypto',
'SubtleCrypto',
'JsonWebKey',
'MessageEvent',
];
// Types that are defined in a common layer but are known to be only
// available in native environments should not be allowed in browser
+1
View File
@@ -82,6 +82,7 @@ const CORE_TYPES = [
'Crypto',
'SubtleCrypto',
'JsonWebKey',
'MessageEvent',
];
// Types that are defined in a common layer but are known to be only
-3
View File
@@ -8,9 +8,6 @@
"name": "code-oss-dev-build",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"tree-sitter-typescript": "0.23.2"
},
"devDependencies": {
"@azure/core-auth": "^1.9.0",
"@azure/cosmos": "^3",
+1
View File
@@ -997,6 +997,7 @@ export default tseslint.config(
{
'target': 'src/vs/workbench/api/~',
'restrictions': [
'@c4312/eventsource-umd',
'vscode',
'vs/base/~',
'vs/base/parts/*/~',
+22
View File
@@ -10,6 +10,7 @@
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@c4312/eventsource-umd": "^3.0.5",
"@microsoft/1ds-core-js": "^3.2.13",
"@microsoft/1ds-post-js": "^3.2.13",
"@parcel/watcher": "2.5.1",
@@ -941,6 +942,18 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"node_modules/@c4312/eventsource-umd": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@c4312/eventsource-umd/-/eventsource-umd-3.0.5.tgz",
"integrity": "sha512-0QhLg51eFB+SS/a4Pv5tHaRSnjJBpdFsjT3WN/Vfh6qzeFXqvaE+evVIIToYvr2lRBLg1NIB635ip8ML+/84Sg==",
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -6687,6 +6700,15 @@
"node": ">=0.8.x"
}
},
"node_modules/eventsource-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz",
"integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/expand-brackets": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz",
+1
View File
@@ -69,6 +69,7 @@
"update-build-ts-version": "npm install typescript@next && tsc -p ./build/tsconfig.build.json"
},
"dependencies": {
"@c4312/eventsource-umd": "^3.0.5",
"@microsoft/1ds-core-js": "^3.2.13",
"@microsoft/1ds-post-js": "^3.2.13",
"@parcel/watcher": "2.5.1",
@@ -5,9 +5,9 @@
import { IConfigurationService } from '../../configuration/common/configuration.js';
import { ILogger } from '../../log/common/log.js';
import { IMcpConfiguration, IMcpConfigurationServer } from './mcpPlatformTypes.js';
import { IMcpConfiguration, IMcpConfigurationSSE, IMcpConfigurationStdio } from './mcpPlatformTypes.js';
type ValidatedConfig = { name: string; config: IMcpConfigurationServer };
type ValidatedConfig = { name: string; config: IMcpConfigurationStdio | IMcpConfigurationSSE };
export class McpManagementCli {
constructor(
@@ -35,7 +35,7 @@ export class McpManagementCli {
}
private validateConfiguration(config: string): ValidatedConfig {
let parsed: IMcpConfigurationServer & { name: string };
let parsed: (IMcpConfigurationStdio | IMcpConfigurationSSE) & { name: string };
try {
parsed = JSON.parse(config);
} catch (e) {
@@ -46,12 +46,12 @@ export class McpManagementCli {
throw new InvalidMcpOperationError(`Missing name property in ${config}`);
}
if (!parsed.command) {
throw new InvalidMcpOperationError(`Missing command property in ${config}`);
if (!('command' in parsed) && !('url' in parsed)) {
throw new InvalidMcpOperationError(`Missing command or URL property in ${config}`);
}
const { name, ...rest } = parsed;
return { name, config: rest };
return { name, config: rest as IMcpConfigurationStdio | IMcpConfigurationSSE };
}
}
+10 -3
View File
@@ -6,12 +6,19 @@
export interface IMcpConfiguration {
inputs: unknown[];
/** @deprecated Only for rough cross-compat with other formats */
mcpServers?: Record<string, IMcpConfigurationServer>;
servers: Record<string, IMcpConfigurationServer>;
mcpServers?: Record<string, IMcpConfigurationStdio>;
servers: Record<string, IMcpConfigurationStdio | IMcpConfigurationSSE>;
}
export interface IMcpConfigurationServer {
export interface IMcpConfigurationStdio {
type?: 'stdio';
command: string;
args?: readonly string[];
env?: Record<string, string | number | null>;
}
export interface IMcpConfigurationSSE {
type: 'sse';
url: string;
headers?: Record<string, string>;
}
@@ -2970,7 +2970,7 @@ export interface ExtHostTestingShape {
}
export interface ExtHostMcpShape {
$startMcp(id: number, launch: McpServerLaunch): void;
$startMcp(id: number, launch: McpServerLaunch.Serialized): void;
$stopMcp(id: number): void;
$sendMessage(id: number, message: string): void;
$waitForInitialCollectionProviders(): Promise<void>;
+134 -9
View File
@@ -3,13 +3,17 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type * as ES from '@c4312/eventsource-umd';
import * as vscode from 'vscode';
import { importAMDNodeModule } from '../../../amdX.js';
import { DeferredPromise, Sequencer } from '../../../base/common/async.js';
import { CancellationToken } from '../../../base/common/cancellation.js';
import { DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { Lazy } from '../../../base/common/lazy.js';
import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { ExtensionIdentifier, IExtensionDescription } from '../../../platform/extensions/common/extensions.js';
import { createDecorator } from '../../../platform/instantiation/common/instantiation.js';
import { StorageScope } from '../../../platform/storage/common/storage.js';
import { extensionPrefixedIdentifier, McpCollectionDefinition, McpServerDefinition, McpServerLaunch, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js';
import { extensionPrefixedIdentifier, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportSSE, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js';
import { ExtHostMcpShape, MainContext, MainThreadMcpShape } from './extHost.protocol.js';
import { IExtHostRpcService } from './extHostRpcService.js';
@@ -19,34 +23,52 @@ export interface IExtHostMpcService extends ExtHostMcpShape {
registerMcpConfigurationProvider(extension: IExtensionDescription, id: string, provider: vscode.McpConfigurationProvider): IDisposable;
}
export class ExtHostMcpService implements IExtHostMpcService {
export class ExtHostMcpService extends Disposable implements IExtHostMpcService {
protected _proxy: MainThreadMcpShape;
private readonly _initialProviderPromises = new Set<Promise<void>>();
private readonly _sseEventSources = this._register(new DisposableMap<number, McpSSEHandle>());
private readonly _eventSource = new Lazy(async () => {
const es = await importAMDNodeModule<typeof ES>('@c4312/eventsource-umd', 'dist/index.umd.js');
return es.EventSource;
});
constructor(
@IExtHostRpcService extHostRpc: IExtHostRpcService,
) {
super();
this._proxy = extHostRpc.getProxy(MainContext.MainThreadMcp);
}
$startMcp(id: number, launch: McpServerLaunch): void {
// todo: SSE launches can be implemented in this common layer
$startMcp(id: number, launch: McpServerLaunch.Serialized): void {
this._startMcp(id, McpServerLaunch.fromSerialized(launch));
}
protected _startMcp(id: number, launch: McpServerLaunch): void {
if (launch.type === McpServerTransportType.SSE) {
this._sseEventSources.set(id, new McpSSEHandle(this._eventSource.value, id, launch, this._proxy));
return;
}
throw new Error('not implemented');
}
$stopMcp(id: number): void {
// no-op
if (this._sseEventSources.has(id)) {
this._sseEventSources.deleteAndDispose(id);
this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Stopped });
}
}
$sendMessage(id: number, message: string): void {
// no-op
this._sseEventSources.get(id)?.send(message);
}
async $waitForInitialCollectionProviders(): Promise<void> {
await Promise.all(this._initialProviderPromises);
}
registerMcpConfigurationProvider(extension: IExtensionDescription, id: string, provider: vscode.McpConfigurationProvider): IDisposable {
/** {@link vscode.lm.registerMcpConfigurationProvider} */
public registerMcpConfigurationProvider(extension: IExtensionDescription, id: string, provider: vscode.McpConfigurationProvider): IDisposable {
const store = new DisposableStore();
const metadata = extension.contributes?.modelContextServerCollections?.find(m => m.id === id);
@@ -78,7 +100,8 @@ export class ExtHostMcpService implements IExtHostMpcService {
launch: isSSEConfig(item)
? {
type: McpServerTransportType.SSE,
uri: item.uri
uri: item.uri,
headers: item.headers,
}
: {
type: McpServerTransportType.Stdio,
@@ -113,3 +136,105 @@ export class ExtHostMcpService implements IExtHostMpcService {
return store;
}
}
class McpSSEHandle extends Disposable {
private readonly _requestSequencer = new Sequencer();
private readonly _postEndpoint = new DeferredPromise<string>();
constructor(
eventSourceCtor: Promise<typeof ES.EventSource>,
private readonly _id: number,
launch: McpServerTransportSSE,
private readonly _proxy: MainThreadMcpShape
) {
super();
eventSourceCtor.then(EventSourceCtor => this._attach(EventSourceCtor, launch));
}
private _attach(EventSourceCtor: typeof ES.EventSource, launch: McpServerTransportSSE) {
if (this._store.isDisposed) {
return;
}
const eventSource = new EventSourceCtor(launch.uri.toString(), {
// recommended way to do things https://github.com/EventSource/eventsource?tab=readme-ov-file#setting-http-request-headers
fetch: (input, init) =>
fetch(input, {
...init,
headers: {
...Object.fromEntries(launch.headers),
...init?.headers,
},
}).then(async res => {
// we get more details on failure at this point, so handle it explicitly:
if (res.status >= 300) {
this._proxy.$onDidChangeState(this._id, { state: McpConnectionState.Kind.Error, message: `${res.status} status connecting to ${launch.uri}: ${await this._getErrText(res)}` });
eventSource.close();
}
return res;
}, err => {
this._proxy.$onDidChangeState(this._id, { state: McpConnectionState.Kind.Error, message: `Error connecting to ${launch.uri}: ${String(err)}` });
eventSource.close();
return Promise.reject(err);
})
});
this._register(toDisposable(() => eventSource.close()));
// https://github.com/modelcontextprotocol/typescript-sdk/blob/0fa2397174eba309b54575294d56754c52b13a65/src/server/sse.ts#L52
eventSource.addEventListener('endpoint', e => {
this._postEndpoint.complete(new URL(e.data, launch.uri.toString()).toString());
});
// https://github.com/modelcontextprotocol/typescript-sdk/blob/0fa2397174eba309b54575294d56754c52b13a65/src/server/sse.ts#L133
eventSource.addEventListener('message', e => {
this._proxy.$onDidReceiveMessage(this._id, e.data);
});
eventSource.addEventListener('open', () => {
this._proxy.$onDidChangeState(this._id, { state: McpConnectionState.Kind.Running });
});
eventSource.addEventListener('error', (err) => {
this._postEndpoint.cancel();
this._proxy.$onDidChangeState(this._id, {
state: McpConnectionState.Kind.Error,
message: `Error connecting to ${launch.uri}: ${err.code || 0} ${err.message || JSON.stringify(err)}`,
});
eventSource.close();
});
}
async send(message: string) {
// only the sending of the request needs to be sequenced
try {
const res = await this._requestSequencer.queue(async () => {
const endpoint = await this._postEndpoint.p;
const asBytes = new TextEncoder().encode(message);
return fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': String(asBytes.length),
},
body: asBytes,
});
});
if (res.status >= 300) {
this._proxy.$onDidPublishLog(this._id, `${res.status} status sending message to ${this._postEndpoint}: ${await this._getErrText(res)}`);
}
} catch (err) {
// ignored
}
}
private async _getErrText(res: Response) {
try {
return await res.text();
} catch {
return res.statusText;
}
}
}
@@ -5053,6 +5053,7 @@ export class McpStdioServerDefinition implements vscode.McpStdioServerDefinition
}
export class McpSSEServerDefinition implements vscode.McpSSEServerDefinition {
headers: [string, string][] = [];
constructor(
public label: string,
public uri: URI
+2 -2
View File
@@ -25,11 +25,11 @@ export class NodeExtHostMpcService extends ExtHostMcpService {
child: ChildProcessWithoutNullStreams;
}>();
override $startMcp(id: number, launch: McpServerLaunch): void {
protected override _startMcp(id: number, launch: McpServerLaunch): void {
if (launch.type === McpServerTransportType.Stdio) {
this.startNodeMpc(id, launch);
} else {
super.$startMcp(id, launch);
super._startMcp(id, launch);
}
}
@@ -43,7 +43,7 @@ export class ListMcpServerCommand extends Action2 {
f1: true,
menu: {
when: ContextKeyExpr.and(
McpContextKeys.hasUnknownTools,
ContextKeyExpr.or(McpContextKeys.hasUnknownTools, McpContextKeys.hasServersWithErrors),
ChatContextKeys.chatMode.isEqualTo(ChatMode.Agent)
),
id: MenuId.ChatInputAttachmentToolbar,
@@ -214,7 +214,7 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo
case McpServerToolsState.RefreshingFromUnknown:
thisState = DisplayedState.Refreshing;
break;
case McpServerToolsState.Cached:
default:
thisState = server.connectionState.read(reader).state === McpConnectionState.Kind.Error ? DisplayedState.Error : DisplayedState.None;
break;
}
@@ -7,6 +7,7 @@ import { equals as arrayEquals } from '../../../../../base/common/arrays.js';
import { Disposable, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../../base/common/network.js';
import { ISettableObservable, observableValue } from '../../../../../base/common/observable.js';
import { URI } from '../../../../../base/common/uri.js';
import { localize } from '../../../../../nls.js';
import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { ILabelService } from '../../../../../platform/label/common/label.js';
@@ -102,7 +103,11 @@ export class ConfigMcpDiscovery extends Disposable implements IMcpDiscovery {
const nextDefinitions = Object.entries(value?.servers || {}).map(([name, value]): McpServerDefinition => ({
id: `${collectionId}.${name}`,
label: name,
launch: {
launch: 'type' in value && value.type === 'sse' ? {
type: McpServerTransportType.SSE,
uri: URI.parse(value.url),
headers: Object.entries(value.headers || {}),
} : {
type: McpServerTransportType.Stdio,
args: value.args || [],
command: value.command,
@@ -10,7 +10,7 @@ import { mcpSchemaId } from '../../../services/configuration/common/configuratio
import { inputsSchema } from '../../../services/configurationResolver/common/configurationResolverSchema.js';
import { IExtensionPointDescriptor } from '../../../services/extensions/common/extensionsRegistry.js';
export type { IMcpConfigurationServer, IMcpConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js';
export type { IMcpConfigurationStdio, IMcpConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js';
const mcpActivationEventPrefix = 'onMcpCollection:';
@@ -44,32 +44,64 @@ export const mcpServerSchema: IJSONSchema = {
servers: {
examples: [mcpSchemaExampleServers],
additionalProperties: {
type: 'object',
additionalProperties: false,
examples: [mcpSchemaExampleServer],
properties: {
command: {
type: 'string',
description: localize('app.mcp.json.command', "The command to run the server.")
},
args: {
type: 'array',
description: localize('app.mcp.args.command', "Arguments passed to the server."),
items: {
type: 'string'
oneOf: [{
type: 'object',
additionalProperties: false,
examples: [mcpSchemaExampleServer],
properties: {
type: {
type: 'string',
enum: ['stdio'],
description: localize('app.mcp.json.type', "The type of the server.")
},
},
env: {
description: localize('app.mcp.env.command', "Environment variables passed to the server."),
additionalProperties: {
anyOf: [
{ type: 'null' },
{ type: 'string' },
{ type: 'number' },
]
}
},
}
command: {
type: 'string',
description: localize('app.mcp.json.command', "The command to run the server.")
},
args: {
type: 'array',
description: localize('app.mcp.args.command', "Arguments passed to the server."),
items: {
type: 'string'
},
},
env: {
description: localize('app.mcp.env.command', "Environment variables passed to the server."),
additionalProperties: {
anyOf: [
{ type: 'null' },
{ type: 'string' },
{ type: 'number' },
]
}
},
}
}, {
type: 'object',
additionalProperties: false,
required: ['url', 'type'],
examples: [{
type: 'sse',
url: 'http://localhost:3001',
headers: {},
}],
properties: {
type: {
type: 'string',
enum: ['sse'],
description: localize('app.mcp.json.type', "The type of the server.")
},
url: {
type: 'string',
format: 'uri',
description: localize('app.mcp.json.url', "The URL of the server-sent-event (SSE) server.")
},
env: {
description: localize('app.mcp.json.headers', "Additional headers sent to the server."),
additionalProperties: { type: 'string' },
},
}
}]
}
},
inputs: inputsSchema.definitions!.inputs
@@ -8,14 +8,23 @@ import { Disposable } from '../../../../base/common/lifecycle.js';
import { autorun } from '../../../../base/common/observable.js';
import { localize } from '../../../../nls.js';
import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js';
import { IWorkbenchContribution } from '../../../common/contributions.js';
import { LazyCollectionState, IMcpService, McpServerToolsState } from './mcpTypes.js';
import { LazyCollectionState, IMcpService, McpServerToolsState, McpConnectionState } from './mcpTypes.js';
export namespace McpContextKeys {
export const serverCount = new RawContextKey<number>('mcp.serverCount', undefined, { type: 'number', description: localize('mcp.serverCount.description', "Context key that has the number of registered MCP servers") });
export const hasUnknownTools = new RawContextKey<boolean>('mcp.hasUnknownTools', undefined, { type: 'boolean', description: localize('mcp.hasUnknownTools.description', "Indicates whether there are MCP servers with unknown tools.") });
/**
* A context key that indicates whether there are any servers with errors.
*
* @type {boolean}
* @default undefined
* @description This key is used to track the presence of servers with errors in the MCP context.
*/
export const hasServersWithErrors = new RawContextKey<boolean>('mcp.hasServersWithErrors', undefined, { type: 'boolean', description: localize('mcp.hasServersWithErrors.description', "Indicates whether there are any MCP servers with errors.") });
export const toolsCount = new RawContextKey<number>('mcp.toolsCount', undefined, { type: 'number', description: localize('mcp.toolsCount.description', "Context key that has the number of registered MCP tools") });
}
@@ -34,6 +43,8 @@ export class McpContextKeysController extends Disposable implements IWorkbenchCo
const ctxToolsCount = McpContextKeys.toolsCount.bindTo(contextKeyService);
const ctxHasUnknownTools = McpContextKeys.hasUnknownTools.bindTo(contextKeyService);
this._store.add(bindContextKey(McpContextKeys.hasServersWithErrors, contextKeyService, r => mcpService.servers.read(r).some(c => c.connectionState.read(r).state === McpConnectionState.Kind.Error)));
this._store.add(autorun(r => {
const servers = mcpService.servers.read(r);
const serverTools = servers.map(s => s.tools.read(r));
@@ -209,6 +209,12 @@ export class McpServer extends Disposable implements IMcpServer {
}
let connection = this._connection.get();
if (connection && McpConnectionState.canBeStarted(connection.state.get().state)) {
connection.dispose();
connection = undefined;
this._connection.set(connection, undefined);
}
if (!connection) {
connection = await this._mcpRegistry.resolveConnection({
collectionRef: this.collection,
@@ -262,6 +262,7 @@ export interface McpServerTransportStdio {
export interface McpServerTransportSSE {
readonly type: McpServerTransportType.SSE;
readonly uri: URI;
readonly headers: [string, string][];
}
export type McpServerLaunch =
@@ -270,7 +271,7 @@ export type McpServerLaunch =
export namespace McpServerLaunch {
export type Serialized =
| { type: McpServerTransportType.SSE; uri: UriComponents }
| { type: McpServerTransportType.SSE; uri: UriComponents; headers: [string, string][] }
| { type: McpServerTransportType.Stdio; cwd: UriComponents | undefined; command: string; args: readonly string[]; env: Record<string, string | number | null> };
export function toSerialized(launch: McpServerLaunch): McpServerLaunch.Serialized {
@@ -280,7 +281,7 @@ export namespace McpServerLaunch {
export function fromSerialized(launch: McpServerLaunch.Serialized): McpServerLaunch {
switch (launch.type) {
case McpServerTransportType.SSE:
return { type: launch.type, uri: URI.revive(launch.uri) };
return { type: launch.type, uri: URI.revive(launch.uri), headers: launch.headers };
case McpServerTransportType.Stdio:
return {
type: launch.type,
@@ -24,6 +24,8 @@ declare module 'vscode' {
uri: Uri;
headers: [string, string][];
constructor(label: string, uri: Uri);
}