Skip to content

feat: enable mcp to communicate with IDE via JSON-RPC server #2640

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 30 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
882af6b
refactor: move socket utilities to shared library and add detection
MaxKless Jul 23, 2025
8653a1b
feat: create JSON-RPC client with content-length protocol compatibility
MaxKless Jul 23, 2025
2152e54
refactor: remove NxIdeProvider in favor of IIdeJsonRpcClient
MaxKless Jul 23, 2025
5f68371
add ide client and use it
MaxKless Jul 24, 2025
679a306
refactor mcp to be more dynamic
MaxKless Jul 24, 2025
e4fee43
support both ways for now
MaxKless Jul 24, 2025
1440624
talking between them works!!
MaxKless Jul 24, 2025
4a3b287
successful communication between vscode & stdio mcp
MaxKless Jul 24, 2025
7b7543c
ensure no stdio output
MaxKless Jul 28, 2025
9319069
formatting
MaxKless Jul 28, 2025
f56f898
type cleanup
MaxKless Jul 28, 2025
d727ed7
add e2e test for ide comms
MaxKless Jul 28, 2025
9c82258
self-review fixes
MaxKless Jul 28, 2025
dec9473
further fixes
MaxKless Jul 28, 2025
89ad46c
fix sync
MaxKless Jul 28, 2025
abd9ee7
fix failure
MaxKless Jul 28, 2025
b8b844e
fix test order
MaxKless Jul 28, 2025
513bc12
kill process.stdin & add logging
MaxKless Jul 30, 2025
644dce8
fix assignment rule error
MaxKless Jul 30, 2025
8d509c9
formatting
MaxKless Jul 30, 2025
25ebd62
add logs
MaxKless Jul 30, 2025
21f40fc
make logging crazier
MaxKless Jul 30, 2025
efb252b
w/ cleanup
MaxKless Jul 31, 2025
27add75
formatting
MaxKless Jul 31, 2025
69ec618
dont try to create named pipe directory
MaxKless Jul 31, 2025
24d4b12
fix
MaxKless Jul 31, 2025
25f5d15
add cleanup
MaxKless Jul 31, 2025
228b0cb
remove overly complicated test & catch error
MaxKless Jul 31, 2025
4530978
nx sync
MaxKless Jul 31, 2025
70b6e5a
changes
MaxKless Jul 31, 2025
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
22 changes: 22 additions & 0 deletions apps/nx-mcp/src/ensureOnlyJsonRpcStdout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export function ensureOnlyJsonRpcStdout() {
process.stdout.write = ((
chunk: any,
encodingOrCallback?: any,
callback?: any,
) => {
const message = chunk.toString();

if (
!message.startsWith('Content-Length:') &&
(!message.startsWith('{') || !message.includes('"jsonrpc":'))
) {
process.stderr.write(message);
return;
}

const originalWrite = process.stdout.constructor.prototype.write.bind(
process.stdout,
);
return originalWrite(chunk, encodingOrCallback, callback);
}) as typeof process.stdout.write;
}
37 changes: 37 additions & 0 deletions apps/nx-mcp/src/ide-client/create-ide-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { IIdeJsonRpcClient } from '@nx-console/shared-types';
import { Logger, testIdeConnection } from '@nx-console/shared-utils';
import { IdeJsonRpcClient } from './json-rpc-client';

/**
* Factory function to create and connect an IDE client
*/
export async function createIdeClient(
workspacePath: string,
logger?: Logger,
): Promise<{ client: IIdeJsonRpcClient | undefined; available: boolean }> {
try {
// Check if IDE is actually listening on the socket
const ideListening = await testIdeConnection(workspacePath);

if (!ideListening) {
return { client: undefined, available: false };
}

// Create and connect client
const client = new IdeJsonRpcClient(workspacePath, 2000, 5, 5000, logger);

await client.connect();

// Verify connection is actually working
const status = client.getStatus();
if (status !== 'connected') {
client.disconnect();
return { client: undefined, available: false };
}

return { client, available: true };
} catch (error) {
logger?.log('Failed to create IDE client:', error);
return { client: undefined, available: false };
}
}
268 changes: 268 additions & 0 deletions apps/nx-mcp/src/ide-client/json-rpc-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import {
ConnectionStatus,
IDE_RPC_METHODS,
OpenGenerateUiResponse,
IIdeJsonRpcClient,
} from '@nx-console/shared-types';
import { getNxConsoleSocketPath, Logger } from '@nx-console/shared-utils';
import { Socket } from 'net';
import { platform } from 'os';
import * as rpc from 'vscode-jsonrpc/node';

// Define typed request and notification types
const focusProjectRequest = new rpc.NotificationType<{ projectName: string }>(
IDE_RPC_METHODS.FOCUS_PROJECT,
);
const focusTaskRequest = new rpc.NotificationType<{
projectName: string;
taskName: string;
}>(IDE_RPC_METHODS.FOCUS_TASK);
const showFullProjectGraphRequest = new rpc.NotificationType<void>(
IDE_RPC_METHODS.SHOW_FULL_PROJECT_GRAPH,
);
const openGenerateUiRequest = new rpc.RequestType<
{ generatorName: string; options: Record<string, unknown>; cwd?: string },
OpenGenerateUiResponse,
void
>(IDE_RPC_METHODS.OPEN_GENERATE_UI);

