diff --git a/.gitignore b/.gitignore index 25ffc794462..01b89c27f29 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,7 @@ apps/remixdesktop/log_input_signals_new.txt logs apps/remix-ide-e2e/src/extensions/chrome/metamask apps/remix-ide-e2e/tmp/ +apps/remix-ide-e2e/tmp/ # IDE - Cursor -.cursor/ +.cursor/ \ No newline at end of file diff --git a/apps/remix-ide-e2e/src/tests/mcp/mcp_remix_server.test.ts b/apps/remix-ide-e2e/src/tests/mcp/mcp_remix_server.test.ts new file mode 100644 index 00000000000..0a23be3a162 --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/mcp/mcp_remix_server.test.ts @@ -0,0 +1,397 @@ +import { NightwatchBrowser } from 'nightwatch' +import init from '../../helpers/init' + +const testContract = ` +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract RemixMCPServerTest { + uint256 public testValue; + string public testString; + + constructor(uint256 _value, string memory _str) { + testValue = _value; + testString = _str; + } + + function updateValue(uint256 _newValue) public { + testValue = _newValue; + } + + function updateString(string memory _newString) public { + testString = _newString; + } +} +`; + +module.exports = { + '@disabled': false, + before: function (browser: NightwatchBrowser, done: VoidFunction) { + init(browser, done) + }, + + 'Should verify RemixMCPServer initialization': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible('*[data-id="verticalIconsKindfilePanel"]') + .click('*[data-id="verticalIconsKindaiTab"]') + .waitForElementVisible('*[data-id="aiTabPanel"]') + .execute(function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + const server = aiPlugin.remixMCPServer; + return { + hasRemixMcpServer: !!server, + serverName: server.serverName || null, + version: server.version || null, + isInitialized: !!server.toolRegistry && !!server.resourceProviders, + hasToolRegistry: !!server.toolRegistry, + hasResourceProviders: !!server.resourceProviders, + capabilities: server.capabilities || null + }; + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('RemixMCPServer error:', data.error); + return; + } + browser.assert.ok(data.hasRemixMcpServer, 'Should have RemixMCPServer instance'); + browser.assert.ok(data.isInitialized, 'Server should be properly initialized'); + browser.assert.ok(data.hasToolRegistry, 'Should have tool registry'); + browser.assert.ok(data.hasResourceProviders, 'Should have resource providers'); + }); + }, + + 'Should test RemixMCPServer tool registration': function (browser: NightwatchBrowser) { + browser + .execute(function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.remixMCPServer?.toolRegistry) { + return { error: 'Tool registry not available' }; + } + + const allTools = aiPlugin.remixMCPServer.tools; + const compilationTools = allTools.filter((t: any) => + t.name.includes('compile') || t.category === 'COMPILATION' + ); + + const deploymentTools = allTools.filter((t: any) => + t.name.includes('deploy') || t.name.includes('account') || t.category === 'DEPLOYMENT' + ); + + const fileTools = allTools.filter((t: any) => + t.name.includes('file') || t.category === 'FILE_SYSTEM' + ); + + return { + totalTools: allTools.length, + compilationToolCount: compilationTools.length, + deploymentToolCount: deploymentTools.length, + fileToolCount: fileTools.length, + sampleTools: allTools.slice(0, 3).map((t: any) => ({ + name: t.name, + category: t.category, + hasHandler: !!t.handler + })) + }; + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Tool registry error:', data.error); + return; + } + browser.assert.ok(data.totalTools > 0, 'Should have registered tools'); + browser.assert.ok(data.compilationToolCount > 0, 'Should have compilation tools'); + browser.assert.ok(data.deploymentToolCount > 0, 'Should have deployment tools'); + }); + }, + + 'Should test RemixMCPServer resource providers': function (browser: NightwatchBrowser) { + browser + .execute(function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.remixMCPServer?.resources) { + return { error: 'Resource providers not available' }; + } + + const resourceProviders = aiPlugin.remixMCPServer.resources.providers; + const deploymentProvider = resourceProviders.get('deployment'); + const projectProvider = resourceProviders.get('project'); + const compilerProvider = resourceProviders.get('compiler'); + + return { + totalProviders: resourceProviders.size, + hasDeploymentProvider: !!deploymentProvider, + hasProjectProvider: !!projectProvider, + hasCompilerProvider: !!compilerProvider, + deploymentProviderMethods: deploymentProvider ? Object.getOwnPropertyNames(Object.getPrototypeOf(deploymentProvider)) : [], + projectProviderMethods: projectProvider ? Object.getOwnPropertyNames(Object.getPrototypeOf(projectProvider)) : [] + }; + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Resource providers error:', data.error); + return; + } + browser.assert.ok(data.totalProviders > 0, 'Should have resource providers'); + browser.assert.ok(data.hasDeploymentProvider, 'Should have deployment provider'); + browser.assert.ok(data.hasProjectProvider, 'Should have project provider'); + }); + }, + + 'Should test RemixMCPServer solidity compile tool execution via server': function (browser: NightwatchBrowser) { + browser + .addFile('contracts/RemixMCPServerTest.sol', { content: testContract }) + .pause(1000) + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const server = aiPlugin.remixMCPServer; + + const compileResult = await server.executeTool({ + name: 'solidity_compile', + arguments: { + file: 'contracts/RemixMCPServerTest.sol', + version: '0.8.20', + optimize: true, + runs: 200 + } + }); + + const configResult = await server.executeTool({ + name: 'get_compiler_config', + arguments: {} + }); + + return { + compileExecuted: !compileResult.isError, + configExecuted: !configResult.isError, + compileContent: compileResult.content?.[0]?.text || null, + configContent: configResult.content?.[0]?.text || null, + compileError: compileResult.isError ? compileResult.content?.[0]?.text : null, + configError: configResult.isError ? configResult.content?.[0]?.text : null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Server tool execution error:', data.error); + return; + } + browser.assert.ok(data.compileExecuted, 'Should execute compile tool successfully'); + browser.assert.ok(data.configExecuted, 'Should execute config tool successfully'); + }); + }, + + 'Should test RemixMCPServer main resources reading via server': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const server = aiPlugin.remixMCPServer; + + // Test resource reading through server + const historyResource = await server.readResource('deployment://history'); + const structureResource = await server.readResource('project://structure'); + const configResource = await server.readResource('compiler://config'); + + return { + historyRead: !!historyResource, + structureRead: !!structureResource, + configRead: !!configResource, + historyMimeType: historyResource?.mimeType || null, + structureMimeType: structureResource?.mimeType || null, + configMimeType: configResource?.mimeType || null, + historyHasContent: !!historyResource?.text, + structureHasContent: !!structureResource?.text, + configHasContent: !!configResource?.text + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Server resource reading error:', data.error); + return; + } + browser.assert.ok(data.historyRead, 'Should read deployment history resource'); + browser.assert.ok(data.structureRead, 'Should read project structure resource'); + browser.assert.ok(data.configRead, 'Should read compiler config resource'); + }); + }, + + 'Should test RemixMCPServer capabilities and metadata': function (browser: NightwatchBrowser) { + browser + .execute(function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + const server = aiPlugin.remixMCPServer; + + // Test server metadata and capabilities + const capabilities = server.capabilities || {}; + const serverInfo = { + name: server.serverName, + version: server.version, + capabilities: capabilities + }; + + // Test tool and resource listing capabilities + const toolList = server.listTools ? server.listTools() : null; + const resourceList = server.listResources ? server.listResources() : null; + + return { + serverInfo, + hasCapabilities: Object.keys(capabilities).length > 0, + supportsTools: !!capabilities.tools, + supportsResources: !!capabilities.resources, + toolListAvailable: !!toolList, + resourceListAvailable: !!resourceList, + toolCount: toolList ? toolList.length : 0, + resourceCount: resourceList ? resourceList.length : 0 + }; + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Server capabilities error:', data.error); + return; + } + browser.assert.ok(data.hasCapabilities, 'Should have server capabilities'); + browser.assert.ok(data.supportsTools, 'Should support tools'); + browser.assert.ok(data.toolCount > 0, 'Should tools'); + browser.assert.ok(data.resourceCount > 0, 'Should resources'); + browser.assert.ok(data.supportsResources, 'Should support resources'); + }); + }, + + 'Should test RemixMCPServer error handling invalid tool execution': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const server = aiPlugin.remixMCPServer; + + let invalidToolResult; + try { + invalidToolResult = await server.executeTool({ + name: 'non_existent_tool', + arguments: {} + }); + } catch (error) { + invalidToolResult = { isError: true, content: [{ text: error.message }] }; + } + + let invalidResourceResult; + try { + invalidResourceResult = await server.readResource('invalid://resource'); + } catch (error) { + invalidResourceResult = null; + } + + let invalidArgsResult; + try { + invalidArgsResult = await server.executeTool({ + name: 'solidity_compile', + arguments: { + runs: 99999 // Invalid: too high + } + }); + } catch (error) { + invalidArgsResult = { isError: true, content: [{ text: error.message }] }; + } + + return { + invalidToolHandled: invalidToolResult?.isError === true, + invalidResourceHandled: invalidResourceResult === null, + invalidArgsHandled: invalidArgsResult?.isError === true, + systemStable: true, + invalidToolMessage: invalidToolResult?.content?.[0]?.text || 'No message', + invalidArgsMessage: invalidArgsResult?.content?.[0]?.text || 'No message' + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Server error handling test error:', data.error); + return; + } + browser.assert.ok(data.invalidToolHandled, 'Should handle invalid tools gracefully'); + browser.assert.ok(data.invalidResourceHandled, 'Should handle invalid resources gracefully'); + browser.assert.ok(data.invalidArgsHandled, 'Should handle invalid arguments gracefully'); + browser.assert.ok(data.systemStable, 'System should remain stable after errors'); + }); + }, + + 'Should test RemixMCPServer performance and caching': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const server = aiPlugin.remixMCPServer; + const startTime = Date.now(); + + // Test multiple operations for performance + const operations = await Promise.all([ + server.readResource('deployment://history'), + server.readResource('project://structure'), + ]); + + const endTime = Date.now(); + const totalTime = endTime - startTime; + + // Test caching behavior + const cachingStart = Date.now(); + const cachedResource1 = await server.readResource('deployment://history'); + const cachedResource2 = await server.readResource('project://structure'); + const cachingEnd = Date.now(); + const cachingTime = cachingEnd - cachingStart; + + return { + operationsCompleted: operations.length, + totalExecutionTime: totalTime, + averageOperationTime: totalTime / operations.length, + cachingTime, + allOperationsSucceeded: operations.every(op => !!op), + performanceAcceptable: totalTime < 1000, // Should complete within 5 seconds + cachingWorking: cachingTime < totalTime // Caching should be faster + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Performance test error:', data.error); + return; + } + browser.assert.ok(data.allOperationsSucceeded, 'All operations should succeed'); + browser.assert.ok(data.performanceAcceptable, 'Performance should be acceptable'); + browser.assert.equal(data.operationsCompleted, 5, 'Should complete all test operations'); + }); + } +}; \ No newline at end of file diff --git a/apps/remix-ide-e2e/src/tests/mcp/mcp_resource_providers.test.ts b/apps/remix-ide-e2e/src/tests/mcp/mcp_resource_providers.test.ts new file mode 100644 index 00000000000..92f397f292f --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/mcp/mcp_resource_providers.test.ts @@ -0,0 +1,239 @@ +import { NightwatchBrowser } from 'nightwatch' +import init from '../../helpers/init' + +module.exports = { + '@disabled': false, + before: function (browser: NightwatchBrowser, done: VoidFunction) { + init(browser, done) + }, + + 'Should get all MCP resources': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible('*[data-id="verticalIconsKindfilePanel"]') + .click('*[data-id="verticalIconsKindaiTab"]') + .waitForElementVisible('*[data-id="aiTabPanel"]') + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + const allResources = await aiPlugin.mcpInferencer.getAllResources(); + const remixServerResources = allResources['Remix IDE Server'] || []; + + const deploymentResources = remixServerResources.filter((r: any) => + r.uri.startsWith('deployment://') + ); + + const projectResources = remixServerResources.filter((r: any) => + r.uri.startsWith('project://') + ); + + const compilerResources = remixServerResources.filter((r: any) => + r.uri.startsWith('compiler://') + ); + + const fileResources = remixServerResources.filter((r: any) => + r.uri.startsWith('file://') + ); + + return { + totalResources: remixServerResources.length, + deploymentResources: deploymentResources.length, + projectResources: projectResources.length, + compilerResources: compilerResources.length, + fileResources: fileResources.length, + resourceTypes: remixServerResources.map((r: any) => r.uri) + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('MCP resources error:', data.error); + return; + } + browser.assert.ok(data.totalResources > 0, 'Should have resources available'); + browser.assert.ok(data.deploymentResources > 0, 'Should have deployment resources'); + browser.assert.ok(data.projectResources > 0, 'Should have project resources'); + browser.assert.ok(data.fileResources > 0, 'Should have file resources'); + }); + }, + + 'Should test deployment resource provider': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + const allResources = await aiPlugin.mcpInferencer.getAllResources(); + const remixServerResources = allResources['Remix IDE Server'] || []; + + const deploymentResources = remixServerResources.filter((r: any) => + r.uri.startsWith('deployment://') + ); + + const expectedResources = [ + 'deployment://history', + 'deployment://active', + 'deployment://networks', + 'deployment://transactions', + 'deployment://config' + ]; + + const foundResources = expectedResources.filter(expected => + deploymentResources.some((r: any) => r.uri === expected) + ); + + return { + expectedCount: expectedResources.length, + foundCount: foundResources.length, + foundResources, + missingResources: expectedResources.filter(expected => + !deploymentResources.some((r: any) => r.uri === expected) + ) + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Deployment resources error:', data.error); + return; + } + browser.assert.equal(data.foundCount, data.expectedCount, 'Should have all deployment resources'); + browser.assert.equal(data.missingResources.length, 0, 'Should not have missing resources'); + }); + }, + + 'Should read deployment history resource': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + // Get the MCP client for Remix IDE Server + const mcpClients = (aiPlugin.mcpInferencer as any).mcpClients; + const remixClient = mcpClients.get('Remix IDE Server'); + + if (!remixClient) { + return { error: 'Remix IDE Server client not found' }; + } + + const historyContent = await remixClient.readResource('deployment://history'); + const historyData = historyContent.text ? JSON.parse(historyContent.text) : null; + + return { + hasContent: !!historyContent, + mimeType: historyContent.mimeType, + hasDeployments: historyData?.deployments?.length > 0, + hasSummary: !!historyData?.summary, + deploymentCount: historyData?.deployments?.length || 0, + summary: historyData?.summary || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Deployment history error:', data.error); + return; + } + browser.assert.ok(data.hasContent, 'Should have deployment history content'); + browser.assert.equal(data.mimeType, 'application/json', 'Should be JSON content'); + browser.assert.ok(data.hasSummary, 'Should have summary information'); + }); + }, + + 'Should read project structure resource': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + const mcpClients = (aiPlugin.mcpInferencer as any).mcpClients; + const remixClient = mcpClients.get('Remix IDE Server'); + + if (!remixClient) { + return { error: 'Remix IDE Server client not found' }; + } + + const structureContent = await remixClient.readResource('project://structure'); + const structureData = structureContent.text ? JSON.parse(structureContent.text) : null; + + return { + hasContent: !!structureContent, + mimeType: structureContent.mimeType, + hasFiles: structureData?.files?.length > 0, + hasDirectories: structureData?.directories?.length > 0, + totalFiles: structureData?.files?.length || 0, + totalDirectories: structureData?.directories?.length || 0 + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Project structure error:', data.error); + return; + } + browser.assert.ok(data.hasContent, 'Should have project structure content'); + browser.assert.equal(data.mimeType, 'application/json', 'Should be JSON content'); + }); + }, + + 'Should handle invalid resource URIs gracefully': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + const mcpClients = (aiPlugin.mcpInferencer as any).mcpClients; + const remixClient = mcpClients.get('Remix IDE Server'); + + let errorOccurred = false; + let errorMessage = ''; + + try { + await remixClient.readResource('invalid://resource'); + } catch (error) { + errorOccurred = true; + errorMessage = error.message; + } + + return { + errorHandled: errorOccurred, + errorMessage, + systemStillWorking: true + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Error handling test error:', data.error); + return; + } + browser.assert.ok(data.errorHandled, 'Should handle invalid URIs with errors'); + browser.assert.ok(data.systemStillWorking, 'System should continue working after errors'); + }); + } +}; \ No newline at end of file diff --git a/apps/remix-ide-e2e/src/tests/mcp/mcp_server_connection.test.ts b/apps/remix-ide-e2e/src/tests/mcp/mcp_server_connection.test.ts new file mode 100644 index 00000000000..c2bfd1cb51c --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/mcp/mcp_server_connection.test.ts @@ -0,0 +1,178 @@ +import { NightwatchBrowser } from 'nightwatch' +import init from '../../helpers/init' + +module.exports = { + '@disabled': false, + before: function (browser: NightwatchBrowser, done: VoidFunction) { + init(browser, done) + }, + + 'Should initialize AI plugin with MCP server by default': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible('*[data-id="verticalIconsKindfilePanel"]') + .click('*[data-id="verticalIconsKindaiTab"]') + .waitForElementVisible('*[data-id="aiTabPanel"]') + .execute(function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin) { + return { error: 'AI Plugin not found' }; + } + + return { + pluginName: aiPlugin.profile?.name, + hasMCPInferencer: !!aiPlugin.mcpInferencer, + mcpIsEnabled: aiPlugin.mcpEnabled, + isActive: aiPlugin.aiIsActivated + }; + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('AI Plugin error:', data.error); + return; + } + browser.assert.equal(data.pluginName, 'remixAI', 'AI plugin should be loaded'); + browser.assert.ok(data.hasMCPInferencer, 'Should have MCP inferencer'); + browser.assert.ok(data.isActive, 'AI plugin should be active'); + browser.assert.ok(data.mcpIsEnabled, 'MCP on AI plugin should be enabled'); + }); + }, + + 'Should connect to MCP default servers': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + // Connect to all default servers - default servers are loaded at startup, see loadMCPServersFromSettings + await aiPlugin.mcpInferencer.connectAllServers(); + + const connectedServers = aiPlugin.mcpInferencer.getConnectedServers(); + const connectionStatuses = aiPlugin.mcpInferencer.getConnectionStatuses(); + + return { + connectedServers, + connectionStatuses, + hasRemixMcpServer: connectedServers.includes('Remix IDE Server'), + totalConnected: connectedServers.length + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('MCP connection error:', data.error); + return; + } + browser.assert.ok(data.hasRemixMcpServer, 'Should be connected to Remix IDE Server'); + browser.assert.ok(data.totalConnected > 0, 'Should have at least one connected server'); + }); + }, + + 'Should handle server disconnection and reconnection': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + const initialConnectionStatuses = aiPlugin.mcpInferencer.getConnectionStatuses(); + const initialConnectedServers = aiPlugin.mcpInferencer.getConnectedServers(); + + await aiPlugin.mcpInferencer.disconnectAllServers(); + const disconnectedServers = aiPlugin.mcpInferencer.getConnectedServers(); + const disconnectedStatuses = aiPlugin.mcpInferencer.getConnectionStatuses(); + + await aiPlugin.mcpInferencer.connectAllServers(); + const reconnectedServers = aiPlugin.mcpInferencer.getConnectedServers(); + const reconnectedStatuses = aiPlugin.mcpInferencer.getConnectionStatuses(); + + return { + initialConnectionStatuses: initialConnectionStatuses.map((s: any) => ({ + serverName: s.serverName, + status: s.status, + connected: s.status === 'connected' + })), + disconnectedStatuses: disconnectedStatuses.map((s: any) => ({ + serverName: s.serverName, + status: s.status, + connected: s.status === 'connected' + })), + reconnectedStatuses: reconnectedStatuses.map((s: any) => ({ + serverName: s.serverName, + status: s.status, + connected: s.status === 'connected' + })), + initialConnectedCount: initialConnectedServers.length, + disconnectedCount: disconnectedServers.length, + reconnectedCount: reconnectedServers.length, + reconnectionSuccessful: reconnectedServers.length > 0, // at leat the remix mcp server + serverStatusSummary: { + totalServers: initialConnectionStatuses.length, + initiallyConnected: initialConnectionStatuses.filter((s: any) => s.status === 'connected').length, + afterDisconnect: disconnectedStatuses.filter((s: any) => s.status === 'disconnected').length, + afterReconnect: reconnectedStatuses.filter((s: any) => s.status === 'connected').length + } + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('MCP reconnection error:', data.error); + return; + } + + // Verify the disconnection/reconnection process + browser.assert.ok(data.initialConnectedCount > 0, 'Should start with connected servers'); + browser.assert.equal(data.disconnectedCount, 0, 'Should have no connected servers after disconnect'); + browser.assert.ok(data.reconnectionSuccessful, 'Should successfully reconnect servers'); + + // Verify status transitions work correctly + browser.assert.ok(data.serverStatusSummary.totalServers > 0, 'Should have servers configured'); + browser.assert.ok(data.serverStatusSummary.initiallyConnected > 0, 'Should start with connected servers'); + browser.assert.ok(data.serverStatusSummary.afterReconnect > 0, 'Should have reconnected servers'); + + // Verify all initially connected servers were included in disconnection list + browser.assert.equal( + data.serverStatusSummary.afterReconnect, + data.serverStatusSummary.initiallyConnected, + 'All connected servers should be listed for disconnection' + ); + }); + }, + + 'Should get default remix mcp server capabilities': function (browser: NightwatchBrowser) { + browser + .execute(function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + const connectionStatuses = aiPlugin.mcpInferencer.getConnectionStatuses(); + const remixServerStatus = connectionStatuses.find((s: any) => s.serverName === 'Remix IDE Server'); + + return { + serverFound: !!remixServerStatus, + capabilities: remixServerStatus?.capabilities || null, + status: remixServerStatus?.status || 'unknown' + }; + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Server capabilities error:', data.error); + return; + } + browser.assert.ok(data.serverFound, 'Should find Remix IDE Server'); + browser.assert.equal(data.status, 'connected', 'Server should be connected'); + browser.assert.ok(data.capabilities, 'Server should have capabilities'); + }); + } +}; \ No newline at end of file diff --git a/apps/remix-ide-e2e/src/tests/mcp/mcp_server_lifecycle.test.ts b/apps/remix-ide-e2e/src/tests/mcp/mcp_server_lifecycle.test.ts new file mode 100644 index 00000000000..c1faf746b9b --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/mcp/mcp_server_lifecycle.test.ts @@ -0,0 +1,263 @@ +import { NightwatchBrowser } from 'nightwatch' +import init from '../../helpers/init' + +module.exports = { + '@disabled': false, + before: function (browser: NightwatchBrowser, done: VoidFunction) { + init(browser, done) + }, + + 'Should test RemixMCPServer startup and initialization': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible('*[data-id="verticalIconsKindfilePanel"]') + .click('*[data-id="verticalIconsKindaiTab"]') + .waitForElementVisible('*[data-id="aiTabPanel"]') + .execute(function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin) { + return { error: 'AI Plugin not found' }; + } + + // Check server initialization state + const serverInitialized = !!aiPlugin.remixMCPServer; + const mcpInferencerInitialized = !!aiPlugin.mcpInferencer; + + let serverDetails = null; + if (serverInitialized) { + const server = aiPlugin.remixMCPServer; + serverDetails = { + hasName: !!server.serverName, + hasVersion: !!server.version, + hasCapabilities: !!server.capabilities, + hasToolRegistry: !!server.toolRegistry, + hasResourceProviders: !!server.resourceProviders, + hasPluginManager: !!server.pluginManager, + readyState: server.readyState || 'unknown' + }; + } + + return { + aiPluginActive: aiPlugin.active, + serverInitialized, + mcpInferencerInitialized, + serverDetails, + initializationComplete: serverInitialized && mcpInferencerInitialized + }; + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Server startup error:', data.error); + return; + } + browser.assert.ok(data.aiPluginActive, 'AI plugin should be active'); + browser.assert.ok(data.serverInitialized, 'RemixMCPServer should be initialized'); + browser.assert.ok(data.mcpInferencerInitialized, 'MCP inferencer should be initialized'); + browser.assert.ok(data.initializationComplete, 'Complete initialization should be finished'); + }); + }, + + 'Should test RemixMCPServer registration and availability': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + // Check if RemixMCPServer is registered with the inferencer + const connectedServers = aiPlugin.mcpInferencer.getConnectedServers(); + const connectionStatuses = aiPlugin.mcpInferencer.getConnectionStatuses(); + + const remixServerConnected = connectedServers.includes('Remix IDE Server'); + const remixServerStatus = connectionStatuses.find((s: any) => s.serverName === 'Remix IDE Server'); + + // Test server availability through inferencer + const allTools = await aiPlugin.mcpInferencer.getAllTools(); + const allResources = await aiPlugin.mcpInferencer.getAllResources(); + + const remixTools = allTools['Remix IDE Server'] || []; + const remixResources = allResources['Remix IDE Server'] || []; + + return { + remixServerConnected, + remixServerStatus: remixServerStatus?.status || 'unknown', + remixToolCount: remixTools.length, + remixResourceCount: remixResources.length, + serverRegistered: remixServerConnected && remixTools.length > 0 && remixResources.length > 0, + connectionStable: remixServerStatus?.status === 'connected' + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Server registration error:', data.error); + return; + } + browser.assert.ok(data.remixServerConnected, 'Remix server should be connected'); + browser.assert.ok(data.serverRegistered, 'Server should be properly registered with tools and resources'); + browser.assert.ok(data.connectionStable, 'Connection should be stable'); + browser.assert.ok(data.remixToolCount > 0, 'Should have Remix tools available'); + browser.assert.ok(data.remixResourceCount > 0, 'Should have Remix resources available'); + }); + }, + + 'Should test RemixMCPServer configuration and settings': function (browser: NightwatchBrowser) { + browser + .execute(function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + const server = aiPlugin.remixMCPServer; + const config = { + capabilities: server.getCapabilities() || {}, + }; + + + const toolRegistry = server.tools; + const resourceProviders = server.resource.providers; + + const toolConfig = toolRegistry ? { + totalTools: toolRegistry.tools.size, + categories: toolRegistry.getToolsByCategory() + } : null; + + const resourceConfig = resourceProviders ? { + totalProviders: Object.keys(resourceProviders).length, + providerTypes: Object.keys(resourceProviders) + } : null; + + return { + config, + toolConfig, + resourceConfig, + configurationComplete: !!config.capabilities && !!toolConfig && !!resourceConfig + }; + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Server configuration error:', data.error); + return; + } + browser.assert.ok(data.config.name, 'Server should have a name'); + browser.assert.ok(Object.keys(data.config.capabilities).length > 0, 'Server should have capabilities'); + browser.assert.ok(data.configurationComplete, 'Server configuration should be complete'); + }); + }, + + 'Should test RemixMCPServer cleanup and shutdown': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.remixMCPServer || !aiPlugin?.mcpInferencer) { + return { error: 'MCP components not available' }; + } + + try { + const initialConnected = aiPlugin.mcpInferencer.getConnectedServers(); + const initialCount = initialConnected.length; + + await aiPlugin.mcpInferencer.disconnectAllServers(); + const afterDisconnect = aiPlugin.mcpInferencer.getConnectedServers(); + + await aiPlugin.mcpInferencer.connectAllServers(); + const afterReconnect = aiPlugin.mcpInferencer.getConnectedServers(); + + return { + initiallyConnected: initialCount > 0, + disconnectedSuccessfully: afterDisconnect.length === 0, + reconnectedSuccessfully: afterReconnect.length > 0, + serverSurvivalTest: afterReconnect.includes('Remix IDE Server'), + cleanupWorking: true + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Cleanup test error:', data.error); + return; + } + browser.assert.ok(data.initiallyConnected, 'Should start with connected servers'); + browser.assert.ok(data.disconnectedSuccessfully, 'Should disconnect cleanly'); + browser.assert.ok(data.reconnectedSuccessfully, 'Should reconnect after disconnect'); + browser.assert.ok(data.serverSurvivalTest, 'Remix server should survive disconnect/reconnect cycle'); + browser.assert.ok(data.cleanupWorking, 'Cleanup mechanism should work properly'); + }); + }, + + 'Should test RemixMCPServer stability under load': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.remixMCPServer || !aiPlugin?.mcpInferencer) { + return { error: 'MCP components not available' }; + } + + try { + const concurrentOperations = []; + const startTime = Date.now(); + + // Create multiple concurrent tool executions + for (let i = 0; i < 5; i++) { + concurrentOperations.push( + aiPlugin.mcpInferencer.executeTool('Remix IDE Server', { + name: 'get_compiler_config', + arguments: {} + }) + ); + } + + for (let i = 0; i < 5; i++) { + concurrentOperations.push( + aiPlugin.mcpInferencer.readResource('Remix IDE Server', 'deployment://history') + ); + } + + const results = await Promise.allSettled(concurrentOperations); + const endTime = Date.now(); + + const successCount = results.filter(r => r.status === 'fulfilled').length; + const failureCount = results.filter(r => r.status === 'rejected').length; + const totalTime = endTime - startTime; + + // Test rapid sequential operations + const sequentialStart = Date.now(); + const sequentialOps = []; + for (let i = 0; i < 10; i++) { + sequentialOps.push(await aiPlugin.mcpInferencer.getAllTools()); + } + const sequentialEnd = Date.now(); + const sequentialTime = sequentialEnd - sequentialStart; + + return { + concurrentOperations: concurrentOperations.length, + successCount, + failureCount, + totalTime, + averageTime: totalTime / concurrentOperations.length, + sequentialTime, + stabilityScore: successCount / concurrentOperations.length, + performanceAcceptable: totalTime < 10000 && sequentialTime < 5000, + highStability: successCount >= concurrentOperations.length * 0.9 // 90% success rate + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Load stability test error:', data.error); + return; + } + browser.assert.ok(data.performanceAcceptable, 'Performance under load should be acceptable'); + browser.assert.ok(data.highStability, 'System should maintain high stability under load'); + browser.assert.ok(data.successCount > data.failureCount, 'Success rate should exceed failure rate'); + }); + } +}; \ No newline at end of file diff --git a/apps/remix-ide-e2e/src/tests/mcp/mcp_tool_handlers.test.ts b/apps/remix-ide-e2e/src/tests/mcp/mcp_tool_handlers.test.ts new file mode 100644 index 00000000000..38c23519e92 --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/mcp/mcp_tool_handlers.test.ts @@ -0,0 +1,378 @@ +import { NightwatchBrowser } from 'nightwatch' +import init from '../../helpers/init' + +const testContract = ` +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract MCPTestContract { + uint256 public value; + address public owner; + + constructor(uint256 _initialValue) { + value = _initialValue; + owner = msg.sender; + } + + function setValue(uint256 _newValue) public { + require(msg.sender == owner, "Only owner can set value"); + value = _newValue; + } + + function getValue() public view returns (uint256) { + return value; + } +} +`; + +module.exports = { + '@disabled': false, + before: function (browser: NightwatchBrowser, done: VoidFunction) { + init(browser, done) + }, + + 'Should get all MCP tools': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible('*[data-id="verticalIconsKindfilePanel"]') + .click('*[data-id="verticalIconsKindaiTab"]') + .waitForElementVisible('*[data-id="aiTabPanel"]') + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + const allTools = await aiPlugin.mcpInferencer.getAllTools(); + const remixTools = allTools['Remix IDE Server'] || []; + + const compileTools = remixTools.filter((t: any) => + t.name.includes('compile') + ); + + const deploymentTools = remixTools.filter((t: any) => + t.name.includes('deploy') || t.name.includes('account') + ); + + return { + totalTools: remixTools.length, + compileTools: compileTools.length, + deploymentTools: deploymentTools.length, + toolNames: remixTools.map((t: any) => t.name), + hasRequiredTools: { + solidityCompile: remixTools.some((t: any) => t.name === 'solidity_compile'), + getCompilerConfig: remixTools.some((t: any) => t.name === 'get_compiler_config'), + setCompilerConfig: remixTools.some((t: any) => t.name === 'set_compiler_config'), + getCompilationResult: remixTools.some((t: any) => t.name === 'get_compilation_result') + } + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Error getting tools:', data.error); + return; + } + browser.assert.ok(data.totalTools > 0, 'Should have tools available'); + browser.assert.ok(data.hasRequiredTools.solidityCompile, 'Should have solidity_compile tool'); + browser.assert.ok(data.hasRequiredTools.getCompilerConfig, 'Should have get_compiler_config tool'); + }); + }, + + 'Should test compiler configuration tools': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + // Test get compiler config + const getConfigResult = await aiPlugin.mcpInferencer.executeTool('Remix IDE Server', { + name: 'get_compiler_config', + arguments: {} + }); + + // Test set compiler config + const setConfigResult = await aiPlugin.mcpInferencer.executeTool('Remix IDE Server', { + name: 'set_compiler_config', + arguments: { + version: '0.8.20', + optimize: true, + runs: 200, + evmVersion: 'london' + } + }); + + return { + getConfigSuccess: !getConfigResult.isError, + setConfigSuccess: !setConfigResult.isError, + getConfigContent: getConfigResult.content?.[0]?.text || null, + setConfigContent: setConfigResult.content?.[0]?.text || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Error with compiler config:', data.error); + return; + } + browser.assert.ok(data.getConfigSuccess, 'Should get compiler config successfully'); + browser.assert.ok(data.setConfigSuccess, 'Should set compiler config successfully'); + }); + }, + + 'Should test solidity compilation tool': function (browser: NightwatchBrowser) { + browser + .addFile('contracts/MCPTestContract.sol', { content: testContract }) + .pause(1000) + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + // Test MCP compilation + const compileResult = await aiPlugin.mcpInferencer.executeTool('Remix IDE Server', { + name: 'solidity_compile', + arguments: { + file: 'contracts/MCPTestContract.sol', + version: '0.8.20', + optimize: true, + runs: 200 + } + }); + + let compilationData = null; + if (!compileResult.isError && compileResult.content?.[0]?.text) { + try { + compilationData = JSON.parse(compileResult.content[0].text); + } catch (parseError) { + console.error('Failed to parse compilation result:', parseError); + } + } + + return { + compileSuccess: !compileResult.isError, + hasCompilationData: !!compilationData, + hasErrors: compilationData?.errors?.length > 0, + errorCount: compilationData?.errors?.length || 0, + success: compilationData?.success || false, + rawResult: compileResult + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Compilation error:', data.error); + return; + } + browser.assert.ok(data.compileSuccess, 'MCP compilation should succeed'); + browser.assert.ok(data.hasCompilationData, 'Should have compilation data'); + }); + }, + + 'Should test compilation result retrieval': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + const result = await aiPlugin.mcpInferencer.executeTool('Remix IDE Server', { + name: 'get_compilation_result', + arguments: {} + }); + + let compilationData = null; + if (!result.isError && result.content?.[0]?.text) { + try { + compilationData = JSON.parse(result.content[0].text); + } catch (parseError) { + console.error('Failed to parse compilation result:', parseError); + } + } + + return { + retrievalSuccess: !result.isError, + hasCompilationData: !!compilationData, + hasContracts: !!compilationData?.contracts, + contractCount: compilationData?.contracts ? Object.keys(compilationData.contracts).length : 0, + success: compilationData?.success || false + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Result retrieval error:', data.error); + return; + } + browser.assert.ok(data.retrievalSuccess, 'Should retrieve compilation result successfully'); + browser.assert.ok(data.hasCompilationData, 'Should have compilation data'); + }); + }, + + 'Should test deployment account tools': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + // Test getting user accounts + const accountsResult = await aiPlugin.mcpInferencer.executeTool('Remix IDE Server', { + name: 'get_user_accounts', + arguments: {} + }); + + // Test getting current environment + const envResult = await aiPlugin.mcpInferencer.executeTool('Remix IDE Server', { + name: 'get_current_environment', + arguments: {} + }); + + let accountsData = null; + let envData = null; + + if (!accountsResult.isError && accountsResult.content?.[0]?.text) { + try { + accountsData = JSON.parse(accountsResult.content[0].text); + } catch (parseError) { + console.error('Failed to parse accounts result:', parseError); + } + } + + if (!envResult.isError && envResult.content?.[0]?.text) { + try { + envData = JSON.parse(envResult.content[0].text); + } catch (parseError) { + console.error('Failed to parse environment result:', parseError); + } + } + + return { + accountsSuccess: !accountsResult.isError, + envSuccess: !envResult.isError, + hasAccounts: !!accountsData?.accounts, + accountCount: accountsData?.accounts?.length || 0, + selectedAccount: accountsData?.selectedAccount || null, + environment: envData?.environment || null, + provider: envData?.provider || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Account tools error:', data.error); + return; + } + browser.assert.ok(data.accountsSuccess, 'Should get accounts successfully'); + browser.assert.ok(data.envSuccess, 'Should get environment successfully'); + }); + }, + + 'Should test tool execution with invalid arguments': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + // Test with invalid arguments + const invalidResult = await aiPlugin.mcpInferencer.executeTool('Remix IDE Server', { + name: 'solidity_compile', + arguments: { + runs: 50000 // Invalid: too high + } + }); + + // Test with non-existent tool + const nonExistentResult = await aiPlugin.mcpInferencer.executeTool('Remix IDE Server', { + name: 'non_existent_tool', + arguments: {} + }); + + return { + invalidArgsHandled: invalidResult.isError, + nonExistentHandled: nonExistentResult.isError, + invalidArgsMessage: invalidResult.content?.[0]?.text || 'No message', + nonExistentMessage: nonExistentResult.content?.[0]?.text || 'No message', + systemStable: true // If we reach here, system didn't crash + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Error handling test failed:', data.error); + return; + } + browser.assert.ok(data.invalidArgsHandled, 'Should handle invalid arguments gracefully'); + browser.assert.ok(data.nonExistentHandled, 'Should handle non-existent tools gracefully'); + browser.assert.ok(data.systemStable, 'System should remain stable after errors'); + }); + }, + + 'Should test LLM tool integration format': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + // Get tools in LLM format + const llmTools = await aiPlugin.mcpInferencer.getToolsForLLMRequest(); + + const sampleTool = llmTools.find((t: any) => t.function.name === 'solidity_compile'); + + return { + toolCount: llmTools.length, + hasLLMFormat: llmTools.every((t: any) => t.type === 'function' && t.function), + sampleToolStructure: sampleTool ? { + hasName: !!sampleTool.function.name, + hasDescription: !!sampleTool.function.description, + hasParameters: !!sampleTool.function.parameters, + parametersType: sampleTool.function.parameters?.type + } : null, + allToolNames: llmTools.map((t: any) => t.function.name) + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('LLM format error:', data.error); + return; + } + browser.assert.ok(data.toolCount > 0, 'Should have tools in LLM format'); + browser.assert.ok(data.hasLLMFormat, 'All tools should have proper LLM format'); + if (data.sampleToolStructure) { + browser.assert.ok(data.sampleToolStructure.hasName, 'Sample tool should have name'); + browser.assert.ok(data.sampleToolStructure.hasDescription, 'Sample tool should have description'); + browser.assert.ok(data.sampleToolStructure.hasParameters, 'Sample tool should have parameters'); + } + }); + } +}; \ No newline at end of file diff --git a/apps/remix-ide-e2e/src/tests/mcp/mcp_transaction_history.test.ts b/apps/remix-ide-e2e/src/tests/mcp/mcp_transaction_history.test.ts new file mode 100644 index 00000000000..27accac8407 --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/mcp/mcp_transaction_history.test.ts @@ -0,0 +1,422 @@ +import { NightwatchBrowser } from 'nightwatch' +import init from '../../helpers/init' + +const deploymentContract = ` +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract TransactionHistoryTest { + uint256 public testValue; + address public deployer; + string public name; + + constructor(uint256 _value, string memory _name) { + testValue = _value; + deployer = msg.sender; + name = _name; + } + + function updateValue(uint256 _newValue) public { + testValue = _newValue; + } +} +`; + +module.exports = { + '@disabled': false, + before: function (browser: NightwatchBrowser, done: VoidFunction) { + init(browser, done) + }, + + 'Should capture transaction data during deployment': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible('*[data-id="verticalIconsKindfilePanel"]') + .addFile('contracts/TransactionHistoryTest.sol', { content: deploymentContract }) + .click('*[data-id="verticalIconsKindsolidity"]') + .waitForElementVisible('*[data-id="compileTabView"]') + .click('*[data-id="compile-btn"]') + .waitForElementContainsText('*[data-id="compilationFinished"]', 'compilation successful') + .click('*[data-id="verticalIconsKindudapp"]') + .waitForElementVisible('*[data-id="runTabView"]') + .setValue('*[data-id="deployAndRunConstructorArgsInput"]', '123, "TestContract"') + .click('*[data-id="Deploy - transact (not payable)"]') + .waitForElementVisible('*[data-id="confirmDialogModalFooterOkButton"]') + .click('*[data-id="confirmDialogModalFooterOkButton"]') + .pause(3000) // Wait for deployment transaction + .execute(function () { + // Test udapp transaction history capture + const udapp = (window as any).remix?.plugins?.udapp; + if (!udapp?.getDeployedContracts) { + return { error: 'UDAPP not available' }; + } + + const deployedContracts = udapp.getDeployedContracts(); + const networks = Object.keys(deployedContracts); + + if (networks.length === 0) { + return { error: 'No deployed contracts found' }; + } + + const network = networks[0]; + const contracts = deployedContracts[network]; + const contractAddresses = Object.keys(contracts); + + if (contractAddresses.length === 0) { + return { error: 'No contracts deployed' }; + } + + const latestContract = contracts[contractAddresses[contractAddresses.length - 1]]; + + return { + network, + contractAddress: contractAddresses[contractAddresses.length - 1], + transactionData: { + hasTransactionHash: !!latestContract.transactionHash && latestContract.transactionHash !== 'unknown', + transactionHash: latestContract.transactionHash, + hasBlockNumber: typeof latestContract.blockNumber === 'number' && latestContract.blockNumber > 0, + blockNumber: latestContract.blockNumber, + hasBlockHash: !!latestContract.blockHash && latestContract.blockHash !== 'unknown', + blockHash: latestContract.blockHash, + hasGasUsed: typeof latestContract.gasUsed === 'number' && latestContract.gasUsed > 0, + gasUsed: latestContract.gasUsed, + hasGasPrice: !!latestContract.gasPrice && latestContract.gasPrice !== '0', + gasPrice: latestContract.gasPrice, + hasTimestamp: !!latestContract.timestamp, + timestamp: latestContract.timestamp, + hasDeployer: !!latestContract.from, + deployer: latestContract.from, + status: latestContract.status, + contractName: latestContract.name + }, + totalContracts: contractAddresses.length + }; + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Transaction capture error:', data.error); + return; + } + + const txData = data.transactionData; + browser.assert.ok(txData.hasTransactionHash, 'Should capture transaction hash'); + browser.assert.ok(txData.hasBlockNumber, 'Should capture block number'); + browser.assert.ok(txData.hasGasUsed, 'Should capture gas used'); + browser.assert.ok(txData.hasTimestamp, 'Should capture timestamp'); + browser.assert.equal(txData.contractName, 'TransactionHistoryTest', 'Should have correct contract name'); + }); + }, + + 'Should verify transaction history in MCP deployment resources': function (browser: NightwatchBrowser) { + browser + .click('*[data-id="verticalIconsKindaiTab"]') + .waitForElementVisible('*[data-id="aiTabPanel"]') + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + // Get deployment history via MCP + const mcpClients = (aiPlugin.mcpInferencer as any).mcpClients; + const remixClient = mcpClients.get('Remix IDE Server'); + + if (!remixClient) { + return { error: 'Remix MCP client not found' }; + } + + const historyContent = await remixClient.readResource('deployment://history'); + const historyData = historyContent.text ? JSON.parse(historyContent.text) : null; + + if (!historyData) { + return { error: 'No deployment history data' }; + } + + const testContract = historyData.deployments.find((d: any) => + d.contractName === 'TransactionHistoryTest' + ); + + return { + hasHistoryData: !!historyData, + totalDeployments: historyData.deployments?.length || 0, + hasTestContract: !!testContract, + testContractData: testContract ? { + hasTransactionHash: !!testContract.transactionHash && testContract.transactionHash !== 'unknown', + hasBlockNumber: typeof testContract.blockNumber === 'number' && testContract.blockNumber > 0, + hasGasUsed: typeof testContract.gasUsed === 'number' && testContract.gasUsed > 0, + hasDeployer: !!testContract.deployer && testContract.deployer !== 'unknown', + hasConstructorArgs: Array.isArray(testContract.constructorArgs), + status: testContract.status, + transactionHash: testContract.transactionHash, + blockNumber: testContract.blockNumber + } : null, + summary: historyData.summary + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('MCP history error:', data.error); + return; + } + + browser.assert.ok(data.hasHistoryData, 'Should have deployment history data'); + browser.assert.ok(data.hasTestContract, 'Should find test contract in history'); + + if (data.testContractData) { + const contractData = data.testContractData; + browser.assert.ok(contractData.hasTransactionHash, 'MCP should have transaction hash'); + browser.assert.ok(contractData.hasBlockNumber, 'MCP should have block number'); + browser.assert.ok(contractData.hasGasUsed, 'MCP should have gas used'); + browser.assert.ok(contractData.hasDeployer, 'MCP should have deployer address'); + } + }); + }, + + 'Should verify transaction history in MCP transactions resource': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + const mcpClients = (aiPlugin.mcpInferencer as any).mcpClients; + const remixClient = mcpClients.get('Remix IDE Server'); + + if (!remixClient) { + return { error: 'Remix MCP client not found' }; + } + + const transactionsContent = await remixClient.readResource('deployment://transactions'); + const transactionsData = transactionsContent.text ? JSON.parse(transactionsContent.text) : null; + + if (!transactionsData) { + return { error: 'No transactions data' }; + } + + const testTransaction = transactionsData.deployments.find((t: any) => + t.contractName === 'TransactionHistoryTest' + ); + + return { + hasTransactionsData: !!transactionsData, + totalTransactions: transactionsData.deployments?.length || 0, + hasTestTransaction: !!testTransaction, + testTransactionData: testTransaction ? { + type: testTransaction.type, + hasHash: !!testTransaction.hash && testTransaction.hash !== 'unknown', + hasBlockNumber: typeof testTransaction.blockNumber === 'number' && testTransaction.blockNumber > 0, + hasGasUsed: typeof testTransaction.gasUsed === 'number' && testTransaction.gasUsed > 0, + hasGasPrice: !!testTransaction.gasPrice && testTransaction.gasPrice !== '0', + hasConstructorArgs: Array.isArray(testTransaction.constructorArgs), + status: testTransaction.status, + network: testTransaction.network + } : null, + summary: transactionsData.summary + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('MCP transactions error:', data.error); + return; + } + + browser.assert.ok(data.hasTransactionsData, 'Should have transactions data'); + browser.assert.ok(data.hasTestTransaction, 'Should find test transaction'); + + if (data.testTransactionData) { + const txData = data.testTransactionData; + browser.assert.equal(txData.type, 'deployment', 'Should be deployment transaction'); + browser.assert.ok(txData.hasHash, 'Should have transaction hash'); + browser.assert.ok(txData.hasBlockNumber, 'Should have block number'); + browser.assert.ok(txData.hasGasUsed, 'Should have gas used data'); + } + }); + }, + + 'Should test transaction history persistence across environment changes': function (browser: NightwatchBrowser) { + browser + .execute(function () { + // Get current transaction history + const udapp = (window as any).remix?.plugins?.udapp; + if (!udapp?.getDeployedContracts) { + return { error: 'UDAPP not available' }; + } + + const initialContracts = udapp.getDeployedContracts(); + const initialCount = Object.values(initialContracts).reduce((total: number, contracts: any) => { + return total + Object.keys(contracts).length; + }, 0); + + return { + initialCount, + hasContracts: Number(initialCount) > 0 + }; + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Initial state error:', data.error); + return; + } + browser.assert.ok(data.hasContracts, 'Should have contracts before environment change'); + }) + // Switch to different VM environment + .click('*[data-id="verticalIconsKindudapp"]') + .waitForElementVisible('*[data-id="runTabView"]') + .click('*[data-id="runTabSelectOption"]') + .click('*[data-id="dropdown-item-vm-cancun"]') + .pause(2000) + .execute(function () { + // Check if transaction history was cleared appropriately + const udapp = (window as any).remix?.plugins?.udapp; + if (!udapp?.getDeployedContracts) { + return { error: 'UDAPP not available' }; + } + + const contractsAfterSwitch = udapp.getDeployedContracts(); + const contractCountAfterSwitch = Object.values(contractsAfterSwitch).reduce((total: number, contracts: any) => { + return total + Object.keys(contracts).length; + }, 0); + + // Check transaction history map size if available + const historyMapSize = udapp.transactionHistory ? udapp.transactionHistory.size : 'not available'; + + return { + contractCountAfterSwitch, + historyMapSize, + environmentChanged: true + }; + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Environment change error:', data.error); + return; + } + browser.assert.ok(data.environmentChanged, 'Environment should be changed'); + // Transaction history should be cleared when environment changes + if (typeof data.historyMapSize === 'number') { + browser.assert.equal(data.historyMapSize, 0, 'Transaction history should be cleared'); + } + }); + }, + + 'Should test manual transaction data addition': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const udapp = (window as any).remix?.plugins?.udapp; + if (!udapp?.addTransactionData) { + return { error: 'Manual transaction data addition not available' }; + } + + try { + // Test manual addition of transaction data (this would be used for existing contracts) + const mockTxHash = '0x1234567890123456789012345678901234567890123456789012345678901234'; + const mockAddress = '0x1234567890123456789012345678901234567890'; + + // This method should exist in the enhanced udapp + const result = await udapp.addTransactionData(mockAddress, mockTxHash); + + return { + manualAdditionAvailable: true, + additionResult: result, + success: !!result + }; + } catch (error) { + return { + manualAdditionAvailable: false, + error: error.message + }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Manual transaction addition error:', data.error); + } + // This test verifies the manual addition capability exists + // It may not succeed due to mock data, but should not crash + browser.assert.ok(true, 'Manual transaction addition test completed'); + }); + }, + + 'Should verify transaction data consistency between UDAPP and MCP': function (browser: NightwatchBrowser) { + browser + // Deploy a new contract to test consistency + .click('*[data-id="runTabSelectOption"]') + .click('*[data-id="dropdown-item-vm-london"]') + .pause(1000) + .setValue('*[data-id="deployAndRunConstructorArgsInput"]', '456, "ConsistencyTest"') + .click('*[data-id="Deploy - transact (not payable)"]') + .waitForElementVisible('*[data-id="confirmDialogModalFooterOkButton"]') + .click('*[data-id="confirmDialogModalFooterOkButton"]') + .pause(3000) + .execute(async function () { + // Compare UDAPP data with MCP data + const udapp = (window as any).remix?.plugins?.udapp; + const aiPlugin = (window as any).getRemixAIPlugin(); + + if (!udapp?.getDeployedContracts || !aiPlugin?.mcpInferencer) { + return { error: 'Required plugins not available' }; + } + + try { + // Get UDAPP data + const udappContracts = udapp.getDeployedContracts(); + const udappLatest = Object.values(udappContracts)[0] as any; + const udappLatestContract = Object.values(udappLatest)[Object.keys(udappLatest).length - 1] as any; + + // Get MCP data + const mcpClients = (aiPlugin.mcpInferencer as any).mcpClients; + const remixClient = mcpClients.get('Remix IDE Server'); + const historyContent = await remixClient.readResource('deployment://history'); + const historyData = historyContent.text ? JSON.parse(historyContent.text) : null; + + const mcpLatest = historyData?.deployments?.find((d: any) => + d.contractName === 'TransactionHistoryTest' && d.address === udappLatestContract.address + ); + + return { + udappData: { + address: udappLatestContract.address, + transactionHash: udappLatestContract.transactionHash, + blockNumber: udappLatestContract.blockNumber, + gasUsed: udappLatestContract.gasUsed, + name: udappLatestContract.name + }, + mcpData: mcpLatest ? { + address: mcpLatest.address, + transactionHash: mcpLatest.transactionHash, + blockNumber: mcpLatest.blockNumber, + gasUsed: mcpLatest.gasUsed, + name: mcpLatest.contractName + } : null, + dataConsistent: mcpLatest ? ( + udappLatestContract.address === mcpLatest.address && + udappLatestContract.transactionHash === mcpLatest.transactionHash && + udappLatestContract.blockNumber === mcpLatest.blockNumber + ) : false + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Consistency check error:', data.error); + return; + } + + if (data.mcpData) { + browser.assert.ok(data.dataConsistent, 'UDAPP and MCP data should be consistent'); + browser.assert.equal(data.udappData.address, data.mcpData.address, 'Addresses should match'); + browser.assert.equal(data.udappData.transactionHash, data.mcpData.transactionHash, 'Transaction hashes should match'); + } + }); + } +}; \ No newline at end of file diff --git a/apps/remix-ide-e2e/src/tests/mcp/mcp_workflow_integration.test.ts b/apps/remix-ide-e2e/src/tests/mcp/mcp_workflow_integration.test.ts new file mode 100644 index 00000000000..d9b2992f93e --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/mcp/mcp_workflow_integration.test.ts @@ -0,0 +1,344 @@ +import { NightwatchBrowser } from 'nightwatch' +import init from '../../helpers/init' + +const workflowContract = ` +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract WorkflowTest { + uint256 public value; + address public owner; + mapping(address => uint256) public balances; + + event ValueChanged(uint256 oldValue, uint256 newValue); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + constructor(uint256 _initialValue) { + value = _initialValue; + owner = msg.sender; + balances[msg.sender] = 1000; + } + + modifier onlyOwner() { + require(msg.sender == owner, "Not the owner"); + _; + } + + function setValue(uint256 _newValue) public onlyOwner { + uint256 oldValue = value; + value = _newValue; + emit ValueChanged(oldValue, _newValue); + } + + function transferOwnership(address _newOwner) public onlyOwner { + require(_newOwner != address(0), "Invalid address"); + address previousOwner = owner; + owner = _newOwner; + emit OwnershipTransferred(previousOwner, _newOwner); + } + + function deposit() public payable { + balances[msg.sender] += msg.value; + } + + function withdraw(uint256 _amount) public { + require(balances[msg.sender] >= _amount, "Insufficient balance"); + balances[msg.sender] -= _amount; + payable(msg.sender).transfer(_amount); + } +} +`; + +module.exports = { + '@disabled': false, + before: function (browser: NightwatchBrowser, done: VoidFunction) { + init(browser, done) + }, + + 'Should test complete MCP workflow: file creation to deployment': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible('*[data-id="verticalIconsKindfilePanel"]') + // Step 1: Create file through MCP if available, otherwise through UI + .addFile('contracts/WorkflowTest.sol', { content: workflowContract }) + .click('*[data-id="verticalIconsKindaiTab"]') + .waitForElementVisible('*[data-id="aiTabPanel"]') + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + // Step 2: Verify file exists through MCP resources + const mcpClients = (aiPlugin.mcpInferencer as any).mcpClients; + const remixClient = mcpClients.get('Remix IDE Server'); + const structureContent = await remixClient.readResource('project://structure'); + const structureData = structureContent.text ? JSON.parse(structureContent.text) : null; + + const workflowFile = structureData?.files?.find((f: any) => + f.path && f.path.includes('WorkflowTest.sol') + ); + + // Step 3: Set compiler configuration through MCP + const setConfigResult = await aiPlugin.mcpInferencer.executeTool('Remix IDE Server', { + name: 'set_compiler_config', + arguments: { + version: '0.8.30', + optimize: true, + runs: 200, + evmVersion: 'london' + } + }); + + // Step 4: Compile through MCP + const compileResult = await aiPlugin.mcpInferencer.executeTool('Remix IDE Server', { + name: 'solidity_compile', + arguments: { + file: 'contracts/WorkflowTest.sol', + version: '0.8.30', + optimize: true, + runs: 200 + } + }); + + // Step 5: Get last/ latest compilation result through MCP + const resultData = await aiPlugin.mcpInferencer.executeTool('Remix IDE Server', { + name: 'get_compilation_result', + arguments: {} + }); + + return { + fileFound: !!workflowFile, + fileName: workflowFile?.name || null, + configSet: !setConfigResult.isError, + compileExecuted: !compileResult.isError, + resultRetrieved: !resultData.isError, + workflowComplete: !!workflowFile && !setConfigResult.isError && !compileResult.isError && !resultData.isError, + compilationContent: resultData.content?.[0]?.text || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('MCP workflow error:', data.error); + return; + } + browser.assert.ok(data.fileFound, 'File should be found through MCP resources'); + browser.assert.ok(data.configSet, 'Compiler config should be set through MCP'); + browser.assert.ok(data.compileExecuted, 'Compilation should execute through MCP'); + browser.assert.ok(data.resultRetrieved, 'Compilation result should be retrieved through MCP'); + browser.assert.ok(data.workflowComplete, 'Complete workflow should succeed'); + }); + }, + + 'Should test MCP integration with Remix deployment workflow': function (browser: NightwatchBrowser) { + browser + // Switch to deployment tab and deploy through UI to capture with MCP + .click('*[data-id="verticalIconsKindudapp"]') + .waitForElementVisible('*[data-id="runTabView"]') + .setValue('*[data-id="deployAndRunConstructorArgsInput"]', '500') + .click('*[data-id="Deploy - transact (not payable)"]') + .waitForElementVisible('*[data-id="confirmDialogModalFooterOkButton"]') + .click('*[data-id="confirmDialogModalFooterOkButton"]') + .pause(3000) // Wait for deployment + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + const mcpClients = (aiPlugin.mcpInferencer as any).mcpClients; + const remixClient = mcpClients.get('Remix IDE Server'); + + const historyContent = await remixClient.readResource('deployment://history'); + const historyData = historyContent.text ? JSON.parse(historyContent.text) : null; + + const activeContent = await remixClient.readResource('deployment://active'); + const activeData = activeContent.text ? JSON.parse(activeContent.text) : null; + + const transactionsContent = await remixClient.readResource('deployment://transactions'); + const transactionsData = transactionsContent.text ? JSON.parse(transactionsContent.text) : null; + + const workflowDeployment = historyData?.deployments?.find((d: any) => + d.contractName === 'WorkflowTest' + ); + + const workflowActive = activeData?.contracts?.find((c: any) => + c.name === 'WorkflowTest' + ); + + const workflowTransaction = transactionsData?.deployments?.find((t: any) => + t.contractName === 'WorkflowTest' + ); + + return { + hasHistory: !!historyData && historyData.deployments.length > 0, + hasActive: !!activeData && activeData.contracts.length > 0, + hasTransactions: !!transactionsData && transactionsData.deployments.length > 0, + workflowInHistory: !!workflowDeployment, + workflowInActive: !!workflowActive, + workflowInTransactions: !!workflowTransaction, + deploymentCaptured: !!workflowDeployment && !!workflowActive && !!workflowTransaction, + deploymentDetails: workflowDeployment ? { + hasAddress: !!workflowDeployment.address, + hasTransactionHash: !!workflowDeployment.transactionHash, + hasBlockNumber: !!workflowDeployment.blockNumber, + hasGasUsed: !!workflowDeployment.gasUsed + } : null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('MCP deployment integration error:', data.error); + return; + } + browser.assert.ok(data.hasHistory, 'Should capture deployment in history'); + browser.assert.ok(data.hasActive, 'Should show active deployments'); + browser.assert.ok(data.hasTransactions, 'Should capture deployment transactions'); + browser.assert.ok(data.deploymentCaptured, 'Workflow deployment should be captured in all MCP resources'); + if (data.deploymentDetails) { + browser.assert.ok(data.deploymentDetails.hasAddress, 'Deployment should have address'); + browser.assert.ok(data.deploymentDetails.hasTransactionHash, 'Deployment should have transaction hash'); + } + }); + }, + + 'Should test MCP integration with contract interaction workflow': function (browser: NightwatchBrowser) { + browser + // Interact with deployed contract through UI + .waitForElementContainsText('*[data-id="deployedContracts"]', 'WORKFLOWTEST') + .click('*[data-id="deployedContracts"] .instance:first-child button[title="Interact with Contract"]') + .pause(1000) + .setValue('*[data-id="deployedContracts"] input[placeholder="uint256 _newValue"]', '750') + .click('*[data-id="deployedContracts"] button[title="call function setValue"]') + .pause(2000) + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer) { + return { error: 'MCP inferencer not available' }; + } + + try { + const mcpClients = (aiPlugin.mcpInferencer as any).mcpClients; + const remixClient = mcpClients.get('Remix IDE Server'); + + const transactionsContent = await remixClient.readResource('deployment://transactions'); + const transactionsData = transactionsContent.text ? JSON.parse(transactionsContent.text) : null; + + const networksContent = await remixClient.readResource('deployment://networks'); + const networksData = networksContent.text ? JSON.parse(networksContent.text) : null; + + const recentTransactions = transactionsData?.deployments || []; + const interactionTransactions = recentTransactions.filter((t: any) => + t.type === 'transaction' || (t.contractName === 'WorkflowTest' && t.method) + ); + + return { + transactionsAvailable: recentTransactions.length > 0, + interactionsCaptured: interactionTransactions.length > 0, + networksAvailable: !!networksData && Object.keys(networksData).length > 0, + transactionCount: recentTransactions.length, + interactionCount: interactionTransactions.length, + networkCount: networksData ? Object.keys(networksData).length : 0, + integrationWorking: recentTransactions.length > 0 && !!networksData + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Contract interaction integration error:', data.error); + return; + } + browser.assert.ok(data.transactionsAvailable, 'Should have transactions available'); + browser.assert.ok(data.networksAvailable, 'Should have network information'); + browser.assert.ok(data.integrationWorking, 'MCP integration should work with contract interactions'); + }); + }, + + 'Should test MCP workflow error recovery': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin(); + if (!aiPlugin?.mcpInferencer || !aiPlugin?.remixMCPServer) { + return { error: 'MCP components not available' }; + } + + try { + let workflowErrors = []; + let workflowRecoveries = []; + + try { + await aiPlugin.mcpInferencer.executeTool('Remix IDE Server', { + name: 'solidity_compile', + arguments: { + file: 'nonexistent.sol', + version: '0.8.30' + } + }); + } catch (error) { + workflowErrors.push('compile_nonexistent'); + + try { + const recovery = await aiPlugin.mcpInferencer.executeTool('Remix IDE Server', { + name: 'get_compiler_config', + arguments: {} + }); + if (!recovery.isError) { + workflowRecoveries.push('compile_recovery'); + } + } catch (recoveryError) { + // Recovery failed + } + } + + // Test 2: Invalid resource access + try { + await aiPlugin.mcpInferencer.readResource('Remix IDE Server', 'invalid://resource'); + } catch (error) { + workflowErrors.push('invalid_resource'); + + try { + const recovery = await aiPlugin.mcpInferencer.readResource('Remix IDE Server', 'deployment://history'); + if (recovery) { + workflowRecoveries.push('resource_recovery'); + } + } catch (recoveryError) { + } + } + + const finalState = await aiPlugin.mcpInferencer.getAllTools(); + const systemStable = !!finalState && Object.keys(finalState).length > 0; + + return { + errorsEncountered: workflowErrors.length, + recoveriesSuccessful: workflowRecoveries.length, + systemStableAfterErrors: systemStable, + errorRecoveryRatio: workflowRecoveries.length / Math.max(workflowErrors.length, 1), + errorRecoveryWorking: systemStable && workflowRecoveries.length > 0, + workflowResilience: systemStable && workflowRecoveries.length >= workflowErrors.length * 0.5, + errorTypes: workflowErrors, + recoveryTypes: workflowRecoveries + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Error recovery test error:', data.error); + return; + } + browser.assert.ok(data.systemStableAfterErrors, 'System should remain stable after workflow errors'); + browser.assert.ok(data.errorRecoveryWorking, 'Error recovery mechanism should work'); + browser.assert.ok(data.workflowResilience, 'Workflow should show resilience to errors'); + }); + } +}; \ No newline at end of file diff --git a/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx b/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx index 48f4433cc0b..207819a7031 100644 --- a/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx +++ b/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx @@ -2,6 +2,9 @@ import * as packageJson from '../../../../../package.json' import { Plugin } from '@remixproject/engine'; import { IModel, RemoteInferencer, IRemoteModel, IParams, GenerationParams, AssistantParams, CodeExplainAgent, SecurityAgent, CompletionParams, OllamaInferencer, isOllamaAvailable, getBestAvailableModel } from '@remix/remix-ai-core'; import { CodeCompletionAgent, ContractAgent, workspaceAgent, IContextType } from '@remix/remix-ai-core'; +import { MCPInferencer } from '@remix/remix-ai-core'; +import { IMCPServer, IMCPConnectionStatus } from '@remix/remix-ai-core'; +import { RemixMCPServer, createRemixMCPServer } from '@remix/remix-ai-core'; import axios from 'axios'; import { endpointUrls } from "@remix-endpoints-helper" const _paq = (window._paq = window._paq || []) @@ -18,7 +21,11 @@ const profile = { "code_insertion", "error_explaining", "vulnerability_check", 'generate', "initialize", 'chatPipe', 'ProcessChatRequestBuffer', 'isChatRequestPending', 'resetChatRequestBuffer', 'setAssistantThrId', - 'getAssistantThrId', 'getAssistantProvider', 'setAssistantProvider', 'setModel'], + 'getAssistantThrId', 'getAssistantProvider', 'setAssistantProvider', 'setModel', + 'addMCPServer', 'removeMCPServer', 'getMCPConnectionStatus', 'getMCPResources', 'getMCPTools', 'executeMCPTool', + 'enableMCPEnhancement', 'disableMCPEnhancement', 'isMCPEnabled', 'getIMCPServers', + 'loadMCPServersFromSettings', 'clearCaches' + ], events: [], icon: 'assets/img/remix-logo-blue.png', description: 'RemixAI provides AI services to Remix IDE.', @@ -45,6 +52,10 @@ export class RemixAIPlugin extends Plugin { assistantThreadId: string = '' useRemoteInferencer:boolean = false completionAgent: CodeCompletionAgent + mcpServers: IMCPServer[] = [] + mcpInferencer: MCPInferencer | null = null + mcpEnabled: boolean = true + remixMCPServer: RemixMCPServer | null = null constructor(inDesktop:boolean) { super(profile) @@ -67,6 +78,9 @@ export class RemixAIPlugin extends Plugin { this.codeExpAgent = new CodeExplainAgent(this) this.contractor = ContractAgent.getInstance(this) this.workspaceAgent = workspaceAgent.getInstance(this) + + // Load MCP servers from settings + this.loadMCPServersFromSettings(); } async initialize(model1?:IModel, model2?:IModel, remoteModel?:IRemoteModel, useRemote?:boolean){ @@ -98,12 +112,29 @@ export class RemixAIPlugin extends Plugin { } this.setAssistantProvider(this.assistantProvider) // propagate the provider to the remote inferencer this.aiIsActivated = true + + this.on('blockchain', 'transactionExecuted', async () => { + console.log('[REMIXAI - ] transactionExecuted: clearing caches') + this.clearCaches() + }) + this.on('web3Provider', 'transactionBroadcasted', (txhash) => { + console.log('[REMIXAI - ] transactionBroadcasted: clearing caches') + this.clearCaches() + }); + + (window as any).getRemixAIPlugin = this + + // initialize the remix MCP server + this.remixMCPServer = await createRemixMCPServer(this) return true } async code_generation(prompt: string, params: IParams=CompletionParams): Promise { + if (this.isOnDesktop && !this.useRemoteInferencer) { return await this.call(this.remixDesktopPluginName, 'code_generation', prompt, params) + } else if (this.mcpEnabled && this.mcpInferencer){ + return this.mcpInferencer.code_generation(prompt, params) } else { return await this.remoteInferencer.code_generation(prompt, params) } @@ -130,6 +161,8 @@ export class RemixAIPlugin extends Plugin { let result if (this.isOnDesktop && !this.useRemoteInferencer) { result = await this.call(this.remixDesktopPluginName, 'answer', newPrompt) + } else if (this.mcpEnabled && this.mcpInferencer){ + return this.mcpInferencer.answer(prompt, params) } else { result = await this.remoteInferencer.answer(newPrompt) } @@ -141,7 +174,8 @@ export class RemixAIPlugin extends Plugin { let result if (this.isOnDesktop && !this.useRemoteInferencer) { result = await this.call(this.remixDesktopPluginName, 'code_explaining', prompt, context, params) - + } else if (this.mcpEnabled && this.mcpInferencer){ + return this.mcpInferencer.code_explaining(prompt, context, params) } else { result = await this.remoteInferencer.code_explaining(prompt, context, params) } @@ -345,6 +379,7 @@ export class RemixAIPlugin extends Plugin { } async setAssistantProvider(provider: string) { + console.log('switching assistant to', provider) if (provider === 'openai' || provider === 'mistralai' || provider === 'anthropic') { GenerationParams.provider = provider CompletionParams.provider = provider @@ -369,6 +404,37 @@ export class RemixAIPlugin extends Plugin { this.isInferencing = false }) } + } else if (provider === 'mcp') { + // Switch to MCP inferencer + if (!this.mcpInferencer || !(this.mcpInferencer instanceof MCPInferencer)) { + this.mcpInferencer = new MCPInferencer(this.mcpServers, undefined, undefined, this.remixMCPServer); + this.mcpInferencer.event.on('onInference', () => { + this.isInferencing = true + }) + this.mcpInferencer.event.on('onInferenceDone', () => { + this.isInferencing = false + }) + this.mcpInferencer.event.on('mcpServerConnected', (serverName: string) => { + console.log(`MCP server connected: ${serverName}`) + }) + this.mcpInferencer.event.on('mcpServerError', (serverName: string, error: Error) => { + console.error(`MCP server error (${serverName}):`, error) + }) + + // Connect to all configured servers + await this.mcpInferencer.connectAllServers(); + } + + this.remoteInferencer = this.mcpInferencer; + + if (this.assistantProvider !== provider){ + // clear the threadIds + this.assistantThreadId = '' + GenerationParams.threadId = '' + CompletionParams.threadId = '' + AssistantParams.threadId = '' + } + this.assistantProvider = provider } else if (provider === 'ollama') { const isAvailable = await isOllamaAvailable(); if (!isAvailable) { @@ -438,4 +504,277 @@ export class RemixAIPlugin extends Plugin { this.chatRequestBuffer = null } + // MCP Server Management Methods + async addMCPServer(server: IMCPServer): Promise { + try { + console.log(`[RemixAI Plugin] Adding MCP server: ${server.name}`); + // Add to local configuration + this.mcpServers.push(server); + + // If MCP inferencer is active, add the server dynamically + if (this.assistantProvider === 'mcp' && this.mcpInferencer) { + console.log(`[RemixAI Plugin] Adding server to active MCP inferencer: ${server.name}`); + await this.mcpInferencer.addMCPServer(server); + } + + // Persist configuration + console.log(`[RemixAI Plugin] Persisting MCP server configuration`); + await this.call('settings', 'set', 'settings/mcp/servers', JSON.stringify(this.mcpServers)); + console.log(`[RemixAI Plugin] MCP server ${server.name} added successfully`); + } catch (error) { + console.error(`[RemixAI Plugin] Failed to add MCP server ${server.name}:`, error); + throw error; + } + } + + async removeMCPServer(serverName: string): Promise { + try { + console.log(`[RemixAI Plugin] Removing MCP server: ${serverName}`); + + // Check if it's a built-in server + const serverToRemove = this.mcpServers.find(s => s.name === serverName); + if (serverToRemove?.isBuiltIn) { + console.error(`[RemixAI Plugin] Cannot remove built-in server: ${serverName}`); + throw new Error(`Cannot remove built-in server: ${serverName}`); + } + + // Remove from local configuration + this.mcpServers = this.mcpServers.filter(s => s.name !== serverName); + + // If MCP inferencer is active, remove the server dynamically + if (this.assistantProvider === 'mcp' && this.mcpInferencer) { + console.log(`[RemixAI Plugin] Removing server from active MCP inferencer: ${serverName}`); + await this.mcpInferencer.removeMCPServer(serverName); + } + + // Persist configuration + console.log(`[RemixAI Plugin] Persisting updated MCP server configuration`); + await this.call('settings', 'set', 'settings/mcp/servers', JSON.stringify(this.mcpServers)); + console.log(`[RemixAI Plugin] MCP server ${serverName} removed successfully`); + } catch (error) { + console.error(`[RemixAI Plugin] Failed to remove MCP server ${serverName}:`, error); + throw error; + } + } + + getMCPConnectionStatus(): IMCPConnectionStatus[] { + console.log(`[RemixAI Plugin] Getting MCP connection status (provider: ${this.assistantProvider})`); + if (this.assistantProvider === 'mcp' && this.mcpInferencer) { + const statuses = this.mcpInferencer.getConnectionStatuses(); + console.log(`[RemixAI Plugin] Found ${statuses.length} MCP server statuses:`, statuses.map(s => `${s.serverName}: ${s.status}`)); + return statuses; + } + console.log(`[RemixAI Plugin] No MCP inferencer active or wrong provider`); + return []; + } + + async getMCPResources(): Promise> { + console.log(`[RemixAI Plugin] Getting MCP resources`); + if (this.assistantProvider === 'mcp' && this.mcpInferencer) { + const resources = await this.mcpInferencer.getAllResources(); + console.log(`[RemixAI Plugin] Found resources from ${Object.keys(resources).length} servers:`, Object.keys(resources)); + return resources; + } + console.log(`[RemixAI Plugin] No MCP inferencer active`); + return {}; + } + + async getMCPTools(): Promise> { + console.log(`[RemixAI Plugin] Getting MCP tools`); + if (this.assistantProvider === 'mcp' && this.mcpInferencer) { + const tools = await this.mcpInferencer.getAllTools(); + console.log(`[RemixAI Plugin] Found tools from ${Object.keys(tools).length} servers:`, Object.keys(tools)); + return tools; + } + console.log(`[RemixAI Plugin] No MCP inferencer active`); + return {}; + } + + async executeMCPTool(serverName: string, toolName: string, arguments_: Record): Promise { + console.log(`[RemixAI Plugin] Executing MCP tool: ${toolName} on server: ${serverName}`, arguments_); + if (this.assistantProvider === 'mcp' && this.mcpInferencer) { + const result = await this.mcpInferencer.executeTool(serverName, { name: toolName, arguments: arguments_ }); + console.log(`[RemixAI Plugin] MCP tool execution result:`, result); + return result; + } + console.error(`[RemixAI Plugin] Cannot execute MCP tool - MCP provider not active (current provider: ${this.assistantProvider})`); + throw new Error('MCP provider not active'); + } + + async loadMCPServersFromSettings(): Promise { + try { + console.log(`[RemixAI Plugin] Loading MCP servers from settings...`); + const savedServers = await this.call('settings', 'get', 'settings/mcp/servers'); + console.log(`[RemixAI Plugin] Raw savedServers from settings:`, savedServers); + console.log(`[RemixAI Plugin] Type of savedServers:`, typeof savedServers); + if (savedServers) { + let loadedServers = JSON.parse(savedServers); + // Ensure built-in servers are always present + const builtInServers: IMCPServer[] = [ + { + name: 'Remix IDE Server', + description: 'Built-in Remix IDE MCP server providing access to workspace files and IDE features', + transport: 'internal', + autoStart: true, + enabled: true, + timeout: 5000, + isBuiltIn: true + } + ]; + + // Add built-in servers if they don't exist + for (const builtInServer of builtInServers) { + if (!loadedServers.find(s => s.name === builtInServer.name)) { + console.log(`[RemixAI Plugin] Adding missing built-in server: ${builtInServer.name}`); + loadedServers.push(builtInServer); + } + } + + this.mcpServers = loadedServers; + console.log(`[RemixAI Plugin] Loaded ${this.mcpServers.length} MCP servers from settings:`, this.mcpServers.map(s => s.name)); + + // Save back to settings if we added built-in servers + if (loadedServers.length > JSON.parse(savedServers).length) { + await this.call('settings', 'set', 'settings/mcp/servers', JSON.stringify(loadedServers)); + } + } else { + console.log(`[RemixAI Plugin] No saved MCP servers found, initializing with defaults`); + // Initialize with default MCP servers + const defaultServers: IMCPServer[] = [ + { + name: 'Remix IDE Server', + description: 'Built-in Remix IDE MCP server providing access to workspace files and IDE features', + transport: 'internal', + autoStart: true, + enabled: true, + timeout: 5000, + isBuiltIn: true + }, + { + name: 'OpenZeppelin Contracts', + description: 'OpenZeppelin smart contract library and security tools', + transport: 'http', + url: 'https://mcp.openzeppelin.com/contracts/solidity/mcp', + autoStart: true, + enabled: true, + timeout: 30000 + } + ]; + this.mcpServers = defaultServers; + // Save default servers to settings + console.log(`[RemixAI Plugin] Saving default MCP servers to settings:`, defaultServers); + await this.call('settings', 'set', 'settings/mcp/servers', JSON.stringify(defaultServers)); + console.log(`[RemixAI Plugin] Default MCP servers saved to settings successfully`); + } + } catch (error) { + console.warn(`[RemixAI Plugin] Failed to load MCP servers from settings:`, error); + this.mcpServers = []; + } + } + + async enableMCPEnhancement(): Promise { + console.log(`[RemixAI Plugin] Enabling MCP enhancement...`); + if (!this.mcpServers || this.mcpServers.length === 0) { + console.warn(`[RemixAI Plugin] No MCP servers configured, cannot enable enhancement`); + return; + } + + console.log(`[RemixAI Plugin] Enabling MCP enhancement with ${this.mcpServers.length} servers`); + + // Initialize MCP inferencer if not already done + if (!this.mcpInferencer) { + console.log(`[RemixAI Plugin] Initializing MCP inferencer`); + this.mcpInferencer = new MCPInferencer(this.mcpServers, undefined, undefined, this.remixMCPServer); + this.mcpInferencer.event.on('mcpServerConnected', (serverName: string) => { + console.log(`[RemixAI Plugin] MCP server connected: ${serverName}`); + }); + this.mcpInferencer.event.on('mcpServerError', (serverName: string, error: Error) => { + console.error(`[RemixAI Plugin] MCP server error (${serverName}):`, error); + }); + + // Connect to all MCP servers + console.log(`[RemixAI Plugin] Connecting to all MCP servers...`); + await this.mcpInferencer.connectAllServers(); + } + + this.mcpEnabled = true; + console.log(`[RemixAI Plugin] MCP enhancement enabled successfully`); + } + + async disableMCPEnhancement(): Promise { + console.log(`[RemixAI Plugin] Disabling MCP enhancement...`); + this.mcpEnabled = false; + console.log(`[RemixAI Plugin] MCP enhancement disabled`); + } + + isMCPEnabled(): boolean { + console.log(`[RemixAI Plugin] MCP enabled status: ${this.mcpEnabled}`); + return this.mcpEnabled; + } + + getIMCPServers(): IMCPServer[] { + console.log(`[RemixAI Plugin] Getting MCP servers list (${this.mcpServers.length} servers)`); + return this.mcpServers; + } + + clearCaches(){ + if (this.mcpInferencer){ + this.mcpInferencer.resetResourceCache() + console.log(`[RemixAI Plugin] clearing mcp inference resource cache `) + } + } + + // private async enrichWithMCPContext(prompt: string, params: IParams): Promise { + // if (!this.mcpEnabled || !this.mcpInferencer) { + // console.log(`[RemixAI Plugin] MCP context enrichment skipped (enabled: ${this.mcpEnabled}, inferencer: ${!!this.mcpInferencer})`); + // return prompt; + // } + + // try { + // console.log(`[RemixAI Plugin] Enriching prompt with MCP context...`); + // // Get MCP resources and tools context + // const resources = await this.mcpInferencer.getAllResources(); + // const tools = await this.mcpInferencer.getAllTools(); + + // let mcpContext = ''; + + // // Add available resources context + // if (Object.keys(resources).length > 0) { + // console.log(`[RemixAI Plugin] Adding resources from ${Object.keys(resources).length} servers to context`); + // mcpContext += '\n--- Available MCP Resources ---\n'; + // for (const [serverName, serverResources] of Object.entries(resources)) { + // if (serverResources.length > 0) { + // mcpContext += `Server: ${serverName}\n`; + // for (const resource of serverResources.slice(0, 3)) { // Limit to first 3 + // mcpContext += `- ${resource.name}: ${resource.description || resource.uri}\n`; + // } + // } + // } + // mcpContext += '--- End Resources ---\n'; + // } + + // // Add available tools context + // if (Object.keys(tools).length > 0) { + // console.log(`[RemixAI Plugin] Adding tools from ${Object.keys(tools).length} servers to context`); + // mcpContext += '\n--- Available MCP Tools ---\n'; + // for (const [serverName, serverTools] of Object.entries(tools)) { + // if (serverTools.length > 0) { + // mcpContext += `Server: ${serverName}\n`; + // for (const tool of serverTools) { + // mcpContext += `- ${tool.name}: ${tool.description || 'No description'}\n`; + // } + // } + // } + // mcpContext += '--- End Tools ---\n'; + // } + + // const enrichedPrompt = mcpContext ? `${mcpContext}\n${prompt}` : prompt; + // console.log(`[RemixAI Plugin] MCP context enrichment completed (added ${mcpContext.length} characters)`); + // return enrichedPrompt; + // } catch (error) { + // console.warn(`[RemixAI Plugin] Failed to enrich with MCP context:`, error); + // return prompt; + // } + // } + } diff --git a/apps/remix-ide/src/app/tabs/compile-tab.js b/apps/remix-ide/src/app/tabs/compile-tab.js index 08c7e7e67b7..e9a418c007b 100644 --- a/apps/remix-ide/src/app/tabs/compile-tab.js +++ b/apps/remix-ide/src/app/tabs/compile-tab.js @@ -23,7 +23,7 @@ const profile = { documentation: 'https://remix-ide.readthedocs.io/en/latest/compile.html', version: packageJson.version, maintainedBy: 'Remix', - methods: ['getCompilationResult', 'compile', 'compileWithParameters', 'setCompilerConfig', 'compileFile', 'getCompilerState', 'getCompilerConfig', 'getCompilerQueryParameters', 'getCompiler'] + methods: ['getCompilationResult', 'compile', 'compileWithParameters', 'setCompilerConfig', 'compileFile', 'getCompilerState', 'getCompilerConfig', 'getCompilerQueryParameters', 'getCompiler', 'getCurrentCompilerConfig', 'compile'] } // EditorApi: diff --git a/apps/remix-ide/src/app/tabs/locales/en/settings.json b/apps/remix-ide/src/app/tabs/locales/en/settings.json index ac9edcda3a8..5ac3129d765 100644 --- a/apps/remix-ide/src/app/tabs/locales/en/settings.json +++ b/apps/remix-ide/src/app/tabs/locales/en/settings.json @@ -65,5 +65,9 @@ "settings.aiCopilotDescription": "RemixAI Copilot assists with code suggestions and improvements.", "settings.aiPrivacyPolicy": "RemixAI Privacy & Data Usage", "settings.viewPrivacyPolicy": "View Privacy Policy", + "settings.mcpServerConfiguration": "MCP Server Configuration", + "settings.mcpServerConfigurationDescription": "Connect to Model Context Protocol servers for enhanced AI context", + "settings.enableMCPEnhancement": "Enable MCP Integration", + "settings.enableMCPEnhancementDescription": "Manage your MCP server connections", "settings.aiPrivacyPolicyDescription": "Understand how RemixAI processes your data." } diff --git a/apps/remix-ide/src/app/tabs/settings-tab.tsx b/apps/remix-ide/src/app/tabs/settings-tab.tsx index 69a653b65db..2b5e2c93833 100644 --- a/apps/remix-ide/src/app/tabs/settings-tab.tsx +++ b/apps/remix-ide/src/app/tabs/settings-tab.tsx @@ -15,7 +15,7 @@ const _paq = (window._paq = window._paq || []) const profile = { name: 'settings', displayName: 'Settings', - methods: ['get', 'updateCopilotChoice', 'getCopilotSetting', 'updateMatomoPerfAnalyticsChoice'], + methods: ['get', 'updateCopilotChoice', 'getCopilotSetting', 'set', 'updateMatomoPerfAnalyticsChoice'], events: [], icon: 'assets/img/settings.webp', description: 'Remix-IDE settings', @@ -92,6 +92,10 @@ export default class SettingsTab extends ViewPlugin { return this.config.get(key) } + set(key, value){ + this.config[key] = value + } + updateCopilotChoice(isChecked) { this.config.set('settings/copilot/suggest/activate', isChecked) this.emit('copilotChoiceUpdated', isChecked) diff --git a/apps/remix-ide/src/app/udapp/run-tab.tsx b/apps/remix-ide/src/app/udapp/run-tab.tsx index 73fe9efaf0e..8a94b857717 100644 --- a/apps/remix-ide/src/app/udapp/run-tab.tsx +++ b/apps/remix-ide/src/app/udapp/run-tab.tsx @@ -54,7 +54,11 @@ const profile = { 'clearAllInstances', 'addInstance', 'resolveContractAndAddInstance', - 'showPluginDetails' + 'showPluginDetails', + 'getRunTabAPI', + 'getDeployedContracts', + 'getAllDeployedInstances', + 'setAccount' ] } @@ -72,6 +76,8 @@ export class RunTab extends ViewPlugin { recorder: any REACT_API: any el: any + transactionHistory: Map = new Map() + constructor(blockchain: Blockchain, config: any, fileManager: any, editor: any, filePanel: any, compilersArtefacts: CompilerArtefacts, networkModule: any, fileProvider: any, engine: any) { super(profile) this.event = new EventManager() @@ -96,6 +102,34 @@ export class RunTab extends ViewPlugin { }) } + onActivation(): void { + // Listen for transaction execution events to collect deployment data + this.on('blockchain','transactionExecuted', (error, from, to, data, useCall, result, timestamp, payload) => { + console.log('[UDAPP] Transaction execution detected:', result.receipt.contractAddress) + + if (!error && result && result.receipt && result.receipt.contractAddress) { + + // Store deployment transaction data + const deploymentData = { + transactionHash: result.receipt.transactionHash, + blockHash: result.receipt.blockHash, + blockNumber: result.receipt.blockNumber, + gasUsed: result.receipt.gasUsed, + gasPrice: result.receipt.gasPrice || result.receipt.effectiveGasPrice || '0', + from: from, + to: to, + timestamp: timestamp, + status: result.receipt.status ? 'success' : 'failed', + constructorArgs: payload?.contractGuess?.constructorArgs || [], + contractName: payload?.contractData?.name || payload?.contractGuess?.name || 'Unknown', + value: result.receipt.value || '0' + } + + this.transactionHistory.set(result.receipt.contractAddress, deploymentData) + } + }) + } + getSettings() { return new Promise((resolve, reject) => { resolve({ @@ -115,11 +149,21 @@ export class RunTab extends ViewPlugin { if (canCall) { env = typeof env === 'string' ? { context: env } : env this.emit('setEnvironmentModeReducer', env, this.currentRequest.from) + this.transactionHistory.clear() } } + setAccount(address: string) { + this.emit('setAccountReducer', address) + } + + getAllDeployedInstances() { + return this.REACT_API.instances?.instanceList + } + clearAllInstances() { this.emit('clearAllInstancesReducer') + this.transactionHistory.clear() } addInstance(address, abi, name, contractData?) { @@ -139,6 +183,49 @@ export class RunTab extends ViewPlugin { return this.blockchain.getAccounts(cb) } + getRunTabAPI(){ + return this.REACT_API; + } + + getDeployedContracts() { + if (!this.REACT_API || !this.REACT_API.instances) { + return {}; + } + const instances = this.REACT_API.instances.instanceList || []; + const deployedContracts = {}; + const currentProvider = this.REACT_API.selectExEnv || 'vm-london'; + + deployedContracts[currentProvider] = {}; + + instances.forEach((instance, index) => { + if (instance && instance.address) { + const txData = this.transactionHistory.get(instance.address) + + const contractInstance = { + name: instance.name || txData?.contractName || 'Unknown', + address: instance.address, + abi: instance.contractData?.abi || instance.abi || [], + timestamp: txData?.timestamp ? new Date(txData.timestamp).toISOString() : new Date().toISOString(), + from: txData?.from || this.REACT_API.accounts?.selectedAccount || 'unknown', + transactionHash: txData?.transactionHash || 'unknown', + blockHash: txData?.blockHash, + blockNumber: Number(txData?.blockNumber) || 0, + gasUsed: Number(txData?.gasUsed)|| 0, + gasPrice: txData?.gasPrice || '0', + value: txData?.value || '0', + status: txData?.status || 'unknown', + constructorArgs: txData?.constructorArgs || [], + verified: false, + index: index + } + + deployedContracts[currentProvider][instance.address] = contractInstance + } + }); + + return deployedContracts; + } + pendingTransactionsCount() { return this.blockchain.pendingTransactionsCount() } diff --git a/apps/remix-ide/src/blockchain/blockchain.tsx b/apps/remix-ide/src/blockchain/blockchain.tsx index 7b24aacd82a..e9812f20c89 100644 --- a/apps/remix-ide/src/blockchain/blockchain.tsx +++ b/apps/remix-ide/src/blockchain/blockchain.tsx @@ -25,7 +25,7 @@ const profile = { name: 'blockchain', displayName: 'Blockchain', description: 'Blockchain - Logic', - methods: ['dumpState', 'getCode', 'getTransactionReceipt', 'addProvider', 'removeProvider', 'getCurrentFork', 'isSmartAccount', 'getAccounts', 'web3VM', 'web3', 'getProvider', 'getCurrentProvider', 'getCurrentNetworkStatus', 'getCurrentNetworkCurrency', 'getAllProviders', 'getPinnedProviders', 'changeExecutionContext', 'getProviderObject'], + methods: ['dumpState', 'getCode', 'getTransactionReceipt', 'addProvider', 'removeProvider', 'getCurrentFork', 'isSmartAccount', 'getAccounts', 'web3VM', 'web3', 'getProvider', 'getCurrentProvider', 'getCurrentNetworkStatus', 'getCurrentNetworkCurrency', 'getAllProviders', 'getPinnedProviders', 'changeExecutionContext', 'getProviderObject', 'runTx', 'getBalanceInEther', 'getCurrentProvider', 'deployContractAndLibraries', 'runOrCallContractMethod'], version: packageJson.version } @@ -303,7 +303,9 @@ export class Blockchain extends Plugin { args, (error, data) => { if (error) { - return statusCb(`creation of ${selectedContract.name} errored: ${error.message ? error.message : error.error ? error.error : error}`) + statusCb(`creation of ${selectedContract.name} errored: ${error.message ? error.message : error.error ? error.error : error}`) + finalCb(error) + return } statusCb(`creation of ${selectedContract.name} pending...`) @@ -546,7 +548,7 @@ export class Blockchain extends Plugin { if (txResult.receipt.status === false || txResult.receipt.status === '0x0' || txResult.receipt.status === 0) { return finalCb(`creation of ${selectedContract.name} errored: transaction execution failed`) } - finalCb(null, selectedContract, address) + finalCb(null, selectedContract, address, txResult) }) } @@ -607,7 +609,7 @@ export class Blockchain extends Plugin { } changeExecutionContext(context, confirmCb, infoCb, cb) { - if (this.currentRequest && this.currentRequest.from && !this.currentRequest.from.startsWith('injected')) { + if (this.currentRequest && this.currentRequest.from && !this.currentRequest.from.startsWith('injected') && this.currentRequest.from !== 'remixAI') { // only injected provider can update the provider. return } @@ -668,7 +670,7 @@ export class Blockchain extends Plugin { return txlistener } - runOrCallContractMethod(contractName, contractAbi, funABI, contract, value, address, callType, lookupOnly, logMsg, logCallback, outputCb, confirmationCb, continueCb, promptCb) { + runOrCallContractMethod(contractName, contractAbi, funABI, contract, value, address, callType, lookupOnly, logMsg, logCallback, outputCb, confirmationCb, continueCb, promptCb, finalCb) { // contractsDetails is used to resolve libraries txFormat.buildData( contractName, @@ -701,6 +703,7 @@ export class Blockchain extends Plugin { if (lookupOnly) { outputCb(returnValue) } + if (finalCb) finalCb(error, {txResult, address: _address, returnValue}) }) }, (msg) => { diff --git a/libs/remix-ai-core/src/agents/contractAgent.ts b/libs/remix-ai-core/src/agents/contractAgent.ts index 0d3d7c871df..b327daccc77 100644 --- a/libs/remix-ai-core/src/agents/contractAgent.ts +++ b/libs/remix-ai-core/src/agents/contractAgent.ts @@ -119,6 +119,7 @@ export class ContractAgent { await statusCallback?.('Compiling contracts...') const result:CompilationResult = await compilecontracts(this.contracts, this.plugin) + console.log('compilation result', result) if (!result.compilationSucceeded) { await statusCallback?.('Compilation failed, fixing errors...') const generatedContracts = (genContrats || []).map(contract => diff --git a/libs/remix-ai-core/src/agents/workspaceAgent.ts b/libs/remix-ai-core/src/agents/workspaceAgent.ts index 32cb10b02c6..9730be91a5a 100644 --- a/libs/remix-ai-core/src/agents/workspaceAgent.ts +++ b/libs/remix-ai-core/src/agents/workspaceAgent.ts @@ -27,7 +27,6 @@ export class workspaceAgent { }); }) this.plugin.on('solidity', 'compilationFinished', async (file: string, source, languageVersion, data, input, version) => { - this.localUsrFiles = await this.getLocalUserImports({ file, source, diff --git a/libs/remix-ai-core/src/helpers/streamHandler.ts b/libs/remix-ai-core/src/helpers/streamHandler.ts index 71331088b17..255161cc8b6 100644 --- a/libs/remix-ai-core/src/helpers/streamHandler.ts +++ b/libs/remix-ai-core/src/helpers/streamHandler.ts @@ -1,5 +1,5 @@ import { ChatHistory } from '../prompts/chat'; -import { JsonStreamParser } from '../types/types'; +import { JsonStreamParser, IAIStreamResponse } from '../types/types'; export const HandleSimpleResponse = async (response, cb?: (streamText: string) => void) => { let resultText = ''; @@ -118,7 +118,10 @@ export const HandleOpenAIResponse = async (streamResponse, cb: (streamText: stri } } -export const HandleMistralAIResponse = async (streamResponse, cb: (streamText: string) => void, done_cb?: (result: string, thrID:string) => void) => { +export const HandleMistralAIResponse = async (aiResponse: IAIStreamResponse, cb: (streamText: string) => void, done_cb?: (result: string, thrID:string) => void) => { + console.log('handling stream response', aiResponse) + const streamResponse = aiResponse.streamResponse + const tool_callback = aiResponse?.callback const reader = streamResponse.body?.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; @@ -150,10 +153,20 @@ export const HandleMistralAIResponse = async (streamResponse, cb: (streamText: s try { const json = JSON.parse(jsonStr); threadId = json?.id || threadId; - - const content = json.choices[0].delta.content - cb(content); - resultText += content; + if (json.choices[0].delta.tool_calls && tool_callback){ + console.log('calling tools in stream:', json.choices[0].delta.tool_calls) + const response = await tool_callback(json.choices[0].delta.tool_calls) + cb("\n\n"); + HandleMistralAIResponse(response, cb, done_cb) + + } else if (json.choices[0].delta.content){ + const content = json.choices[0].delta.content + cb(content); + resultText += content; + } else { + console.log('mistralai stream data not processed!', json.choices[0]) + continue + } } catch (e) { console.error("⚠️ MistralAI Stream parse error:", e); } diff --git a/libs/remix-ai-core/src/index.ts b/libs/remix-ai-core/src/index.ts index 1a8a9693bdd..3e09955e637 100644 --- a/libs/remix-ai-core/src/index.ts +++ b/libs/remix-ai-core/src/index.ts @@ -7,6 +7,8 @@ import { DefaultModels, InsertionParams, CompletionParams, GenerationParams, Ass import { buildChatPrompt } from './prompts/promptBuilder' import { RemoteInferencer } from './inferencers/remote/remoteInference' import { OllamaInferencer } from './inferencers/local/ollamaInferencer' +import { MCPInferencer } from './inferencers/mcp/mcpInferencer' +import { RemixMCPServer, createRemixMCPServer } from './remix-mcp-server' import { isOllamaAvailable, getBestAvailableModel, listModels, discoverOllamaHost } from './inferencers/local/ollama' import { FIMModelManager, FIMModelConfig, FIM_MODEL_CONFIGS } from './inferencers/local/fimModelConfig' import { ChatHistory } from './prompts/chat' @@ -15,13 +17,14 @@ import { ChatCommandParser } from './helpers/chatCommandParser' export { IModel, IModelResponse, ChatCommandParser, ModelType, DefaultModels, ICompletions, IParams, IRemoteModel, buildChatPrompt, - RemoteInferencer, OllamaInferencer, isOllamaAvailable, getBestAvailableModel, listModels, discoverOllamaHost, - FIMModelManager, FIMModelConfig, FIM_MODEL_CONFIGS, + RemoteInferencer, OllamaInferencer, MCPInferencer, RemixMCPServer, isOllamaAvailable, getBestAvailableModel, listModels, discoverOllamaHost, + FIMModelManager, FIMModelConfig, FIM_MODEL_CONFIGS, createRemixMCPServer, InsertionParams, CompletionParams, GenerationParams, AssistantParams, ChatEntry, AIRequestType, ChatHistory, downloadLatestReleaseExecutable } export * from './types/types' +export * from './types/mcp' export * from './helpers/streamHandler' export * from './agents/codeExplainAgent' export * from './agents/completionAgent' diff --git a/libs/remix-ai-core/src/inferencers/local/systemPrompts.ts b/libs/remix-ai-core/src/inferencers/local/systemPrompts.ts index e325ebc2193..81f7fa4a333 100644 --- a/libs/remix-ai-core/src/inferencers/local/systemPrompts.ts +++ b/libs/remix-ai-core/src/inferencers/local/systemPrompts.ts @@ -43,9 +43,9 @@ For a simple ERC-20 token contract, the JSON output might look like this: ] }`; -export const WORKSPACE_PROMPT = "You are a coding assistant with full access to the user's project workspace.\nWhen the user provides a prompt describing a desired change or feature, follow these steps:\nAnalyze the Prompt: Understand the user's intent, including what functionality or change is required.\nInspect the Codebase: Review the relevant parts of the workspace to identify which files are related to the requested change.\nDetermine Affected Files: Decide which files need to be modified or created.\nGenerate Full Modified Files: For each affected file, return the entire updated file content, not just the diff or patch.\n\nOutput format\n {\n \"files\": [\n {\n \"fileName\": \"\",\n \"content\": \"FULL CONTENT OF THE MODIFIED FILE HERE\"\n }\n ]\n }\nOnly include files that need to be modified or created. Do not include files that are unchanged.\nBe precise, complete, and maintain formatting and coding conventions consistent with the rest of the project.\nIf the change spans multiple files, ensure that all related parts are synchronized.\n" +export const WORKSPACE_PROMPT = "You are a coding assistant with full access to the user's project workspace and intelligent access to relevant contextual resources.\nWhen the user provides a prompt describing a desired change or feature, follow these steps:\nAnalyze the Prompt: Understand the user's intent, including what functionality or change is required. Consider any provided contextual resources that may contain relevant patterns, examples, or documentation.\nInspect the Codebase: Review the relevant parts of the workspace to identify which files are related to the requested change. Use insights from contextual resources to better understand existing patterns and conventions.\nDetermine Affected Files: Decide which files need to be modified or created based on both workspace analysis and contextual insights from relevant resources.\nGenerate Full Modified Files: For each affected file, return the entire updated file content, not just the diff or patch. Ensure consistency with patterns and best practices shown in contextual resources.\n\nOutput format\n {\n \"files\": [\n {\n \"fileName\": \"\",\n \"content\": \"FULL CONTENT OF THE MODIFIED FILE HERE\"\n }\n ]\n }\nOnly include files that need to be modified or created. Do not include files that are unchanged.\nBe precise, complete, and maintain formatting and coding conventions consistent with the rest of the project.\nIf the change spans multiple files, ensure that all related parts are synchronized.\nLeverage provided contextual resources (documentation, examples, API references, code patterns) to ensure best practices, compatibility, and adherence to established conventions.\n" -export const CHAT_PROMPT = "You are a Web3 AI assistant integrated into the Remix IDE named RemixAI. Your primary role is to help developers write, understand, debug, and optimize smart contracts and other related Web3 code. You must provide secure, gas-efficient, and up-to-date advice. Be concise and accurate, especially when dealing with smart contract vulnerabilities, compiler versions, and Ethereum development best practices.\nYour capabilities include:\nExplaining Major web3 programming (solidity, noir, circom, Vyper) syntax, security issues (e.g., reentrancy, underflow/overflow), and design patterns.\nReviewing and improving smart contracts for gas efficiency, security, and readability.\nHelping with Remix plugins, compiler settings, and deployment via the Remix IDE interface.\nExplaining interactions with web3.js, ethers.js, Hardhat, Foundry, OpenZeppelin, etc., if needed.\nWriting and explaining unit tests, especially in JavaScript/typescript or Solidity.\nRules:\nPrioritize secure coding and modern Solidity (e.g., ^0.8.x).\nNever give advice that could result in loss of funds (e.g., suggest unguarded delegatecall).\nIf unsure about a version-specific feature or behavior, clearly state the assumption.\nDefault to using best practices (e.g., require, SafeERC20, OpenZeppelin libraries).\nBe helpful but avoid speculative or misleading answers — if a user asks for something unsafe, clearly warn them.\nIf a user shares code, analyze it carefully and suggest improvements with reasoning. If they ask for a snippet, return a complete, copy-pastable example formatted in Markdown code blocks." +export const CHAT_PROMPT = "You are a Web3 AI assistant integrated into the Remix IDE named RemixAI with intelligent access to contextual resources. Your primary role is to help developers write, understand, debug, and optimize smart contracts and other related Web3 code. You must provide secure, gas-efficient, and up-to-date advice. Be concise and accurate, especially when dealing with smart contract vulnerabilities, compiler versions, and Ethereum development best practices.\nWhen contextual resources are provided (documentation, examples, API references), use them to enhance your responses with relevant, up-to-date information and established patterns.\nYour capabilities include:\nExplaining Major web3 programming (solidity, noir, circom, Vyper) syntax, security issues (e.g., reentrancy, underflow/overflow), and design patterns, enhanced by relevant contextual resources.\nReviewing and improving smart contracts for gas efficiency, security, and readability using best practices from provided resources.\nHelping with Remix plugins, compiler settings, and deployment via the Remix IDE interface, referencing current documentation when available.\nExplaining interactions with web3.js, ethers.js, Hardhat, Foundry, OpenZeppelin, etc., using the most current information from contextual resources.\nWriting and explaining unit tests, especially in JavaScript/typescript or Solidity, following patterns from relevant examples.\nRules:\nPrioritize secure coding and modern Solidity (e.g., ^0.8.x), referencing security best practices from contextual resources.\nNever give advice that could result in loss of funds (e.g., suggest unguarded delegatecall).\nIf unsure about a version-specific feature or behavior, clearly state the assumption and reference contextual resources when available.\nDefault to using best practices (e.g., require, SafeERC20, OpenZeppelin libraries) and patterns shown in contextual resources.\nBe helpful but avoid speculative or misleading answers — if a user asks for something unsafe, clearly warn them and reference security resources if available.\nIf a user shares code, analyze it carefully and suggest improvements with reasoning. If they ask for a snippet, return a complete, copy-pastable example formatted in Markdown code blocks, incorporating patterns from contextual resources when relevant." // Additional system prompts for specific use cases export const CODE_COMPLETION_PROMPT = "You are a code completion assistant. Complete the code provided, focusing on the immediate next lines needed. Provide only the code that should be added, without explanations or comments unless they are part of the code itself. Do not return ``` for signalising code." @@ -58,4 +58,9 @@ export const CODE_EXPLANATION_PROMPT = "You are a code explanation assistant. Pr export const ERROR_EXPLANATION_PROMPT = "You are a debugging assistant. Help explain errors and provide practical solutions. Focus on what the error means, common causes, step-by-step solutions, and prevention tips." -export const SECURITY_ANALYSIS_PROMPT = "You are a security analysis assistant. Identify vulnerabilities and provide security recommendations for code. Check for common security issues, best practice violations, potential attack vectors, and provide detailed recommendations for fixes." +export const SECURITY_ANALYSIS_PROMPT = "You are a security analysis assistant with access to security documentation and best practices. Identify vulnerabilities and provide security recommendations for code. Check for common security issues, best practice violations, potential attack vectors, and provide detailed recommendations for fixes. Reference security patterns and guidelines from contextual resources when available." + +// MCP-enhanced prompts that leverage contextual resources +export const MCP_CONTEXT_INTEGRATION_PROMPT = "When contextual resources are provided, integrate them intelligently into your responses:\n- Use documentation resources to provide accurate, up-to-date information\n- Reference code examples to show established patterns and conventions\n- Apply API references to ensure correct usage and parameters\n- Follow security guidelines from relevant security resources\n- Adapt to project-specific patterns shown in contextual resources\nAlways indicate when you're referencing contextual resources and explain their relevance." + +export const INTENT_AWARE_PROMPT = "Based on the user's intent and query complexity:\n- For coding tasks: Prioritize code examples, templates, and implementation guides\n- For documentation tasks: Focus on explanatory resources, concept definitions, and tutorials\n- For debugging tasks: Emphasize troubleshooting guides, error references, and solution patterns\n- For explanation tasks: Use educational resources, concept explanations, and theoretical guides\n- For generation tasks: Leverage templates, boilerplates, and scaffold examples\n- For completion tasks: Reference API documentation, method signatures, and usage examples\nAdjust resource selection and response style to match the identified intent." diff --git a/libs/remix-ai-core/src/inferencers/mcp/mcpInferencer.ts b/libs/remix-ai-core/src/inferencers/mcp/mcpInferencer.ts new file mode 100644 index 00000000000..75de8881384 --- /dev/null +++ b/libs/remix-ai-core/src/inferencers/mcp/mcpInferencer.ts @@ -0,0 +1,909 @@ +import { ICompletions, IGeneration, IParams, AIRequestType, IAIStreamResponse } from "../../types/types"; +import { GenerationParams, CompletionParams, InsertionParams } from "../../types/models"; +import { RemoteInferencer } from "../remote/remoteInference"; +import EventEmitter from "events"; +import { + IMCPServer, + IMCPResource, + IMCPResourceContent, + IMCPTool, + IMCPToolCall, + IMCPToolResult, + IMCPConnectionStatus, + IMCPInitializeResult, + IEnhancedMCPProviderParams, +} from "../../types/mcp"; +import { IntentAnalyzer } from "../../services/intentAnalyzer"; +import { ResourceScoring } from "../../services/resourceScoring"; +import { RemixMCPServer } from '@remix/remix-ai-core'; +export class MCPClient { + private server: IMCPServer; + private connected: boolean = false; + private capabilities?: any; + private eventEmitter: EventEmitter; + private resources: IMCPResource[] = []; + private tools: IMCPTool[] = []; + private remixMCPServer?: RemixMCPServer; // Will be injected for internal transport + private requestId: number = 1; + + constructor(server: IMCPServer, remixMCPServer?: any) { + this.server = server; + this.eventEmitter = new EventEmitter(); + this.remixMCPServer = remixMCPServer; + } + + async connect(): Promise { + try { + console.log(`[MCP] Connecting to server: ${this.server.name} (transport: ${this.server.transport})`); + this.eventEmitter.emit('connecting', this.server.name); + + if (this.server.transport === 'internal') { + // Handle internal transport using RemixMCPServer + if (!this.remixMCPServer) { + throw new Error(`Internal RemixMCPServer not available for ${this.server.name}`); + } + + console.log(`[MCP] Connecting to internal RemixMCPServer: ${this.server.name}`); + const result = await this.remixMCPServer.initialize(); + this.connected = true; + this.capabilities = result.capabilities; + + console.log(`[MCP] Successfully connected to internal server ${this.server.name} with capabilities ${this.capabilities}`); + this.eventEmitter.emit('connected', this.server.name, result); + return result; + + } else { + console.log(`[MCP] Transport ${this.server.transport} not yet implemented, using placeholder for ${this.server.name}...`); + return null; + } + + } catch (error) { + console.error(`[MCP] Failed to connect to ${this.server.name}:`, error); + this.eventEmitter.emit('error', this.server.name, error); + throw error; + } + } + + + async disconnect(): Promise { + if (this.connected) { + console.log(`[MCP] Disconnecting from server: ${this.server.name}`); + + // Handle different transport types + if (this.server.transport === 'internal' && this.remixMCPServer) { + await this.remixMCPServer.stop(); + } + + this.connected = false; + this.resources = []; + this.tools = []; + this.eventEmitter.emit('disconnected', this.server.name); + console.log(`[MCP] Disconnected from ${this.server.name}`); + } + } + + async listResources(): Promise { + if (!this.connected) { + console.error(`[MCP] Cannot list resources - ${this.server.name} is not connected`); + throw new Error(`MCP server ${this.server.name} is not connected`); + } + + console.log(`[MCP] Listing resources from ${this.server.name}...`); + + if (this.server.transport === 'internal' && this.remixMCPServer) { + // Use internal RemixMCPServer + const response = await this.remixMCPServer.handleMessage({ + id: Date.now().toString(), + method: 'resources/list', + params: {} + }); + + if (response.error) { + throw new Error(`Failed to list resources: ${response.error.message}`); + } + + this.resources = response.result.resources || []; + console.log(`[MCP] Found ${this.resources.length} resources from internal server ${this.server.name}:`, this.resources.map(r => r.name)); + return this.resources; + + } else { + // TODO: Implement actual resource listing for external servers + // Placeholder implementation + const mockResources: IMCPResource[] = [ + { + uri: `file://${this.server.name}/README.md`, + name: "README", + description: "Project documentation", + mimeType: "text/markdown" + } + ]; + + this.resources = mockResources; + console.log(`[MCP] Found ${mockResources.length} resources from ${this.server.name}:`, mockResources.map(r => r.name)); + return mockResources; + } + } + + async readResource(uri: string): Promise { + if (!this.connected) { + console.error(`[MCP] Cannot read resource - ${this.server.name} is not connected`); + throw new Error(`MCP server ${this.server.name} is not connected`); + } + + console.log(`[MCP] Reading resource: ${uri} from ${this.server.name}`); + + if (this.server.transport === 'internal' && this.remixMCPServer) { + // Use internal RemixMCPServer + const response = await this.remixMCPServer.handleMessage({ + id: Date.now().toString(), + method: 'resources/read', + params: { uri } + }); + + if (response.error) { + throw new Error(`Failed to read resource: ${response.error.message}`); + } + + console.log(`[MCP] Resource read successfully from internal server: ${uri}`); + return response.result; + + } else { + // TODO: Implement actual resource reading for external servers + const content: IMCPResourceContent = { + uri, + mimeType: "text/plain", + text: `Content from ${uri} via ${this.server.name}` + }; + console.log(`[MCP] Resource read successfully: ${uri}`); + return content; + } + } + + async listTools(): Promise { + if (!this.connected) { + console.error(`[MCP] Cannot list tools - ${this.server.name} is not connected`); + throw new Error(`MCP server ${this.server.name} is not connected`); + } + + console.log(`[MCP] Listing tools from ${this.server.name}...`); + + if (this.server.transport === 'internal' && this.remixMCPServer) { + // Use internal RemixMCPServer + const response = await this.remixMCPServer.handleMessage({ + id: Date.now().toString(), + method: 'tools/list', + params: {} + }); + + if (response.error) { + throw new Error(`Failed to list tools: ${response.error.message}`); + } + + this.tools = response.result.tools || []; + console.log(`[MCP] Found ${this.tools.length} tools from internal server ${this.server.name}:`, this.tools.map(t => t.name)); + return this.tools; + + } else { + // TODO: Implement actual tool listing for external servers + const mockTools: IMCPTool[] = [ + { + name: "file_read", + description: "Read file contents", + inputSchema: { + type: "object", + properties: { + path: { type: "string" } + }, + required: ["path"] + } + } + ]; + + this.tools = mockTools; + console.log(`[MCP] Found ${mockTools.length} tools from ${this.server.name}:`, mockTools.map(t => t.name)); + return mockTools; + } + } + + async callTool(toolCall: IMCPToolCall): Promise { + if (!this.connected) { + console.error(`[MCP] Cannot call tool - ${this.server.name} is not connected`); + throw new Error(`MCP server ${this.server.name} is not connected`); + } + + console.log(`[MCP] Calling tool: ${toolCall.name} with args:`, toolCall.arguments); + + if (this.server.transport === 'internal' && this.remixMCPServer) { + // Use internal RemixMCPServer + const response = await this.remixMCPServer.handleMessage({ + id: Date.now().toString(), + method: 'tools/call', + params: toolCall + }); + + if (response.error) { + throw new Error(`Failed to call tool: ${response.error.message}`); + } + + console.log(`[MCP] Tool ${toolCall.name} executed successfully on internal server`); + return response.result; + + } else { + // TODO: Implement actual tool execution for external servers + const result: IMCPToolResult = { + content: [{ + type: 'text', + text: `Tool ${toolCall.name} executed with args: ${JSON.stringify(toolCall.arguments)}` + }] + }; + console.log(`[MCP] Tool ${toolCall.name} executed successfully`); + return result; + } + } + + isConnected(): boolean { + return this.connected; + } + + getServerName(): string { + return this.server.name; + } + + on(event: string, listener: (...args: any[]) => void): void { + this.eventEmitter.on(event, listener); + } + + off(event: string, listener: (...args: any[]) => void): void { + this.eventEmitter.off(event, listener); + } + + /** + * Check if the server has a specific capability + */ + hasCapability(capability: string): boolean { + if (!this.capabilities) return false; + + const parts = capability.split('.'); + let current = this.capabilities; + + for (const part of parts) { + if (current[part] === undefined) return false; + current = current[part]; + } + + return !!current; + } + + /** + * Get server capabilities + */ + getCapabilities(): any { + return this.capabilities; + } + + /** + * Utility methods for future use + */ + private getNextRequestId(): number { + return this.requestId++; + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + +/** + * MCPInferencer extends RemoteInferencer to support Model Context Protocol + * It manages MCP server connections and integrates MCP resources/tools with AI requests + */ +export class MCPInferencer extends RemoteInferencer implements ICompletions, IGeneration { + private mcpClients: Map = new Map(); + private connectionStatuses: Map = new Map(); + private resourceCache: Map = new Map(); + private cacheTimeout: number = 5000; + private intentAnalyzer: IntentAnalyzer = new IntentAnalyzer(); + private resourceScoring: ResourceScoring = new ResourceScoring(); + private remixMCPServer?: any; // Internal RemixMCPServer instance + + constructor(servers: IMCPServer[] = [], apiUrl?: string, completionUrl?: string, remixMCPServer?: any) { + super(apiUrl, completionUrl); + this.remixMCPServer = remixMCPServer; + console.log(`[MCP Inferencer] Initializing with ${servers.length} servers:`, servers.map(s => s.name)); + this.initializeMCPServers(servers); + } + + private initializeMCPServers(servers: IMCPServer[]): void { + console.log(`[MCP Inferencer] Initializing MCP servers...`); + for (const server of servers) { + if (server.enabled !== false) { + console.log(`[MCP Inferencer] Setting up client for server: ${server.name}`); + const client = new MCPClient( + server, + server.transport === 'internal' ? this.remixMCPServer : undefined + ); + this.mcpClients.set(server.name, client); + this.connectionStatuses.set(server.name, { + status: 'disconnected', + serverName: server.name + }); + + // Set up event listeners + client.on('connected', (serverName: string, result: IMCPInitializeResult) => { + console.log(`[MCP Inferencer] Server connected: ${serverName}`); + this.connectionStatuses.set(serverName, { + status: 'connected', + serverName, + capabilities: result.capabilities + }); + this.event.emit('mcpServerConnected', serverName, result); + }); + + client.on('error', (serverName: string, error: Error) => { + console.error(`[MCP Inferencer] Server error: ${serverName}:`, error); + this.connectionStatuses.set(serverName, { + status: 'error', + serverName, + error: error.message, + lastAttempt: Date.now() + }); + this.event.emit('mcpServerError', serverName, error); + }); + + client.on('disconnected', (serverName: string) => { + console.log(`[MCP Inferencer] Server disconnected: ${serverName}`); + this.connectionStatuses.set(serverName, { + status: 'disconnected', + serverName + }); + this.event.emit('mcpServerDisconnected', serverName); + }); + } + } + } + + async connectAllServers(): Promise { + console.log(`[MCP Inferencer] Connecting to all ${this.mcpClients.size} servers...`); + const promises = Array.from(this.mcpClients.values()).map(async (client) => { + try { + await client.connect(); + } catch (error) { + console.warn(`[MCP Inferencer] Failed to connect to MCP server ${client.getServerName()}:`, error); + } + }); + + await Promise.allSettled(promises); + console.log(`[MCP Inferencer] Connection attempts completed`); + } + + async disconnectAllServers(): Promise { + console.log(`[MCP Inferencer] Disconnecting from all servers...`); + const promises = Array.from(this.mcpClients.values()).map(client => client.disconnect()); + await Promise.allSettled(promises); + console.log(`[MCP Inferencer] All servers disconnected`); + this.resourceCache.clear(); + } + + async resetResourceCache(){ + this.resourceCache.clear() + } + + async addMCPServer(server: IMCPServer): Promise { + console.log(`[MCP Inferencer] Adding MCP server: ${server.name}`); + if (this.mcpClients.has(server.name)) { + console.error(`[MCP Inferencer] Server ${server.name} already exists`); + throw new Error(`MCP server ${server.name} already exists`); + } + + const client = new MCPClient( + server, + server.transport === 'internal' ? this.remixMCPServer : undefined + ); + this.mcpClients.set(server.name, client); + this.connectionStatuses.set(server.name, { + status: 'disconnected', + serverName: server.name + }); + + if (server.autoStart !== false) { + console.log(`[MCP Inferencer] Auto-connecting to server: ${server.name}`); + try { + await client.connect(); + } catch (error) { + console.warn(`[MCP Inferencer] Failed to auto-connect to MCP server ${server.name}:`, error); + } + } + console.log(`[MCP Inferencer] Server ${server.name} added successfully`); + } + + async removeMCPServer(serverName: string): Promise { + console.log(`[MCP Inferencer] Removing MCP server: ${serverName}`); + const client = this.mcpClients.get(serverName); + if (client) { + await client.disconnect(); + this.mcpClients.delete(serverName); + this.connectionStatuses.delete(serverName); + console.log(`[MCP Inferencer] Server ${serverName} removed successfully`); + } else { + console.warn(`[MCP Inferencer] Server ${serverName} not found`); + } + } + + private async enrichContextWithMCPResources(params: IParams, prompt?: string): Promise { + console.log(`[MCP Inferencer] Enriching context with MCP resources...`); + const connectedServers = this.getConnectedServers(); + if (!connectedServers.length) { + console.log(`[MCP Inferencer] No connected MCP servers available for enrichment`); + return ""; + } + + console.log(`[MCP Inferencer] Using ${connectedServers.length} connected servers:`, connectedServers); + + // Extract MCP params for configuration (optional) + const mcpParams = (params as any).mcp as IEnhancedMCPProviderParams; + const enhancedParams: IEnhancedMCPProviderParams = { + mcpServers: connectedServers, + enableIntentMatching: mcpParams?.enableIntentMatching || true, + maxResources: mcpParams?.maxResources || 10, + resourcePriorityThreshold: mcpParams?.resourcePriorityThreshold, + selectionStrategy: mcpParams?.selectionStrategy || 'hybrid' + }; + + // Use intelligent resource selection if enabled + if (enhancedParams.enableIntentMatching && prompt) { + console.log(`[MCP Inferencer] Using intelligent resource selection`); + return this.intelligentResourceSelection(prompt, enhancedParams); + } + + // Fallback to original logic + console.log(`[MCP Inferencer] Using legacy resource selection`); + return this.legacyResourceSelection(enhancedParams); + } + + private async intelligentResourceSelection(prompt: string, mcpParams: IEnhancedMCPProviderParams): Promise { + try { + console.log(`[MCP Inferencer] Starting intelligent resource selection for prompt: "${prompt.substring(0, 100)}..."`); + // Analyze user intent + const intent = await this.intentAnalyzer.analyzeIntent(prompt); + console.log(`[MCP Inferencer] Analyzed intent:`, intent); + + // Gather all available resources + const allResources: Array<{ resource: IMCPResource; serverName: string }> = []; + + for (const serverName of mcpParams.mcpServers || []) { + const client = this.mcpClients.get(serverName); + if (!client || !client.isConnected()) { + console.warn(`[MCP Inferencer] Server ${serverName} is not connected, skipping`); + continue; + } + + try { + console.log(`[MCP Inferencer] Listing resources from server: ${serverName}`); + const resources = await client.listResources(); + resources.forEach(resource => { + allResources.push({ resource, serverName }); + }); + console.log(`[MCP Inferencer] Found ${resources.length} resources from ${serverName}`); + } catch (error) { + console.warn(`[MCP Inferencer] Failed to list resources from ${serverName}:`, error); + } + } + + if (allResources.length === 0) { + console.log('no resource to be used') + return ""; + } + + console.log('all resources length', allResources.length) + // Score resources against intent + const scoredResources = await this.resourceScoring.scoreResources( + allResources, + intent, + mcpParams + ); + + console.log('Intent', intent) + console.log('scored resources', scoredResources) + + // Select best resources + const selectedResources = this.resourceScoring.selectResources( + scoredResources, + mcpParams.maxResources || 5, + mcpParams.selectionStrategy || 'hybrid' + ); + + // Log selection for debugging + this.event.emit('mcpResourceSelection', { + intent, + totalResourcesConsidered: allResources.length, + selectedResources: selectedResources.map(r => ({ + name: r.resource.name, + score: r.score, + reasoning: r.reasoning + })) + }); + + const workspaceResource: IMCPResource = { + uri: 'project://structure', + name: 'Project Structure', + description: 'Hierarchical view of project files and folders', + mimeType: 'application/json', + }; + + // Always add project structure for internal remix MCP server + const hasInternalServer = this.mcpClients.has('Remix IDE Server') + console.log('hasInternalServer project structure:', hasInternalServer) + console.log('hasInternalServer project structure:', this.mcpClients) + + if (hasInternalServer) { + console.log('adding project structure') + const existingProjectStructure = selectedResources.find(r => r.resource.uri === 'project://structure'); + console.log('existingProjectStructure project structure', existingProjectStructure) + if (existingProjectStructure === undefined) { + console.log('pushing project stucture') + selectedResources.push({ + resource: workspaceResource, + serverName: 'Remix IDE Server', + score: 1.0, // High score to ensure it's included + components: { keywordMatch: 1.0, domainRelevance: 1.0, typeRelevance:1, priority:1, freshness:1 }, + reasoning: 'Project structure always included for internal remix MCP server' + }); + } + } + + console.log(selectedResources) + + // Build context from selected resources + let mcpContext = ""; + for (const scoredResource of selectedResources) { + const { resource, serverName } = scoredResource; + + try { + // Try to get from cache first + let content = null //this.resourceCache.get(resource.uri); + const client = this.mcpClients.get(serverName); + if (client) { + content = await client.readResource(resource.uri); + console.log('read resource', resource.uri, content) + } + + if (content?.text) { + mcpContext += `\n--- Resource: ${resource.name} (Score: ${Math.round(scoredResource.score * 100)}%) ---\n`; + mcpContext += `Relevance: ${scoredResource.reasoning}\n`; + mcpContext += content.text; + mcpContext += "\n--- End Resource ---\n"; + } + } catch (error) { + console.warn(`Failed to read resource ${resource.uri}:`, error); + } + } + + console.log('MCP INFERENCER: new context', mcpContext ) + return mcpContext; + } catch (error) { + console.error('Error in intelligent resource selection:', error); + // Fallback to legacy selection + return this.legacyResourceSelection(mcpParams); + } + } + + private async legacyResourceSelection(mcpParams: IEnhancedMCPProviderParams): Promise { + let mcpContext = ""; + const maxResources = mcpParams.maxResources || 10; + let resourceCount = 0; + + for (const serverName of mcpParams.mcpServers || []) { + if (resourceCount >= maxResources) break; + + const client = this.mcpClients.get(serverName); + if (!client || !client.isConnected()) continue; + + try { + const resources = await client.listResources(); + + for (const resource of resources) { + if (resourceCount >= maxResources) break; + + // Check resource priority if specified + if (mcpParams.resourcePriorityThreshold && + resource.annotations?.priority && + resource.annotations.priority < mcpParams.resourcePriorityThreshold) { + continue; + } + + const content = await client.readResource(resource.uri); + if (content.text) { + mcpContext += `\n--- Resource: ${resource.name} (${resource.uri}) ---\n`; + mcpContext += content.text; + mcpContext += "\n--- End Resource ---\n"; + resourceCount++; + } + } + } catch (error) { + console.warn(`Failed to get resources from MCP server ${serverName}:`, error); + } + } + + return mcpContext; + } + + // Override completion methods to include MCP context + + async answer(prompt: string, options: IParams = GenerationParams): Promise { + const mcpContext = await this.enrichContextWithMCPResources(options, prompt); + const enrichedPrompt = mcpContext ? `${mcpContext}\n\n${prompt}` : prompt; + + // Add available tools to the request in LLM format + const llmFormattedTools = await this.getToolsForLLMRequest(); + const enhancedOptions = { + ...options, + tools: llmFormattedTools.length > 0 ? llmFormattedTools : undefined, + tool_choice: llmFormattedTools.length > 0 ? "auto" : undefined + }; + + console.log(`[MCP Inferencer] Sending request with ${llmFormattedTools.length} available tools in LLM format`); + + try { + const response = await super.answer(enrichedPrompt, enhancedOptions); + console.log('got initial response', response) + + const toolExecutionCallback = async (tool_calls) => { + console.log('calling tool execution callback') + // Handle tool calls in the response + if (tool_calls && tool_calls.length > 0) { + console.log(`[MCP Inferencer] LLM requested ${tool_calls.length} tool calls`); + const toolResults = []; + + for (const llmToolCall of tool_calls) { + try { + // Convert LLM tool call to internal MCP format + const mcpToolCall = this.convertLLMToolCallToMCP(llmToolCall); + const result = await this.executeToolForLLM(mcpToolCall); + + toolResults.push({ + role: 'tool', + name: llmToolCall.function.name, + tool_call_id: llmToolCall.id, + content: result.content[0]?.text || JSON.stringify(result) + }); + } catch (error) { + console.error(`[MCP Inferencer] Tool execution failed:`, error); + toolResults.push({ + tool_call_id: llmToolCall.id, + content: `Error: ${error.message}` + }); + } + } + + // Send tool results back to LLM for final response + if (toolResults.length > 0) { + toolResults.unshift({role:'assistant', tool_calls: tool_calls}) + toolResults.unshift({role:'user', content:enrichedPrompt}) + const followUpOptions = { + ...enhancedOptions, + toolsMessages: toolResults + }; + + console.log('finalizing tool request') + return { streamResponse: await super.answer("Follow up on tool call ", followUpOptions), callback: toolExecutionCallback} as IAIStreamResponse; + } + } + } + + return { streamResponse: response, callback:toolExecutionCallback} as IAIStreamResponse; + } catch (error) { + console.error(`[MCP Inferencer] Error in enhanced answer:`, error); + return { streamResponse: await super.answer(enrichedPrompt, options)}; + } + } + + async code_explaining(prompt: string, context: string = "", options: IParams = GenerationParams): Promise { + const mcpContext = await this.enrichContextWithMCPResources(options, prompt); + const enrichedContext = mcpContext ? `${mcpContext}\n\n${context}` : context; + + // Add available tools to the request in LLM format + const llmFormattedTools = await this.getToolsForLLMRequest(); + options.stream_result = false + const enhancedOptions = { + ...options, + tools: llmFormattedTools.length > 0 ? llmFormattedTools : undefined, + tool_choice: llmFormattedTools.length > 0 ? "auto" : undefined + }; + + console.log(`[MCP Inferencer] Code explaining with ${llmFormattedTools.length} available tools in LLM format`); + + try { + const response = await super.code_explaining(prompt, enrichedContext, enhancedOptions); + + // Handle tool calls in the response + if (response?.tool_calls && response.tool_calls.length > 0) { + console.log(`[MCP Inferencer] LLM requested ${response.tool_calls.length} tool calls during code explanation`); + const toolResults = []; + + for (const llmToolCall of response.tool_calls) { + try { + // Convert LLM tool call to internal MCP format + const mcpToolCall = this.convertLLMToolCallToMCP(llmToolCall); + const result = await this.executeToolForLLM(mcpToolCall); + + toolResults.push({ + tool_call_id: llmToolCall.id, + content: result.content[0]?.text || JSON.stringify(result) + }); + } catch (error) { + console.error(`[MCP Inferencer] Tool execution failed:`, error); + toolResults.push({ + tool_call_id: llmToolCall.id, + content: `Error: ${error.message}` + }); + } + } + + // Send tool results back to LLM for final response + if (toolResults.length > 0) { + const followUpOptions = { + ...enhancedOptions, + messages: [ + ...(prompt || []), + response, + { + role: "tool", + tool_calls: toolResults + } + ] + }; + + return super.code_explaining("", "", followUpOptions); + } + } + + return response; + } catch (error) { + console.error(`[MCP Inferencer] Error in enhanced code_explaining:`, error); + return super.code_explaining(prompt, enrichedContext, options); + } + } + + // MCP-specific methods + getConnectionStatuses(): IMCPConnectionStatus[] { + return Array.from(this.connectionStatuses.values()); + } + + getConnectedServers(): string[] { + return Array.from(this.connectionStatuses.entries()) + .filter(([_, status]) => status.status === 'connected') + .map(([name, _]) => name); + } + + async getAllResources(): Promise> { + const result: Record = {}; + + for (const [serverName, client] of this.mcpClients) { + if (client.isConnected()) { + try { + result[serverName] = await client.listResources(); + } catch (error) { + console.warn(`Failed to list resources from ${serverName}:`, error); + result[serverName] = []; + } + } + } + + return result; + } + + async getAllTools(): Promise> { + const result: Record = {}; + + for (const [serverName, client] of this.mcpClients) { + if (client.isConnected()) { + try { + result[serverName] = await client.listTools(); + } catch (error) { + console.warn(`Failed to list tools from ${serverName}:`, error); + result[serverName] = []; + } + } + } + + return result; + } + + async executeTool(serverName: string, toolCall: IMCPToolCall): Promise { + const client = this.mcpClients.get(serverName); + if (!client) { + throw new Error(`MCP server ${serverName} not found`); + } + + if (!client.isConnected()) { + throw new Error(`MCP server ${serverName} is not connected`); + } + + return client.callTool(toolCall); + } + + /** + * Get available tools for LLM integration + */ + async getAvailableToolsForLLM(): Promise { + const allTools: IMCPTool[] = []; + const toolsFromServers = await this.getAllTools(); + + for (const [serverName, tools] of Object.entries(toolsFromServers)) { + for (const tool of tools) { + // Add server context to tool for execution routing + const enhancedTool: IMCPTool & { _mcpServer?: string } = { + ...tool, + _mcpServer: serverName + }; + allTools.push(enhancedTool); + } + } + + console.log(`[MCP Inferencer] Available tools for LLM: ${allTools.length} total from ${Object.keys(toolsFromServers).length} servers`); + return allTools; + } + + async getToolsForLLMRequest(): Promise { + const mcpTools = await this.getAvailableToolsForLLM(); + + const convertedTools = mcpTools.map(tool => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema + } + })); + + console.log(`[MCP Inferencer] Converted ${convertedTools.length} tools to LLM request format`); + return convertedTools; + } + + convertLLMToolCallToMCP(llmToolCall: any): IMCPToolCall { + return { + name: llmToolCall.function.name, + arguments: typeof llmToolCall.function.arguments === 'string' + ? JSON.parse(llmToolCall.function.arguments) + : llmToolCall.function.arguments + }; + } + + /** + * Execute a tool call from the LLM + */ + async executeToolForLLM(toolCall: IMCPToolCall): Promise { + console.log(`[MCP Inferencer] Executing tool for LLM: ${toolCall.name}`); + + // Find which server has this tool + const toolsFromServers = await this.getAllTools(); + let targetServer: string | undefined; + + for (const [serverName, tools] of Object.entries(toolsFromServers)) { + if (tools.some(tool => tool.name === toolCall.name)) { + targetServer = serverName; + break; + } + } + + if (!targetServer) { + throw new Error(`Tool '${toolCall.name}' not found in any connected MCP server`); + } + + console.log(`[MCP Inferencer] Routing tool '${toolCall.name}' to server '${targetServer}'`); + return this.executeTool(targetServer, toolCall); + } + + /** + * Check if tools are available for LLM integration + */ + async hasAvailableTools(): Promise { + try { + const tools = await this.getAvailableToolsForLLM(); + return tools.length > 0; + } catch (error) { + console.warn(`[MCP Inferencer] Error checking available tools:`, error); + return false; + } + } +} \ No newline at end of file diff --git a/libs/remix-ai-core/src/prompts/chat.ts b/libs/remix-ai-core/src/prompts/chat.ts index 81cd8311831..015e7f6650c 100644 --- a/libs/remix-ai-core/src/prompts/chat.ts +++ b/libs/remix-ai-core/src/prompts/chat.ts @@ -6,6 +6,7 @@ export abstract class ChatHistory{ static queueSize:number = 7 // change the queue size wrt the GPU size public static pushHistory(prompt, result){ + if (result === "") return // do not allow empty assistant message due to nested stream handles on toolcalls const chat:ChatEntry = [prompt, result] this.chatEntries.push(chat) if (this.chatEntries.length > this.queueSize){this.chatEntries.shift()} diff --git a/libs/remix-ai-core/src/remix-mcp-server/RemixMCPServer.ts b/libs/remix-ai-core/src/remix-mcp-server/RemixMCPServer.ts new file mode 100644 index 00000000000..1fae2a5bca0 --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/RemixMCPServer.ts @@ -0,0 +1,588 @@ +import EventEmitter from 'events'; +import { + IMCPInitializeResult, + IMCPServerCapabilities, + IMCPToolCall, + IMCPToolResult, + IMCPResourceContent +} from '../types/mcp'; +import { + IRemixMCPServer, + RemixMCPServerConfig, + ServerState, + ServerStats, + ToolExecutionStatus, + ResourceCacheEntry, + AuditLogEntry, + PermissionCheckResult, + MCPMessage, + MCPResponse, + MCPErrorCode, +} from './types/mcpServer'; +import { ToolRegistry } from './types/mcpTools'; +import { ResourceProviderRegistry } from './types/mcpResources'; +import { RemixToolRegistry } from './registry/RemixToolRegistry'; +import { RemixResourceProviderRegistry } from './registry/RemixResourceProviderRegistry'; + +// Import tool handlers +import { createCompilationTools } from './handlers/CompilationHandler'; +import { createFileManagementTools } from './handlers/FileManagementHandler'; +import { createDeploymentTools } from './handlers/DeploymentHandler'; +import { createDebuggingTools } from './handlers/DebuggingHandler'; +import { createCodeAnalysisTools } from './handlers/CodeAnalysisHandler'; + +// Import resource providers +import { ProjectResourceProvider } from './providers/ProjectResourceProvider'; +import { CompilationResourceProvider } from './providers/CompilationResourceProvider'; +import { DeploymentResourceProvider } from './providers/DeploymentResourceProvider'; + +/** + * Main Remix MCP Server implementation + */ +export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { + private _config: RemixMCPServerConfig; + private _state: ServerState = ServerState.STOPPED; + private _stats: ServerStats; + private _tools: ToolRegistry; + private _resources: ResourceProviderRegistry; + private _plugin + private _activeExecutions: Map = new Map(); + private _resourceCache: Map = new Map(); + private _auditLog: AuditLogEntry[] = []; + private _startTime: Date = new Date(); + + constructor(plugin, config: RemixMCPServerConfig) { + super(); + this._config = config; + this._plugin = plugin + this._tools = new RemixToolRegistry(); + this._resources = new RemixResourceProviderRegistry(plugin); + + this._stats = { + uptime: 0, + totalToolCalls: 0, + totalResourcesServed: 0, + activeToolExecutions: 0, + cacheHitRate: 0, + errorCount: 0, + lastActivity: new Date() + }; + + this.setupEventHandlers(); + } + + get config(): RemixMCPServerConfig { + return this._config; + } + + get state(): ServerState { + return this._state; + } + + get stats(): ServerStats { + this._stats.uptime = Date.now() - this._startTime.getTime(); + this._stats.activeToolExecutions = this._activeExecutions.size; + return this._stats; + } + + get tools(): ToolRegistry { + return this._tools; + } + + get resources(): ResourceProviderRegistry { + return this._resources; + } + + get plugin(): any{ + return this.plugin + } + + /** + * Initialize the MCP server + */ + async initialize(): Promise { + try { + this.setState(ServerState.STARTING); + + await this.initializeDefaultTools(); + + await this.initializeDefaultResourceProviders(); + + this.setupCleanupIntervals(); + + const result: IMCPInitializeResult = { + protocolVersion: '2024-11-05', + capabilities: this.getCapabilities(), + serverInfo: { + name: this._config.name, + version: this._config.version + }, + instructions: `Remix IDE MCP Server initialized. Available tools: ${this._tools.list().length}, Resource providers: ${this._resources.list().length}` + }; + + this.setState(ServerState.RUNNING); + console.log('Server initialized successfully', 'info'); + + return result; + } catch (error) { + this.setState(ServerState.ERROR); + console.log(`Server initialization failed: ${error.message}`, 'error'); + throw error; + } + } + + async start(): Promise { + if (this._state !== ServerState.STOPPED) { + throw new Error(`Cannot start server in state: ${this._state}`); + } + + await this.initialize(); + } + + /** + * Stop the server + */ + async stop(): Promise { + this.setState(ServerState.STOPPING); + + // Cancel active tool executions + for (const [id, execution] of this._activeExecutions) { + execution.status = 'failed'; + execution.error = 'Server shutdown'; + execution.endTime = new Date(); + this.emit('tool-executed', execution); + } + this._activeExecutions.clear(); + + // Clear cache + this._resourceCache.clear(); + this.emit('cache-cleared'); + + this.setState(ServerState.STOPPED); + console.log('Server stopped', 'info'); + } + + /** + * Get server capabilities + */ + getCapabilities(): IMCPServerCapabilities { + return { + resources: { + subscribe: true, + listChanged: true + }, + tools: { + listChanged: true + }, + prompts: { + listChanged: false + }, + logging: {}, + experimental: { + remix: { + compilation: this._config.features?.compilation !== false, + deployment: this._config.features?.deployment !== false, + debugging: this._config.features?.debugging !== false, + analysis: this._config.features?.analysis !== false, + testing: this._config.features?.testing !== false, + git: this._config.features?.git !== false + } + } + }; + } + + /** + * Handle MCP protocol messages + */ + async handleMessage(message: MCPMessage): Promise { + try { + this._stats.lastActivity = new Date(); + + switch (message.method) { + case 'initialize': + const initResult = await this.initialize(); + return { id: message.id, result: initResult }; + + case 'tools/list': + const tools = this._tools.list().map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema + })); + return { id: message.id, result: { tools } }; + + case 'tools/call': + const toolResult = await this.executeTool(message.params as IMCPToolCall); + return { id: message.id, result: toolResult }; + + case 'resources/list': + const resources = await this._resources.getResources(); + console.log('listing resources', resources) + return { id: message.id, result: { resources: resources.resources } }; + + case 'resources/read': + const content = await this.getResourceContent(message.params.uri); + return { id: message.id, result: content }; + + case 'server/capabilities': + return { id: message.id, result: this.getCapabilities() }; + + case 'server/stats': + return { id: message.id, result: this.stats }; + + default: + return { + id: message.id, + error: { + code: MCPErrorCode.METHOD_NOT_FOUND, + message: `Unknown method: ${message.method}` + } + }; + } + } catch (error) { + this._stats.errorCount++; + console.log(`Message handling error: ${error.message}`, 'error'); + + return { + id: message.id, + error: { + code: MCPErrorCode.INTERNAL_ERROR, + message: error.message, + data: this._config.debug ? error.stack : undefined + } + }; + } + } + + /** + * Execute a tool + */ + private async executeTool(call: IMCPToolCall): Promise { + const executionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const startTime = new Date(); + + const execution: ToolExecutionStatus = { + id: executionId, + toolName: call.name, + startTime, + status: 'running', + context: { + workspace: await this.getCurrentWorkspace(), + user: 'default', // TODO: Get actual user + permissions: ["*"] // TODO: Get actual permissions + } + }; + + this._activeExecutions.set(executionId, execution); + this.emit('tool-executed', execution); + + try { + // Check permissions + const permissionCheck = await this.checkPermissions(`tool:${call.name}`, 'default'); + if (!permissionCheck.allowed) { + throw new Error(`Permission denied: ${permissionCheck.reason}`); + } + + // Set timeout + const timeout = this._config.toolTimeout || 30000; + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Tool execution timeout')), timeout); + }); + + // Execute tool + const toolPromise = this._tools.execute(call, { + workspace: execution.context.workspace, + currentFile: await this.getCurrentFile(), + permissions: execution.context.permissions, + timestamp: Date.now(), + requestId: executionId + }, this._plugin); + + const result = await Promise.race([toolPromise, timeoutPromise]); + + // Update execution status + execution.status = 'completed'; + execution.endTime = new Date(); + this._stats.totalToolCalls++; + + this.emit('tool-executed', execution); + console.log(`Tool executed: ${call.name}`, 'info', { executionId, duration: execution.endTime.getTime() - startTime.getTime() }, 'result:', result); + + return result; + + } catch (error) { + execution.status = error.message.includes('timeout') ? 'timeout' : 'failed'; + execution.error = error.message; + execution.endTime = new Date(); + this._stats.errorCount++; + + this.emit('tool-executed', execution); + console.log(`Tool execution failed: ${call.name}`, 'error', { executionId, error: error.message }); + + throw error; + } finally { + this._activeExecutions.delete(executionId); + } + } + + /** + * Get resource content with caching + */ + private async getResourceContent(uri: string): Promise { + // Check cache first + if (this._config.enableResourceCache !== false) { + const cached = this._resourceCache.get(uri); + if (cached && Date.now() - cached.timestamp.getTime() < cached.ttl) { + cached.accessCount++; + cached.lastAccess = new Date(); + this._stats.totalResourcesServed++; + this.emit('resource-accessed', uri, 'default'); + return cached.content; + } + } + + // Get from provider + const content = await this._resources.getResourceContent(uri); + + // Cache result + if (this._config.enableResourceCache !== false) { + this._resourceCache.set(uri, { + uri, + content, + timestamp: new Date(), + ttl: this._config.resourceCacheTTL || 300000, // 5 minutes default + accessCount: 1, + lastAccess: new Date() + }); + } + + this._stats.totalResourcesServed++; + this.emit('resource-accessed', uri, 'default'); + + return content; + } + + async checkPermissions(operation: string, user: string, resource?: string): Promise { + // TODO: Implement actual permission checking + // For now, allow all operations + return { + allowed: true, + requiredPermissions: [], + userPermissions: ['*'] + }; + } + + getActiveExecutions(): ToolExecutionStatus[] { + return Array.from(this._activeExecutions.values()); + } + + getCacheStats() { + const entries = Array.from(this._resourceCache.values()); + const totalAccess = entries.reduce((sum, entry) => sum + entry.accessCount, 0); + const cacheHits = totalAccess - entries.length; + + return { + size: entries.length, + hitRate: totalAccess > 0 ? cacheHits / totalAccess : 0, + entries + }; + } + + getAuditLog(limit: number = 100): AuditLogEntry[] { + return this._auditLog.slice(-limit); + } + + clearCache(): void { + this._resourceCache.clear(); + this.emit('cache-cleared'); + console.log('Resource cache cleared', 'info'); + } + + async refreshResources(): Promise { + try { + const result = await this._resources.getResources(); + this.emit('resources-refreshed', result.resources.length); + console.log(`Resources refreshed: ${result.resources.length}`, 'info'); + } catch (error) { + console.log(`Failed to refresh resources: ${error.message}`, 'error'); + throw error; + } + } + + /** + * Set server state + */ + private setState(newState: ServerState): void { + const oldState = this._state; + this._state = newState; + this.emit('state-changed', newState, oldState); + } + + /** + * Setup event handlers + */ + private setupEventHandlers(): void { + // Tool registry events + this._tools.on('tool-registered', (toolName: string) => { + console.log(`Tool registered: ${toolName}`, 'info'); + }); + + this._tools.on('tool-unregistered', (toolName: string) => { + console.log(`Tool unregistered: ${toolName}`, 'info'); + }); + + this._tools.on('batch-registered', (registered: string[], failed: Array<{ tool: any; error: Error }>) => { + console.log(`Batch registration completed: ${registered.length} successful, ${failed.length} failed`, 'info'); + if (failed.length > 0) { + console.log(`Failed tools: ${failed.map(f => f.tool.name).join(', ')}`, 'warning'); + } + }); + + // Resource registry events + this._resources.subscribe((event) => { + console.log(`Resource ${event.type}: ${event.resource.uri}`, 'info'); + }); + } + + private async initializeDefaultTools(): Promise { + if (this._tools.list().length > 0) return + try { + console.log('Initializing default tools...', 'info'); + + // Register compilation tools + const compilationTools = createCompilationTools(); + this._tools.registerBatch(compilationTools); + console.log(`Registered ${compilationTools.length} compilation tools`, 'info'); + + // Register file management tools + const fileManagementTools = createFileManagementTools(); + this._tools.registerBatch(fileManagementTools); + console.log(`Registered ${fileManagementTools.length} file management tools`, 'info'); + + // Register deployment tools + const deploymentTools = createDeploymentTools(); + this._tools.registerBatch(deploymentTools); + console.log(`Registered ${deploymentTools.length} deployment tools`, 'info'); + + // Register debugging tools + const debuggingTools = createDebuggingTools(); + this._tools.registerBatch(debuggingTools); + console.log(`Registered ${debuggingTools.length} debugging tools`, 'info'); + + // Register debugging tools + const codeAnalysisTools = createCodeAnalysisTools(); + this._tools.registerBatch(codeAnalysisTools); + console.log(`Registered ${codeAnalysisTools.length} code analysis tools`, 'info'); + + const totalTools = this._tools.list().length; + console.log(`Total tools registered: ${totalTools}`, 'info'); + + } catch (error) { + console.log(`Failed to initialize default tools: ${error.message}`, 'error'); + throw error; + } + } + + /** + * Initialize default resource providers + */ + private async initializeDefaultResourceProviders(): Promise { + if (this._resources.list().length > 0) return + try { + console.log('Initializing default resource providers...', 'info'); + + // Register project resource provider + const projectProvider = new ProjectResourceProvider(this._plugin); + this._resources.register(projectProvider); + console.log(`Registered project resource provider: ${projectProvider.name}`, 'info'); + + // Register compilation resource provider + const compilationProvider = new CompilationResourceProvider(this._plugin); + this._resources.register(compilationProvider); + console.log(`Registered compilation resource provider: ${compilationProvider.name}`, 'info'); + + // Register deployment resource provider + const deploymentProvider = new DeploymentResourceProvider(); + this._resources.register(deploymentProvider); + console.log(`Registered deployment resource provider: ${deploymentProvider.name}`, 'info'); + + const totalProviders = this._resources.list().length; + console.log(`Total resource providers registered: ${totalProviders}`, 'info'); + + } catch (error) { + console.log(`Failed to initialize default resource providers: ${error.message}`, 'error'); + throw error; + } + } + + /** + * Setup cleanup intervals + */ + private setupCleanupIntervals(): void { + // Clean up old cache entries + setInterval(() => { + const now = Date.now(); + for (const [uri, entry] of this._resourceCache.entries()) { + if (now - entry.timestamp.getTime() > entry.ttl) { + this._resourceCache.delete(uri); + } + } + }, 60000); + + setInterval(() => { + if (this._auditLog.length > 1000) { + this._auditLog = this._auditLog.slice(-500); + } + }, 300000); + } + + /** + * Get current workspace + */ + private async getCurrentWorkspace(): Promise { + try { + // TODO: Get actual current workspace from Remix API + return 'default'; + } catch (error) { + return 'default'; + } + } + + /** + * Get current file + */ + private async getCurrentFile(): Promise { + try { + // TODO: Get actual current file from Remix API + return ''; + } catch (error) { + return ''; + } + } + + /** + * Log message with audit trail + */ + // private log(message: string, level: 'info' | 'warning' | 'error', details?: any): void { + // if (this._config.debug || level !== 'info') { + // console.log(`[RemixMCPServer] ${level.toUpperCase()}: ${message}`, details || ''); + // } + + // if (this._config.security?.enableAuditLog !== false) { + // const entry: AuditLogEntry = { + // id: `audit_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + // timestamp: new Date(), + // type: level === 'error' ? 'error' : 'info', + // user: 'system', + // details: { + // message, + // ...details + // }, + // severity: level + // }; + + // this._auditLog.push(entry); + // this.emit('audit-log', entry); + // } + // } +} \ No newline at end of file diff --git a/libs/remix-ai-core/src/remix-mcp-server/handlers/CodeAnalysisHandler.ts b/libs/remix-ai-core/src/remix-mcp-server/handlers/CodeAnalysisHandler.ts new file mode 100644 index 00000000000..43297fe267b --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/handlers/CodeAnalysisHandler.ts @@ -0,0 +1,126 @@ +/** + * Code Analysis Tool Handlers for Remix MCP Server + */ + +import { IMCPToolResult } from '../../types/mcp'; +import { BaseToolHandler } from '../registry/RemixToolRegistry'; +import { + ToolCategory, + RemixToolDefinition +} from '../types/mcpTools'; +import { Plugin } from '@remixproject/engine'; +import { performSolidityScan } from '@remix-project/core-plugin'; + +/** + * Solidity Scan Tool Handler + * Analyzes Solidity code for security vulnerabilities and code quality issues + */ +export class SolidityScanHandler extends BaseToolHandler { + name = 'solidity_scan'; + description = 'Scan Solidity smart contracts for security vulnerabilities and code quality issues using SolidityScan API'; + inputSchema = { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the Solidity file to scan (relative to workspace root)' + } + }, + required: ['filePath'] + }; + + getPermissions(): string[] { + return ['analysis:scan', 'file:read']; + } + + validate(args: { filePath: string }): boolean | string { + const required = this.validateRequired(args, ['filePath']); + if (required !== true) return required; + + const types = this.validateTypes(args, { + filePath: 'string' + }); + if (types !== true) return types; + + if (!args.filePath.endsWith('.sol')) { + return 'File must be a Solidity file (.sol)'; + } + + return true; + } + + async execute(args: { filePath: string }, plugin: Plugin): Promise { + try { + // Check if file exists + const workspace = await plugin.call('filePanel', 'getCurrentWorkspace'); + const fileName = `${workspace.name}/${args.filePath}`; + const filePath = `.workspaces/${fileName}`; + + const exists = await plugin.call('fileManager', 'exists', filePath); + if (!exists) { + return this.createErrorResult(`File not found: ${args.filePath}`); + } + + // Use the core scanning function from remix-core-plugin + const scanReport = await performSolidityScan(plugin, args.filePath); + + // Process scan results into structured format + const findings = []; + + for (const template of scanReport.multi_file_scan_details || []) { + if (template.metric_wise_aggregated_findings?.length) { + for (const details of template.metric_wise_aggregated_findings) { + for (const finding of details.findings) { + findings.push({ + metric: details.metric_name, + severity: details.severity || 'unknown', + title: finding.title || details.metric_name, + description: finding.description || details.description, + lineStart: finding.line_nos_start?.[0], + lineEnd: finding.line_nos_end?.[0], + file: template.file_name, + recommendation: finding.recommendation + }); + } + } + } + } + + const result = { + success: true, + fileName, + scanCompletedAt: new Date().toISOString(), + totalFindings: findings.length, + findings, + summary: { + critical: findings.filter(f => f.severity === 'critical').length, + high: findings.filter(f => f.severity === 'high').length, + medium: findings.filter(f => f.severity === 'medium').length, + low: findings.filter(f => f.severity === 'low').length, + informational: findings.filter(f => f.severity === 'informational').length + } + }; + + return this.createSuccessResult(result); + + } catch (error) { + return this.createErrorResult(`Scan failed: ${error.message}`); + } + } +} + +/** + * Create code analysis tool definitions + */ +export function createCodeAnalysisTools(): RemixToolDefinition[] { + return [ + { + name: 'solidity_scan', + description: 'Scan Solidity smart contracts for security vulnerabilities and code quality issues using SolidityScan API', + inputSchema: new SolidityScanHandler().inputSchema, + category: ToolCategory.ANALYSIS, + permissions: ['analysis:scan', 'file:read'], + handler: new SolidityScanHandler() + } + ]; +} diff --git a/libs/remix-ai-core/src/remix-mcp-server/handlers/CompilationHandler.ts b/libs/remix-ai-core/src/remix-mcp-server/handlers/CompilationHandler.ts new file mode 100644 index 00000000000..79ea7b006f6 --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/handlers/CompilationHandler.ts @@ -0,0 +1,525 @@ +/** + * Compilation Tool Handlers for Remix MCP Server + */ + +import { IMCPToolResult } from '../../types/mcp'; +import { BaseToolHandler } from '../registry/RemixToolRegistry'; +import { + ToolCategory, + RemixToolDefinition, + SolidityCompileArgs, + CompilerConfigArgs, + CompilationResult +} from '../types/mcpTools'; +import { Plugin } from '@remixproject/engine'; + +/** + * Solidity Compile Tool Handler + */ +export class SolidityCompileHandler extends BaseToolHandler { + name = 'solidity_compile'; + description = 'Compile Solidity smart contracts'; + inputSchema = { + type: 'object', + properties: { + file: { + type: 'string', + description: 'Specific file to compile (optional, compiles all if not specified)' + }, + version: { + type: 'string', + description: 'Solidity compiler version (e.g., 0.8.30)', + default: 'latest' + }, + optimize: { + type: 'boolean', + description: 'Enable optimization', + default: true + }, + runs: { + type: 'number', + description: 'Number of optimization runs', + default: 200 + }, + evmVersion: { + type: 'string', + description: 'EVM version target', + enum: ['london', 'berlin', 'istanbul', 'petersburg', 'constantinople', 'byzantium'], + default: 'london' + } + }, + required: ['file'] + }; + + getPermissions(): string[] { + return ['compile:solidity']; + } + + validate(args: SolidityCompileArgs): boolean | string { + const types = this.validateTypes(args, { + file: 'string', + version: 'string', + optimize: 'boolean', + runs: 'number', + evmVersion: 'string' + }); + if (types !== true) return types; + + if (args.runs !== undefined && (args.runs < 1 || args.runs > 10000)) { + return 'Optimization runs must be between 1 and 10000'; + } + + return true; + } + + async execute(args: SolidityCompileArgs, plugin: Plugin): Promise { + try { + let compilerConfig: any = {}; + + try { + // Try to get existing compiler config + compilerConfig = await plugin.call('solidity' as any , 'getCurrentCompilerConfig'); + } catch (error) { + compilerConfig = { + version: args.version || 'latest', + optimize: args.optimize !== undefined ? args.optimize : true, + runs: args.runs || 200, + evmVersion: args.evmVersion || 'london', + language: 'Solidity' + }; + } + + // if (args.version) compilerConfig.version = args.version; + // if (args.optimize !== undefined) compilerConfig.optimize = args.optimize; + // if (args.runs) compilerConfig.runs = args.runs; + // if (args.evmVersion) compilerConfig.evmVersion = args.evmVersion; + + // await plugin.call('solidity' as any, 'setCompilerConfig', JSON.stringify(compilerConfig)); + + let compilationResult: any; + if (args.file) { + console.log('[TOOL] compiling ', args.file, compilerConfig) + // Compile specific file - need to use plugin API or direct compilation + const content = await plugin.call('fileManager', 'readFile', args.file); + let contract = {} + contract[args.file] = { content: content } + + const compilerPayload = await plugin.call('solidity' as any, 'compileWithParameters', contract, compilerConfig) + await plugin.call('solidity' as any, 'compile', args.file) // this will enable the UI + compilationResult = compilerPayload + } else { + compilationResult = { success: false, message: 'Workspace compilation not yet implemented' }; + } + console.log('compilation result', compilationResult) + // Process compilation result + const result: CompilationResult = { + success: !compilationResult.data?.errors || compilationResult.data?.errors.length === 0 || !compilationResult.data?.error, + contracts: {}, + errors: compilationResult.data.errors || [], + errorFiles: compilationResult?.errFiles || [], + warnings: [], //compilationResult?.data?.errors.find((error) => error.type === 'Warning') || [], + sources: compilationResult?.source || {} + }; + + console.log('emitting compilationFinished event with proper UI trigger') + // Emit compilationFinished event with correct parameters to trigger UI effects + plugin.emit('compilationFinished', + args.file, // source target + { sources: compilationResult?.source || {} }, // source files + 'soljson', // compiler type + compilationResult.data, // compilation data + { sources: compilationResult?.source || {} }, // input + compilerConfig.version || 'latest' // version + ) + + // Extract contract data + // if (compilationResult.data.contracts) { + // for (const [fileName, fileContracts] of Object.entries(compilationResult.contracts)) { + // for (const [contractName, contractData] of Object.entries(fileContracts as any)) { + // const contract = contractData as any; + // result.contracts[`${fileName}:${contractName}`] = { + // abi: contract.abi || [], + // bytecode: contract.evm?.bytecode?.object || '', + // deployedBytecode: contract.evm?.deployedBytecode?.object || '', + // metadata: contract.metadata ? JSON.parse(contract.metadata) : {}, + // gasEstimates: contract.evm?.gasEstimates || {} + // }; + // } + // } + // } + + return this.createSuccessResult(result); + } catch (error) { + return this.createErrorResult(`Compilation failed: ${error.message}`); + } + } +} + +/** + * Get Compilation Result Tool Handler + */ +export class GetCompilationResultHandler extends BaseToolHandler { + name = 'get_compilation_result'; + description = 'Get the latest compilation result'; + inputSchema = { + type: 'object', + properties: {} + }; + + getPermissions(): string[] { + return ['compile:read']; + } + + async execute(args: any, plugin: Plugin): Promise { + try { + const compilationResult: any = await plugin.call('solidity' as any, 'getCompilationResult') + if (!compilationResult) { + return this.createErrorResult('No compilation result available'); + } + + console.log('Got latest compilation result', compilationResult) + + const result: CompilationResult = { + success: !compilationResult.data?.errors || compilationResult.data?.errors.length === 0 || !compilationResult.data?.error, + contracts: {'target': compilationResult.source?.target}, + errors: compilationResult?.data?.errors || [], + errorFiles: compilationResult?.errFiles || [], + warnings: [], //compilationResult?.data?.errors.find((error) => error.type === 'Warning') || [], + sources: compilationResult?.source || {} + }; + + if (compilationResult.data?.contracts) { + for (const [fileName, fileContracts] of Object.entries(compilationResult.data.contracts)) { + for (const [contractName, contractData] of Object.entries(fileContracts as any)) { + const contract = contractData as any; + result.contracts[`${fileName}:${contractName}`] = { + abi: contract.abi || [], + bytecode: contract.evm?.bytecode?.object || '', + deployedBytecode: contract.evm?.deployedBytecode?.object || '', + metadata: contract.metadata ? JSON.parse(contract.metadata) : {}, + gasEstimates: contract.evm?.gasEstimates || {} + }; + } + } + } + + return this.createSuccessResult(result); + } catch (error) { + return this.createErrorResult(`Failed to get compilation result: ${error.message}`); + } + } +} + +/** + * Set Compiler Config Tool Handler + */ +export class SetCompilerConfigHandler extends BaseToolHandler { + name = 'set_compiler_config'; + description = 'Set Solidity compiler configuration'; + inputSchema = { + type: 'object', + properties: { + version: { + type: 'string', + description: 'Compiler version' + }, + optimize: { + type: 'boolean', + description: 'Enable optimization' + }, + runs: { + type: 'number', + description: 'Number of optimization runs' + }, + evmVersion: { + type: 'string', + description: 'EVM version target' + }, + language: { + type: 'string', + description: 'Programming language', + default: 'Solidity' + } + }, + required: ['version'] + }; + + getPermissions(): string[] { + return ['compile:config']; + } + + validate(args: CompilerConfigArgs): boolean | string { + const required = this.validateRequired(args, ['version']); + if (required !== true) return required; + + const types = this.validateTypes(args, { + version: 'string', + optimize: 'boolean', + runs: 'number', + evmVersion: 'string', + language: 'string' + }); + if (types !== true) return types; + + return true; + } + + async execute(args: CompilerConfigArgs, plugin: Plugin): Promise { + try { + const config = { + version: args.version, + optimize: args.optimize !== undefined ? args.optimize : true, + runs: args.runs || 200, + evmVersion: args.evmVersion || 'london', + language: args.language || 'Solidity' + }; + + await plugin.call('solidity' as any, 'setCompilerConfig', JSON.stringify(config)); + + return this.createSuccessResult({ + success: true, + message: 'Compiler configuration updated', + config: config + }); + } catch (error) { + return this.createErrorResult(`Failed to set compiler config: ${error.message}`); + } + } +} + +/** + * Get Compiler Config Tool Handler + */ +export class GetCompilerConfigHandler extends BaseToolHandler { + name = 'get_compiler_config'; + description = 'Get current Solidity compiler configuration'; + inputSchema = { + type: 'object', + properties: {} + }; + + getPermissions(): string[] { + return ['compile:read']; + } + + async execute(args: any, plugin: Plugin): Promise { + try { + let config = await plugin.call('solidity' as any , 'getCurrentCompilerConfig'); + if (!config) { + config = { + version: 'latest', + optimize: true, + runs: 200, + evmVersion: 'london', + language: 'Solidity' + }; + } + + return this.createSuccessResult({ + success: true, + config: config + }); + } catch (error) { + return this.createErrorResult(`Failed to get compiler config: ${error.message}`); + } + } +} + +/** + * Compile with Hardhat Tool Handler + */ +export class CompileWithHardhatHandler extends BaseToolHandler { + name = 'compile_with_hardhat'; + description = 'Compile using Hardhat framework'; + inputSchema = { + type: 'object', + properties: { + configPath: { + type: 'string', + description: 'Path to hardhat.config.js file', + default: 'hardhat.config.js' + } + } + }; + + getPermissions(): string[] { + return ['compile:hardhat']; + } + + validate(args: { configPath?: string }): boolean | string { + const types = this.validateTypes(args, { configPath: 'string' }); + if (types !== true) return types; + + return true; + } + + async execute(args: { configPath?: string }, plugin: Plugin): Promise { + try { + const configPath = args.configPath || 'hardhat.config.js'; + + // Check if hardhat config exists + const exists = await plugin.call('fileManager', 'exists', configPath); + if (!exists) { + return this.createErrorResult(`Hardhat config file not found: ${configPath}`); + } + + const result = await plugin.call('solidity' as any , 'compileWithHardhat', configPath); + + return this.createSuccessResult({ + success: true, + message: 'Compiled with Hardhat successfully', + result: result + }); + } catch (error) { + return this.createErrorResult(`Hardhat compilation failed: ${error.message}`); + } + } +} + +/** + * Compile with Truffle Tool Handler + */ +export class CompileWithTruffleHandler extends BaseToolHandler { + name = 'compile_with_truffle'; + description = 'Compile using Truffle framework'; + inputSchema = { + type: 'object', + properties: { + configPath: { + type: 'string', + description: 'Path to truffle.config.js file', + default: 'truffle.config.js' + } + } + }; + + getPermissions(): string[] { + return ['compile:truffle']; + } + + validate(args: { configPath?: string }): boolean | string { + const types = this.validateTypes(args, { configPath: 'string' }); + if (types !== true) return types; + + return true; + } + + async execute(args: { configPath?: string }, plugin: Plugin): Promise { + try { + const configPath = args.configPath || 'truffle.config.js'; + + // Check if truffle config exists + const exists = await plugin.call('fileManager', 'exists', configPath); + if (!exists) { + return this.createErrorResult(`Truffle config file not found: ${configPath}`); + } + + const result = await plugin.call('solidity' as any , 'compileWithTruffle', configPath); + + return this.createSuccessResult({ + success: true, + message: 'Compiled with Truffle successfully', + result: result + }); + } catch (error) { + return this.createErrorResult(`Truffle compilation failed: ${error.message}`); + } + } +} + +/** + * Get Available Compiler Versions Tool Handler + */ +export class GetCompilerVersionsHandler extends BaseToolHandler { + name = 'get_compiler_versions'; + description = 'Get list of available Solidity compiler versions'; + inputSchema = { + type: 'object', + properties: {} + }; + + getPermissions(): string[] { + return ['compile:read']; + } + + async execute(_args: any, plugin: Plugin): Promise { + try { + // TODO: Get available compiler versions from Remix API + const compilerList = await plugin.call('compilerloader', 'listCompilers') + //const solJson = await plugin.call('compilerloader', 'getJsonBinData') + const versions = ['0.8.20', '0.8.25', '0.8.26', '0.8.28', '0.8.30']; // Mock data + + return this.createSuccessResult({ + success: true, + versions: versions || [], + count: versions?.length || 0 + }); + } catch (error) { + return this.createErrorResult(`Failed to get compiler versions: ${error.message}`); + } + } +} + +/** + * Create compilation tool definitions + */ +export function createCompilationTools(): RemixToolDefinition[] { + return [ + { + name: 'solidity_compile', + description: 'Compile Solidity smart contracts', + inputSchema: new SolidityCompileHandler().inputSchema, + category: ToolCategory.COMPILATION, + permissions: ['compile:solidity'], + handler: new SolidityCompileHandler() + }, + { + name: 'get_compilation_result', + description: 'Get the latest compilation result', + inputSchema: new GetCompilationResultHandler().inputSchema, + category: ToolCategory.COMPILATION, + permissions: ['compile:read'], + handler: new GetCompilationResultHandler() + }, + { + name: 'set_compiler_config', + description: 'Set Solidity compiler configuration', + inputSchema: new SetCompilerConfigHandler().inputSchema, + category: ToolCategory.COMPILATION, + permissions: ['compile:config'], + handler: new SetCompilerConfigHandler() + }, + { + name: 'get_compiler_config', + description: 'Get current Solidity compiler configuration', + inputSchema: new GetCompilerConfigHandler().inputSchema, + category: ToolCategory.COMPILATION, + permissions: ['compile:read'], + handler: new GetCompilerConfigHandler() + }, + { + name: 'compile_with_hardhat', + description: 'Compile using Hardhat framework', + inputSchema: new CompileWithHardhatHandler().inputSchema, + category: ToolCategory.COMPILATION, + permissions: ['compile:hardhat'], + handler: new CompileWithHardhatHandler() + }, + { + name: 'compile_with_truffle', + description: 'Compile using Truffle framework', + inputSchema: new CompileWithTruffleHandler().inputSchema, + category: ToolCategory.COMPILATION, + permissions: ['compile:truffle'], + handler: new CompileWithTruffleHandler() + }, + { + name: 'get_compiler_versions', + description: 'Get list of available Solidity compiler versions', + inputSchema: new GetCompilerVersionsHandler().inputSchema, + category: ToolCategory.COMPILATION, + permissions: ['compile:read'], + handler: new GetCompilerVersionsHandler() + } + ]; +} \ No newline at end of file diff --git a/libs/remix-ai-core/src/remix-mcp-server/handlers/DebuggingHandler.ts b/libs/remix-ai-core/src/remix-mcp-server/handlers/DebuggingHandler.ts new file mode 100644 index 00000000000..47c419b516a --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/handlers/DebuggingHandler.ts @@ -0,0 +1,673 @@ +/** + * Debugging Tool Handlers for Remix MCP Server + */ + +import { ICustomRemixApi } from '@remix-api'; +import { IMCPToolResult } from '../../types/mcp'; +import { BaseToolHandler } from '../registry/RemixToolRegistry'; +import { + ToolCategory, + RemixToolDefinition, + DebugSessionArgs, + BreakpointArgs, + DebugStepArgs, + DebugWatchArgs, + DebugEvaluateArgs, + DebugCallStackArgs, + DebugVariablesArgs, + DebugSessionResult, + BreakpointResult, + DebugStepResult +} from '../types/mcpTools'; +import { Plugin } from '@remixproject/engine'; + +/** + * Start Debug Session Tool Handler + */ +export class StartDebugSessionHandler extends BaseToolHandler { + name = 'start_debug_session'; + description = 'Start a debugging session for a smart contract'; + inputSchema = { + type: 'object', + properties: { + transactionHash: { + type: 'string', + description: 'Transaction hash to debug (optional)', + pattern: '^0x[a-fA-F0-9]{64}$' + }, + /* + network: { + type: 'string', + description: 'Network to debug on', + default: 'local' + } + */ + }, + required: ['transactionHash'] + }; + + getPermissions(): string[] { + return ['debug:start']; + } + + validate(args: DebugSessionArgs): boolean | string { + const required = this.validateRequired(args, ['transactionHash']); + if (required !== true) return required; + + const types = this.validateTypes(args, { + transactionHash: 'string', + }); + if (types !== true) return types; + + if (args.transactionHash && !args.transactionHash.match(/^0x[a-fA-F0-9]{64}$/)) { + return 'Invalid transaction hash format'; + } + + return true; + } + + async execute(args: DebugSessionArgs, plugin: Plugin): Promise { + try { + await plugin.call('debugger', 'debug', args.transactionHash) + // Mock debug session creation + const result: DebugSessionResult = { + success: true, + transactionHash: args.transactionHash, + status: 'started', + createdAt: new Date().toISOString() + }; + plugin.call('menuicons', 'select', 'debugger') + return this.createSuccessResult(result); + + } catch (error) { + return this.createErrorResult(`Failed to start debug session: ${error.message}`); + } + } +} + +/** + * Set Breakpoint Tool Handler + */ +export class SetBreakpointHandler extends BaseToolHandler { + name = 'set_breakpoint'; + description = 'Set a breakpoint in smart contract code'; + inputSchema = { + type: 'object', + properties: { + sourceFile: { + type: 'string', + description: 'Source file path' + }, + lineNumber: { + type: 'number', + description: 'Line number to set breakpoint', + minimum: 1 + }, + condition: { + type: 'string', + description: 'Conditional breakpoint expression (optional)' + }, + hitCount: { + type: 'number', + description: 'Hit count condition (optional)', + minimum: 1 + } + }, + required: ['sourceFile', 'lineNumber'] + }; + + getPermissions(): string[] { + return ['debug:breakpoint']; + } + + validate(args: BreakpointArgs): boolean | string { + const required = this.validateRequired(args, ['sourceFile', 'lineNumber']); + if (required !== true) return required; + + const types = this.validateTypes(args, { + sourceFile: 'string', + lineNumber: 'number', + condition: 'string', + hitCount: 'number' + }); + if (types !== true) return types; + + if (args.lineNumber < 1) { + return 'Line number must be at least 1'; + } + + if (args.hitCount !== undefined && args.hitCount < 1) { + return 'Hit count must be at least 1'; + } + + return true; + } + + async execute(args: BreakpointArgs, plugin: Plugin): Promise { + try { + // Check if source file exists + const exists = await plugin.call('fileManager', 'exists', args.sourceFile); + if (!exists) { + return this.createErrorResult(`Source file not found: ${args.sourceFile}`); + } + + // TODO: Set breakpoint via Remix debugger API + const breakpointId = `bp_${Date.now()}`; + + const result: BreakpointResult = { + success: true, + breakpointId, + sourceFile: args.sourceFile, + lineNumber: args.lineNumber, + condition: args.condition, + hitCount: args.hitCount, + enabled: true, + setAt: new Date().toISOString() + }; + + return this.createSuccessResult(result); + + } catch (error) { + return this.createErrorResult(`Failed to set breakpoint: ${error.message}`); + } + } +} + +/** + * Debug Step Tool Handler + */ +export class DebugStepHandler extends BaseToolHandler { + name = 'debug_step'; + description = 'Step through code during debugging'; + inputSchema = { + type: 'object', + properties: { + sessionId: { + type: 'string', + description: 'Debug session ID' + }, + stepType: { + type: 'string', + enum: ['into', 'over', 'out', 'continue'], + description: 'Type of step to perform' + } + }, + required: ['sessionId', 'stepType'] + }; + + getPermissions(): string[] { + return ['debug:step']; + } + + validate(args: DebugStepArgs): boolean | string { + const required = this.validateRequired(args, ['sessionId', 'stepType']); + if (required !== true) return required; + + const types = this.validateTypes(args, { + sessionId: 'string', + stepType: 'string' + }); + if (types !== true) return types; + + const validStepTypes = ['into', 'over', 'out', 'continue']; + if (!validStepTypes.includes(args.stepType)) { + return `Invalid step type. Must be one of: ${validStepTypes.join(', ')}`; + } + + return true; + } + + async execute(args: DebugStepArgs, plugin: Plugin): Promise { + try { + // TODO: Execute step via Remix debugger API + + const result: DebugStepResult = { + success: true, + sessionId: args.sessionId, + stepType: args.stepType, + currentLocation: { + sourceFile: 'contracts/example.sol', + lineNumber: Math.floor(Math.random() * 100) + 1, + columnNumber: 1 + }, + stackTrace: [ + { + function: 'main', + sourceFile: 'contracts/example.sol', + lineNumber: 25 + } + ], + steppedAt: new Date().toISOString() + }; + + return this.createSuccessResult(result); + + } catch (error) { + return this.createErrorResult(`Debug step failed: ${error.message}`); + } + } +} + +/** + * Debug Watch Variable Tool Handler + */ +export class DebugWatchHandler extends BaseToolHandler { + name = 'debug_watch'; + description = 'Watch a variable or expression during debugging'; + inputSchema = { + type: 'object', + properties: { + sessionId: { + type: 'string', + description: 'Debug session ID' + }, + expression: { + type: 'string', + description: 'Variable name or expression to watch' + }, + watchType: { + type: 'string', + enum: ['variable', 'expression', 'memory'], + description: 'Type of watch to add', + default: 'variable' + } + }, + required: ['sessionId', 'expression'] + }; + + getPermissions(): string[] { + return ['debug:watch']; + } + + validate(args: DebugWatchArgs): boolean | string { + const required = this.validateRequired(args, ['sessionId', 'expression']); + if (required !== true) return required; + + const types = this.validateTypes(args, { + sessionId: 'string', + expression: 'string', + watchType: 'string' + }); + if (types !== true) return types; + + if (args.watchType) { + const validTypes = ['variable', 'expression', 'memory']; + if (!validTypes.includes(args.watchType)) { + return `Invalid watch type. Must be one of: ${validTypes.join(', ')}`; + } + } + + return true; + } + + async execute(args: DebugWatchArgs, plugin: Plugin): Promise { + try { + // TODO: Add watch via Remix debugger API + const watchId = `watch_${Date.now()}`; + + const result = { + success: true, + watchId, + sessionId: args.sessionId, + expression: args.expression, + watchType: args.watchType || 'variable', + currentValue: 'undefined', // Mock value + addedAt: new Date().toISOString() + }; + + return this.createSuccessResult(result); + + } catch (error) { + return this.createErrorResult(`Failed to add watch: ${error.message}`); + } + } +} + +/** + * Debug Evaluate Expression Tool Handler + */ +export class DebugEvaluateHandler extends BaseToolHandler { + name = 'debug_evaluate'; + description = 'Evaluate an expression in the current debug context'; + inputSchema = { + type: 'object', + properties: { + sessionId: { + type: 'string', + description: 'Debug session ID' + }, + expression: { + type: 'string', + description: 'Expression to evaluate' + }, + context: { + type: 'string', + enum: ['current', 'global', 'local'], + description: 'Evaluation context', + default: 'current' + } + }, + required: ['sessionId', 'expression'] + }; + + getPermissions(): string[] { + return ['debug:evaluate']; + } + + validate(args: DebugEvaluateArgs): boolean | string { + const required = this.validateRequired(args, ['sessionId', 'expression']); + if (required !== true) return required; + + const types = this.validateTypes(args, { + sessionId: 'string', + expression: 'string', + context: 'string' + }); + if (types !== true) return types; + + if (args.context) { + const validContexts = ['current', 'global', 'local']; + if (!validContexts.includes(args.context)) { + return `Invalid context. Must be one of: ${validContexts.join(', ')}`; + } + } + + return true; + } + + async execute(args: DebugEvaluateArgs, plugin: Plugin): Promise { + try { + // TODO: Evaluate expression via Remix debugger API + + const result = { + success: true, + sessionId: args.sessionId, + expression: args.expression, + result: '42', // Mock evaluation result + type: 'uint256', + context: args.context || 'current', + evaluatedAt: new Date().toISOString() + }; + + return this.createSuccessResult(result); + + } catch (error) { + return this.createErrorResult(`Expression evaluation failed: ${error.message}`); + } + } +} + +/** + * Get Debug Call Stack Tool Handler + */ +export class GetDebugCallStackHandler extends BaseToolHandler { + name = 'get_debug_call_stack'; + description = 'Get the current call stack during debugging'; + inputSchema = { + type: 'object', + properties: { + sessionId: { + type: 'string', + description: 'Debug session ID' + } + }, + required: ['sessionId'] + }; + + getPermissions(): string[] { + return ['debug:read']; + } + + validate(args: DebugCallStackArgs): boolean | string { + const required = this.validateRequired(args, ['sessionId']); + if (required !== true) return required; + + const types = this.validateTypes(args, { sessionId: 'string' }); + if (types !== true) return types; + + return true; + } + + async execute(args: DebugCallStackArgs, plugin: Plugin): Promise { + try { + // TODO: Get call stack via Remix debugger API + + const result = { + success: true, + sessionId: args.sessionId, + callStack: [ + { + function: 'transfer', + contract: 'ERC20Token', + sourceFile: 'contracts/ERC20Token.sol', + lineNumber: 45, + address: '0x' + Math.random().toString(16).substr(2, 40) + }, + { + function: 'main', + contract: 'Main', + sourceFile: 'contracts/Main.sol', + lineNumber: 12, + address: '0x' + Math.random().toString(16).substr(2, 40) + } + ], + depth: 2, + retrievedAt: new Date().toISOString() + }; + + return this.createSuccessResult(result); + + } catch (error) { + return this.createErrorResult(`Failed to get call stack: ${error.message}`); + } + } +} + +/** + * Get Debug Variables Tool Handler + */ +export class GetDebugVariablesHandler extends BaseToolHandler { + name = 'get_debug_variables'; + description = 'Get current variable values during debugging'; + inputSchema = { + type: 'object', + properties: { + sessionId: { + type: 'string', + description: 'Debug session ID' + }, + scope: { + type: 'string', + enum: ['local', 'global', 'storage', 'memory'], + description: 'Variable scope to retrieve', + default: 'local' + } + }, + required: ['sessionId'] + }; + + getPermissions(): string[] { + return ['debug:read']; + } + + validate(args: DebugVariablesArgs): boolean | string { + const required = this.validateRequired(args, ['sessionId']); + if (required !== true) return required; + + const types = this.validateTypes(args, { + sessionId: 'string', + scope: 'string' + }); + if (types !== true) return types; + + if (args.scope) { + const validScopes = ['local', 'global', 'storage', 'memory']; + if (!validScopes.includes(args.scope)) { + return `Invalid scope. Must be one of: ${validScopes.join(', ')}`; + } + } + + return true; + } + + async execute(args: DebugVariablesArgs, plugin: Plugin): Promise { + try { + // TODO: Get variables via Remix debugger API + + const result = { + success: true, + sessionId: args.sessionId, + scope: args.scope || 'local', + variables: [ + { + name: 'balance', + value: '1000000000000000000', + type: 'uint256', + location: 'storage' + }, + { + name: 'owner', + value: '0x' + Math.random().toString(16).substr(2, 40), + type: 'address', + location: 'storage' + }, + { + name: 'amount', + value: '500', + type: 'uint256', + location: 'local' + } + ], + retrievedAt: new Date().toISOString() + }; + + return this.createSuccessResult(result); + + } catch (error) { + return this.createErrorResult(`Failed to get variables: ${error.message}`); + } + } +} + +/** + * Stop Debug Session Tool Handler + */ +export class StopDebugSessionHandler extends BaseToolHandler { + name = 'stop_debug_session'; + description = 'Stop an active debugging session'; + inputSchema = { + type: 'object', + properties: { + sessionId: { + type: 'string', + description: 'Debug session ID to stop' + } + }, + required: ['sessionId'] + }; + + getPermissions(): string[] { + return ['debug:stop']; + } + + validate(args: { sessionId: string }): boolean | string { + const required = this.validateRequired(args, ['sessionId']); + if (required !== true) return required; + + const types = this.validateTypes(args, { sessionId: 'string' }); + if (types !== true) return types; + + return true; + } + + async execute(args: { sessionId: string }, plugin: Plugin): Promise { + try { + // TODO: Stop debug session via Remix debugger API + + const result = { + success: true, + sessionId: args.sessionId, + status: 'stopped', + stoppedAt: new Date().toISOString(), + message: 'Debug session stopped successfully' + }; + + return this.createSuccessResult(result); + + } catch (error) { + return this.createErrorResult(`Failed to stop debug session: ${error.message}`); + } + } +} + +/** + * Create debugging tool definitions + */ +export function createDebuggingTools(): RemixToolDefinition[] { + return [ + { + name: 'start_debug_session', + description: 'Start a debugging session for a smart contract', + inputSchema: new StartDebugSessionHandler().inputSchema, + category: ToolCategory.DEBUGGING, + permissions: ['debug:start'], + handler: new StartDebugSessionHandler() + }, + { + name: 'set_breakpoint', + description: 'Set a breakpoint in smart contract code', + inputSchema: new SetBreakpointHandler().inputSchema, + category: ToolCategory.DEBUGGING, + permissions: ['debug:breakpoint'], + handler: new SetBreakpointHandler() + }, + { + name: 'debug_step', + description: 'Step through code during debugging', + inputSchema: new DebugStepHandler().inputSchema, + category: ToolCategory.DEBUGGING, + permissions: ['debug:step'], + handler: new DebugStepHandler() + }, + { + name: 'debug_watch', + description: 'Watch a variable or expression during debugging', + inputSchema: new DebugWatchHandler().inputSchema, + category: ToolCategory.DEBUGGING, + permissions: ['debug:watch'], + handler: new DebugWatchHandler() + }, + { + name: 'debug_evaluate', + description: 'Evaluate an expression in the current debug context', + inputSchema: new DebugEvaluateHandler().inputSchema, + category: ToolCategory.DEBUGGING, + permissions: ['debug:evaluate'], + handler: new DebugEvaluateHandler() + }, + { + name: 'get_debug_call_stack', + description: 'Get the current call stack during debugging', + inputSchema: new GetDebugCallStackHandler().inputSchema, + category: ToolCategory.DEBUGGING, + permissions: ['debug:read'], + handler: new GetDebugCallStackHandler() + }, + { + name: 'get_debug_variables', + description: 'Get current variable values during debugging', + inputSchema: new GetDebugVariablesHandler().inputSchema, + category: ToolCategory.DEBUGGING, + permissions: ['debug:read'], + handler: new GetDebugVariablesHandler() + }, + { + name: 'stop_debug_session', + description: 'Stop an active debugging session', + inputSchema: new StopDebugSessionHandler().inputSchema, + category: ToolCategory.DEBUGGING, + permissions: ['debug:stop'], + handler: new StopDebugSessionHandler() + } + ]; +} \ No newline at end of file diff --git a/libs/remix-ai-core/src/remix-mcp-server/handlers/DeploymentHandler.ts b/libs/remix-ai-core/src/remix-mcp-server/handlers/DeploymentHandler.ts new file mode 100644 index 00000000000..25de0520252 --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/handlers/DeploymentHandler.ts @@ -0,0 +1,891 @@ +/** + * Deployment and Contract Interaction Tool Handlers for Remix MCP Server + */ + +import { IMCPToolResult } from '../../types/mcp'; +import { BaseToolHandler } from '../registry/RemixToolRegistry'; +import { + ToolCategory, + RemixToolDefinition, + DeployContractArgs, + CallContractArgs, + SendTransactionArgs, + DeploymentResult, + AccountInfo, + ContractInteractionResult, + RunScriptArgs, + RunScriptResult +} from '../types/mcpTools'; +import { Plugin } from '@remixproject/engine'; +import { getContractData } from '@remix-project/core-plugin' +import type { TxResult } from '@remix-project/remix-lib'; +import type { TransactionReceipt } from 'web3' +import { BrowserProvider } from "ethers" +import web3, { Web3 } from 'web3' + +/** + * Deploy Contract Tool Handler + */ +export class DeployContractHandler extends BaseToolHandler { + name = 'deploy_contract'; + description = 'Deploy a smart contract'; + inputSchema = { + type: 'object', + properties: { + contractName: { + type: 'string', + description: 'Name of the contract to deploy' + }, + constructorArgs: { + type: 'array', + description: 'Constructor arguments', + items: { + type: 'string' + }, + default: [] + }, + gasLimit: { + type: 'number', + description: 'Gas limit for deployment', + minimum: 21000 + }, + gasPrice: { + type: 'string', + description: 'Gas price in wei' + }, + value: { + type: 'string', + description: 'ETH value to send with deployment', + default: '0' + }, + account: { + type: 'string', + description: 'Account to deploy from (address or index)' + }, + file: { + type: 'string', + description: 'The file containing the contract to deploy' + } + }, + required: ['contractName', 'file'] + }; + + getPermissions(): string[] { + return ['deploy:contract']; + } + + validate(args: DeployContractArgs): boolean | string { + const required = this.validateRequired(args, ['contractName']); + if (required !== true) return required; + + const types = this.validateTypes(args, { + contractName: 'string', + gasLimit: 'number', + gasPrice: 'string', + value: 'string', + account: 'string' + }); + if (types !== true) return types; + + if (args.gasLimit && args.gasLimit < 21000) { + return 'Gas limit must be at least 21000'; + } + + return true; + } + + async execute(args: DeployContractArgs, plugin: Plugin): Promise { + try { + // Get compilation result to find contract + const compilerAbstract = await plugin.call('compilerArtefacts', 'getCompilerAbstract', args.file) as any; + const data = getContractData(args.contractName, compilerAbstract) + if (!data) { + return this.createErrorResult(`Could not retrieve contract data for '${args.contractName}'`); + } + + let txReturn + try { + txReturn = await new Promise(async (resolve, reject) => { + const callbacks = { continueCb: (error, continueTxExecution, cancelCb) => { + continueTxExecution() + }, promptCb: () => {}, statusCb: (error) => { + console.log(error) + }, finalCb: (error, contractObject, address: string, txResult: TxResult) => { + if (error) return reject(error) + resolve({contractObject, address, txResult}) + }} + const confirmationCb = (network, tx, gasEstimation, continueTxExecution, cancelCb) => { + continueTxExecution(null) + } + const compilerContracts = await plugin.call('compilerArtefacts', 'getLastCompilationResult') + plugin.call('blockchain', 'deployContractAndLibraries', + data, + args.constructorArgs ? args.constructorArgs : [], + null, + compilerContracts.getData().contracts, + callbacks, + confirmationCb + ) + }) + } catch (e) { + return this.createErrorResult(`Deployment error: ${e.message || e}`); + } + + + const receipt = (txReturn.txResult.receipt as TransactionReceipt) + const result: DeploymentResult = { + transactionHash: web3.utils.bytesToHex(receipt.transactionHash), + gasUsed: web3.utils.toNumber(receipt.gasUsed), + effectiveGasPrice: args.gasPrice || '20000000000', + blockNumber: web3.utils.toNumber(receipt.blockNumber), + logs: receipt.logs, + contractAddress: receipt.contractAddress, + success: receipt.status === BigInt(1) ? true : false + }; + + plugin.call('udapp', 'addInstance', result.contractAddress, data.abi, args.contractName, data) + + return this.createSuccessResult(result); + + } catch (error) { + return this.createErrorResult(`Deployment failed: ${error.message}`); + } + } +} + +/** + * Call Contract Method Tool Handler + */ +export class CallContractHandler extends BaseToolHandler { + name = 'call_contract'; + description = 'Call a smart contract method'; + inputSchema = { + type: 'object', + properties: { + contractName: { + type: 'string', + description: 'Contract name', + pattern: '^0x[a-fA-F0-9]{40}$' + }, + address: { + type: 'string', + description: 'Contract address', + pattern: '^0x[a-fA-F0-9]{40}$' + }, + abi: { + type: 'array', + description: 'Contract ABI', + items: { + type: 'object' + } + }, + methodName: { + type: 'string', + description: 'Method name to call' + }, + args: { + type: 'array', + description: 'Method arguments', + items: { + type: 'string' + }, + default: [] + }, + gasLimit: { + type: 'number', + description: 'Gas limit for transaction', + minimum: 21000 + }, + gasPrice: { + type: 'string', + description: 'Gas price in wei' + }, + value: { + type: 'string', + description: 'ETH value to send', + default: '0' + }, + account: { + type: 'string', + description: 'Account to call from' + } + }, + required: ['address', 'abi', 'methodName', 'contractName'] + }; + + getPermissions(): string[] { + return ['contract:interact']; + } + + validate(args: CallContractArgs): boolean | string { + const required = this.validateRequired(args, ['address', 'abi', 'methodName', 'contractName']); + if (required !== true) return required; + + const types = this.validateTypes(args, { + address: 'string', + methodName: 'string', + gasLimit: 'number', + gasPrice: 'string', + value: 'string', + account: 'string' + }); + if (types !== true) return types; + + if (!args.address.match(/^0x[a-fA-F0-9]{40}$/)) { + return 'Invalid contract address format'; + } + + + if (!Array.isArray(args.abi)) { + try { + args.abi = JSON.parse(args.abi as any) + if (!Array.isArray(args.abi)) { + return 'ABI must be an array' + } + } catch (e) { + return 'ABI must be an array' + } + } + + return true; + } + + async execute(args: CallContractArgs, plugin: Plugin): Promise { + try { + const funcABI = args.abi.find((item: any) => item.name === args.methodName && item.type === 'function') + const isView = funcABI.stateMutability === 'view' || funcABI.stateMutability === 'pure'; + let txReturn + try { + txReturn = await new Promise(async (resolve, reject) => { + const params = funcABI.type !== 'fallback' ? args.args.join(',') : '' + plugin.call('blockchain', 'runOrCallContractMethod', + args.contractName, + args.abi, + funcABI, + undefined, + args.args ? args.args : [], + args.address, + params, + isView, + (msg) => { + // logMsg + console.log(msg) + }, + (msg) => { + // logCallback + console.log(msg) + }, + (returnValue) => { + // outputCb + }, + (network, tx, gasEstimation, continueTxExecution, cancelCb) => { + // confirmationCb + continueTxExecution(null) + }, + (error, continueTxExecution, cancelCb) => { + if (error) reject(error) + // continueCb + continueTxExecution() + }, + (okCb, cancelCb) => { + // promptCb + }, + (error, {txResult, address, returnValue}) => { + if (error) return reject(error) + resolve({txResult, address, returnValue}) + }, + ) + }) + } catch (e) { + return this.createErrorResult(`Deployment error: ${e.message}`); + } + + // TODO: Execute contract call via Remix Run Tab API + const receipt = (txReturn.txResult.receipt as TransactionReceipt) + const result: ContractInteractionResult = { + result: txReturn.returnValue, + transactionHash: isView ? undefined : web3.utils.bytesToHex(receipt.transactionHash), + gasUsed: web3.utils.toNumber(receipt.gasUsed), + logs: receipt.logs, + success: receipt.status === BigInt(1) ? true : false + }; + + return this.createSuccessResult(result); + + } catch (error) { + return this.createErrorResult(`Contract call failed: ${error.message}`); + } + } +} + +/** + * Run Script + */ +export class RunScriptHandler extends BaseToolHandler { + name = 'send_transaction'; + description = 'Run a script in the current environment'; + inputSchema = { + type: 'object', + properties: { + file: { + type: 'string', + description: 'path to the file', + pattern: '^0x[a-fA-F0-9]{40}$' + } + }, + required: ['file'] + }; + + getPermissions(): string[] { + return ['transaction:send']; + } + + validate(args: RunScriptArgs): boolean | string { + const required = this.validateRequired(args, ['file']); + if (required !== true) return required; + + return true; + } + + async execute(args: RunScriptArgs, plugin: Plugin): Promise { + try { + const content = await plugin.call('fileManager', 'readFile', args.file) + await plugin.call('scriptRunnerBridge', 'execute', content, args.file) + + const result: RunScriptResult = {} + + return this.createSuccessResult(result); + + } catch (error) { + console.log(error) + return this.createErrorResult(`Run script failed: ${error.message}`); + } + } +} + +/** + * Send Transaction Tool Handler + */ +export class SendTransactionHandler extends BaseToolHandler { + name = 'send_transaction'; + description = 'Send a raw transaction'; + inputSchema = { + type: 'object', + properties: { + to: { + type: 'string', + description: 'Recipient address', + pattern: '^0x[a-fA-F0-9]{40}$' + }, + value: { + type: 'string', + description: 'ETH value to send in wei', + default: '0' + }, + data: { + type: 'string', + description: 'Transaction data (hex)', + pattern: '^0x[a-fA-F0-9]*$' + }, + gasLimit: { + type: 'number', + description: 'Gas limit', + minimum: 21000 + }, + gasPrice: { + type: 'string', + description: 'Gas price in wei' + }, + account: { + type: 'string', + description: 'Account to send from' + } + }, + required: ['to'] + }; + + getPermissions(): string[] { + return ['transaction:send']; + } + + validate(args: SendTransactionArgs): boolean | string { + const required = this.validateRequired(args, ['to']); + if (required !== true) return required; + + const types = this.validateTypes(args, { + to: 'string', + value: 'string', + data: 'string', + gasLimit: 'number', + gasPrice: 'string', + account: 'string' + }); + if (types !== true) return types; + + if (!args.to.match(/^0x[a-fA-F0-9]{40}$/)) { + return 'Invalid recipient address format'; + } + + if (args.data && !args.data.match(/^0x[a-fA-F0-9]*$/)) { + return 'Invalid data format (must be hex)'; + } + + return true; + } + + async execute(args: SendTransactionArgs, plugin: Plugin): Promise { + try { + // Get accounts + const sendAccount = args.account + + if (!sendAccount) { + return this.createErrorResult('No account available for sending transaction'); + } + const web3: Web3 = await plugin.call('blockchain', 'web3') + const ethersProvider = new BrowserProvider(web3.currentProvider) + const signer = await ethersProvider.getSigner(); + const tx = await signer.sendTransaction({ + from: args.account, + to: args.to, + value: args.value || '0', + data: args.data, + gasLimit: args.gasLimit, + gasPrice: args.gasPrice + }); + + // Wait for the transaction to be mined + const receipt = await tx.wait() + // TODO: Send a real transaction via Remix Run Tab API + const mockResult = { + success: true, + transactionHash: receipt.hash, + from: args.account, + to: args.to, + value: args.value || '0', + gasUsed: web3.utils.toNumber(receipt.gasUsed), + blockNumber: receipt.blockNumber + }; + + return this.createSuccessResult(mockResult); + + } catch (error) { + console.log(error) + return this.createErrorResult(`Transaction failed: ${error.message}`); + } + } +} + +/** + * Get Deployed Contracts Tool Handler + */ +export class GetDeployedContractsHandler extends BaseToolHandler { + name = 'get_deployed_contracts'; + description = 'Get list of deployed contracts'; + inputSchema = { + type: 'object', + properties: { + network: { + type: 'string', + description: 'Network name (optional)' + } + } + }; + + getPermissions(): string[] { + return ['deploy:read']; + } + + async execute(args: { network?: string }, plugin: Plugin): Promise { + try { + const deployedContracts = await plugin.call('udapp', 'getAllDeployedInstances') + return this.createSuccessResult({ + success: true, + contracts: deployedContracts, + count: deployedContracts.length + }); + + } catch (error) { + return this.createErrorResult(`Failed to get deployed contracts: ${error.message}`); + } + } +} + +/** + * Set Execution Environment Tool Handler + */ +export class SetExecutionEnvironmentHandler extends BaseToolHandler { + name = 'set_execution_environment'; + description = 'Set the execution environment for deployments'; + inputSchema = { + type: 'object', + properties: { + environment: { + type: 'string', + enum: ['vm-prague', 'vm-cancun', 'vm-shanghai', 'vm-paris', 'vm-london', 'vm-berlin', 'vm-mainnet-fork', 'vm-sepolia-fork', 'vm-custom-fork', 'walletconnect', 'basic-http-provider', 'hardhat-provider', 'ganache-provider', 'foundry-provider', 'injected-Rabby Wallet', 'injected-MetaMask', 'injected-metamask-optimism', 'injected-metamask-arbitrum', 'injected-metamask-sepolia', 'injected-metamask-ephemery', 'injected-metamask-gnosis', 'injected-metamask-chiado', 'injected-metamask-linea'], + description: 'Execution environment' + }, + networkUrl: { + type: 'string', + description: 'Network URL (for web3 environment)' + } + }, + required: ['environment'] + }; + + getPermissions(): string[] { + return ['environment:config']; + } + + validate(args: { environment: string; networkUrl?: string }): boolean | string { + // we validate in the execute method to have access to the list of available providers. + return true; + } + + async execute(args: { environment: string }, plugin: Plugin): Promise { + try { + const providers = await plugin.call('blockchain', 'getAllProviders') + console.log('available providers', Object.keys(providers)) + const provider = Object.keys(providers).find((p) => p === args.environment) + if (!provider) { + return this.createErrorResult(`Could not find provider for environment '${args.environment}'`); + } + await plugin.call('blockchain', 'changeExecutionContext', { context: args.environment }) + return this.createSuccessResult({ + success: true, + message: `Execution environment set to: ${args.environment}`, + environment: args.environment, + }); + + } catch (error) { + return this.createErrorResult(`Failed to set execution environment: ${error.message}`); + } + } +} + +/** + * Get Account Balance Tool Handler + */ +export class GetAccountBalanceHandler extends BaseToolHandler { + name = 'get_account_balance'; + description = 'Get account balance'; + inputSchema = { + type: 'object', + properties: { + account: { + type: 'string', + description: 'Account address', + pattern: '^0x[a-fA-F0-9]{40}$' + } + }, + required: ['account'] + }; + + getPermissions(): string[] { + return ['account:read']; + } + + validate(args: { account: string }): boolean | string { + const required = this.validateRequired(args, ['account']); + if (required !== true) return required; + + if (!args.account.match(/^0x[a-fA-F0-9]{40}$/)) { + return 'Invalid account address format'; + } + + return true; + } + + async execute(args: { account: string }, plugin: Plugin): Promise { + try { + const web3 = await plugin.call('blockchain', 'web3') + const balance = await web3.eth.getBalance(args.account) + return this.createSuccessResult({ + success: true, + account: args.account, + balance: web3.utils.fromWei(balance, 'ether'), + unit: 'ETH' + }) + } catch (error) { + return this.createErrorResult(`Failed to get account balance: ${error.message}`); + } + } +} + +/** + * Get User Accounts Tool Handler + */ +export class GetUserAccountsHandler extends BaseToolHandler { + name = 'get_user_accounts'; + description = 'Get user accounts from the current execution environment'; + inputSchema = { + type: 'object', + properties: { + includeBalances: { + type: 'boolean', + description: 'Whether to include account balances', + default: true + } + } + }; + + getPermissions(): string[] { + return ['accounts:read']; + } + + validate(args: { includeBalances?: boolean }): boolean | string { + const types = this.validateTypes(args, { includeBalances: 'boolean' }); + if (types !== true) return types; + return true; + } + + async execute(args: { includeBalances?: boolean }, plugin: Plugin): Promise { + try { + // Get accounts from the run-tab plugin (udapp) + const runTabApi = await plugin.call('udapp' as any, 'getRunTabAPI'); + console.log('geetting accounts returned', runTabApi) + + if (!runTabApi || !runTabApi.accounts) { + return this.createErrorResult('Could not retrieve accounts from execution environment'); + } + + const accounts: AccountInfo[] = []; + const loadedAccounts = runTabApi.accounts.loadedAccounts || {}; + const selectedAccount = runTabApi.accounts.selectedAccount; + console.log('loadedAccounts', loadedAccounts) + console.log('selected account', selectedAccount) + + for (const [address, displayName] of Object.entries(loadedAccounts)) { + const account: AccountInfo = { + address: address, + displayName: displayName as string, + isSmartAccount: (displayName as string)?.includes('[SMART]') || false + }; + + // Get balance if requested + if (args.includeBalances !== false) { + try { + const balance = await plugin.call('blockchain' as any, 'getBalanceInEther', address); + account.balance = balance || '0'; + } catch (error) { + console.warn(`Could not get balance for account ${address}:`, error); + account.balance = 'unknown'; + } + } + + accounts.push(account); + } + + const result = { + success: true, + accounts: accounts, + selectedAccount: selectedAccount, + totalAccounts: accounts.length, + environment: await this.getCurrentEnvironment(plugin) + }; + + return this.createSuccessResult(result); + } catch (error) { + return this.createErrorResult(`Failed to get user accounts: ${error.message}`); + } + } + + private async getCurrentEnvironment(plugin: Plugin): Promise { + try { + const provider = await plugin.call('blockchain' as any, 'getCurrentProvider'); + return provider?.displayName || provider?.name || 'unknown'; + } catch (error) { + return 'unknown'; + } + } +} + +/** + * Set Selected Account Tool Handler + */ +export class SetSelectedAccountHandler extends BaseToolHandler { + name = 'set_selected_account'; + description = 'Set the currently selected account in the execution environment'; + inputSchema = { + type: 'object', + properties: { + address: { + type: 'string', + description: 'The account address to select' + } + }, + required: ['address'] + }; + + getPermissions(): string[] { + return ['accounts:write']; + } + + validate(args: { address: string }): boolean | string { + const required = this.validateRequired(args, ['address']); + if (required !== true) return required; + + const types = this.validateTypes(args, { address: 'string' }); + if (types !== true) return types; + + // Basic address validation + if (!/^0x[a-fA-F0-9]{40}$/.test(args.address)) { + return 'Invalid Ethereum address format'; + } + + return true; + } + + async execute(args: { address: string }, plugin: Plugin): Promise { + try { + // Set the selected account through the udapp plugin + await plugin.call('udapp' as any, 'setAccount', args.address); + await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait a moment for the change to propagate + + // Verify the account was set + const runTabApi = await plugin.call('udapp' as any, 'getRunTabAPI'); + const currentSelected = runTabApi?.accounts?.selectedAccount; + + if (currentSelected !== args.address) { + return this.createErrorResult(`Failed to set account. Current selected: ${currentSelected}`); + } + + return this.createSuccessResult({ + success: true, + selectedAccount: args.address, + message: `Successfully set account ${args.address} as selected` + }); + } catch (error) { + return this.createErrorResult(`Failed to set selected account: ${error.message}`); + } + } +} + +/** + * Get Current Environment Tool Handler + */ +export class GetCurrentEnvironmentHandler extends BaseToolHandler { + name = 'get_current_environment'; + description = 'Get information about the current execution environment'; + inputSchema = { + type: 'object', + properties: {} + }; + + getPermissions(): string[] { + return ['environment:read']; + } + + async execute(_args: any, plugin: Plugin): Promise { + try { + // Get environment information + const provider = await plugin.call('blockchain' as any, 'getProvider'); + const network = await plugin.call('network', 'detectNetwork') + + // Verify the account was set + const runTabApi = await plugin.call('udapp' as any, 'getRunTabAPI'); + const accounts = runTabApi?.accounts; + + const result = { + success: true, + environment: { + provider, + network, + accounts + } + }; + + return this.createSuccessResult(result); + } catch (error) { + console.error(error) + return this.createErrorResult(`Failed to get environment information: ${error.message}`); + } + } +} + +/** + * Create deployment and interaction tool definitions + */ +export function createDeploymentTools(): RemixToolDefinition[] { + return [ + { + name: 'deploy_contract', + description: 'Deploy a smart contract', + inputSchema: new DeployContractHandler().inputSchema, + category: ToolCategory.DEPLOYMENT, + permissions: ['deploy:contract'], + handler: new DeployContractHandler() + }, + { + name: 'call_contract', + description: 'Call a smart contract method', + inputSchema: new CallContractHandler().inputSchema, + category: ToolCategory.DEPLOYMENT, + permissions: ['contract:interact'], + handler: new CallContractHandler() + }, + { + name: 'send_transaction', + description: 'Send a raw transaction', + inputSchema: new SendTransactionHandler().inputSchema, + category: ToolCategory.DEPLOYMENT, + permissions: ['transaction:send'], + handler: new SendTransactionHandler() + }, + { + name: 'get_deployed_contracts', + description: 'Get list of deployed contracts', + inputSchema: new GetDeployedContractsHandler().inputSchema, + category: ToolCategory.DEPLOYMENT, + permissions: ['deploy:read'], + handler: new GetDeployedContractsHandler() + }, + { + name: 'set_execution_environment', + description: 'Set the execution environment for deployments', + inputSchema: new SetExecutionEnvironmentHandler().inputSchema, + category: ToolCategory.DEPLOYMENT, + permissions: ['environment:config'], + handler: new SetExecutionEnvironmentHandler() + }, + { + name: 'get_account_balance', + description: 'Get account balance', + inputSchema: new GetAccountBalanceHandler().inputSchema, + category: ToolCategory.DEPLOYMENT, + permissions: ['account:read'], + handler: new GetAccountBalanceHandler() + }, + { + name: 'get_user_accounts', + description: 'Get user accounts from the current execution environment', + inputSchema: new GetUserAccountsHandler().inputSchema, + category: ToolCategory.DEPLOYMENT, + permissions: ['accounts:read'], + handler: new GetUserAccountsHandler() + }, + { + name: 'set_selected_account', + description: 'Set the currently selected account in the execution environment', + inputSchema: new SetSelectedAccountHandler().inputSchema, + category: ToolCategory.DEPLOYMENT, + permissions: ['accounts:write'], + handler: new SetSelectedAccountHandler() + }, + { + name: 'get_current_environment', + description: 'Get information about the current execution environment', + inputSchema: new GetCurrentEnvironmentHandler().inputSchema, + category: ToolCategory.DEPLOYMENT, + permissions: ['environment:read'], + handler: new GetCurrentEnvironmentHandler() + }, + { + name: 'run_script', + description: 'Run a script in the current environment', + inputSchema: new RunScriptHandler().inputSchema, + category: ToolCategory.DEPLOYMENT, + permissions: ['transaction:send'], + handler: new RunScriptHandler() + } + ]; +} \ No newline at end of file diff --git a/libs/remix-ai-core/src/remix-mcp-server/handlers/FileManagementHandler.ts b/libs/remix-ai-core/src/remix-mcp-server/handlers/FileManagementHandler.ts new file mode 100644 index 00000000000..38b04215dda --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/handlers/FileManagementHandler.ts @@ -0,0 +1,603 @@ +/** + * File Management Tool Handlers for Remix MCP Server + */ + +import { IMCPToolResult } from '../../types/mcp'; +import { BaseToolHandler } from '../registry/RemixToolRegistry'; +import { + ToolCategory, + RemixToolDefinition, + FileReadArgs, + FileWriteArgs, + FileCreateArgs, + FileDeleteArgs, + FileMoveArgs, + FileCopyArgs, + DirectoryListArgs, + FileOperationResult +} from '../types/mcpTools'; +import { Plugin } from '@remixproject/engine'; + +/** + * File Read Tool Handler + */ +export class FileReadHandler extends BaseToolHandler { + name = 'file_read'; + description = 'Read contents of a file'; + inputSchema = { + type: 'object', + properties: { + path: { + type: 'string', + description: 'File path to read' + } + }, + required: ['path'] + }; + + getPermissions(): string[] { + return ['file:read']; + } + + validate(args: FileReadArgs): boolean | string { + const required = this.validateRequired(args, ['path']); + if (required !== true) return required; + + const types = this.validateTypes(args, { path: 'string' }); + if (types !== true) return types; + + return true; + } + + async execute(args: FileReadArgs, plugin: Plugin): Promise { + try { + const exists = await plugin.call('fileManager', 'exists', args.path) + if (!exists) { + return this.createErrorResult(`File not found: ${args.path}`); + } + + const content = await plugin.call('fileManager', 'readFile', args.path) + + const result: FileOperationResult = { + success: true, + path: args.path, + content: content, + size: content.length + }; + + return this.createSuccessResult(result); + } catch (error) { + return this.createErrorResult(`Failed to read file: ${error.message}`); + } + } +} + +/** + * File Write Tool Handler + */ +export class FileWriteHandler extends BaseToolHandler { + name = 'file_write'; + description = 'Write content to a file'; + inputSchema = { + type: 'object', + properties: { + path: { + type: 'string', + description: 'File path to write' + }, + content: { + type: 'string', + description: 'Content to write to the file' + }, + encoding: { + type: 'string', + description: 'File encoding (default: utf8)', + default: 'utf8' + } + }, + required: ['path', 'content'] + }; + + getPermissions(): string[] { + return ['file:write']; + } + + validate(args: FileWriteArgs): boolean | string { + const required = this.validateRequired(args, ['path', 'content']); + if (required !== true) return required; + + const types = this.validateTypes(args, { + path: 'string', + content: 'string', + encoding: 'string' + }); + if (types !== true) return types; + + return true; + } + + async execute(args: FileWriteArgs, plugin: Plugin): Promise { + try { + await plugin.call('fileManager', 'writeFile', args.path, args.content); + // TODO - Add diff here to signalize users about the changes + const result: FileOperationResult = { + success: true, + path: args.path, + message: 'File written successfully', + size: args.content.length, + lastModified: new Date().toISOString() + }; + + return this.createSuccessResult(result); + } catch (error) { + return this.createErrorResult(`Failed to write file: ${error.message}`); + } + } +} + +/** + * File Create Tool Handler + */ +export class FileCreateHandler extends BaseToolHandler { + name = 'file_create'; + description = 'Create a new file or directory'; + inputSchema = { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Path for the new file or directory' + }, + content: { + type: 'string', + description: 'Initial content for the file (optional)', + default: '' + }, + type: { + type: 'string', + enum: ['file', 'directory'], + description: 'Type of item to create', + default: 'file' + } + }, + required: ['path'] + }; + + getPermissions(): string[] { + return ['file:create']; + } + + validate(args: FileCreateArgs): boolean | string { + const required = this.validateRequired(args, ['path']); + if (required !== true) return required; + + const types = this.validateTypes(args, { + path: 'string', + content: 'string', + type: 'string' + }); + if (types !== true) return types; + + if (args.type && !['file', 'directory'].includes(args.type)) { + return 'Invalid type: must be "file" or "directory"'; + } + + return true; + } + + async execute(args: FileCreateArgs, plugin: Plugin): Promise { + try { + const exists = await plugin.call('fileManager', 'exists', args.path) + if (exists) { + return this.createErrorResult(`Path already exists: ${args.path}`); + } + + if (args.type === 'directory') { + await plugin.call('fileManager', 'mkdir', args.path); + } else { + await plugin.call('fileManager', 'writeFile', args.path, args.content || ''); + } + + const result: FileOperationResult = { + success: true, + path: args.path, + message: `${args.type === 'directory' ? 'Directory' : 'File'} created successfully`, + lastModified: new Date().toISOString() + }; + + return this.createSuccessResult(result); + } catch (error) { + return this.createErrorResult(`Failed to create ${args.type || 'file'}: ${error.message}`); + } + } +} + +/** + * File Delete Tool Handler + */ +export class FileDeleteHandler extends BaseToolHandler { + name = 'file_delete'; + description = 'Delete a file or directory'; + inputSchema = { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Path of the file or directory to delete' + } + }, + required: ['path'] + }; + + getPermissions(): string[] { + return ['file:delete']; + } + + validate(args: FileDeleteArgs): boolean | string { + const required = this.validateRequired(args, ['path']); + if (required !== true) return required; + + const types = this.validateTypes(args, { path: 'string' }); + if (types !== true) return types; + + return true; + } + + async execute(args: FileDeleteArgs, plugin: Plugin): Promise { + try { + const exists = await plugin.call('fileManager', 'exists', args.path) + if (!exists) { + return this.createErrorResult(`Path not found: ${args.path}`); + } + + await plugin.call('fileManager', 'remove', args.path); + + const result: FileOperationResult = { + success: true, + path: args.path, + message: 'Path deleted successfully' + }; + + return this.createSuccessResult(result); + } catch (error) { + return this.createErrorResult(`Failed to delete: ${error.message}`); + } + } +} + +/** + * File Move Tool Handler + */ +export class FileMoveHandler extends BaseToolHandler { + name = 'file_move'; + description = 'Move or rename a file or directory'; + inputSchema = { + type: 'object', + properties: { + from: { + type: 'string', + description: 'Source path' + }, + to: { + type: 'string', + description: 'Destination path' + } + }, + required: ['from', 'to'] + }; + + getPermissions(): string[] { + return ['file:move']; + } + + validate(args: FileMoveArgs): boolean | string { + const required = this.validateRequired(args, ['from', 'to']); + if (required !== true) return required; + + const types = this.validateTypes(args, { from: 'string', to: 'string' }); + if (types !== true) return types; + + return true; + } + + async execute(args: FileMoveArgs, plugin: Plugin): Promise { + try { + const exists = await plugin.call('fileManager', 'exists', args.from); + if (!exists) { + return this.createErrorResult(`Source path not found: ${args.from}`); + } + + const destExists = await plugin.call('fileManager', 'exists', args.to); + if (destExists) { + return this.createErrorResult(`Destination path already exists: ${args.to}`); + } + + await await plugin.call('fileManager', 'rename', args.from, args.to); + + const result: FileOperationResult = { + success: true, + path: args.to, + message: `Moved from ${args.from} to ${args.to}`, + lastModified: new Date().toISOString() + }; + + return this.createSuccessResult(result); + } catch (error) { + return this.createErrorResult(`Failed to move: ${error.message}`); + } + } +} + +/** + * File Copy Tool Handler + */ +export class FileCopyHandler extends BaseToolHandler { + name = 'file_copy'; + description = 'Copy a file or directory'; + inputSchema = { + type: 'object', + properties: { + from: { + type: 'string', + description: 'Source path' + }, + to: { + type: 'string', + description: 'Destination path' + } + }, + required: ['from', 'to'] + }; + + getPermissions(): string[] { + return ['file:copy']; + } + + validate(args: FileCopyArgs): boolean | string { + const required = this.validateRequired(args, ['from', 'to']); + if (required !== true) return required; + + const types = this.validateTypes(args, { from: 'string', to: 'string' }); + if (types !== true) return types; + + return true; + } + + async execute(args: FileCopyArgs, plugin: Plugin): Promise { + try { + const exists = await plugin.call('fileManager', 'exists', args.from); + if (!exists) { + return this.createErrorResult(`Source path not found: ${args.from}`); + } + + const content = await plugin.call('fileManager', 'readFile',args.from); + await plugin.call('fileManager', 'writeFile',args.to, content); + + const result: FileOperationResult = { + success: true, + path: args.to, + message: `Copied from ${args.from} to ${args.to}`, + size: content.length, + lastModified: new Date().toISOString() + }; + + return this.createSuccessResult(result); + } catch (error) { + return this.createErrorResult(`Failed to copy: ${error.message}`); + } + } +} + +/** + * Directory List Tool Handler + */ +export class DirectoryListHandler extends BaseToolHandler { + name = 'directory_list'; + description = 'List contents of a directory'; + inputSchema = { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Directory path to list' + }, + recursive: { + type: 'boolean', + description: 'List recursively', + default: false + } + }, + required: ['path'] + }; + + getPermissions(): string[] { + return ['file:read']; + } + + validate(args: DirectoryListArgs): boolean | string { + const required = this.validateRequired(args, ['path']); + if (required !== true) return required; + + const types = this.validateTypes(args, { path: 'string', recursive: 'boolean' }); + if (types !== true) return types; + + return true; + } + + async execute(args: DirectoryListArgs, plugin: Plugin): Promise { + try { + const exists = await plugin.call('fileManager', 'exists', args.path) + if (!exists) { + return this.createErrorResult(`Directory not found: ${args.path}`); + } + + const files = await await plugin.call('fileManager', 'readdir', args.path); + const fileList = []; + + for (const file of files) { + const fullPath = `${args.path}/${file}`; + try { + const isDir = await await plugin.call('fileManager', 'isDirectory', fullPath); + let size = 0; + + if (!isDir) { + const content = await plugin.call('fileManager', 'readFile',fullPath); + size = content.length; + } + + fileList.push({ + name: file, + path: fullPath, + isDirectory: isDir, + size: size + }); + + // Recursive listing + if (args.recursive && isDir) { + const subFiles = await this.execute({ path: fullPath, recursive: true }, plugin); + if (!subFiles.isError && subFiles.content[0]?.text) { + const subResult = JSON.parse(subFiles.content[0].text); + if (subResult.files) { + fileList.push(...subResult.files); + } + } + } + } catch (error) { + // Skip files that can't be accessed + console.warn(`Couldn't access ${fullPath}:`, error.message); + } + } + + const result = { + success: true, + path: args.path, + files: fileList, + count: fileList.length + }; + + return this.createSuccessResult(result); + } catch (error) { + return this.createErrorResult(`Failed to list directory: ${error.message}`); + } + } +} + +/** + * File Exists Tool Handler + */ +export class FileExistsHandler extends BaseToolHandler { + name = 'file_exists'; + description = 'Check if a file or directory exists'; + inputSchema = { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Path to check' + } + }, + required: ['path'] + }; + + getPermissions(): string[] { + return ['file:read']; + } + + validate(args: { path: string }): boolean | string { + const required = this.validateRequired(args, ['path']); + if (required !== true) return required; + + const types = this.validateTypes(args, { path: 'string' }); + if (types !== true) return types; + + return true; + } + + async execute(args: { path: string }, plugin: Plugin): Promise { + try { + const exists = await plugin.call('fileManager', 'exists', args.path) + + const result = { + success: true, + path: args.path, + exists: exists + }; + + return this.createSuccessResult(result); + } catch (error) { + return this.createErrorResult(`Failed to check file existence: ${error.message}`); + } + } +} + +/** + * Create file management tool definitions + */ +export function createFileManagementTools(): RemixToolDefinition[] { + return [ + { + name: 'file_read', + description: 'Read contents of a file', + inputSchema: new FileReadHandler().inputSchema, + category: ToolCategory.FILE_MANAGEMENT, + permissions: ['file:read'], + handler: new FileReadHandler() + }, + { + name: 'file_write', + description: 'Write content to a file', + inputSchema: new FileWriteHandler().inputSchema, + category: ToolCategory.FILE_MANAGEMENT, + permissions: ['file:write'], + handler: new FileWriteHandler() + }, + { + name: 'file_create', + description: 'Create a new file or directory', + inputSchema: new FileCreateHandler().inputSchema, + category: ToolCategory.FILE_MANAGEMENT, + permissions: ['file:create'], + handler: new FileCreateHandler() + }, + { + name: 'file_delete', + description: 'Delete a file or directory', + inputSchema: new FileDeleteHandler().inputSchema, + category: ToolCategory.FILE_MANAGEMENT, + permissions: ['file:delete'], + handler: new FileDeleteHandler() + }, + { + name: 'file_move', + description: 'Move or rename a file or directory', + inputSchema: new FileMoveHandler().inputSchema, + category: ToolCategory.FILE_MANAGEMENT, + permissions: ['file:move'], + handler: new FileMoveHandler() + }, + { + name: 'file_copy', + description: 'Copy a file or directory', + inputSchema: new FileCopyHandler().inputSchema, + category: ToolCategory.FILE_MANAGEMENT, + permissions: ['file:copy'], + handler: new FileCopyHandler() + }, + { + name: 'directory_list', + description: 'List contents of a directory', + inputSchema: new DirectoryListHandler().inputSchema, + category: ToolCategory.FILE_MANAGEMENT, + permissions: ['file:read'], + handler: new DirectoryListHandler() + }, + { + name: 'file_exists', + description: 'Check if a file or directory exists', + inputSchema: new FileExistsHandler().inputSchema, + category: ToolCategory.FILE_MANAGEMENT, + permissions: ['file:read'], + handler: new FileExistsHandler() + } + ]; +} \ No newline at end of file diff --git a/libs/remix-ai-core/src/remix-mcp-server/index.ts b/libs/remix-ai-core/src/remix-mcp-server/index.ts new file mode 100644 index 00000000000..abc6a3cb16b --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/index.ts @@ -0,0 +1,140 @@ +/** + * Remix MCP Server - Main Export File + * Provides a comprehensive in-browser MCP server for Remix IDE + */ + +// Core Server +export { RemixMCPServer } from './RemixMCPServer'; +import { RemixMCPServer } from './RemixMCPServer'; +import { defaultSecurityConfig } from './middleware/SecurityMiddleware'; +import { defaultValidationConfig } from './middleware/ValidationMiddleware'; +import type { SecurityConfig } from './middleware/SecurityMiddleware'; +import type { ValidationConfig } from './middleware/ValidationMiddleware'; + +// Tool Handlers +export { createFileManagementTools } from './handlers/FileManagementHandler'; +export { createCompilationTools } from './handlers/CompilationHandler'; +export { createDeploymentTools } from './handlers/DeploymentHandler'; +export { createDebuggingTools } from './handlers/DebuggingHandler'; +export { createCodeAnalysisTools } from './handlers/CodeAnalysisHandler'; + +// Resource Providers +export { ProjectResourceProvider } from './providers/ProjectResourceProvider'; +export { CompilationResourceProvider } from './providers/CompilationResourceProvider'; +export { DeploymentResourceProvider } from './providers/DeploymentResourceProvider'; + +// Middleware +export { + SecurityMiddleware, + defaultSecurityConfig +} from './middleware/SecurityMiddleware'; +export type { + SecurityConfig, + SecurityValidationResult, + AuditLogEntry +} from './middleware/SecurityMiddleware'; + +export { + ValidationMiddleware, + defaultValidationConfig +} from './middleware/ValidationMiddleware'; +export type { + ValidationConfig, + ValidationResult, + ValidationError, + ValidationWarning +} from './middleware/ValidationMiddleware'; + +// Registries +export { + RemixToolRegistry, + BaseToolHandler +} from './registry/RemixToolRegistry'; + +export { + RemixResourceProviderRegistry, + BaseResourceProvider +} from './registry/RemixResourceProviderRegistry'; + +// Types +export * from './types/mcpTools'; +export * from './types/mcpResources'; + +/** + * Factory function to create and initialize a complete Remix MCP Server + */ +export async function createRemixMCPServer( + plugin, + options: { + enableSecurity?: boolean; + enableValidation?: boolean; + securityConfig?: SecurityConfig; + validationConfig?: ValidationConfig; + customTools?: any[]; + customProviders?: any[]; + } = {}, +): Promise { + const { + enableSecurity = true, + enableValidation = true, + securityConfig = defaultSecurityConfig, + validationConfig = defaultValidationConfig, + customTools = [], + customProviders = [] + } = options; + + // Create server with configuration + const serverConfig = { + name: 'Remix MCP Server', + version: '1.0.0', + description: 'In-browser MCP server for Remix IDE providing comprehensive smart contract development tools', + debug: false, + maxConcurrentTools: 10, + toolTimeout: 30000, + resourceCacheTTL: 5000, + enableResourceCache: false, + security: enableSecurity ? { + enablePermissions: securityConfig.requirePermissions, + enableAuditLog: securityConfig.enableAuditLog, + allowedFilePatterns: [], + blockedFilePatterns: [] + } : undefined, + features: { + compilation: true, + deployment: true, + debugging: true, + fileManagement: true, + analysis: true, + workspace: true, + testing: true + } + }; + + const server = new RemixMCPServer(plugin, serverConfig); + + // Register custom tools if provided + if (customTools.length > 0) { + // TODO: Add batch registration method to server + // for (const tool of customTools) { + // server.registerTool(tool); + // } + } + + // Register custom providers if provided + if (customProviders.length > 0) { + // TODO: Add provider registration method to server + // for (const provider of customProviders) { + // server.registerResourceProvider(provider); + // } + } + + // Initialize the server + await server.initialize(); + + return server; +} + +/** + * Default export + */ +export default RemixMCPServer; \ No newline at end of file diff --git a/libs/remix-ai-core/src/remix-mcp-server/middleware/SecurityMiddleware.ts b/libs/remix-ai-core/src/remix-mcp-server/middleware/SecurityMiddleware.ts new file mode 100644 index 00000000000..ca2405570c2 --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/middleware/SecurityMiddleware.ts @@ -0,0 +1,473 @@ +/** + * Security Middleware for Remix MCP Server + */ + +import { Plugin } from '@remixproject/engine'; +import { IMCPToolCall, IMCPToolResult } from '../../types/mcp'; +import { ToolExecutionContext } from '../types/mcpTools'; + +export interface SecurityConfig { + maxRequestsPerMinute: number; + maxFileSize: number; + allowedFileTypes: string[]; + blockedPaths: string[]; + requirePermissions: boolean; + enableAuditLog: boolean; + maxExecutionTime: number; +} + +export interface SecurityValidationResult { + allowed: boolean; + reason?: string; + risk?: 'low' | 'medium' | 'high'; +} + +export interface AuditLogEntry { + timestamp: Date; + toolName: string; + userId?: string; + arguments: any; + result: 'success' | 'error' | 'blocked'; + reason?: string; + executionTime: number; + riskLevel: 'low' | 'medium' | 'high'; +} + +/** + * Security middleware for validating and securing MCP tool calls + */ +export class SecurityMiddleware { + private rateLimitTracker = new Map(); + private auditLog: AuditLogEntry[] = []; + private blockedIPs = new Set(); + + constructor(private config: SecurityConfig) {} + + /** + * Validate a tool call before execution + */ + async validateToolCall( + call: IMCPToolCall, + context: ToolExecutionContext, + plugin: Plugin + ): Promise { + const startTime = Date.now(); + + try { + // Rate limiting check + const rateLimitResult = this.checkRateLimit(context); + if (!rateLimitResult.allowed) { + this.logAudit(call, context, 'blocked', rateLimitResult.reason, startTime, 'medium'); + return rateLimitResult; + } + + // Permission validation + const permissionResult = this.validatePermissions(call, context); + if (!permissionResult.allowed) { + this.logAudit(call, context, 'blocked', permissionResult.reason, startTime, 'high'); + return permissionResult; + } + + // Argument validation + const argumentResult = await this.validateArguments(call, plugin); + if (!argumentResult.allowed) { + this.logAudit(call, context, 'blocked', argumentResult.reason, startTime, argumentResult.risk || 'medium'); + return argumentResult; + } + + // File operation security checks + const fileResult = await this.validateFileOperations(call, plugin); + if (!fileResult.allowed) { + this.logAudit(call, context, 'blocked', fileResult.reason, startTime, fileResult.risk || 'high'); + return fileResult; + } + + // Input sanitization + const sanitizationResult = this.validateInputSanitization(call); + if (!sanitizationResult.allowed) { + this.logAudit(call, context, 'blocked', sanitizationResult.reason, startTime, 'high'); + return sanitizationResult; + } + + this.logAudit(call, context, 'success', 'Validation passed', startTime, 'low'); + return { allowed: true, risk: 'low' }; + + } catch (error) { + this.logAudit(call, context, 'error', `Validation error: ${error.message}`, startTime, 'high'); + return { + allowed: false, + reason: `Security validation failed: ${error.message}`, + risk: 'high' + }; + } + } + + /** + * Wrap tool execution with security monitoring + */ + async secureExecute( + toolName: string, + context: ToolExecutionContext, + executor: () => Promise + ): Promise { + const startTime = Date.now(); + const timeoutId = setTimeout(() => { + throw new Error(`Tool execution timeout: ${toolName} exceeded ${this.config.maxExecutionTime}ms`); + }, this.config.maxExecutionTime); + + try { + const result = await executor(); + clearTimeout(timeoutId); + + this.logAudit( + { name: toolName, arguments: {} }, + context, + 'success', + 'Execution completed', + startTime, + 'low' + ); + + return result; + } catch (error) { + clearTimeout(timeoutId); + + this.logAudit( + { name: toolName, arguments: {} }, + context, + 'error', + error.message, + startTime, + 'high' + ); + + throw error; + } + } + + /** + * Check rate limiting for user/session + */ + private checkRateLimit(context: ToolExecutionContext): SecurityValidationResult { + const identifier = context.userId || context.sessionId || 'anonymous'; + const now = Date.now(); + const resetTime = Math.floor(now / 60000) * 60000 + 60000; // Next minute + + const userLimit = this.rateLimitTracker.get(identifier); + if (!userLimit || userLimit.resetTime <= now) { + this.rateLimitTracker.set(identifier, { count: 1, resetTime }); + return { allowed: true, risk: 'low' }; + } + + if (userLimit.count >= this.config.maxRequestsPerMinute) { + return { + allowed: false, + reason: `Rate limit exceeded: ${userLimit.count}/${this.config.maxRequestsPerMinute} requests per minute`, + risk: 'medium' + }; + } + + userLimit.count++; + return { allowed: true, risk: 'low' }; + } + + /** + * Validate user permissions for tool execution + */ + private validatePermissions(call: IMCPToolCall, context: ToolExecutionContext): SecurityValidationResult { + if (!this.config.requirePermissions) { + return { allowed: true, risk: 'low' }; + } + + // Check if user has wildcard permission + if (context.permissions.includes('*')) { + return { allowed: true, risk: 'low' }; + } + + // Get required permissions for this tool (would need to be passed from tool definition) + const requiredPermissions = this.getRequiredPermissions(call.name); + + for (const permission of requiredPermissions) { + if (!context.permissions.includes(permission)) { + return { + allowed: false, + reason: `Missing required permission: ${permission}`, + risk: 'high' + }; + } + } + + return { allowed: true, risk: 'low' }; + } + + /** + * Validate tool arguments for security issues + */ + private async validateArguments(call: IMCPToolCall, plugin: Plugin): Promise { + const args = call.arguments || {}; + + // Check for potentially dangerous patterns + const dangerousPatterns = [ + /eval\s*\(/i, + /function\s*\(/i, + /javascript:/i, + /