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" } ] } diff --git a/.github/copilot-instructions.md b/AGENTS.md similarity index 100% rename from .github/copilot-instructions.md rename to AGENTS.md 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/controllers/ExtensionController.ts b/src/controllers/ExtensionController.ts index db5b6f6ae..5c1117f0f 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,9 +424,11 @@ export class ExtensionController implements IDisposable { this._mcpController = new McpController( this._context, this._config, + this._extensionInfo.mcpVersion, this._serverManager, this._pythonDiagnostics, - this._pythonWorkspace + this._pythonWorkspace, + this._pipServerController ); this._context.subscriptions.push(this._mcpController); @@ -486,7 +486,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..3948bd6c4 100644 --- a/src/controllers/McpController.ts +++ b/src/controllers/McpController.ts @@ -2,9 +2,14 @@ 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, + PipServerController, +} 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,9 +25,11 @@ 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; + private _pipServerController: PipServerController | null; private _mcpServer: McpServer | null = null; private _mcpServerDefinitionProvider: McpServerDefinitionProvider | null = @@ -32,17 +39,21 @@ export class McpController extends ControllerBase { constructor( context: vscode.ExtensionContext, config: IConfigService, + mcpVersion: McpVersion, serverManager: IServerManager, pythonDiagnostics: vscode.DiagnosticCollection, - pythonWorkspace: FilteredWorkspace + pythonWorkspace: FilteredWorkspace, + pipServerController: PipServerController | null ) { super(); this._context = context; this._config = config; + this._mcpVersion = mcpVersion; 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); @@ -98,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); @@ -135,7 +147,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/mcp/McpServer.ts b/src/mcp/McpServer.ts index 9d9b6e362..852a39482 100644 --- a/src/mcp/McpServer.ts +++ b/src/mcp/McpServer.ts @@ -2,15 +2,24 @@ 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'; +import { createConnectToServerTool } from './tools/connectToServer'; /** * MCP Server for Deephaven extension. @@ -24,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({ @@ -42,9 +54,13 @@ 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)); + 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/connectToServer.spec.ts b/src/mcp/tools/connectToServer.spec.ts new file mode 100644 index 000000000..aac032986 --- /dev/null +++ b/src/mcp/tools/connectToServer.spec.ts @@ -0,0 +1,175 @@ +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, +}; + +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(), + } 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).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', () => { + 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, + }); + + expect(result.structuredContent).toEqual(EXPECTED_SUCCESS); + }); + + 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); + } + ); + }); +}); diff --git a/src/mcp/tools/connectToServer.ts b/src/mcp/tools/connectToServer.ts new file mode 100644 index 000000000..d6ee52578 --- /dev/null +++ b/src/mcp/tools/connectToServer.ts @@ -0,0 +1,82 @@ +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, +}: { + 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 server = serverManager.getServer(parsedUrlResult.value); + + if (server == null) { + return response.errorWithHint( + 'Server not found', + undefined, + 'Use listServers to see available servers.', + { url } + ); + } + + try { + await execConnectToServer({ + type: server.type, + url: parsedUrlResult.value, + }); + + 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/index.ts b/src/mcp/tools/index.ts index c7a0479ed..ee93f1842 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -1,3 +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/listServers.spec.ts b/src/mcp/tools/listServers.spec.ts new file mode 100644 index 000000000..fb9ac73d3 --- /dev/null +++ b/src/mcp/tools/listServers.spec.ts @@ -0,0 +1,145 @@ +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).toBe( + 'List all Deephaven servers with optional filtering by running status, connection status, or type.' + ); + }); + + 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/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); 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); + } + }, + }; +} 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..e133fa444 --- /dev/null +++ b/src/mcp/utils/serverUtils.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { connectionToResult, serverToResult } from './serverUtils'; +import type { ConnectionState, ServerState, Psk, UniqueID } from '../../types'; +import { boolValues, matrixObject } from '../../testUtils'; + +describe('serverUtils', () => { + const serverUrl = new URL('http://localhost:10000'); + + describe('connectionToResult', () => { + it.each( + matrixObject({ + isConnected: boolValues, + isRunningCode: boolValues, + tagId: ['mock-tag' as UniqueID, undefined], + }) + )( + 'should map isConnected=$isConnected, isRunningCode=$isRunningCode, tagId=$tagId', + ({ isConnected, isRunningCode, tagId }) => { + const resultWithoutTag = connectionToResult({ + isConnected, + isRunningCode, + serverUrl, + tagId, + }); + + expect(resultWithoutTag, 'without tag').toEqual({ + isConnected, + isRunningCode, + serverUrl: serverUrl.toString(), + tagId, + }); + } + ); + }); + + 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, + serverUrl, + tagId, + } as ConnectionState; + + it.each( + matrixObject({ + isConnected: boolValues, + isRunning: boolValues, + isManaged: boolValues, + type: ['DHC', 'DHE'], + connectionCount: [1, 2], + connections: [[MOCK_CONNECTION], []], + }) + )( + 'should map isConnected=$isConnected, isRunning=$isRunning, isManaged=$isManaged, type=$type, connectionCount=$connectionCount, connections=$connections', + ({ + isConnected, + isRunning, + isManaged, + type, + connectionCount, + connections, + }) => { + const server: ServerState = { + type, + connectionCount, + isConnected, + isRunning, + isManaged, + label, + psk, + url: serverUrl, + } as ServerState; + + const result = serverToResult(server, connections); + + expect(result).toEqual({ + type, + url: serverUrl.toString(), + connectionCount, + label, + isConnected, + isManaged, + isRunning, + tags: isManaged ? ['pip', 'managed'] : [], + connections: connections.map(connectionToResult), + }); + } + ); + }); +}); diff --git a/src/mcp/utils/serverUtils.ts b/src/mcp/utils/serverUtils.ts new file mode 100644 index 000000000..6cc52e7a6 --- /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(), + 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(), + url: z.string(), +}); + +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, + }; +} + +/** + * Maps a ServerState to a server result object with connections. + */ +export function serverToResult( + { + type, + connectionCount, + isConnected, + isManaged, + isRunning, + label, + url, + }: ServerState, + connections: ConnectionState[] +): ServerResult { + return { + type, + connectionCount, + isConnected, + isManaged, + isRunning, + label, + tags: isManaged ? ['pip', 'managed'] : [], + url: url.toString(), + connections: connections.map(connectionToResult), + }; +} 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/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, diff --git a/src/types/commonTypes.d.ts b/src/types/commonTypes.d.ts index b231b0e5f..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,6 +25,13 @@ export type ParseSuccessOrError = ParseSuccess | ParseError; export type UniqueID = Brand<'UniqueID', string>; +export type ExtensionInfo = { + instanceId: UniqueID; + version: ExtensionVersion; + mode: vscode.ExtensionMode; + mcpVersion: McpVersion; +}; + export type Port = Brand<'Port', number>; export type ConnectionType = 'DHC'; 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/extensionApiUtils.ts b/src/util/extensionApiUtils.ts index 066b5c1e7..f7bdc675b 100644 --- a/src/util/extensionApiUtils.ts +++ b/src/util/extensionApiUtils.ts @@ -1,4 +1,6 @@ import * as vscode from 'vscode'; +import type { ExtensionInfo, ExtensionVersion, McpVersion } from '../types'; +import { uniqueId } from './idUtils'; const MS_PYTHON_EXTENSION_ID = 'ms-python.python'; @@ -9,20 +11,46 @@ 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 */ -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 */ +export function getMsPythonExtensionApi(): + | vscode.Extension + | undefined { + return vscode.extensions.getExtension( + MS_PYTHON_EXTENSION_ID + ); } 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(() => {