From fc06cf1443c47296a359deadfbd50c950cf4686e Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Fri, 23 Jan 2026 16:32:51 -0600 Subject: [PATCH 01/19] listServers MCP tool (DH-20954-3) --- src/mcp/McpServer.ts | 2 + src/mcp/tools/index.ts | 1 + src/mcp/tools/listServers.spec.ts | 143 ++++++++++++++++++++++++++++++ src/mcp/tools/listServers.ts | 82 +++++++++++++++++ src/mcp/utils/index.ts | 1 + src/mcp/utils/serverUtils.spec.ts | 133 +++++++++++++++++++++++++++ src/mcp/utils/serverUtils.ts | 69 ++++++++++++++ 7 files changed, 431 insertions(+) create mode 100644 src/mcp/tools/listServers.spec.ts create mode 100644 src/mcp/tools/listServers.ts create mode 100644 src/mcp/utils/serverUtils.spec.ts create mode 100644 src/mcp/utils/serverUtils.ts diff --git a/src/mcp/McpServer.ts b/src/mcp/McpServer.ts index 9d9b6e362..65b5e0440 100644 --- a/src/mcp/McpServer.ts +++ b/src/mcp/McpServer.ts @@ -6,6 +6,7 @@ import type { IServerManager, McpTool, McpToolSpec } from '../types'; import { MCP_SERVER_NAME } from '../common'; import { createListConnectionsTool, + createListServersTool, createRunCodeFromUriTool, createRunCodeTool, } from './tools'; @@ -45,6 +46,7 @@ export class McpServer extends DisposableBase { this.registerTool(createRunCodeTool(this)); this.registerTool(createRunCodeFromUriTool(this)); this.registerTool(createListConnectionsTool(this)); + this.registerTool(createListServersTool(this)); } private registerTool({ diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts index c7a0479ed..93dd27bc5 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -1,3 +1,4 @@ export * from './listConnections'; +export * from './listServers'; export * from './runCode'; export * from './runCodeFromUri'; diff --git a/src/mcp/tools/listServers.spec.ts b/src/mcp/tools/listServers.spec.ts new file mode 100644 index 000000000..4f9047000 --- /dev/null +++ b/src/mcp/tools/listServers.spec.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createListServersTool } from './listServers'; +import type { IServerManager, ServerState, ConnectionState } from '../../types'; +import { McpToolResponse } from '../utils/mcpUtils'; +import type { ServerResult } from '../utils/serverUtils'; +import * as serverUtils from '../utils/serverUtils'; + +vi.mock('vscode'); +vi.mock('../utils/serverUtils', async () => { + const actual = await vi.importActual( + '../utils/serverUtils' + ); + return { + ...actual, + serverToResult: vi.fn(), + }; +}); + +const MOCK_EXECUTION_TIME_MS = 100; + +const MOCK_SERVER_RESULT1 = { label: 'mock server 1' } as ServerResult; +const MOCK_SERVER_RESULT2 = { label: 'mock server 2' } as ServerResult; +const MOCK_SERVER1 = { url: new URL('http://server1') } as ServerState; +const MOCK_SERVER2 = { url: new URL('http://server2') } as ServerState; +const MOCK_CONNECTION1 = { + serverUrl: new URL('http://conn1'), +} as ConnectionState; +const MOCK_CONNECTION2 = { + serverUrl: new URL('http://conn2'), +} as ConnectionState; + +describe('createListServersTool', () => { + const serverManager: IServerManager = { + getServers: vi.fn(), + getConnections: vi.fn(), + } as unknown as IServerManager; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(McpToolResponse.prototype, 'getElapsedTimeMs').mockReturnValue( + MOCK_EXECUTION_TIME_MS + ); + + vi.mocked(serverManager.getServers).mockReturnValue([ + MOCK_SERVER1, + MOCK_SERVER2, + ]); + vi.mocked(serverManager.getConnections) + .mockReturnValueOnce([MOCK_CONNECTION1]) + .mockReturnValueOnce([MOCK_CONNECTION2]); + vi.mocked(serverUtils.serverToResult) + .mockReturnValueOnce(MOCK_SERVER_RESULT1) + .mockReturnValueOnce(MOCK_SERVER_RESULT2); + }); + + it('should create tool with correct name and spec', () => { + const tool = createListServersTool({ serverManager }); + + expect(tool.name).toBe('listServers'); + expect(tool.spec.title).toBe('List Servers'); + expect(tool.spec.description).toContain('List all Deephaven servers'); + }); + + describe('handler', () => { + it('should call serverToResult for each server with connections', async () => { + const tool = createListServersTool({ serverManager }); + const result = await tool.handler({}); + + expect(serverManager.getServers).toHaveBeenCalledWith({}); + expect(serverManager.getConnections).toHaveBeenCalledWith( + MOCK_SERVER1.url + ); + expect(serverManager.getConnections).toHaveBeenCalledWith( + MOCK_SERVER2.url + ); + expect(serverUtils.serverToResult).toHaveBeenCalledWith(MOCK_SERVER1, [ + MOCK_CONNECTION1, + ]); + expect(serverUtils.serverToResult).toHaveBeenCalledWith(MOCK_SERVER2, [ + MOCK_CONNECTION2, + ]); + + const expected = { + success: true, + message: 'Found 2 server(s)', + executionTimeMs: MOCK_EXECUTION_TIME_MS, + details: { + servers: [MOCK_SERVER_RESULT1, MOCK_SERVER_RESULT2], + }, + }; + + expect(result.structuredContent).toEqual(expected); + }); + + it('should pass filters to serverManager.getServers', async () => { + const filters = { isRunning: true, type: 'DHC' as const }; + + const tool = createListServersTool({ serverManager }); + await tool.handler(filters); + + expect(serverManager.getServers).toHaveBeenCalledWith(filters); + }); + + it('should handle empty server list', async () => { + vi.mocked(serverManager.getServers).mockReturnValue([]); + + const tool = createListServersTool({ serverManager }); + const result = await tool.handler({}); + + expect(serverUtils.serverToResult).not.toHaveBeenCalled(); + + const expected = { + success: true, + message: 'Found 0 server(s)', + executionTimeMs: MOCK_EXECUTION_TIME_MS, + details: { + servers: [], + }, + }; + + expect(result.structuredContent).toEqual(expected); + }); + + it('should handle errors from serverManager', async () => { + const error = new Error('Mock error object'); + vi.mocked(serverManager.getServers).mockImplementation(() => { + throw error; + }); + + const tool = createListServersTool({ serverManager }); + const result = await tool.handler({}); + + const expected = { + success: false, + message: 'Failed to list servers: Mock error object', + executionTimeMs: MOCK_EXECUTION_TIME_MS, + }; + + expect(result.structuredContent).toEqual(expected); + }); + }); +}); diff --git a/src/mcp/tools/listServers.ts b/src/mcp/tools/listServers.ts new file mode 100644 index 000000000..c7a3beb4e --- /dev/null +++ b/src/mcp/tools/listServers.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; +import type { + IServerManager, + McpTool, + McpToolHandlerArg, + McpToolHandlerResult, +} from '../../types'; +import { McpToolResponse } from '../utils'; +import { serverToResult, serverResultSchema } from '../utils/serverUtils'; + +const spec = { + title: 'List Servers', + description: + 'List all Deephaven servers with optional filtering by running status, connection status, or type.', + inputSchema: { + isRunning: z + .boolean() + .optional() + .describe('Filter by running status (true = running, false = stopped)'), + hasConnections: z + .boolean() + .optional() + .describe( + 'Filter by connection status (true = has connections, false = no connections)' + ), + type: z + .enum(['DHC', 'DHE']) + .optional() + .describe('Filter by server type (DHC = Community, DHE = Enterprise)'), + }, + outputSchema: { + success: z.boolean(), + message: z.string().optional(), + executionTimeMs: z + .number() + .optional() + .describe('Execution time in milliseconds'), + details: z + .object({ + servers: z.array(serverResultSchema), + }) + .optional(), + }, +} as const; + +type Spec = typeof spec; +type HandlerArg = McpToolHandlerArg; +type HandlerResult = McpToolHandlerResult; +type CreateListServersTool = McpTool; + +export function createListServersTool({ + serverManager, +}: { + serverManager: IServerManager; +}): CreateListServersTool { + return { + name: 'listServers', + spec, + handler: async ({ + isRunning, + hasConnections, + type, + }: HandlerArg): Promise => { + const response = new McpToolResponse(); + + try { + const servers = serverManager + .getServers({ isRunning, hasConnections, type }) + .map(server => { + const connections = serverManager.getConnections(server.url); + return serverToResult(server, connections); + }); + + return response.success(`Found ${servers.length} server(s)`, { + servers, + }); + } catch (error) { + return response.error('Failed to list servers', error); + } + }, + }; +} diff --git a/src/mcp/utils/index.ts b/src/mcp/utils/index.ts index 23d5d11dc..1c2c29228 100644 --- a/src/mcp/utils/index.ts +++ b/src/mcp/utils/index.ts @@ -1,2 +1,3 @@ export * from './mcpUtils'; export * from './runCodeUtils'; +export * from './serverUtils'; diff --git a/src/mcp/utils/serverUtils.spec.ts b/src/mcp/utils/serverUtils.spec.ts new file mode 100644 index 000000000..4abcdcbbe --- /dev/null +++ b/src/mcp/utils/serverUtils.spec.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { connectionToResult, serverToResult } from './serverUtils'; +import type { ConnectionState, ServerState, Psk } from '../../types'; + +describe('serverUtils', () => { + const serverUrl = new URL('http://localhost:10000'); + const tagId = 'worker-1'; + const psk = 'test-psk' as Psk; + + describe('connectionToResult', () => { + it.each([ + { + name: 'connection with all fields', + connection: { + isConnected: true, + isRunningCode: true, + serverUrl, + tagId, + } as ConnectionState, + expected: { + isConnected: true, + isRunningCode: true, + serverUrl: serverUrl.toString(), + tagId, + }, + }, + { + name: 'connection without tagId', + connection: { + isConnected: true, + isRunningCode: false, + serverUrl, + } as ConnectionState, + expected: { + isConnected: true, + isRunningCode: false, + serverUrl: serverUrl.toString(), + tagId: undefined, + }, + }, + { + name: 'disconnected connection', + connection: { + isConnected: false, + isRunningCode: false, + serverUrl, + tagId, + } as ConnectionState, + expected: { + isConnected: false, + isRunningCode: false, + serverUrl: serverUrl.toString(), + tagId, + }, + }, + ])('should map $name', ({ connection, expected }) => { + const result = connectionToResult(connection); + + expect(result).toEqual(expected); + }); + }); + + describe('serverToResult', () => { + const MOCK_CONNECTION: ConnectionState = { + isConnected: true, + isRunningCode: true, + serverUrl, + tagId, + } as ConnectionState; + + it.each([ + { + name: 'managed server with connections', + server: { + type: 'DHE', + url: serverUrl, + label: 'Test Server', + isConnected: true, + isRunning: true, + connectionCount: 1, + isManaged: true, + psk, + } as ServerState, + connections: [MOCK_CONNECTION], + expected: { + type: 'DHE', + url: serverUrl.toString(), + label: 'Test Server', + isConnected: true, + isRunning: true, + connectionCount: 1, + isManaged: true, + tags: ['pip', 'managed'], + connections: [ + { + isConnected: true, + isRunningCode: true, + serverUrl: serverUrl.toString(), + tagId, + }, + ], + }, + }, + { + name: 'unmanaged server without label', + server: { + type: 'DHC', + url: serverUrl, + isConnected: false, + isRunning: false, + connectionCount: 0, + isManaged: false, + } as ServerState, + connections: [], + expected: { + type: 'DHC', + url: serverUrl.toString(), + label: undefined, + isConnected: false, + isRunning: false, + connectionCount: 0, + isManaged: false, + tags: [], + connections: [], + }, + }, + ])('should map $name', ({ server, connections, expected }) => { + const result = serverToResult(server, connections); + + expect(result).toEqual(expected); + }); + }); +}); diff --git a/src/mcp/utils/serverUtils.ts b/src/mcp/utils/serverUtils.ts new file mode 100644 index 000000000..1f82732b4 --- /dev/null +++ b/src/mcp/utils/serverUtils.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; +import type { ConnectionState, ServerState } from '../../types'; + +export const connectionResultSchema = z.object({ + isConnected: z.boolean(), + isRunningCode: z.boolean().optional(), + serverUrl: z.string(), + tagId: z.string().optional(), +}); + +export const serverResultSchema = z.object({ + type: z.string(), + url: z.string(), + label: z.string().optional(), + isConnected: z.boolean(), + isRunning: z.boolean(), + connectionCount: z.number(), + isManaged: z.boolean().optional(), + tags: z.array(z.string()).optional(), + connections: z.array(connectionResultSchema).optional(), +}); + +export type ConnectionResult = z.infer; +export type ServerResult = z.infer; + +/** + * Maps a ConnectionState to a connection result object. + */ +export function connectionToResult({ + isConnected, + isRunningCode, + serverUrl, + tagId, +}: ConnectionState): ConnectionResult { + return { + isConnected, + isRunningCode, + serverUrl: serverUrl.toString(), + tagId: tagId ? String(tagId) : undefined, + }; +} + +/** + * Maps a ServerState to a server result object with connections. + */ +export function serverToResult( + { + type, + url, + label, + isConnected, + isRunning, + connectionCount, + isManaged, + }: ServerState, + connections: ConnectionState[] +): ServerResult { + return { + type, + url: url.toString(), + label, + isConnected, + isRunning, + connectionCount, + isManaged, + tags: isManaged ? ['pip', 'managed'] : [], + connections: connections.map(connectionToResult), + }; +} From b4968fad326d0f77537632ee125b30b684a184b2 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Fri, 23 Jan 2026 17:03:59 -0600 Subject: [PATCH 02/19] connectToServer MCP tool (DH-20954-3) --- src/common/commands.ts | 35 +++++++++++- src/controllers/ConnectionController.ts | 4 +- src/mcp/tools/connectToServer.ts | 75 +++++++++++++++++++++++++ src/mcp/tools/runCode.ts | 8 +-- src/mcp/tools/runCodeFromUri.ts | 18 ++---- 5 files changed, 116 insertions(+), 24 deletions(-) create mode 100644 src/mcp/tools/connectToServer.ts diff --git a/src/common/commands.ts b/src/common/commands.ts index 50f76ccc6..68849679a 100644 --- a/src/common/commands.ts +++ b/src/common/commands.ts @@ -1,6 +1,7 @@ -import type * as vscode from 'vscode'; +import * as vscode from 'vscode'; +import type { dh as DhcType } from '@deephaven/jsapi-types'; import { EXTENSION_ID } from './constants'; -import type { SerializedRange } from '../types'; +import type { SerializedRange, ServerState } from '../types'; /** Arguments passed to `RUN_CODE_COMMAND` handler */ export type RunCodeCmdArgs = [ @@ -25,6 +26,12 @@ export type RunSelectionCmdArgs = [ languageId?: string, ]; +/** Arguments passed to `CONNECT_TO_SERVER_CMD` handler */ +export type ConnectToServerCmdArgs = [ + serverState: Pick, + operateAsAnotherUser?: boolean, +]; + /** * Create a command string prefixed with the extension id. * @param cmd The command string suffix. @@ -72,3 +79,27 @@ export const START_SERVER_CMD = cmd('startServer'); export const STOP_SERVER_CMD = cmd('stopServer'); export const ADD_REMOTE_FILE_SOURCE_CMD = cmd('addRemoteFileSource'); export const REMOVE_REMOTE_FILE_SOURCE_CMD = cmd('removeRemoteFileSource'); + +/** + * Execute the connect to server command with type safety. + * @param serverState The server to connect to (type and url). + * @param operateAsAnotherUser Whether to operate as another user. + */ +export function execConnectToServer( + ...args: ConnectToServerCmdArgs +): Thenable { + return vscode.commands.executeCommand(CONNECT_TO_SERVER_CMD, ...args); +} + +/** + * Execute the run code command with type safety. + * @returns The command result from the Deephaven server, or null. + */ +export function execRunCode( + ...args: RunCodeCmdArgs +): Thenable { + return vscode.commands.executeCommand( + RUN_CODE_COMMAND, + ...args + ); +} diff --git a/src/controllers/ConnectionController.ts b/src/controllers/ConnectionController.ts index 2f9fefa94..bef0cfaff 100644 --- a/src/controllers/ConnectionController.ts +++ b/src/controllers/ConnectionController.ts @@ -23,6 +23,7 @@ import { getConnectionsForConsoleType } from '../services'; import { CONNECT_TO_SERVER_CMD, CONNECT_TO_SERVER_OPERATE_AS_CMD, + ConnectToServerCmdArgs, DISCONNECT_EDITOR_CMD, DISCONNECT_FROM_SERVER_CMD, SELECT_CONNECTION_COMMAND, @@ -317,8 +318,7 @@ export class ConnectionController * Handle connecting to a server */ onConnectToServer = async ( - serverState: Pick, - operateAsAnotherUser?: boolean + ...[serverState, operateAsAnotherUser]: ConnectToServerCmdArgs ): Promise => { const languageId = vscode.window.activeTextEditor?.document.languageId; diff --git a/src/mcp/tools/connectToServer.ts b/src/mcp/tools/connectToServer.ts new file mode 100644 index 000000000..6fdccb562 --- /dev/null +++ b/src/mcp/tools/connectToServer.ts @@ -0,0 +1,75 @@ +import { z } from 'zod'; +import { execConnectToServer } from '../../common/commands'; +import type { + IServerManager, + McpTool, + McpToolHandlerArg, + McpToolHandlerResult, +} from '../../types'; +import { parseUrl } from '../../util'; +import { McpToolResponse } from '../utils'; + +const spec = { + title: 'Connect to Server', + description: + 'Create a connection to a Deephaven server. The server must already be configured in the extension. For DHE (Enterprise) servers, this will create a new worker.', + inputSchema: { + url: z.string().describe('Server URL (e.g., "http://localhost:10000")'), + }, + outputSchema: { + success: z.boolean(), + message: z.string().optional(), + executionTimeMs: z + .number() + .optional() + .describe('Execution time in milliseconds'), + }, +} as const; + +type Spec = typeof spec; +type HandlerArg = McpToolHandlerArg; +type HandlerResult = McpToolHandlerResult; +type ConnectToServerTool = McpTool; + +export function createConnectToServerTool( + serverManager: IServerManager +): ConnectToServerTool { + return { + name: 'connectToServer', + spec, + handler: async ({ url }: HandlerArg): Promise => { + const response = new McpToolResponse(); + + const parsedUrlResult = parseUrl(url); + if (!parsedUrlResult.success) { + return response.errorWithHint( + 'Invalid server URL', + parsedUrlResult.error, + `Please provide a valid URL (e.g., 'http://localhost:10000'). If this was a server label, use listServers to find the corresponding URL.`, + { url } + ); + } + + const serverUrl = parsedUrlResult.value; + const server = serverManager.getServer(serverUrl); + if (!server) { + return response.errorWithHint( + 'Server not found', + undefined, + 'Use listServers to see available servers.', + { url } + ); + } + + try { + await execConnectToServer({ type: server.type, url: serverUrl }); + return response.success('Connecting to server', { + type: server.type, + url, + }); + } catch (error) { + return response.error('Failed to connect to server', error); + } + }, + }; +} diff --git a/src/mcp/tools/runCode.ts b/src/mcp/tools/runCode.ts index a760d4670..b27e3bac1 100644 --- a/src/mcp/tools/runCode.ts +++ b/src/mcp/tools/runCode.ts @@ -1,5 +1,4 @@ -import * as vscode from 'vscode'; -import { CONNECT_TO_SERVER_CMD } from '../../common/commands'; +import { execConnectToServer } from '../../common/commands'; import { z } from 'zod'; import type { McpTool, McpToolHandlerResult } from '../../types'; import type { IServerManager } from '../../types'; @@ -100,10 +99,7 @@ export function createRunCodeTool({ const serverState = { type: server.type, url: server.url }; - await vscode.commands.executeCommand( - CONNECT_TO_SERVER_CMD, - serverState - ); + await execConnectToServer(serverState); connections = serverManager.getConnections(parsedConnectionURL.value); diff --git a/src/mcp/tools/runCodeFromUri.ts b/src/mcp/tools/runCodeFromUri.ts index 88a2984a3..53d760b12 100644 --- a/src/mcp/tools/runCodeFromUri.ts +++ b/src/mcp/tools/runCodeFromUri.ts @@ -1,6 +1,5 @@ import * as vscode from 'vscode'; -import type { dh as DhcType } from '@deephaven/jsapi-types'; -import { RUN_CODE_COMMAND, type RunCodeCmdArgs } from '../../common/commands'; +import { execRunCode } from '../../common/commands'; import { z } from 'zod'; import type { McpTool, @@ -97,22 +96,13 @@ export function createRunCodeFromUriTool({ const languageId = document.languageId; try { - // This is split out into an Array so that we can get type safety for - // the command args since the signature for `vscode.commands.executeCommand` - // takes ...any[] - const cmdArgs: RunCodeCmdArgs = [ + const result = await execRunCode( parsedUriResult.value, undefined, constrainTo, languageId, - parsedURLResult.value ?? undefined, - ]; - - const result = - await vscode.commands.executeCommand( - RUN_CODE_COMMAND, - ...cmdArgs - ); + parsedURLResult.value ?? undefined + ); // Extract variables from result const variables = extractVariables(result); From 7637135406cf0e94436bd3ab6e63ff241f5f1650 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Fri, 23 Jan 2026 18:57:08 -0600 Subject: [PATCH 03/19] ExtensionInfo type (DH-20954-3) --- src/controllers/ExtensionController.ts | 20 +++++----- src/controllers/McpController.ts | 9 +++-- src/providers/McpServerDefinitionProvider.ts | 5 ++- src/types/commonTypes.d.ts | 9 +++++ src/util/extensionApiUtils.ts | 39 ++++++++++++++++---- 5 files changed, 61 insertions(+), 21 deletions(-) diff --git a/src/controllers/ExtensionController.ts b/src/controllers/ExtensionController.ts index db5b6f6ae..5732a7795 100644 --- a/src/controllers/ExtensionController.ts +++ b/src/controllers/ExtensionController.ts @@ -39,9 +39,9 @@ import { type ViewID, } from '../common'; import { + createExtensionInfo, deserializeRange, getEditorForUri, - getExtensionVersion, getTempDir, isInstanceOf, isSerializedRange, @@ -54,7 +54,6 @@ import { saveLogFiles, serializeRefreshToken, Toaster, - uniqueId, URLMap, withResolvers, } from '../util'; @@ -108,6 +107,7 @@ import type { ConnectionState, WorkerURL, UniqueID, + ExtensionInfo, SerializedRange, CodeBlock, IInteractiveConsoleQueryFactory, @@ -137,14 +137,13 @@ export class ExtensionController implements IDisposable { constructor(context: vscode.ExtensionContext, configService: IConfigService) { this._context = context; this._config = configService; - this._instanceId = uniqueId(8); - this._version = getExtensionVersion(this._context); + this._extensionInfo = createExtensionInfo(this._context); const envInfo = { /* eslint-disable @typescript-eslint/naming-convention */ 'VS Code version': vscode.version, - 'Deephaven Extension version': this._version, - 'Deephaven Extension instanceId': this._instanceId, + 'Deephaven Extension version': this._extensionInfo.version, + 'Deephaven Extension instanceId': this._extensionInfo.instanceId, 'Electron version': process.versions.electron, 'Chromium version': process.versions.chrome, 'Node version': process.versions.node, @@ -166,8 +165,7 @@ export class ExtensionController implements IDisposable { private readonly _context: vscode.ExtensionContext; private readonly _config: IConfigService; - private readonly _instanceId: UniqueID; - private readonly _version: string; + private readonly _extensionInfo: ExtensionInfo; private readonly _envInfoText: string; private _codeBlockCache: ParsedDocumentCache | null = null; @@ -426,6 +424,7 @@ export class ExtensionController implements IDisposable { this._mcpController = new McpController( this._context, this._config, + this._extensionInfo.mcpVersion, this._serverManager, this._pythonDiagnostics, this._pythonWorkspace @@ -486,7 +485,10 @@ export class ExtensionController implements IDisposable { Logger.addConsoleHandler(); Logger.addOutputChannelHandler(this._outputChannelDebug); - this._logFileHandler = new LogFileHandler(this._instanceId, this._context); + this._logFileHandler = new LogFileHandler( + this._extensionInfo.instanceId, + this._context + ); Logger.handlers.add(this._logFileHandler); const gRPCOutputChannelHandler = Logger.createOutputChannelHandler( diff --git a/src/controllers/McpController.ts b/src/controllers/McpController.ts index b90d2bb76..af0010dcf 100644 --- a/src/controllers/McpController.ts +++ b/src/controllers/McpController.ts @@ -2,9 +2,9 @@ import * as vscode from 'vscode'; import { ControllerBase } from './ControllerBase'; import { McpServer } from '../mcp'; import { McpServerDefinitionProvider } from '../providers'; -import type { IServerManager, IConfigService } from '../types'; +import type { IServerManager, IConfigService, McpVersion } from '../types'; import type { FilteredWorkspace } from '../services'; -import { getExtensionVersion, isWindsurf, Logger } from '../util'; +import { isWindsurf, Logger } from '../util'; import { COPY_MCP_URL_CMD, MCP_SERVER_NAME, @@ -20,6 +20,7 @@ const logger = new Logger('McpController'); export class McpController extends ControllerBase { private _context: vscode.ExtensionContext; private _config: IConfigService; + private _mcpVersion: McpVersion; private _serverManager: IServerManager; private _pythonDiagnostics: vscode.DiagnosticCollection; private _pythonWorkspace: FilteredWorkspace; @@ -32,6 +33,7 @@ export class McpController extends ControllerBase { constructor( context: vscode.ExtensionContext, config: IConfigService, + mcpVersion: McpVersion, serverManager: IServerManager, pythonDiagnostics: vscode.DiagnosticCollection, pythonWorkspace: FilteredWorkspace @@ -40,6 +42,7 @@ export class McpController extends ControllerBase { this._context = context; this._config = config; + this._mcpVersion = mcpVersion; this._serverManager = serverManager; this._pythonDiagnostics = pythonDiagnostics; this._pythonWorkspace = pythonWorkspace; @@ -135,7 +138,7 @@ export class McpController extends ControllerBase { // Register provider for VS Code Copilot this._mcpServerDefinitionProvider = new McpServerDefinitionProvider( - getExtensionVersion(this._context), + this._mcpVersion, this._mcpServer ); this.disposables.push(this._mcpServerDefinitionProvider); diff --git a/src/providers/McpServerDefinitionProvider.ts b/src/providers/McpServerDefinitionProvider.ts index 6a5140cce..135101362 100644 --- a/src/providers/McpServerDefinitionProvider.ts +++ b/src/providers/McpServerDefinitionProvider.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { MCP_SERVER_NAME } from '../common'; import type { McpServer } from '../mcp/McpServer'; import { DisposableBase } from '../services'; +import type { McpVersion } from '../types'; /** * Provides MCP server definitions to VS Code Copilot. This allows Copilot to @@ -12,7 +13,7 @@ export class McpServerDefinitionProvider implements vscode.McpServerDefinitionProvider { constructor( - private readonly version: string | undefined, + private readonly mcpVersion: McpVersion, private readonly mcpServer: McpServer ) { super(); @@ -38,7 +39,7 @@ export class McpServerDefinitionProvider MCP_SERVER_NAME, vscode.Uri.parse(`http://localhost:${port}/mcp`), undefined, - this.version // Important to tell VS Code when MCP tools may have changed + this.mcpVersion // Important to tell VS Code when MCP tools may have changed ), ]; } diff --git a/src/types/commonTypes.d.ts b/src/types/commonTypes.d.ts index b231b0e5f..53a738484 100644 --- a/src/types/commonTypes.d.ts +++ b/src/types/commonTypes.d.ts @@ -21,6 +21,15 @@ export type ParseSuccessOrError = ParseSuccess | ParseError; export type UniqueID = Brand<'UniqueID', string>; +export type McpVersion = Brand<'McpVersion', string>; + +export type ExtensionInfo = { + instanceId: UniqueID; + version: string; + mode: vscode.ExtensionMode; + mcpVersion: McpVersion; +}; + export type Port = Brand<'Port', number>; export type ConnectionType = 'DHC'; diff --git a/src/util/extensionApiUtils.ts b/src/util/extensionApiUtils.ts index 066b5c1e7..e40a7a5d2 100644 --- a/src/util/extensionApiUtils.ts +++ b/src/util/extensionApiUtils.ts @@ -1,4 +1,6 @@ import * as vscode from 'vscode'; +import type { ExtensionInfo, McpVersion } from '../types'; +import { uniqueId } from './idUtils'; const MS_PYTHON_EXTENSION_ID = 'ms-python.python'; @@ -9,13 +11,27 @@ interface MsPythonExtensionApi { }; } -/** Get Microsoft Python extension api */ -export function getMsPythonExtensionApi(): - | vscode.Extension - | undefined { - return vscode.extensions.getExtension( - MS_PYTHON_EXTENSION_ID - ); +/** Create ExtensionInfo from the ExtensionContext */ +export function createExtensionInfo( + context: vscode.ExtensionContext +): ExtensionInfo { + const instanceId = uniqueId(8); + const version = getExtensionVersion(context); + + // In development mode, append instanceId to force MCP tool cache refresh per + // session + const mcpVersion = ( + context.extensionMode === vscode.ExtensionMode.Development + ? `${version}-${instanceId}` + : version + ) as McpVersion; + + return { + instanceId, + version, + mode: context.extensionMode, + mcpVersion, + }; } /** Get the extension version from the ExtensionContext */ @@ -26,3 +42,12 @@ export function getExtensionVersion(context: vscode.ExtensionContext): string { } return version; } + +/** Get Microsoft Python extension api */ +export function getMsPythonExtensionApi(): + | vscode.Extension + | undefined { + return vscode.extensions.getExtension( + MS_PYTHON_EXTENSION_ID + ); +} From 46ee2b12f690120de4139e6a931047a179074bf2 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Fri, 23 Jan 2026 19:02:19 -0600 Subject: [PATCH 04/19] registered connectToServerTool (DH-20954-3) --- src/mcp/McpServer.ts | 2 ++ src/mcp/tools/connectToServer.ts | 21 ++++++++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/mcp/McpServer.ts b/src/mcp/McpServer.ts index 65b5e0440..6a473a1d1 100644 --- a/src/mcp/McpServer.ts +++ b/src/mcp/McpServer.ts @@ -12,6 +12,7 @@ import { } from './tools'; import { withResolvers } from '../util'; import { DisposableBase, type FilteredWorkspace } from '../services'; +import { createConnectToServerTool } from './tools/connectToServer'; /** * MCP Server for Deephaven extension. @@ -43,6 +44,7 @@ export class McpServer extends DisposableBase { version: '1.0.0', }); + this.registerTool(createConnectToServerTool(this)); this.registerTool(createRunCodeTool(this)); this.registerTool(createRunCodeFromUriTool(this)); this.registerTool(createListConnectionsTool(this)); diff --git a/src/mcp/tools/connectToServer.ts b/src/mcp/tools/connectToServer.ts index 6fdccb562..d6ee52578 100644 --- a/src/mcp/tools/connectToServer.ts +++ b/src/mcp/tools/connectToServer.ts @@ -31,9 +31,11 @@ type HandlerArg = McpToolHandlerArg; type HandlerResult = McpToolHandlerResult; type ConnectToServerTool = McpTool; -export function createConnectToServerTool( - serverManager: IServerManager -): ConnectToServerTool { +export function createConnectToServerTool({ + serverManager, +}: { + serverManager: IServerManager; +}): ConnectToServerTool { return { name: 'connectToServer', spec, @@ -41,6 +43,7 @@ export function createConnectToServerTool( const response = new McpToolResponse(); const parsedUrlResult = parseUrl(url); + if (!parsedUrlResult.success) { return response.errorWithHint( 'Invalid server URL', @@ -50,9 +53,9 @@ export function createConnectToServerTool( ); } - const serverUrl = parsedUrlResult.value; - const server = serverManager.getServer(serverUrl); - if (!server) { + const server = serverManager.getServer(parsedUrlResult.value); + + if (server == null) { return response.errorWithHint( 'Server not found', undefined, @@ -62,7 +65,11 @@ export function createConnectToServerTool( } try { - await execConnectToServer({ type: server.type, url: serverUrl }); + await execConnectToServer({ + type: server.type, + url: parsedUrlResult.value, + }); + return response.success('Connecting to server', { type: server.type, url, From 9e2db2e57820a2601ff96b59f087d353ad1fd728 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 26 Jan 2026 12:09:45 -0600 Subject: [PATCH 05/19] server utils tests (DH-20954-3) --- src/mcp/utils/serverUtils.spec.ts | 171 +++++++++++------------------- 1 file changed, 64 insertions(+), 107 deletions(-) diff --git a/src/mcp/utils/serverUtils.spec.ts b/src/mcp/utils/serverUtils.spec.ts index 4abcdcbbe..f61cf96a5 100644 --- a/src/mcp/utils/serverUtils.spec.ts +++ b/src/mcp/utils/serverUtils.spec.ts @@ -1,66 +1,39 @@ import { describe, expect, it } from 'vitest'; import { connectionToResult, serverToResult } from './serverUtils'; -import type { ConnectionState, ServerState, Psk } from '../../types'; +import type { ConnectionState, ServerState, Psk, UniqueID } from '../../types'; +import { boolValues, matrix } from '../../testUtils'; describe('serverUtils', () => { const serverUrl = new URL('http://localhost:10000'); - const tagId = 'worker-1'; - const psk = 'test-psk' as Psk; describe('connectionToResult', () => { - it.each([ - { - name: 'connection with all fields', - connection: { - isConnected: true, - isRunningCode: true, + it.each( + matrix(boolValues, boolValues, ['mock-tag' as UniqueID, undefined]) + )( + 'should map isConnected=%s, isRunningCode=%s', + (isConnected, isRunningCode, tagId) => { + const resultWithoutTag = connectionToResult({ + isConnected, + isRunningCode, serverUrl, tagId, - } as ConnectionState, - expected: { - isConnected: true, - isRunningCode: true, - serverUrl: serverUrl.toString(), - tagId, - }, - }, - { - name: 'connection without tagId', - connection: { - isConnected: true, - isRunningCode: false, - serverUrl, - } as ConnectionState, - expected: { - isConnected: true, - isRunningCode: false, - serverUrl: serverUrl.toString(), - tagId: undefined, - }, - }, - { - name: 'disconnected connection', - connection: { - isConnected: false, - isRunningCode: false, - serverUrl, - tagId, - } as ConnectionState, - expected: { - isConnected: false, - isRunningCode: false, + }); + + expect(resultWithoutTag, 'without tag').toEqual({ + isConnected, + isRunningCode, serverUrl: serverUrl.toString(), tagId, - }, - }, - ])('should map $name', ({ connection, expected }) => { - const result = connectionToResult(connection); - - expect(result).toEqual(expected); - }); + }); + } + ); }); describe('serverToResult', () => { + const label = 'mock label'; + const psk = 'test-psk' as Psk; + const tagId = 'mock-tag' as UniqueID; + const MOCK_CONNECTION: ConnectionState = { isConnected: true, isRunningCode: true, @@ -68,66 +41,50 @@ describe('serverUtils', () => { tagId, } as ConnectionState; - it.each([ - { - name: 'managed server with connections', - server: { - type: 'DHE', - url: serverUrl, - label: 'Test Server', - isConnected: true, - isRunning: true, - connectionCount: 1, - isManaged: true, + it.each( + matrix( + boolValues, + boolValues, + boolValues, + ['DHC', 'DHE'], + [1, 2], + [[MOCK_CONNECTION], []] + ) + )( + 'should map isConnected=%s, isRunning=%s, isManaged=%s, type=%s, connectionCount=%d, connections=%o', + ( + isConnected, + isRunning, + isManaged, + type, + connectionCount, + connections + ) => { + const server: ServerState = { + type, + connectionCount, + isConnected, + isRunning, + isManaged, + label, psk, - } as ServerState, - connections: [MOCK_CONNECTION], - expected: { - type: 'DHE', - url: serverUrl.toString(), - label: 'Test Server', - isConnected: true, - isRunning: true, - connectionCount: 1, - isManaged: true, - tags: ['pip', 'managed'], - connections: [ - { - isConnected: true, - isRunningCode: true, - serverUrl: serverUrl.toString(), - tagId, - }, - ], - }, - }, - { - name: 'unmanaged server without label', - server: { - type: 'DHC', url: serverUrl, - isConnected: false, - isRunning: false, - connectionCount: 0, - isManaged: false, - } as ServerState, - connections: [], - expected: { - type: 'DHC', - url: serverUrl.toString(), - label: undefined, - isConnected: false, - isRunning: false, - connectionCount: 0, - isManaged: false, - tags: [], - connections: [], - }, - }, - ])('should map $name', ({ server, connections, expected }) => { - const result = serverToResult(server, connections); + } as ServerState; + + const result = serverToResult(server, connections); - expect(result).toEqual(expected); - }); + expect(result).toEqual({ + type, + url: serverUrl.toString(), + connectionCount, + label, + isConnected, + isManaged, + isRunning, + tags: isManaged ? ['pip', 'managed'] : [], + connections: connections.map(connectionToResult), + }); + } + ); }); }); From efb3f7f51be6467474e9c9d3bacaac538daa1d1c Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 26 Jan 2026 12:11:16 -0600 Subject: [PATCH 06/19] fixed test label (DH-20954-3) --- src/mcp/utils/serverUtils.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/utils/serverUtils.spec.ts b/src/mcp/utils/serverUtils.spec.ts index f61cf96a5..0be8c69bf 100644 --- a/src/mcp/utils/serverUtils.spec.ts +++ b/src/mcp/utils/serverUtils.spec.ts @@ -10,7 +10,7 @@ describe('serverUtils', () => { it.each( matrix(boolValues, boolValues, ['mock-tag' as UniqueID, undefined]) )( - 'should map isConnected=%s, isRunningCode=%s', + 'should map isConnected=%s, isRunningCode=%s, tagId=%s', (isConnected, isRunningCode, tagId) => { const resultWithoutTag = connectionToResult({ isConnected, From 12404c7969d81bdf7ab0b823e24d5ffcae46c436 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 26 Jan 2026 12:23:47 -0600 Subject: [PATCH 07/19] moved to matrixObject (DH-20954-3) --- src/mcp/utils/serverUtils.spec.ts | 36 +++++++++++++++++-------------- src/testUtils.ts | 33 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/src/mcp/utils/serverUtils.spec.ts b/src/mcp/utils/serverUtils.spec.ts index 0be8c69bf..e133fa444 100644 --- a/src/mcp/utils/serverUtils.spec.ts +++ b/src/mcp/utils/serverUtils.spec.ts @@ -1,17 +1,21 @@ import { describe, expect, it } from 'vitest'; import { connectionToResult, serverToResult } from './serverUtils'; import type { ConnectionState, ServerState, Psk, UniqueID } from '../../types'; -import { boolValues, matrix } from '../../testUtils'; +import { boolValues, matrixObject } from '../../testUtils'; describe('serverUtils', () => { const serverUrl = new URL('http://localhost:10000'); describe('connectionToResult', () => { it.each( - matrix(boolValues, boolValues, ['mock-tag' as UniqueID, undefined]) + matrixObject({ + isConnected: boolValues, + isRunningCode: boolValues, + tagId: ['mock-tag' as UniqueID, undefined], + }) )( - 'should map isConnected=%s, isRunningCode=%s, tagId=%s', - (isConnected, isRunningCode, tagId) => { + 'should map isConnected=$isConnected, isRunningCode=$isRunningCode, tagId=$tagId', + ({ isConnected, isRunningCode, tagId }) => { const resultWithoutTag = connectionToResult({ isConnected, isRunningCode, @@ -42,24 +46,24 @@ describe('serverUtils', () => { } as ConnectionState; it.each( - matrix( - boolValues, - boolValues, - boolValues, - ['DHC', 'DHE'], - [1, 2], - [[MOCK_CONNECTION], []] - ) + matrixObject({ + isConnected: boolValues, + isRunning: boolValues, + isManaged: boolValues, + type: ['DHC', 'DHE'], + connectionCount: [1, 2], + connections: [[MOCK_CONNECTION], []], + }) )( - 'should map isConnected=%s, isRunning=%s, isManaged=%s, type=%s, connectionCount=%d, connections=%o', - ( + 'should map isConnected=$isConnected, isRunning=$isRunning, isManaged=$isManaged, type=$type, connectionCount=$connectionCount, connections=$connections', + ({ isConnected, isRunning, isManaged, type, connectionCount, - connections - ) => { + connections, + }) => { const server: ServerState = { type, connectionCount, diff --git a/src/testUtils.ts b/src/testUtils.ts index 3cf2323d7..3f03829c6 100644 --- a/src/testUtils.ts +++ b/src/testUtils.ts @@ -87,6 +87,39 @@ export function matrix< ); } +/** + * Generate an array of all possible combinations of the given object's property + * values. Each property should be an array of possible values for that property. + * + * e.g. + * matrixObject({ a: [1, 2], b: ['x', 'y'] }) => [ + * { a: 1, b: 'x' }, + * { a: 1, b: 'y' }, + * { a: 2, b: 'x' }, + * { a: 2, b: 'y' }, + * ] + * + * @param obj Object where each property is an array of possible values + * @returns Array of objects with all possible combinations + */ +export function matrixObject< + T extends Record, + TReturn extends { [K in keyof T]: T[K][number] }, +>(obj: T): TReturn[] { + const keys = Object.keys(obj) as (keyof T)[]; + const values = keys.map(key => obj[key]); + + const combinations = matrix(...values); + + return combinations.map(combo => { + const result = {} as TReturn; + keys.forEach((key, index) => { + result[key] = combo[index] as TReturn[keyof T]; + }); + return result; + }); +} + function lineAt(this: vscode.TextDocument, line: number): vscode.TextLine; function lineAt( this: vscode.TextDocument, From 3194190d2f64f98b1fbff34a880c24fdd0563b35 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 26 Jan 2026 12:27:29 -0600 Subject: [PATCH 08/19] sorted props (DH-20954-3) --- src/mcp/utils/serverUtils.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/mcp/utils/serverUtils.ts b/src/mcp/utils/serverUtils.ts index 1f82732b4..c40cfc845 100644 --- a/src/mcp/utils/serverUtils.ts +++ b/src/mcp/utils/serverUtils.ts @@ -10,14 +10,14 @@ export const connectionResultSchema = z.object({ export const serverResultSchema = z.object({ type: z.string(), - url: z.string(), - label: z.string().optional(), - isConnected: z.boolean(), - isRunning: z.boolean(), connectionCount: z.number(), + connections: z.array(connectionResultSchema).optional(), + isConnected: z.boolean(), isManaged: z.boolean().optional(), + isRunning: z.boolean(), + label: z.string().optional(), tags: z.array(z.string()).optional(), - connections: z.array(connectionResultSchema).optional(), + url: z.string(), }); export type ConnectionResult = z.infer; From 5b7b7483bf279509ce2b06d32e4d20d6999f18fa Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 26 Jan 2026 12:31:16 -0600 Subject: [PATCH 09/19] removed unnecessary logic (DH-20954-3) --- src/mcp/utils/serverUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/utils/serverUtils.ts b/src/mcp/utils/serverUtils.ts index c40cfc845..54359f866 100644 --- a/src/mcp/utils/serverUtils.ts +++ b/src/mcp/utils/serverUtils.ts @@ -36,7 +36,7 @@ export function connectionToResult({ isConnected, isRunningCode, serverUrl: serverUrl.toString(), - tagId: tagId ? String(tagId) : undefined, + tagId, }; } From a109337eecd19584aea1d6755645d6997ab388b7 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 26 Jan 2026 12:32:37 -0600 Subject: [PATCH 10/19] sorting (DH-20954-3) --- src/mcp/utils/serverUtils.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/mcp/utils/serverUtils.ts b/src/mcp/utils/serverUtils.ts index 54359f866..6cc52e7a6 100644 --- a/src/mcp/utils/serverUtils.ts +++ b/src/mcp/utils/serverUtils.ts @@ -46,24 +46,24 @@ export function connectionToResult({ export function serverToResult( { type, - url, - label, - isConnected, - isRunning, connectionCount, + isConnected, isManaged, + isRunning, + label, + url, }: ServerState, connections: ConnectionState[] ): ServerResult { return { type, - url: url.toString(), - label, - isConnected, - isRunning, connectionCount, + isConnected, isManaged, + isRunning, + label, tags: isManaged ? ['pip', 'managed'] : [], + url: url.toString(), connections: connections.map(connectionToResult), }; } From 76f6fa7af2bb6b9876cf36c133f4828328ae03ff Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 26 Jan 2026 12:36:11 -0600 Subject: [PATCH 11/19] Branded type for ExtensionVersion (DH-20954-3) --- src/types/commonTypes.d.ts | 8 +++++--- src/util/extensionApiUtils.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/types/commonTypes.d.ts b/src/types/commonTypes.d.ts index 53a738484..f46a3ff44 100644 --- a/src/types/commonTypes.d.ts +++ b/src/types/commonTypes.d.ts @@ -11,6 +11,10 @@ import type { } from '@deephaven-enterprise/auth-nodejs'; import type { Brand, QuerySerial, SerializableRefreshToken } from '../shared'; +export type ExtensionVersion = Brand<'ExtensionVersion', string>; + +export type McpVersion = Brand<'McpVersion', string>; + export type NonEmptyArray = [T, ...T[]]; export type ParseSuccess = { success: true; value: T }; @@ -21,11 +25,9 @@ export type ParseSuccessOrError = ParseSuccess | ParseError; export type UniqueID = Brand<'UniqueID', string>; -export type McpVersion = Brand<'McpVersion', string>; - export type ExtensionInfo = { instanceId: UniqueID; - version: string; + version: ExtensionVersion; mode: vscode.ExtensionMode; mcpVersion: McpVersion; }; diff --git a/src/util/extensionApiUtils.ts b/src/util/extensionApiUtils.ts index e40a7a5d2..f7bdc675b 100644 --- a/src/util/extensionApiUtils.ts +++ b/src/util/extensionApiUtils.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import type { ExtensionInfo, McpVersion } from '../types'; +import type { ExtensionInfo, ExtensionVersion, McpVersion } from '../types'; import { uniqueId } from './idUtils'; const MS_PYTHON_EXTENSION_ID = 'ms-python.python'; @@ -35,12 +35,15 @@ export function createExtensionInfo( } /** Get the extension version from the ExtensionContext */ -export function getExtensionVersion(context: vscode.ExtensionContext): string { +export function getExtensionVersion( + context: vscode.ExtensionContext +): ExtensionVersion { const version = context.extension.packageJSON.version; if (typeof version !== 'string') { throw new Error('Extension version is not a string'); } - return version; + + return version as ExtensionVersion; } /** Get Microsoft Python extension api */ From 9c2454e7ed01ebccf4d4e8bff9a47372dac0d911 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 26 Jan 2026 13:07:09 -0600 Subject: [PATCH 12/19] connectToServer tests (DH-20954-3) --- src/mcp/tools/connectToServer.spec.ts | 181 ++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 src/mcp/tools/connectToServer.spec.ts diff --git a/src/mcp/tools/connectToServer.spec.ts b/src/mcp/tools/connectToServer.spec.ts new file mode 100644 index 000000000..4332ebc26 --- /dev/null +++ b/src/mcp/tools/connectToServer.spec.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createConnectToServerTool } from './connectToServer'; +import type { IServerManager, ServerState } from '../../types'; +import { McpToolResponse } from '../utils/mcpUtils'; +import * as uriUtils from '../../util/uriUtils'; +import * as commands from '../../common/commands'; + +vi.mock('vscode'); +vi.mock('../../util/uriUtils', async () => { + return { + parseUrl: vi.fn(), + }; +}); +vi.mock('../../common/commands', async () => { + return { + execConnectToServer: vi.fn(), + }; +}); + +const MOCK_EXECUTION_TIME_MS = 100; +const MOCK_URL = 'http://localhost:10000'; +const MOCK_PARSED_URL = new URL(MOCK_URL); +const MOCK_SERVER = { + type: 'DHC', + url: MOCK_PARSED_URL, +} as ServerState; + +const EXPECTED_INVALID_URL_ERROR = { + success: false, + message: 'Invalid server URL: Invalid URL format', + executionTimeMs: MOCK_EXECUTION_TIME_MS, + hint: "Please provide a valid URL (e.g., 'http://localhost:10000'). If this was a server label, use listServers to find the corresponding URL.", + details: { + url: 'invalid-url', + }, +}; + +const EXPECTED_SERVER_NOT_FOUND_ERROR = { + success: false, + message: 'Server not found', + executionTimeMs: MOCK_EXECUTION_TIME_MS, + hint: 'Use listServers to see available servers.', + details: { + url: MOCK_URL, + }, +}; + +const EXPECTED_CONNECTION_ERROR = { + success: false, + message: 'Failed to connect to server: Connection command error', + executionTimeMs: MOCK_EXECUTION_TIME_MS, +}; + +describe('createConnectToServerTool', () => { + const serverManager: IServerManager = { + getServer: vi.fn(), + } as unknown as IServerManager; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(McpToolResponse.prototype, 'getElapsedTimeMs').mockReturnValue( + MOCK_EXECUTION_TIME_MS + ); + }); + + it('should create tool with correct name and spec', () => { + const tool = createConnectToServerTool({ serverManager }); + + expect(tool.name).toBe('connectToServer'); + expect(tool.spec.title).toBe('Connect to Server'); + expect(tool.spec.description).toContain('Create a connection'); + }); + + describe('handler', () => { + it.each([{ type: 'DHC' as const }, { type: 'DHE' as const }])( + 'should connect to $type server successfully', + async ({ type }) => { + const server: ServerState = { + ...MOCK_SERVER, + type, + }; + + vi.mocked(uriUtils.parseUrl).mockReturnValue({ + success: true, + value: MOCK_PARSED_URL, + }); + vi.mocked(serverManager.getServer).mockReturnValue(server); + vi.mocked(commands.execConnectToServer).mockResolvedValue(undefined); + + const tool = createConnectToServerTool({ serverManager }); + const result = await tool.handler({ url: MOCK_URL }); + + expect(uriUtils.parseUrl).toHaveBeenCalledWith(MOCK_URL); + expect(serverManager.getServer).toHaveBeenCalledWith(MOCK_PARSED_URL); + expect(commands.execConnectToServer).toHaveBeenCalledWith({ + type, + url: MOCK_PARSED_URL, + }); + + const expected = { + success: true, + message: 'Connecting to server', + executionTimeMs: MOCK_EXECUTION_TIME_MS, + details: { + type, + url: MOCK_URL, + }, + }; + + expect(result.structuredContent).toEqual(expected); + } + ); + + it.each([ + { + testName: 'invalid URL', + url: 'invalid-url', + parseResult: { success: false as const, error: 'Invalid URL format' }, + serverResult: undefined, + commandError: undefined, + expected: EXPECTED_INVALID_URL_ERROR, + }, + { + testName: 'server not found', + url: MOCK_URL, + parseResult: { success: true as const, value: MOCK_PARSED_URL }, + serverResult: undefined, + commandError: undefined, + expected: EXPECTED_SERVER_NOT_FOUND_ERROR, + }, + { + testName: 'connection command error', + url: MOCK_URL, + parseResult: { success: true as const, value: MOCK_PARSED_URL }, + serverResult: MOCK_SERVER, + commandError: new Error('Connection command error'), + expected: EXPECTED_CONNECTION_ERROR, + }, + ])( + 'should handle $testName', + async ({ url, parseResult, serverResult, commandError, expected }) => { + vi.mocked(uriUtils.parseUrl).mockReturnValue(parseResult); + vi.mocked(serverManager.getServer).mockReturnValue(serverResult); + + if (commandError !== undefined) { + vi.mocked(commands.execConnectToServer).mockRejectedValue( + commandError + ); + } else { + vi.mocked(commands.execConnectToServer).mockResolvedValue(undefined); + } + + const tool = createConnectToServerTool({ serverManager }); + const result = await tool.handler({ url }); + + expect(uriUtils.parseUrl).toHaveBeenCalledWith(url); + + if (parseResult.success) { + expect(serverManager.getServer).toHaveBeenCalledWith( + parseResult.value + ); + + if (serverResult !== undefined) { + expect(commands.execConnectToServer).toHaveBeenCalledWith({ + type: serverResult.type, + url: parseResult.value, + }); + } else { + expect(commands.execConnectToServer).not.toHaveBeenCalled(); + } + } else { + expect(serverManager.getServer).not.toHaveBeenCalled(); + expect(commands.execConnectToServer).not.toHaveBeenCalled(); + } + + expect(result.structuredContent).toEqual(expected); + } + ); + }); +}); From bbfffefd393c3f5d0dea04e68e4797ab11d918a8 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 26 Jan 2026 13:09:30 -0600 Subject: [PATCH 13/19] Made spec description tests explicit (DH-20954-3) --- src/mcp/tools/connectToServer.spec.ts | 4 +++- src/mcp/tools/listServers.spec.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/mcp/tools/connectToServer.spec.ts b/src/mcp/tools/connectToServer.spec.ts index 4332ebc26..f7c02918f 100644 --- a/src/mcp/tools/connectToServer.spec.ts +++ b/src/mcp/tools/connectToServer.spec.ts @@ -69,7 +69,9 @@ describe('createConnectToServerTool', () => { expect(tool.name).toBe('connectToServer'); expect(tool.spec.title).toBe('Connect to Server'); - expect(tool.spec.description).toContain('Create a connection'); + expect(tool.spec.description).toBe( + 'Create a connection to a Deephaven server. The server must already be configured in the extension. For DHE (Enterprise) servers, this will create a new worker.' + ); }); describe('handler', () => { diff --git a/src/mcp/tools/listServers.spec.ts b/src/mcp/tools/listServers.spec.ts index 4f9047000..fb9ac73d3 100644 --- a/src/mcp/tools/listServers.spec.ts +++ b/src/mcp/tools/listServers.spec.ts @@ -59,7 +59,9 @@ describe('createListServersTool', () => { expect(tool.name).toBe('listServers'); expect(tool.spec.title).toBe('List Servers'); - expect(tool.spec.description).toContain('List all Deephaven servers'); + expect(tool.spec.description).toBe( + 'List all Deephaven servers with optional filtering by running status, connection status, or type.' + ); }); describe('handler', () => { From e0d8547fa968e73da183c6cff6684aa21cdb3e20 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 26 Jan 2026 13:13:38 -0600 Subject: [PATCH 14/19] simplified tests (DH-20954-3) --- src/mcp/tools/connectToServer.spec.ts | 68 ++++++++++++--------------- 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/src/mcp/tools/connectToServer.spec.ts b/src/mcp/tools/connectToServer.spec.ts index f7c02918f..b604abc23 100644 --- a/src/mcp/tools/connectToServer.spec.ts +++ b/src/mcp/tools/connectToServer.spec.ts @@ -75,44 +75,36 @@ describe('createConnectToServerTool', () => { }); describe('handler', () => { - it.each([{ type: 'DHC' as const }, { type: 'DHE' as const }])( - 'should connect to $type server successfully', - async ({ type }) => { - const server: ServerState = { - ...MOCK_SERVER, - type, - }; - - vi.mocked(uriUtils.parseUrl).mockReturnValue({ - success: true, - value: MOCK_PARSED_URL, - }); - vi.mocked(serverManager.getServer).mockReturnValue(server); - vi.mocked(commands.execConnectToServer).mockResolvedValue(undefined); - - const tool = createConnectToServerTool({ serverManager }); - const result = await tool.handler({ url: MOCK_URL }); - - expect(uriUtils.parseUrl).toHaveBeenCalledWith(MOCK_URL); - expect(serverManager.getServer).toHaveBeenCalledWith(MOCK_PARSED_URL); - expect(commands.execConnectToServer).toHaveBeenCalledWith({ - type, - url: MOCK_PARSED_URL, - }); - - const expected = { - success: true, - message: 'Connecting to server', - executionTimeMs: MOCK_EXECUTION_TIME_MS, - details: { - type, - url: MOCK_URL, - }, - }; - - expect(result.structuredContent).toEqual(expected); - } - ); + it('should connect to server successfully', async () => { + vi.mocked(uriUtils.parseUrl).mockReturnValue({ + success: true, + value: MOCK_PARSED_URL, + }); + vi.mocked(serverManager.getServer).mockReturnValue(MOCK_SERVER); + vi.mocked(commands.execConnectToServer).mockResolvedValue(undefined); + + const tool = createConnectToServerTool({ serverManager }); + const result = await tool.handler({ url: MOCK_URL }); + + expect(uriUtils.parseUrl).toHaveBeenCalledWith(MOCK_URL); + expect(serverManager.getServer).toHaveBeenCalledWith(MOCK_PARSED_URL); + expect(commands.execConnectToServer).toHaveBeenCalledWith({ + type: MOCK_SERVER.type, + url: MOCK_PARSED_URL, + }); + + const expected = { + success: true, + message: 'Connecting to server', + executionTimeMs: MOCK_EXECUTION_TIME_MS, + details: { + type: MOCK_SERVER.type, + url: MOCK_URL, + }, + }; + + expect(result.structuredContent).toEqual(expected); + }); it.each([ { From 7fdd22b84527c5a934ec369e209e44004b8e21c5 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 26 Jan 2026 13:16:56 -0600 Subject: [PATCH 15/19] split out const (DH-20954-3) --- src/mcp/tools/connectToServer.spec.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/mcp/tools/connectToServer.spec.ts b/src/mcp/tools/connectToServer.spec.ts index b604abc23..aac032986 100644 --- a/src/mcp/tools/connectToServer.spec.ts +++ b/src/mcp/tools/connectToServer.spec.ts @@ -51,6 +51,16 @@ const EXPECTED_CONNECTION_ERROR = { executionTimeMs: MOCK_EXECUTION_TIME_MS, }; +const EXPECTED_SUCCESS = { + success: true, + message: 'Connecting to server', + executionTimeMs: MOCK_EXECUTION_TIME_MS, + details: { + type: 'DHC', + url: MOCK_URL, + }, +}; + describe('createConnectToServerTool', () => { const serverManager: IServerManager = { getServer: vi.fn(), @@ -93,17 +103,7 @@ describe('createConnectToServerTool', () => { url: MOCK_PARSED_URL, }); - const expected = { - success: true, - message: 'Connecting to server', - executionTimeMs: MOCK_EXECUTION_TIME_MS, - details: { - type: MOCK_SERVER.type, - url: MOCK_URL, - }, - }; - - expect(result.structuredContent).toEqual(expected); + expect(result.structuredContent).toEqual(EXPECTED_SUCCESS); }); it.each([ From f0c82dbb7c5c1e9ab565dccba13d641f09aeaa39 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 26 Jan 2026 13:26:58 -0600 Subject: [PATCH 16/19] fixed failing tests (DH-20954-3) --- src/util/dataUtils.spec.ts | 4 +++- src/util/tmpUtils.spec.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/util/dataUtils.spec.ts b/src/util/dataUtils.spec.ts index efeba2dd6..3b23c952e 100644 --- a/src/util/dataUtils.spec.ts +++ b/src/util/dataUtils.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import type { dh as DhType } from '@deephaven/jsapi-types'; import { parseSamlScopes, serializeRefreshToken } from './dataUtils'; import { @@ -6,6 +6,8 @@ import { DH_SAML_SERVER_URL_SCOPE_KEY, } from '../common'; +vi.mock('vscode'); + describe('parseSamlScopes', () => { it('should return null if no SAML scopes are found', () => { const scopes = ['scope1', 'scope2']; diff --git a/src/util/tmpUtils.spec.ts b/src/util/tmpUtils.spec.ts index 028f3123e..fc644918d 100644 --- a/src/util/tmpUtils.spec.ts +++ b/src/util/tmpUtils.spec.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import { getTempDir } from './tmpUtils'; import { TMP_DIR_ROOT } from '../common'; +vi.mock('vscode'); vi.mock('node:fs'); beforeEach(() => { From f1c280c6b797d06e844ee65f658e06837bb5fe3a Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 26 Jan 2026 14:46:51 -0600 Subject: [PATCH 17/19] Moved agent instructions (DH-20954-3) --- .github/copilot-instructions.md => AGENTS.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/copilot-instructions.md => AGENTS.md (100%) diff --git a/.github/copilot-instructions.md b/AGENTS.md similarity index 100% rename from .github/copilot-instructions.md rename to AGENTS.md From 402711f62e8e32675226ecd82d3c1e85cc884630 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Wed, 28 Jan 2026 13:26:01 -0600 Subject: [PATCH 18/19] Explicitly set preLaunchTask (#DH-20954-3) --- .vscode/launch.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index bd4a4ab8d..463f6fdac 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,7 @@ "request": "launch", "args": ["--extensionDevelopmentPath=${workspaceFolder}"], "outFiles": ["${workspaceFolder}/out/**/*.js"], - "preLaunchTask": "${defaultBuildTask}" + "preLaunchTask": "watch" }, { "name": "e2e Tests", @@ -21,7 +21,7 @@ "args": ["--debug"], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", - "preLaunchTask": "${defaultBuildTask}" + "preLaunchTask": "watch" } ] } From 938fe1c4cfd4708e58c01082f1e04ef3c7019d00 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 26 Jan 2026 17:50:25 -0600 Subject: [PATCH 19/19] feat: Add Pip Server Management MCP tools Implement checkPythonEnvironment and startPipServer MCP tools: - checkPythonEnvironment: Checks if Python environment supports Deephaven pip server - startPipServer: Starts a managed Deephaven pip server Key changes: - Created MCP tools following established patterns - Added pipServerController dependency to McpServer - Wired dependency through McpController and ExtensionController - Full test coverage with 10 passing tests - Includes helpful hints for missing dependencies (#copilot-worktree-2026-01) --- src/controllers/ExtensionController.ts | 3 +- src/controllers/McpController.ts | 15 ++- src/mcp/McpServer.ts | 16 ++- src/mcp/tools/checkPythonEnvironment.spec.ts | 113 ++++++++++++++++++ src/mcp/tools/checkPythonEnvironment.ts | 64 +++++++++++ src/mcp/tools/index.ts | 2 + src/mcp/tools/startPipServer.spec.ts | 115 +++++++++++++++++++ src/mcp/tools/startPipServer.ts | 59 ++++++++++ 8 files changed, 381 insertions(+), 6 deletions(-) create mode 100644 src/mcp/tools/checkPythonEnvironment.spec.ts create mode 100644 src/mcp/tools/checkPythonEnvironment.ts create mode 100644 src/mcp/tools/startPipServer.spec.ts create mode 100644 src/mcp/tools/startPipServer.ts diff --git a/src/controllers/ExtensionController.ts b/src/controllers/ExtensionController.ts index 5732a7795..5c1117f0f 100644 --- a/src/controllers/ExtensionController.ts +++ b/src/controllers/ExtensionController.ts @@ -427,7 +427,8 @@ export class ExtensionController implements IDisposable { this._extensionInfo.mcpVersion, this._serverManager, this._pythonDiagnostics, - this._pythonWorkspace + this._pythonWorkspace, + this._pipServerController ); this._context.subscriptions.push(this._mcpController); diff --git a/src/controllers/McpController.ts b/src/controllers/McpController.ts index af0010dcf..3948bd6c4 100644 --- a/src/controllers/McpController.ts +++ b/src/controllers/McpController.ts @@ -2,7 +2,12 @@ import * as vscode from 'vscode'; import { ControllerBase } from './ControllerBase'; import { McpServer } from '../mcp'; import { McpServerDefinitionProvider } from '../providers'; -import type { IServerManager, IConfigService, McpVersion } from '../types'; +import type { + IServerManager, + IConfigService, + McpVersion, + PipServerController, +} from '../types'; import type { FilteredWorkspace } from '../services'; import { isWindsurf, Logger } from '../util'; import { @@ -24,6 +29,7 @@ export class McpController extends ControllerBase { private _serverManager: IServerManager; private _pythonDiagnostics: vscode.DiagnosticCollection; private _pythonWorkspace: FilteredWorkspace; + private _pipServerController: PipServerController | null; private _mcpServer: McpServer | null = null; private _mcpServerDefinitionProvider: McpServerDefinitionProvider | null = @@ -36,7 +42,8 @@ export class McpController extends ControllerBase { mcpVersion: McpVersion, serverManager: IServerManager, pythonDiagnostics: vscode.DiagnosticCollection, - pythonWorkspace: FilteredWorkspace + pythonWorkspace: FilteredWorkspace, + pipServerController: PipServerController | null ) { super(); @@ -46,6 +53,7 @@ export class McpController extends ControllerBase { this._serverManager = serverManager; this._pythonDiagnostics = pythonDiagnostics; this._pythonWorkspace = pythonWorkspace; + this._pipServerController = pipServerController; // Register copy MCP URL command this.registerCommand(COPY_MCP_URL_CMD, this.copyUrl, this); @@ -101,7 +109,8 @@ export class McpController extends ControllerBase { this._mcpServer = new McpServer( this._pythonDiagnostics, this._pythonWorkspace, - this._serverManager + this._serverManager, + this._pipServerController ); this.disposables.push(this._mcpServer); diff --git a/src/mcp/McpServer.ts b/src/mcp/McpServer.ts index 6a473a1d1..852a39482 100644 --- a/src/mcp/McpServer.ts +++ b/src/mcp/McpServer.ts @@ -2,13 +2,20 @@ import * as vscode from 'vscode'; import { McpServer as SdkMcpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import * as http from 'http'; -import type { IServerManager, McpTool, McpToolSpec } from '../types'; +import type { + IServerManager, + McpTool, + McpToolSpec, + PipServerController, +} from '../types'; import { MCP_SERVER_NAME } from '../common'; import { + createCheckPythonEnvTool, createListConnectionsTool, createListServersTool, createRunCodeFromUriTool, createRunCodeTool, + createStartPipServerTool, } from './tools'; import { withResolvers } from '../util'; import { DisposableBase, type FilteredWorkspace } from '../services'; @@ -26,17 +33,20 @@ export class McpServer extends DisposableBase { readonly pythonDiagnostics: vscode.DiagnosticCollection; readonly pythonWorkspace: FilteredWorkspace; readonly serverManager: IServerManager; + readonly pipServerController: PipServerController | null; constructor( pythonDiagnostics: vscode.DiagnosticCollection, pythonWorkspace: FilteredWorkspace, - serverManager: IServerManager + serverManager: IServerManager, + pipServerController: PipServerController | null ) { super(); this.pythonDiagnostics = pythonDiagnostics; this.pythonWorkspace = pythonWorkspace; this.serverManager = serverManager; + this.pipServerController = pipServerController; // Create an MCP server this.server = new SdkMcpServer({ @@ -49,6 +59,8 @@ export class McpServer extends DisposableBase { this.registerTool(createRunCodeFromUriTool(this)); this.registerTool(createListConnectionsTool(this)); this.registerTool(createListServersTool(this)); + this.registerTool(createCheckPythonEnvTool(this)); + this.registerTool(createStartPipServerTool(this)); } private registerTool({ diff --git a/src/mcp/tools/checkPythonEnvironment.spec.ts b/src/mcp/tools/checkPythonEnvironment.spec.ts new file mode 100644 index 000000000..00567b30c --- /dev/null +++ b/src/mcp/tools/checkPythonEnvironment.spec.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createCheckPythonEnvTool } from './checkPythonEnvironment'; +import type { PipServerController } from '../../types'; +import { McpToolResponse } from '../utils/mcpUtils'; + +vi.mock('vscode'); + +const MOCK_EXECUTION_TIME_MS = 100; + +const EXPECTED_AVAILABLE = { + success: true, + message: 'Python environment is available', + details: { + isAvailable: true, + interpreterPath: '/path/to/python', + }, + executionTimeMs: MOCK_EXECUTION_TIME_MS, +} as const; + +const EXPECTED_NOT_AVAILABLE = { + success: true, + message: 'Python environment is not available', + details: { + isAvailable: false, + }, + executionTimeMs: MOCK_EXECUTION_TIME_MS, +} as const; + +const EXPECTED_CONTROLLER_NOT_AVAILABLE = { + success: false, + message: 'PipServerController not available', + executionTimeMs: MOCK_EXECUTION_TIME_MS, +} as const; + +const EXPECTED_CHECK_ERROR = { + success: false, + message: 'Failed to check Python environment: Check failed', + executionTimeMs: MOCK_EXECUTION_TIME_MS, +} as const; + +describe('checkPythonEnvironment', () => { + const mockPipServerController = { + checkPipInstall: vi.fn(), + } as unknown as PipServerController; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(McpToolResponse.prototype, 'getElapsedTimeMs').mockReturnValue( + MOCK_EXECUTION_TIME_MS + ); + }); + + it('should return correct tool spec', () => { + const tool = createCheckPythonEnvTool({ + pipServerController: mockPipServerController, + }); + + expect(tool.name).toBe('checkPythonEnvironment'); + expect(tool.spec.title).toBe('Check Python Environment'); + expect(tool.spec.description).toBe( + 'Check if the Python environment supports starting a Deephaven pip server.' + ); + }); + + it('should return error when pipServerController is null', async () => { + const tool = createCheckPythonEnvTool({ pipServerController: null }); + const result = await tool.handler({}); + + expect(result.structuredContent).toEqual(EXPECTED_CONTROLLER_NOT_AVAILABLE); + }); + + it('should return success when Python environment is available', async () => { + vi.mocked(mockPipServerController.checkPipInstall).mockResolvedValue({ + isAvailable: true, + interpreterPath: '/path/to/python', + }); + + const tool = createCheckPythonEnvTool({ + pipServerController: mockPipServerController, + }); + const result = await tool.handler({}); + + expect(mockPipServerController.checkPipInstall).toHaveBeenCalledOnce(); + expect(result.structuredContent).toEqual(EXPECTED_AVAILABLE); + }); + + it('should return success when Python environment is not available', async () => { + vi.mocked(mockPipServerController.checkPipInstall).mockResolvedValue({ + isAvailable: false, + }); + + const tool = createCheckPythonEnvTool({ + pipServerController: mockPipServerController, + }); + const result = await tool.handler({}); + + expect(mockPipServerController.checkPipInstall).toHaveBeenCalledOnce(); + expect(result.structuredContent).toEqual(EXPECTED_NOT_AVAILABLE); + }); + + it('should handle errors from checkPipInstall', async () => { + const error = new Error('Check failed'); + vi.mocked(mockPipServerController.checkPipInstall).mockRejectedValue(error); + + const tool = createCheckPythonEnvTool({ + pipServerController: mockPipServerController, + }); + const result = await tool.handler({}); + + expect(result.structuredContent).toEqual(EXPECTED_CHECK_ERROR); + }); +}); diff --git a/src/mcp/tools/checkPythonEnvironment.ts b/src/mcp/tools/checkPythonEnvironment.ts new file mode 100644 index 000000000..48a2982c7 --- /dev/null +++ b/src/mcp/tools/checkPythonEnvironment.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; +import type { + McpTool, + McpToolHandlerResult, + PipServerController, +} from '../../types'; +import { McpToolResponse } from '../utils'; + +const spec = { + title: 'Check Python Environment', + description: + 'Check if the Python environment supports starting a Deephaven pip server.', + inputSchema: {}, + outputSchema: { + success: z.boolean(), + message: z.string(), + executionTimeMs: z.number().describe('Execution time in milliseconds'), + details: z + .object({ + isAvailable: z.boolean(), + interpreterPath: z.string().optional(), + }) + .optional(), + }, +} as const; + +type Spec = typeof spec; +type HandlerResult = McpToolHandlerResult; +type CheckPythonEnvTool = McpTool; + +export function createCheckPythonEnvTool({ + pipServerController, +}: { + pipServerController: PipServerController | null; +}): CheckPythonEnvTool { + return { + name: 'checkPythonEnvironment', + spec, + handler: async (): Promise => { + const response = new McpToolResponse(); + + if (pipServerController == null) { + return response.error('PipServerController not available'); + } + + try { + const result = await pipServerController.checkPipInstall(); + + if (result.isAvailable) { + return response.success('Python environment is available', { + isAvailable: true, + interpreterPath: result.interpreterPath, + }); + } + + return response.success('Python environment is not available', { + isAvailable: false, + }); + } catch (error) { + return response.error('Failed to check Python environment', error); + } + }, + }; +} diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts index 93dd27bc5..ee93f1842 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -1,4 +1,6 @@ +export * from './checkPythonEnvironment'; export * from './listConnections'; export * from './listServers'; export * from './runCode'; export * from './runCodeFromUri'; +export * from './startPipServer'; diff --git a/src/mcp/tools/startPipServer.spec.ts b/src/mcp/tools/startPipServer.spec.ts new file mode 100644 index 000000000..15d2ba740 --- /dev/null +++ b/src/mcp/tools/startPipServer.spec.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createStartPipServerTool } from './startPipServer'; +import type { PipServerController } from '../../types'; +import { McpToolResponse } from '../utils/mcpUtils'; + +vi.mock('vscode'); + +const MOCK_EXECUTION_TIME_MS = 100; + +const EXPECTED_SUCCESS = { + success: true, + message: 'Pip server started successfully', + executionTimeMs: MOCK_EXECUTION_TIME_MS, +} as const; + +const EXPECTED_CONTROLLER_NOT_AVAILABLE = { + success: false, + message: 'PipServerController not available', + executionTimeMs: MOCK_EXECUTION_TIME_MS, +} as const; + +const EXPECTED_ENV_NOT_AVAILABLE = { + success: false, + message: 'Python environment is not available', + hint: 'Install the deephaven-server package with: pip install deephaven-server', + executionTimeMs: MOCK_EXECUTION_TIME_MS, +} as const; + +const EXPECTED_START_ERROR = { + success: false, + message: 'Failed to start pip server: Start failed', + executionTimeMs: MOCK_EXECUTION_TIME_MS, +} as const; + +describe('startPipServer', () => { + const mockPipServerController = { + checkPipInstall: vi.fn(), + startServer: vi.fn(), + } as unknown as PipServerController; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(McpToolResponse.prototype, 'getElapsedTimeMs').mockReturnValue( + MOCK_EXECUTION_TIME_MS + ); + }); + + it('should return correct tool spec', () => { + const tool = createStartPipServerTool({ + pipServerController: mockPipServerController, + }); + + expect(tool.name).toBe('startPipServer'); + expect(tool.spec.title).toBe('Start Pip Server'); + expect(tool.spec.description).toBe( + 'Start a managed Deephaven pip server if the environment supports it.' + ); + }); + + it('should return error when pipServerController is null', async () => { + const tool = createStartPipServerTool({ pipServerController: null }); + const result = await tool.handler({}); + + expect(result.structuredContent).toEqual(EXPECTED_CONTROLLER_NOT_AVAILABLE); + }); + + it('should return error with hint when Python environment is not available', async () => { + vi.mocked(mockPipServerController.checkPipInstall).mockResolvedValue({ + isAvailable: false, + }); + + const tool = createStartPipServerTool({ + pipServerController: mockPipServerController, + }); + const result = await tool.handler({}); + + expect(mockPipServerController.checkPipInstall).toHaveBeenCalledOnce(); + expect(mockPipServerController.startServer).not.toHaveBeenCalled(); + expect(result.structuredContent).toEqual(EXPECTED_ENV_NOT_AVAILABLE); + }); + + it('should successfully start pip server when environment is available', async () => { + vi.mocked(mockPipServerController.checkPipInstall).mockResolvedValue({ + isAvailable: true, + interpreterPath: '/path/to/python', + }); + vi.mocked(mockPipServerController.startServer).mockResolvedValue(undefined); + + const tool = createStartPipServerTool({ + pipServerController: mockPipServerController, + }); + const result = await tool.handler({}); + + expect(mockPipServerController.checkPipInstall).toHaveBeenCalledOnce(); + expect(mockPipServerController.startServer).toHaveBeenCalledOnce(); + expect(result.structuredContent).toEqual(EXPECTED_SUCCESS); + }); + + it('should handle errors from startServer', async () => { + vi.mocked(mockPipServerController.checkPipInstall).mockResolvedValue({ + isAvailable: true, + interpreterPath: '/path/to/python', + }); + const error = new Error('Start failed'); + vi.mocked(mockPipServerController.startServer).mockRejectedValue(error); + + const tool = createStartPipServerTool({ + pipServerController: mockPipServerController, + }); + const result = await tool.handler({}); + + expect(result.structuredContent).toEqual(EXPECTED_START_ERROR); + }); +}); diff --git a/src/mcp/tools/startPipServer.ts b/src/mcp/tools/startPipServer.ts new file mode 100644 index 000000000..dc814d058 --- /dev/null +++ b/src/mcp/tools/startPipServer.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; +import type { + McpTool, + McpToolHandlerResult, + PipServerController, +} from '../../types'; +import { McpToolResponse } from '../utils'; + +const spec = { + title: 'Start Pip Server', + description: + 'Start a managed Deephaven pip server if the environment supports it.', + inputSchema: {}, + outputSchema: { + success: z.boolean(), + message: z.string(), + executionTimeMs: z.number().describe('Execution time in milliseconds'), + hint: z.string().optional(), + }, +} as const; + +type Spec = typeof spec; +type HandlerResult = McpToolHandlerResult; +type StartPipServerTool = McpTool; + +export function createStartPipServerTool({ + pipServerController, +}: { + pipServerController: PipServerController | null; +}): StartPipServerTool { + return { + name: 'startPipServer', + spec, + handler: async (): Promise => { + const response = new McpToolResponse(); + + if (pipServerController == null) { + return response.error('PipServerController not available'); + } + + try { + const result = await pipServerController.checkPipInstall(); + + if (!result.isAvailable) { + return response.errorWithHint( + 'Python environment is not available', + null, + 'Install the deephaven-server package with: pip install deephaven-server' + ); + } + + await pipServerController.startServer(); + return response.success('Pip server started successfully'); + } catch (error) { + return response.error('Failed to start pip server', error); + } + }, + }; +}