Skip to content

feat(mcp): enhance server management with resource handling and trans… #15413

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/ai-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "1.60.0",
"description": "Theia - MCP Integration",
"dependencies": {
"@modelcontextprotocol/sdk": "1.0.1",
"@modelcontextprotocol/sdk": "^1.0.1"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"@modelcontextprotocol/sdk": "^1.0.1"
"@modelcontextprotocol/sdk": "^1.0.1",

This breaks the build

"@theia/ai-core": "1.60.0",
"@theia/core": "1.60.0"
},
Expand Down
14 changes: 7 additions & 7 deletions packages/ai-mcp/src/browser/mcp-command-contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { AICommandHandlerFactory } from '@theia/ai-core/lib/browser/ai-command-h
import { CommandContribution, CommandRegistry, MessageService, nls } from '@theia/core';
import { QuickInputService } from '@theia/core/lib/browser';
import { inject, injectable } from '@theia/core/shared/inversify';
import { MCPFrontendService, MCPServerStatus } from '../common/mcp-server-manager';
import { MCPFrontendService, MCPServerStatus } from '../common';

export const StartMCPServer = {
id: 'mcp.startserver',
Expand Down Expand Up @@ -57,7 +57,7 @@ export class MCPCommandContribution implements CommandContribution {
try {
const startedServers = await this.mcpFrontendService.getStartedServers();
if (!startedServers || startedServers.length === 0) {
this.messageService.error(nls.localize('theia/ai/mcp/error/noRunningServers', 'No MCP servers running.'));
await this.messageService.error(nls.localize('theia/ai/mcp/error/noRunningServers', 'No MCP servers running.'));
return;
}
const selection = await this.getMCPServerSelection(startedServers);
Expand All @@ -79,9 +79,9 @@ export class MCPCommandContribution implements CommandContribution {
const startableServers = servers.filter(server => !startedServers.includes(server));
if (!startableServers || startableServers.length === 0) {
if (startedServers && startedServers.length > 0) {
this.messageService.error(nls.localize('theia/ai/mcp/error/allServersRunning', 'All MCP servers are already running.'));
await this.messageService.error(nls.localize('theia/ai/mcp/error/allServersRunning', 'All MCP servers are already running.'));
} else {
this.messageService.error(nls.localize('theia/ai/mcp/error/noServersConfigured', 'No MCP servers configured.'));
await this.messageService.error(nls.localize('theia/ai/mcp/error/noServersConfigured', 'No MCP servers configured.'));
}
return;
}
Expand All @@ -98,7 +98,7 @@ export class MCPCommandContribution implements CommandContribution {
if (serverDescription.tools) {
toolNames = serverDescription.tools.map(tool => tool.name).join(',');
}
this.messageService.info(
await this.messageService.info(
nls.localize('theia/ai/mcp/info/serverStarted', 'MCP server "{0}" successfully started. Registered tools: {1}', selection, toolNames ||
nls.localize('theia/ai/mcp/tool/noTools', 'No tools available.'))
);
Expand All @@ -108,9 +108,9 @@ export class MCPCommandContribution implements CommandContribution {
console.error('Error while starting MCP server:', serverDescription.error);
}
}
this.messageService.error(nls.localize('theia/ai/mcp/error/startFailed', 'An error occurred while starting the MCP server.'));
await this.messageService.error(nls.localize('theia/ai/mcp/error/startFailed', 'An error occurred while starting the MCP server.'));
} catch (error) {
this.messageService.error(nls.localize('theia/ai/mcp/error/startFailed', 'An error occurred while starting the MCP server.'));
await this.messageService.error(nls.localize('theia/ai/mcp/error/startFailed', 'An error occurred while starting the MCP server.'));
console.error('Error while starting MCP server:', error);
}
}
Expand Down
6 changes: 6 additions & 0 deletions packages/ai-mcp/src/common/mcp-server-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export interface MCPFrontendNotificationService {
export interface MCPServer {
callTool(toolName: string, arg_string: string): ReturnType<Client['callTool']>;
getTools(): ReturnType<Client['listTools']>;
listResources(): ReturnType<Client['listResources']>;

readResource(resourceId: string): ReturnType<Client['readResource']>;
description: MCPServerDescription;
}

Expand All @@ -53,6 +56,9 @@ export interface MCPServerManager {
getRunningServers(): Promise<string[]>;
setClient(client: MCPFrontendNotificationService): void;
disconnectClient(client: MCPFrontendNotificationService): void;
listResources(serverName: string): ReturnType<MCPServer['listResources']>;

readResource(serverName: string, resourceId: string): ReturnType<MCPServer['readResource']>;
}

export interface ToolInformation {
Expand Down
16 changes: 16 additions & 0 deletions packages/ai-mcp/src/node/mcp-server-manager-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,20 @@ export class MCPServerManagerImpl implements MCPServerManager {
private notifyClients(): void {
this.clients.forEach(client => client.didUpdateMCPServers());
}

listResources(serverName: string): ReturnType<MCPServer['listResources']> {
const server = this.servers.get(serverName);
if (!server) {
throw new Error(`MCP server "${serverName}" not found.`);
}
return server.listResources();
}

readResource(serverName: string, resourceId: string): ReturnType<MCPServer['readResource']> {
const server = this.servers.get(serverName);
if (!server) {
throw new Error(`MCP server "${serverName}" not found.`);
}
return server.readResource(resourceId);
}
}
82 changes: 60 additions & 22 deletions packages/ai-mcp/src/node/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { MCPServerDescription, MCPServerStatus, ToolInformation } from '../common';
import { Emitter } from '@theia/core/lib/common/event';
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio';
import {SSEClientTransport} from '@modelcontextprotocol/sdk/client/sse';
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
import {MCPServerDescription, MCPServerStatus, ToolInformation} from '../common';
import {Emitter} from '@theia/core/lib/common/event';

export type TransportType = 'stdio' | 'sse';

export class MCPServer {
private name: string;
Expand All @@ -31,13 +34,21 @@ export class MCPServer {
// Event emitter for status updates
private readonly onDidUpdateStatusEmitter = new Emitter<MCPServerStatus>();
readonly onDidUpdateStatus = this.onDidUpdateStatusEmitter.event;
private readonly transportType: TransportType;
private readonly sseUrl?: string;

constructor(description: MCPServerDescription) {
this.name = description.name;
this.command = description.command;
this.args = description.args;
this.env = description.env;
this.autostart = description.autostart;
if (this.env?.sseUrl) {
this.sseUrl = this.env.sseUrl;
this.transportType = 'sse';
} else {
this.transportType = 'stdio';
}
console.log(this.autostart);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
console.log(this.autostart);

This was there before, but we could remove it.

}

Expand Down Expand Up @@ -84,23 +95,41 @@ export class MCPServer {
if (this.isRunnning() && this.status === MCPServerStatus.Starting) {
return;
}

this.setStatus(MCPServerStatus.Starting);
console.log(`Starting server "${this.name}" with command: ${this.command} and args: ${this.args?.join(' ')} and env: ${JSON.stringify(this.env)}`);
// Filter process.env to exclude undefined values
const sanitizedEnv: Record<string, string> = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)
);

const mergedEnv: Record<string, string> = {
...sanitizedEnv,
...(this.env || {})
};
const transport = new StdioClientTransport({
command: this.command,
args: this.args,
env: mergedEnv,
});
transport.onerror = error => {
console.log(`Starting server "${this.name}" with transport: ${this.transportType}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We log two messages before we start a server now. I think we can combine it to one covering both cases. What I mean: I would merge the content of the two messages below into the message here so that we get only one log message including all information (the URL for SSE, the command, args and env for stdio)


let transport;

if (this.transportType === 'sse') {
if (!this.sseUrl) {
throw new Error(`SSE transport requires a URL, but none was provided for server "${this.name}"`);
}

console.log(`Using SSE transport with URL: ${this.sseUrl}`);
transport = new SSEClientTransport(new URL(this.sseUrl));
} else {
// Default to stdio transport
console.log(`Using stdio transport with command: ${this.command} and args: ${this.args?.join(' ')} and env: ${JSON.stringify(this.env)}`);

// Filter process.env to exclude undefined values
const sanitizedEnv: Record<string, string> = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)
);

const mergedEnv: Record<string, string> = {
...sanitizedEnv,
...(this.env || {})
};

transport = new StdioClientTransport({
command: this.command,
args: this.args,
env: mergedEnv,
});
}

transport.onerror = (error: Error) => {
console.error('Error: ' + error);
this.error = 'Error: ' + error;
this.setStatus(MCPServerStatus.Errored);
Expand All @@ -112,7 +141,7 @@ export class MCPServer {
}, {
capabilities: {}
});
this.client.onerror = error => {
this.client.onerror = (error: Error) => {
console.error('Error in MCP client: ' + error);
this.error = 'Error in MCP client: ' + error;
this.setStatus(MCPServerStatus.Errored);
Expand All @@ -132,7 +161,7 @@ export class MCPServer {
let args;
try {
args = JSON.parse(arg_string);
} catch (error) {
} catch (error: unknown) {
console.error(
`Failed to parse arguments for calling tool "${toolName}" in MCP server "${this.name}" with command "${this.command}".
Invalid JSON: ${arg_string}`,
Expand Down Expand Up @@ -166,4 +195,13 @@ export class MCPServer {
this.client.close();
this.setStatus(MCPServerStatus.NotRunning);
}

listResources(): ReturnType<Client['listResources']> {
return this.client.listResources();
}

readResource(resourceId: string): ReturnType<Client['readResource']> {
const params = {uri: resourceId};
return this.client.readResource(params);
}
}
Loading