/**
* JSON-RPC client for communicating with the IDE using vscode-jsonrpc
*/
export class IdeJsonRpcClient implements IIdeJsonRpcClient {
private socket: Socket | null = null;
private connection: rpc.MessageConnection | null = null;
private status: ConnectionStatus = 'disconnected';
private reconnectAttempts = 0;
private reconnectTimer: NodeJS.Timeout | null = null;
private disconnectionHandler?: (client: IdeJsonRpcClient) => void;

constructor(
private workspacePath: string,
private reconnectInterval?: number,
private maxReconnectAttempts?: number,
private requestTimeout?: number,
private logger?: Logger,
) {}

/**
* Set a handler to be called when the client disconnects
*/
onDisconnection(handler: (client: IdeJsonRpcClient) => void): void {
this.disconnectionHandler = handler;
}

/**
* Connect to the IDE socket
*/
async connect(): Promise<void> {
if (this.status === 'connected' || this.status === 'connecting') {
return;
}

this.status = 'connecting';

try {
const socketPath = getNxConsoleSocketPath(this.workspacePath);

// Create socket connection
this.socket = new Socket();

// Wait for socket to connect
await new Promise<void>((resolve, reject) => {
this.socket!.on('connect', () => {
this.status = 'connected';
this.reconnectAttempts = 0;
this.logger?.log(`Connected to IDE at ${socketPath}`);
resolve();
});

this.socket!.on('error', (error) => {
this.logger?.log('Socket connection error:', error);
reject(error);
});

// Connect to socket
if (platform() === 'win32') {
// On Windows, connect to named pipe
this.socket!.connect(socketPath);
} else {
// On Unix, connect to socket file
this.socket!.connect(socketPath);
}
});

// Create vscode-jsonrpc message connection
const reader = new rpc.SocketMessageReader(this.socket);
const writer = new rpc.SocketMessageWriter(this.socket);
this.connection = rpc.createMessageConnection(reader, writer);

// Set up connection event handlers
this.connection.onClose(() => {
this.logger?.log('JSON-RPC connection closed');
this.handleDisconnection();
});

this.connection.onError((error) => {
this.logger?.log('JSON-RPC connection error:', error);
this.handleDisconnection();
});

// Start listening for messages
this.connection.listen();
} catch (error) {
this.logger?.log('Failed to connect to IDE:', error);
this.handleDisconnection();
throw error;
}
}

/**
* Disconnect from the IDE
*/
disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}

if (this.connection) {
this.connection.dispose();
this.connection = null;
}

if (this.socket) {
this.socket.destroy();
this.socket = null;
}

this.status = 'disconnected';
}

/**
* Get current connection status
*/
getStatus(): ConnectionStatus {
return this.status;
}

/**
* Handle disconnection and attempt reconnection
*/
private handleDisconnection(): void {
if (this.status === 'disconnected') {
return; // Already handling disconnection
}

this.status = 'error';

if (this.connection) {
this.connection.dispose();
this.connection = null;
}

if (this.socket) {
this.socket.destroy();
this.socket = null;
}

// Attempt reconnection if configured
const maxAttempts = this.maxReconnectAttempts || 5;
const interval = this.reconnectInterval || 2000;

if (this.reconnectAttempts < maxAttempts) {
this.reconnectAttempts++;
this.logger?.log(
`Attempting to reconnect (${this.reconnectAttempts}/${maxAttempts})...`,
);

this.reconnectTimer = setTimeout(() => {
this.connect().catch((error) => {
this.logger?.log('Reconnection failed:', error);
if (this.reconnectAttempts >= maxAttempts) {
this.status = 'disconnected';
this.logger?.log('Max reconnection attempts reached. Giving up.');
}
});
}, interval);
} else {
this.status = 'disconnected';
this.logger?.log('Max reconnection attempts reached. Connection lost.');

// Notify handler of permanent disconnection
if (this.disconnectionHandler) {
this.disconnectionHandler(this);
}
}
}

/**
* Focus on a specific project in the IDE
*/
async focusProject(projectName: string): Promise<void> {
if (!this.connection || this.status !== 'connected') {
throw new Error('Not connected to IDE');
}

await this.connection.sendNotification(focusProjectRequest, {
projectName,
});
}

/**
* Focus on a specific task in the IDE
*/
async focusTask(projectName: string, taskName: string): Promise<void> {
if (!this.connection || this.status !== 'connected') {
throw new Error('Not connected to IDE');
}

await this.connection.sendNotification(focusTaskRequest, {
projectName,
taskName,
});
}

/**
* Show the full project graph in the IDE
*/
async showFullProjectGraph(): Promise<void> {
if (!this.connection || this.status !== 'connected') {
throw new Error('Not connected to IDE');
}

await this.connection.sendNotification(showFullProjectGraphRequest);
}

/**
* Open the generator UI in the IDE
*/
async openGenerateUi(
generatorName: string,
options: Record<string, unknown>,
cwd?: string,
): Promise<string> {
if (!this.connection || this.status !== 'connected') {
throw new Error('Not connected to IDE');
}

const response = await this.connection.sendRequest(openGenerateUiRequest, {
generatorName,
options,
cwd,
});
return response.logFileName;
}

/**
* Send a notification to the IDE (fire-and-forget)
*/
async sendNotification(method: string, params?: unknown): Promise<void> {
if (!this.connection || this.status !== 'connected') {
throw new Error('Not connected to IDE');
}

const notificationType = new rpc.NotificationType<unknown>(method);
this.connection.sendNotification(notificationType, params);
}
}
Loading
Loading