diff --git a/.gitignore b/.gitignore index 991ebd45da2..fff83ce6d98 100644 --- a/.gitignore +++ b/.gitignore @@ -79,7 +79,12 @@ 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 +<<<<<<< HEAD +.cursor/ +======= .cursor/ PR_MESSAGE.md +>>>>>>> master diff --git a/apps/remix-ide-e2e/src/tests/mcp_all_resources.test.ts b/apps/remix-ide-e2e/src/tests/mcp_all_resources.test.ts new file mode 100644 index 00000000000..afeb2814862 --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/mcp_all_resources.test.ts @@ -0,0 +1,804 @@ +import { NightwatchBrowser } from 'nightwatch' +import init from '../helpers/init' + +/** + * Comprehensive test suite for all RemixMCPServer resource providers + * Tests all three main resource providers: Project, Compilation, and Deployment + */ + +module.exports = { + '@disabled': false, + before: function (browser: NightwatchBrowser, done: VoidFunction) { + init(browser, done) + }, + + /** + * Test: Verify all resource providers are registered + */ + 'Should have all resource providers registered': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible('*[data-id="remix-ai-assistant"]') + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const server = aiPlugin.remixMCPServer; + const providers = server.resources.list(); + + const providerNames = providers.map((p: any) => p.name); + const hasProject = providerNames.includes('project'); + const hasCompilation = providerNames.includes('compilation'); + const hasDeployment = providerNames.includes('deployment'); + + return { + totalProviders: providers.length, + providerNames, + hasProject, + hasCompilation, + hasDeployment, + allProvidersRegistered: hasProject && hasCompilation && hasDeployment + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Resource providers error:', data.error); + return; + } + browser.assert.ok(data.totalProviders >= 3, 'Should have at least 3 resource providers'); + browser.assert.ok(data.hasProject, 'Should have project resource provider'); + browser.assert.ok(data.hasCompilation, 'Should have compilation resource provider'); + browser.assert.ok(data.hasDeployment, 'Should have deployment resource provider'); + browser.assert.ok(data.allProvidersRegistered, 'All required providers should be registered'); + }); + }, + + /** + * PROJECT RESOURCE PROVIDER TESTS + */ + 'Should list all project resources': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/list', + id: 'test-1' + }); + + const resources = response.result.resources || []; + const projectResources = resources.filter((r: any) => r.uri.startsWith('project://')); + const fileResources = resources.filter((r: any) => r.uri.startsWith('file://')); + + const expectedProjectResources = [ + 'project://structure', + 'project://config', + 'project://dependencies' + ]; + + const foundProjectResources = expectedProjectResources.filter(uri => + projectResources.some((r: any) => r.uri === uri) + ); + + return { + totalResources: resources.length, + projectResourceCount: projectResources.length, + fileResourceCount: fileResources.length, + expectedCount: expectedProjectResources.length, + foundCount: foundProjectResources.length, + foundResources: foundProjectResources, + missingResources: expectedProjectResources.filter(uri => + !projectResources.some((r: any) => r.uri === uri) + ), + sampleProjectResource: projectResources[0] || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Project resources list error:', data.error); + return; + } + browser.assert.ok(data.totalResources > 0, 'Should have resources'); + browser.assert.ok(data.projectResourceCount >= 3, 'Should have at least 3 project resources'); + browser.assert.equal(data.foundCount, data.expectedCount, 'Should have all expected project resources'); + browser.assert.equal(data.missingResources.length, 0, 'Should not have missing project resources'); + }); + }, + + 'Should read project structure resource': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/read', + params: { uri: 'project://structure' }, + id: 'test-2' + }); + + const content = response.result; + let structureData = null; + + if (content.text) { + try { + structureData = JSON.parse(content.text); + } catch (e) { + return { error: 'Failed to parse structure JSON' }; + } + } + + return { + hasContent: !!content, + uri: content.uri, + mimeType: content.mimeType, + hasText: !!content.text, + hasStructure: !!structureData?.structure, + hasRoot: !!structureData?.root, + hasGeneratedAt: !!structureData?.generatedAt, + isValidJSON: content.mimeType === 'application/json' + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Project structure read error:', data.error); + return; + } + browser.assert.ok(data.hasContent, 'Should have structure content'); + browser.assert.equal(data.mimeType, 'application/json', 'Should be JSON content'); + browser.assert.ok(data.hasStructure, 'Should have structure data'); + browser.assert.ok(data.hasGeneratedAt, 'Should have timestamp'); + }); + }, + + 'Should read project config resource': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/read', + params: { uri: 'project://config' }, + id: 'test-3' + }); + + const content = response.result; + let configData = null; + + if (content.text) { + configData = JSON.parse(content.text); + } + + return { + hasContent: !!content, + mimeType: content.mimeType, + hasConfigs: !!configData?.configs, + hasGeneratedAt: !!configData?.generatedAt, + configKeys: configData?.configs ? Object.keys(configData.configs) : [] + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Project config read error:', data.error); + return; + } + browser.assert.ok(data.hasContent, 'Should have config content'); + browser.assert.equal(data.mimeType, 'application/json', 'Should be JSON content'); + browser.assert.ok(data.hasConfigs, 'Should have configs object'); + browser.assert.ok(data.hasGeneratedAt, 'Should have timestamp'); + }); + }, + + 'Should read project dependencies resource': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/read', + params: { uri: 'project://dependencies' }, + id: 'test-4' + }); + + const content = response.result; + let depsData = null; + + if (content.text) { + depsData = JSON.parse(content.text); + } + + return { + hasContent: !!content, + mimeType: content.mimeType, + hasNpm: !!depsData?.npm, + hasImports: !!depsData?.imports, + hasContracts: !!depsData?.contracts, + hasGeneratedAt: !!depsData?.generatedAt, + importsCount: depsData?.imports?.length || 0 + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Project dependencies read error:', data.error); + return; + } + browser.assert.ok(data.hasContent, 'Should have dependencies content'); + browser.assert.equal(data.mimeType, 'application/json', 'Should be JSON content'); + browser.assert.ok(data.hasNpm, 'Should have npm section'); + browser.assert.ok(data.hasImports, 'Should have imports array'); + browser.assert.ok(data.hasContracts, 'Should have contracts array'); + }); + }, + + /** + * COMPILATION RESOURCE PROVIDER TESTS + */ + 'Should list all compilation resources': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/list', + id: 'test-5' + }); + + const resources = response.result.resources || []; + const compilationResources = resources.filter((r: any) => + r.uri.startsWith('compilation://') || r.uri.startsWith('contract://') + ); + + const expectedCompilationResources = [ + 'compilation://latest', + 'compilation://contracts', + 'compilation://errors', + 'compilation://artifacts', + 'compilation://dependencies', + 'compilation://config' + ]; + + const foundResources = expectedCompilationResources.filter(uri => + compilationResources.some((r: any) => r.uri === uri) + ); + + return { + compilationResourceCount: compilationResources.length, + expectedCount: expectedCompilationResources.length, + foundCount: foundResources.length, + foundResources, + missingResources: expectedCompilationResources.filter(uri => + !compilationResources.some((r: any) => r.uri === uri) + ) + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Compilation resources list error:', data.error); + return; + } + browser.assert.ok(data.compilationResourceCount >= 6, 'Should have at least 6 compilation resources'); + browser.assert.equal(data.foundCount, data.expectedCount, 'Should have all expected compilation resources'); + browser.assert.equal(data.missingResources.length, 0, 'Should not have missing compilation resources'); + }); + }, + + 'Should read compilation latest resource': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/read', + params: { uri: 'compilation://latest' }, + id: 'test-6' + }); + + const content = response.result; + let compilationData = null; + + if (content.text) { + compilationData = JSON.parse(content.text); + } + + return { + hasContent: !!content, + mimeType: content.mimeType, + hasSuccess: compilationData?.success !== undefined, + hasTimestamp: !!compilationData?.timestamp, + hasContracts: !!compilationData?.contracts, + hasErrors: !!compilationData?.errors, + hasSources: !!compilationData?.sources, + contractCount: compilationData?.contracts ? Object.keys(compilationData.contracts).length : 0, + errorCount: compilationData?.errors?.length || 0 + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Compilation latest read error:', data.error); + return; + } + browser.assert.ok(data.hasContent, 'Should have latest compilation content'); + browser.assert.equal(data.mimeType, 'application/json', 'Should be JSON content'); + browser.assert.ok(data.hasSuccess !== undefined, 'Should have success flag'); + browser.assert.ok(data.hasContracts, 'Should have contracts object'); + browser.assert.ok(data.hasErrors, 'Should have errors array'); + }); + }, + + 'Should read compilation contracts resource': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/read', + params: { uri: 'compilation://contracts' }, + id: 'test-7' + }); + + const content = response.result; + let contractsData = null; + + if (content.text) { + contractsData = JSON.parse(content.text); + } + + return { + hasContent: !!content, + mimeType: content.mimeType, + hasCompiledContracts: !!contractsData?.compiledContracts, + hasCount: contractsData?.count !== undefined, + hasGeneratedAt: !!contractsData?.generatedAt, + contractCount: contractsData?.count || 0 + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Compilation contracts read error:', data.error); + return; + } + browser.assert.ok(data.hasContent, 'Should have contracts content'); + browser.assert.equal(data.mimeType, 'application/json', 'Should be JSON content'); + browser.assert.ok(data.hasCount !== undefined, 'Should have count'); + browser.assert.ok(data.hasGeneratedAt, 'Should have timestamp'); + }); + }, + + 'Should read compilation config resource': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/read', + params: { uri: 'compilation://config' }, + id: 'test-8' + }); + + const content = response.result; + let configData = null; + + if (content.text) { + configData = JSON.parse(content.text); + } + + return { + hasContent: !!content, + mimeType: content.mimeType, + hasVersion: !!configData?.version, + hasOptimize: configData?.optimize !== undefined, + hasRuns: configData?.runs !== undefined, + hasEvmVersion: !!configData?.evmVersion, + hasLanguage: !!configData?.language, + config: configData + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Compilation config read error:', data.error); + return; + } + browser.assert.ok(data.hasContent, 'Should have config content'); + browser.assert.equal(data.mimeType, 'application/json', 'Should be JSON content'); + browser.assert.ok(data.hasVersion, 'Should have version'); + browser.assert.ok(data.hasOptimize !== undefined, 'Should have optimize flag'); + }); + }, + + /** + * DEPLOYMENT RESOURCE PROVIDER TESTS + */ + 'Should list all deployment resources': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/list', + id: 'test-9' + }); + + const resources = response.result.resources || []; + const deploymentResources = resources.filter((r: any) => + r.uri.startsWith('deployment://') || r.uri.startsWith('instance://') + ); + + const expectedDeploymentResources = [ + 'deployment://history', + 'deployment://active', + 'deployment://networks', + 'deployment://transactions', + 'deployment://config' + ]; + + const foundResources = expectedDeploymentResources.filter(uri => + deploymentResources.some((r: any) => r.uri === uri) + ); + + return { + deploymentResourceCount: deploymentResources.length, + expectedCount: expectedDeploymentResources.length, + foundCount: foundResources.length, + foundResources, + missingResources: expectedDeploymentResources.filter(uri => + !deploymentResources.some((r: any) => r.uri === uri) + ), + instanceResources: deploymentResources.filter((r: any) => + r.uri.startsWith('instance://') + ).length + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Deployment resources list error:', data.error); + return; + } + browser.assert.ok(data.deploymentResourceCount >= 5, 'Should have at least 5 deployment resources'); + browser.assert.equal(data.foundCount, data.expectedCount, 'Should have all expected deployment resources'); + browser.assert.equal(data.missingResources.length, 0, 'Should not have missing deployment resources'); + }); + }, + + 'Should read deployment history resource': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/read', + params: { uri: 'deployment://history' }, + id: 'test-10' + }); + + const content = response.result; + let historyData = null; + + if (content.text) { + historyData = JSON.parse(content.text); + } + + return { + hasContent: !!content, + mimeType: content.mimeType, + hasDeployments: !!historyData?.deployments, + hasSummary: !!historyData?.summary, + hasGeneratedAt: !!historyData?.generatedAt, + 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 read error:', data.error); + return; + } + browser.assert.ok(data.hasContent, 'Should have history content'); + browser.assert.equal(data.mimeType, 'application/json', 'Should be JSON content'); + browser.assert.ok(data.hasDeployments, 'Should have deployments array'); + browser.assert.ok(data.hasSummary, 'Should have summary'); + }); + }, + + 'Should read deployment networks resource': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/read', + params: { uri: 'deployment://networks' }, + id: 'test-11' + }); + + const content = response.result; + let networksData = null; + + if (content.text) { + networksData = JSON.parse(content.text); + } + + return { + hasContent: !!content, + mimeType: content.mimeType, + hasConfigured: !!networksData?.configured, + hasCurrent: !!networksData?.current, + hasEnvironment: !!networksData?.environment, + hasStatistics: !!networksData?.statistics, + networkCount: networksData?.configured?.length || 0, + currentNetwork: networksData?.current || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Deployment networks read error:', data.error); + return; + } + browser.assert.ok(data.hasContent, 'Should have networks content'); + browser.assert.equal(data.mimeType, 'application/json', 'Should be JSON content'); + browser.assert.ok(data.hasConfigured, 'Should have configured networks'); + browser.assert.ok(data.hasCurrent, 'Should have current network'); + browser.assert.ok(data.hasStatistics, 'Should have statistics'); + }); + }, + + 'Should read deployment config resource': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/read', + params: { uri: 'deployment://config' }, + id: 'test-12' + }); + + const content = response.result; + let configData = null; + + if (content.text) { + configData = JSON.parse(content.text); + } + + return { + hasContent: !!content, + mimeType: content.mimeType, + hasEnvironment: !!configData?.environment, + hasAccounts: !!configData?.accounts, + hasGas: !!configData?.gas, + hasCompiler: !!configData?.compiler, + hasDeployment: !!configData?.deployment, + hasCapabilities: !!configData?.capabilities, + accountCount: configData?.accounts?.length || 0, + selectedAccount: configData?.selectedAccount || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Deployment config read error:', data.error); + return; + } + browser.assert.ok(data.hasContent, 'Should have config content'); + browser.assert.equal(data.mimeType, 'application/json', 'Should be JSON content'); + browser.assert.ok(data.hasEnvironment, 'Should have environment config'); + browser.assert.ok(data.hasAccounts, 'Should have accounts'); + browser.assert.ok(data.hasGas, 'Should have gas config'); + }); + }, + + /** + * ERROR HANDLING TESTS + */ + 'Should handle invalid resource URIs gracefully': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const invalidURIs = [ + 'invalid://resource', + 'project://nonexistent', + 'compilation://invalid', + 'deployment://missing', + 'file://../../etc/passwd', // Path traversal attempt + 'http://example.com' // External URI + ]; + + const results = []; + + for (const uri of invalidURIs) { + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/read', + params: { uri }, + id: `test-invalid-${uri}` + }); + + results.push({ + uri, + hasError: !!response.error, + errorCode: response.error?.code || null, + handled: true + }); + } catch (error) { + results.push({ + uri, + hasError: true, + errorMessage: error.message, + handled: true + }); + } + } + + return { + totalTests: invalidURIs.length, + allHandled: results.every(r => r.handled), + allErrored: results.every(r => r.hasError), + results, + systemStable: true + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Invalid URI handling error:', data.error); + return; + } + browser.assert.equal(data.totalTests, 6, 'Should test all invalid URIs'); + browser.assert.ok(data.allHandled, 'All invalid URIs should be handled'); + browser.assert.ok(data.systemStable, 'System should remain stable'); + }); + }, + + /** + * PERFORMANCE & CACHING TESTS + */ + 'Should test resource caching performance': 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 testURI = 'deployment://history'; + + // First read (uncached) + const startTime1 = Date.now(); + await server.handleMessage({ + method: 'resources/read', + params: { uri: testURI }, + id: 'test-cache-1' + }); + const firstReadTime = Date.now() - startTime1; + + // Second read (should be cached) + const startTime2 = Date.now(); + await server.handleMessage({ + method: 'resources/read', + params: { uri: testURI }, + id: 'test-cache-2' + }); + const secondReadTime = Date.now() - startTime2; + + // Get cache stats + const cacheStats = server.getCacheStats(); + + return { + firstReadTime, + secondReadTime, + cachingWorking: secondReadTime <= firstReadTime, + hasCacheStats: !!cacheStats, + cacheSize: cacheStats?.size || 0, + cacheHitRate: cacheStats?.hitRate || 0, + performanceImprovement: firstReadTime > 0 ? + ((firstReadTime - secondReadTime) / firstReadTime * 100).toFixed(2) : 0 + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Resource caching error:', data.error); + return; + } + browser.assert.ok(data.hasCacheStats, 'Should have cache statistics'); + browser.assert.ok(data.cacheSize >= 0, 'Should have cache size'); + }); + } +}; diff --git a/apps/remix-ide-e2e/src/tests/mcp_all_tools.test.ts b/apps/remix-ide-e2e/src/tests/mcp_all_tools.test.ts new file mode 100644 index 00000000000..79d69067fb7 --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/mcp_all_tools.test.ts @@ -0,0 +1,1009 @@ +import { NightwatchBrowser } from 'nightwatch' +import init from '../helpers/init' + +/** + * Comprehensive test suite for all RemixMCPServer tool handlers + * Tests File Management, Compilation, Deployment, Code Analysis, and Debugging tools + */ + +const testContract = ` +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract MCPToolTest { + uint256 public value; + address public owner; + + event ValueChanged(uint256 newValue); + + constructor(uint256 _initialValue) { + value = _initialValue; + owner = msg.sender; + } + + function setValue(uint256 _newValue) public { + require(msg.sender == owner, "Only owner can set value"); + value = _newValue; + emit ValueChanged(_newValue); + } + + function getValue() public view returns (uint256) { + return value; + } +} +`; + +module.exports = { + '@disabled': false, + before: function (browser: NightwatchBrowser, done: VoidFunction) { + init(browser, done) + }, + + /** + * Test: Verify all tools are registered + */ + 'Should have all tool categories registered': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible('*[data-id="remix-ai-assistant"]') + .execute(function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer?.tools) { + return { error: 'Tool registry not available' }; + } + + try { + const allTools = aiPlugin.remixMCPServer.tools.list(); + + const fileTools = allTools.filter((t: any) => + t.category === 'file_management' || t.name.includes('file') || t.name.includes('directory') + ); + + const compilationTools = allTools.filter((t: any) => + t.category === 'compilation' || t.name.includes('compile') + ); + + const deploymentTools = allTools.filter((t: any) => + t.category === 'deployment' || t.name.includes('deploy') || + t.name.includes('account') || t.name.includes('contract') + ); + + const analysisTools = allTools.filter((t: any) => + t.category === 'analysis' || t.name.includes('analysis') + ); + + const debuggingTools = allTools.filter((t: any) => + t.category === 'debugging' || t.name.includes('debug') + ); + + return { + totalTools: allTools.length, + fileToolsCount: fileTools.length, + compilationToolsCount: compilationTools.length, + deploymentToolsCount: deploymentTools.length, + analysisToolsCount: analysisTools.length, + debuggingToolsCount: debuggingTools.length, + allToolNames: allTools.map((t: any) => t.name) + }; + } catch (error) { + return { error: error.message }; + } + }, [], 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 tools registered'); + browser.assert.ok(data.fileToolsCount > 0, 'Should have file management tools'); + browser.assert.ok(data.compilationToolsCount > 0, 'Should have compilation tools'); + browser.assert.ok(data.deploymentToolsCount > 0, 'Should have deployment tools'); + console.log(`Total tools: ${data.totalTools}, File: ${data.fileToolsCount}, Compilation: ${data.compilationToolsCount}, Deployment: ${data.deploymentToolsCount}`); + }); + }, + + /** + * FILE MANAGEMENT TOOLS TESTS + */ + 'Should test file_write tool': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const result = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'file_write', + arguments: { + path: 'test_mcp_write.txt', + content: 'Hello from MCP test!' + } + }, + id: 'test-file-write' + }); + + let resultData = null; + if (!result.error && result.result?.content?.[0]?.text) { + resultData = JSON.parse(result.result.content[0].text); + } + + return { + success: !result.error, + hasResult: !!resultData, + writeSuccess: resultData?.success || false, + path: resultData?.path || null, + errorMessage: result.error?.message || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('File write error:', data.error); + return; + } + browser.assert.ok(data.success, 'File write tool should succeed'); + browser.assert.ok(data.writeSuccess, 'File should be written successfully'); + browser.assert.equal(data.path, 'test_mcp_write.txt', 'Path should match'); + }); + }, + + 'Should test file_read tool': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const result = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'file_read', + arguments: { + path: 'test_mcp_write.txt' + } + }, + id: 'test-file-read' + }); + + let resultData = null; + if (!result.error && result.result?.content?.[0]?.text) { + resultData = JSON.parse(result.result.content[0].text); + } + + return { + success: !result.error, + hasResult: !!resultData, + readSuccess: resultData?.success || false, + content: resultData?.content || null, + path: resultData?.path || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('File read error:', data.error); + return; + } + browser.assert.ok(data.success, 'File read tool should succeed'); + browser.assert.ok(data.readSuccess, 'File should be read successfully'); + browser.assert.equal(data.content, 'Hello from MCP test!', 'Content should match written content'); + }); + }, + + 'Should test file_exists tool': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const existingResult = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'file_exists', + arguments: { + path: 'test_mcp_write.txt' + } + }, + id: 'test-exists-1' + }); + + const nonExistingResult = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'file_exists', + arguments: { + path: 'nonexistent_file_12345.txt' + } + }, + id: 'test-exists-2' + }); + + let existingData = null; + let nonExistingData = null; + + if (!existingResult.error && existingResult.result?.content?.[0]?.text) { + existingData = JSON.parse(existingResult.result.content[0].text); + } + + if (!nonExistingResult.error && nonExistingResult.result?.content?.[0]?.text) { + nonExistingData = JSON.parse(nonExistingResult.result.content[0].text); + } + + return { + existingFileSuccess: !existingResult.error, + existingFileExists: existingData?.exists || false, + nonExistingFileSuccess: !nonExistingResult.error, + nonExistingFileExists: nonExistingData?.exists || false + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('File exists error:', data.error); + return; + } + browser.assert.ok(data.existingFileSuccess, 'File exists tool should succeed for existing file'); + browser.assert.ok(data.existingFileExists, 'Existing file should be detected'); + browser.assert.ok(data.nonExistingFileSuccess, 'File exists tool should succeed for non-existing file'); + browser.assert.ok(!data.nonExistingFileExists, 'Non-existing file should not be detected'); + }); + }, + + 'Should test file_delete tool': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const result = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'file_delete', + arguments: { + path: 'test_mcp_write.txt' + } + }, + id: 'test-file-delete' + }); + + let resultData = null; + if (!result.error && result.result?.content?.[0]?.text) { + resultData = JSON.parse(result.result.content[0].text); + } + + return { + success: !result.error, + deleteSuccess: resultData?.success || false, + path: resultData?.path || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('File delete error:', data.error); + return; + } + browser.assert.ok(data.success, 'File delete tool should succeed'); + browser.assert.ok(data.deleteSuccess, 'File should be deleted successfully'); + }); + }, + + 'Should test directory_list tool': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const result = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'directory_list', + arguments: { + path: '', + recursive: false + } + }, + id: 'test-dir-list' + }); + + let resultData = null; + if (!result.error && result.result?.content?.[0]?.text) { + resultData = JSON.parse(result.result.content[0].text); + } + + return { + success: !result.error, + hasFiles: !!resultData?.files, + fileCount: resultData?.files?.length || 0, + listSuccess: resultData?.success || false + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Directory list error:', data.error); + return; + } + browser.assert.ok(data.success, 'Directory list tool should succeed'); + browser.assert.ok(data.listSuccess, 'Directory listing should be successful'); + }); + }, + + /** + * COMPILATION TOOLS TESTS + */ + 'Should test get_compiler_config tool': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const result = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'get_compiler_config', + arguments: {} + }, + id: 'test-get-config' + }); + + let configData = null; + if (!result.error && result.result?.content?.[0]?.text) { + configData = JSON.parse(result.result.content[0].text); + } + + return { + success: !result.error, + hasConfig: !!configData?.config, + configSuccess: configData?.success || false, + version: configData?.config?.version || null, + optimize: configData?.config?.optimize, + runs: configData?.config?.runs || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Get compiler config error:', data.error); + return; + } + browser.assert.ok(data.success, 'Get compiler config should succeed'); + browser.assert.ok(data.hasConfig, 'Should return config object'); + browser.assert.ok(data.configSuccess, 'Config retrieval should be successful'); + }); + }, + + 'Should test set_compiler_config tool': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const result = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'set_compiler_config', + arguments: { + version: '0.8.20', + optimize: true, + runs: 200, + evmVersion: 'london', + language: 'Solidity' + } + }, + id: 'test-set-config' + }); + + let resultData = null; + if (!result.error && result.result?.content?.[0]?.text) { + resultData = JSON.parse(result.result.content[0].text); + } + + return { + success: !result.error, + setSuccess: resultData?.success || false, + hasConfig: !!resultData?.config, + version: resultData?.config?.version || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Set compiler config error:', data.error); + return; + } + browser.assert.ok(data.success, 'Set compiler config should succeed'); + browser.assert.ok(data.setSuccess, 'Config should be set successfully'); + browser.assert.equal(data.version, '0.8.20', 'Version should match'); + }); + }, + + 'Should test solidity_compile tool': function (browser: NightwatchBrowser) { + browser + .addFile('contracts/MCPToolTest.sol', { content: testContract }) + .pause(1000) + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const result = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'solidity_compile', + arguments: { + file: 'contracts/MCPToolTest.sol', + version: '0.8.20', + optimize: true, + runs: 200 + } + }, + id: 'test-compile' + }); + + let compileData = null; + if (!result.error && result.result?.content?.[0]?.text) { + compileData = JSON.parse(result.result.content[0].text); + } + + return { + success: !result.error, + compileSuccess: compileData?.success || false, + hasContracts: !!compileData?.contracts, + hasErrors: !!compileData?.errors, + contractCount: compileData?.contracts ? Object.keys(compileData.contracts).length : 0, + errorCount: compileData?.errors?.length || 0 + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Compile error:', data.error); + return; + } + browser.assert.ok(data.success, 'Solidity compile should succeed'); + browser.assert.ok(data.hasContracts, 'Should have contracts object'); + }); + }, + + 'Should test get_compilation_result tool': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const result = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'get_compilation_result', + arguments: {} + }, + id: 'test-get-result' + }); + + let resultData = null; + if (!result.error && result.result?.content?.[0]?.text) { + resultData = JSON.parse(result.result.content[0].text); + } + + return { + success: !result.error, + hasContracts: !!resultData?.contracts, + compileSuccess: resultData?.success, + contractCount: resultData?.contracts ? Object.keys(resultData.contracts).length : 0 + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Get compilation result error:', data.error); + return; + } + browser.assert.ok(data.success, 'Get compilation result should succeed'); + browser.assert.ok(data.hasContracts, 'Should have contracts'); + }); + }, + + /** + * DEPLOYMENT & ACCOUNT TOOLS TESTS + */ + 'Should test get_user_accounts tool': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const result = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'get_user_accounts', + arguments: { + includeBalances: true + } + }, + id: 'test-accounts' + }); + + let accountsData = null; + if (!result.error && result.result?.content?.[0]?.text) { + accountsData = JSON.parse(result.result.content[0].text); + } + + return { + success: !result.error, + getSuccess: accountsData?.success || false, + hasAccounts: !!accountsData?.accounts, + accountCount: accountsData?.accounts?.length || 0, + hasSelectedAccount: !!accountsData?.selectedAccount, + hasEnvironment: !!accountsData?.environment, + firstAccountHasBalance: accountsData?.accounts?.[0]?.balance !== undefined + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Get accounts error:', data.error); + return; + } + browser.assert.ok(data.success, 'Get user accounts should succeed'); + browser.assert.ok(data.hasAccounts, 'Should have accounts'); + browser.assert.ok(data.accountCount > 0, 'Should have at least one account'); + browser.assert.ok(data.firstAccountHasBalance, 'Accounts should include balance when requested'); + }); + }, + + 'Should test get_current_environment tool': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const result = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'get_current_environment', + arguments: {} + }, + id: 'test-environment' + }); + + let envData = null; + if (!result.error && result.result?.content?.[0]?.text) { + envData = JSON.parse(result.result.content[0].text); + } + + return { + success: !result.error, + getSuccess: envData?.success || false, + hasEnvironment: !!envData?.environment, + hasProvider: !!envData?.environment?.provider, + hasNetwork: !!envData?.environment?.network, + hasAccounts: !!envData?.environment?.accounts + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Get environment error:', data.error); + return; + } + browser.assert.ok(data.success, 'Get current environment should succeed'); + browser.assert.ok(data.hasEnvironment, 'Should have environment object'); + }); + }, + + 'Should test deploy_contract tool': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const result = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'deploy_contract', + arguments: { + contractName: 'MCPToolTest', + file: 'contracts/MCPToolTest.sol', + constructorArgs: ['100'], + value: '0' + } + }, + id: 'test-deploy' + }); + + let deployData = null; + if (!result.error && result.result?.content?.[0]?.text) { + deployData = JSON.parse(result.result.content[0].text); + } + + return { + success: !result.error, + deploySuccess: deployData?.success || false, + hasTransactionHash: !!deployData?.transactionHash, + hasContractAddress: !!deployData?.contractAddress, + hasGasUsed: deployData?.gasUsed !== undefined, + contractAddress: deployData?.contractAddress || null, + errorMessage: result.error?.message || deployData?.error || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Deploy contract error:', data.error); + return; + } + browser.assert.ok(data.success, 'Deploy contract tool should succeed'); + if (data.deploySuccess) { + browser.assert.ok(data.hasContractAddress, 'Should have contract address'); + browser.assert.ok(data.hasTransactionHash, 'Should have transaction hash'); + browser.assert.ok(data.hasGasUsed, 'Should have gas used'); + } + }); + }, + + 'Should test call_contract tool': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + // First get deployed contracts to find an address + const deployedResult = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/read', + params: { uri: 'deployment://active' }, + id: 'test-get-deployed' + }); + + let deployedData = null; + if (deployedResult.result?.text) { + deployedData = JSON.parse(deployedResult.result.text); + } + + const contracts = deployedData?.contracts || []; + if (contracts.length === 0) { + return { skipped: true, reason: 'No deployed contracts available' }; + } + + const testContract = contracts[0]; + + // Call the contract + const result = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'call_contract', + arguments: { + contractName: testContract.name, + address: testContract.address, + abi: testContract.abi, + methodName: 'getValue', + args: [] + } + }, + id: 'test-call' + }); + + let callData = null; + if (!result.error && result.result?.content?.[0]?.text) { + callData = JSON.parse(result.result.content[0].text); + } + + return { + success: !result.error, + callSuccess: callData?.success || false, + hasResult: callData?.result !== undefined, + result: callData?.result || null, + errorMessage: result.error?.message || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Call contract error:', data.error); + return; + } + if (data.skipped) { + console.log('Test skipped:', data.reason); + return; + } + browser.assert.ok(data.success, 'Call contract tool should succeed'); + }); + }, + + 'Should test get_deployed_contracts tool': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const result = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'get_deployed_contracts', + arguments: {} + }, + id: 'test-get-deployed' + }); + + let resultData = null; + if (!result.error && result.result?.content?.[0]?.text) { + resultData = JSON.parse(result.result.content[0].text); + } + + return { + success: !result.error, + getSuccess: resultData?.success || false, + hasContracts: !!resultData?.contracts, + contractCount: resultData?.count || 0 + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Get deployed contracts error:', data.error); + return; + } + browser.assert.ok(data.success, 'Get deployed contracts should succeed'); + browser.assert.ok(data.hasContracts, 'Should have contracts array'); + }); + }, + + /** + * ERROR HANDLING & VALIDATION TESTS + */ + 'Should validate tool arguments': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const tests = [ + // Missing required argument + { + name: 'file_read', + arguments: {}, + expectedError: true + }, + // Invalid type + { + name: 'solidity_compile', + arguments: { + file: 'test.sol', + runs: 'invalid' // Should be number + }, + expectedError: true + }, + // Invalid value range + { + name: 'solidity_compile', + arguments: { + file: 'test.sol', + runs: 99999 // Too high + }, + expectedError: true + } + ]; + + const results = []; + + for (const test of tests) { + try { + const result = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: test, + id: `test-validation-${test.name}` + }); + + results.push({ + tool: test.name, + hasError: !!result.error || result.result?.isError, + errorMessage: result.error?.message || result.result?.content?.[0]?.text || null, + expectedError: test.expectedError + }); + } catch (error) { + results.push({ + tool: test.name, + hasError: true, + errorMessage: error.message, + expectedError: test.expectedError + }); + } + } + + return { + totalTests: tests.length, + results, + allValidated: results.every(r => r.hasError === r.expectedError), + systemStable: true + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Validation test error:', data.error); + return; + } + browser.assert.equal(data.totalTests, 3, 'Should run all validation tests'); + browser.assert.ok(data.allValidated, 'All validation tests should behave as expected'); + browser.assert.ok(data.systemStable, 'System should remain stable after validation errors'); + }); + }, + + 'Should handle non-existent tools gracefully': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const result = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'non_existent_tool_12345', + arguments: {} + }, + id: 'test-nonexistent' + }); + + return { + hasError: !!result.error, + errorCode: result.error?.code || null, + errorMessage: result.error?.message || null, + systemStable: true + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Non-existent tool test error:', data.error); + return; + } + browser.assert.ok(data.hasError, 'Should return error for non-existent tool'); + browser.assert.ok(data.systemStable, 'System should remain stable'); + }); + }, + + /** + * PERFORMANCE TESTS + */ + 'Should handle concurrent 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 startTime = Date.now(); + + // Execute multiple tools concurrently + const promises = [ + aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { name: 'get_compiler_config', arguments: {} }, + id: 'concurrent-1' + }), + aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { name: 'get_user_accounts', arguments: {} }, + id: 'concurrent-2' + }), + aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { name: 'get_current_environment', arguments: {} }, + id: 'concurrent-3' + }), + aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { name: 'get_compilation_result', arguments: {} }, + id: 'concurrent-4' + }) + ]; + + const results = await Promise.all(promises); + const endTime = Date.now(); + + const allSucceeded = results.every(r => !r.error); + const executionTime = endTime - startTime; + + return { + totalTools: promises.length, + allSucceeded, + executionTime, + averageTime: executionTime / promises.length, + performanceAcceptable: executionTime < 5000 // 5 seconds for 4 tools + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Concurrent execution error:', data.error); + return; + } + browser.assert.equal(data.totalTools, 4, 'Should execute all tools'); + browser.assert.ok(data.allSucceeded, 'All concurrent executions should succeed'); + browser.assert.ok(data.performanceAcceptable, 'Performance should be acceptable'); + console.log(`Concurrent execution completed in ${data.executionTime}ms (avg: ${data.averageTime}ms per tool)`); + }); + } +}; diff --git a/apps/remix-ide-e2e/src/tests/mcp_server_complete.test.ts b/apps/remix-ide-e2e/src/tests/mcp_server_complete.test.ts new file mode 100644 index 00000000000..237293949f1 --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/mcp_server_complete.test.ts @@ -0,0 +1,892 @@ +import { NightwatchBrowser } from 'nightwatch' +import init from '../helpers/init' + +/** + * Comprehensive test suite for RemixMCPServer core functionality + * Tests server lifecycle, MCP protocol compliance, capabilities, statistics, and error handling + */ + +module.exports = { + '@disabled': false, + before: function (browser: NightwatchBrowser, done: VoidFunction) { + init(browser, done) + }, + + /** + * SERVER INITIALIZATION & STATE TESTS + */ + 'Should initialize RemixMCPServer correctly': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible('*[data-id="remix-ai-assistant"]') + .execute(function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const server = aiPlugin.remixMCPServer; + + return { + hasServer: !!server, + hasConfig: !!server.config, + hasState: server.state !== undefined, + hasStats: !!server.stats, + hasTools: !!server.tools, + hasResources: !!server.resources, + serverName: server.config?.name || null, + serverVersion: server.config?.version || null, + currentState: server.state || null, + toolCount: server.tools?.list()?.length || 0, + resourceProviderCount: server.resources?.list()?.length || 0 + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Server initialization error:', data.error); + return; + } + browser.assert.ok(data.hasServer, 'Should have server instance'); + browser.assert.ok(data.hasConfig, 'Should have server config'); + browser.assert.ok(data.hasState, 'Should have server state'); + browser.assert.ok(data.hasStats, 'Should have server stats'); + browser.assert.ok(data.hasTools, 'Should have tool registry'); + browser.assert.ok(data.hasResources, 'Should have resource registry'); + browser.assert.ok(data.toolCount > 0, 'Should have registered tools'); + browser.assert.ok(data.resourceProviderCount > 0, 'Should have registered resource providers'); + console.log(`Server: ${data.serverName} v${data.serverVersion}, State: ${data.currentState}, Tools: ${data.toolCount}, Providers: ${data.resourceProviderCount}`); + }); + }, + + 'Should have correct server configuration': function (browser: NightwatchBrowser) { + browser + .execute(function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const server = aiPlugin.remixMCPServer; + const config = server.config; + + return { + hasName: !!config.name, + hasVersion: !!config.version, + hasDescription: !!config.description, + hasDebug: config.debug !== undefined, + hasMaxConcurrentTools: config.maxConcurrentTools !== undefined, + hasToolTimeout: config.toolTimeout !== undefined, + hasResourceCacheTTL: config.resourceCacheTTL !== undefined, + hasFeatures: !!config.features, + name: config.name, + version: config.version, + features: config.features || null, + maxConcurrentTools: config.maxConcurrentTools, + toolTimeout: config.toolTimeout + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Server config error:', data.error); + return; + } + browser.assert.ok(data.hasName, 'Config should have name'); + browser.assert.ok(data.hasVersion, 'Config should have version'); + browser.assert.ok(data.hasFeatures, 'Config should have features'); + browser.assert.ok(data.maxConcurrentTools > 0, 'Should allow concurrent tools'); + browser.assert.ok(data.toolTimeout > 0, 'Should have tool timeout configured'); + }); + }, + + /** + * MCP PROTOCOL COMPLIANCE TESTS + */ + 'Should handle initialize method': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'initialize', + id: 'test-init-1' + }); + + return { + hasResult: !!response.result, + hasError: !!response.error, + hasProtocolVersion: !!response.result?.protocolVersion, + hasCapabilities: !!response.result?.capabilities, + hasServerInfo: !!response.result?.serverInfo, + hasInstructions: !!response.result?.instructions, + protocolVersion: response.result?.protocolVersion || null, + serverInfo: response.result?.serverInfo || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Initialize method error:', data.error); + return; + } + browser.assert.ok(data.hasResult, 'Should have result'); + browser.assert.ok(!data.hasError, 'Should not have error'); + browser.assert.ok(data.hasProtocolVersion, 'Should have protocol version'); + browser.assert.ok(data.hasCapabilities, 'Should have capabilities'); + browser.assert.ok(data.hasServerInfo, 'Should have server info'); + browser.assert.equal(data.protocolVersion, '2024-11-05', 'Protocol version should match MCP spec'); + }); + }, + + 'Should handle tools/list method': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/list', + id: 'test-tools-list-1' + }); + + const tools = response.result?.tools || []; + + return { + hasResult: !!response.result, + hasError: !!response.error, + hasTools: !!response.result?.tools, + toolCount: tools.length, + allToolsValid: tools.every((t: any) => + t.name && t.description && t.inputSchema + ), + sampleTools: tools.slice(0, 3).map((t: any) => ({ + name: t.name, + hasDescription: !!t.description, + hasInputSchema: !!t.inputSchema + })) + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Tools list method error:', data.error); + return; + } + browser.assert.ok(data.hasResult, 'Should have result'); + browser.assert.ok(!data.hasError, 'Should not have error'); + browser.assert.ok(data.hasTools, 'Should have tools array'); + browser.assert.ok(data.toolCount > 0, 'Should have tools'); + browser.assert.ok(data.allToolsValid, 'All tools should have required fields'); + }); + }, + + 'Should handle tools/call method': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'tools/call', + params: { + name: 'get_compiler_config', + arguments: {} + }, + id: 'test-tools-call-1' + }); + + return { + hasResult: !!response.result, + hasError: !!response.error, + hasContent: !!response.result?.content, + isArray: Array.isArray(response.result?.content), + firstContentHasText: response.result?.content?.[0]?.text !== undefined, + isError: response.result?.isError || false + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Tools call method error:', data.error); + return; + } + browser.assert.ok(data.hasResult, 'Should have result'); + browser.assert.ok(data.hasContent, 'Should have content'); + browser.assert.ok(data.isArray, 'Content should be array'); + browser.assert.ok(data.firstContentHasText, 'Content should have text'); + }); + }, + + 'Should handle resources/list method': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/list', + id: 'test-resources-list-1' + }); + + const resources = response.result?.resources || []; + + return { + hasResult: !!response.result, + hasError: !!response.error, + hasResources: !!response.result?.resources, + resourceCount: resources.length, + allResourcesValid: resources.every((r: any) => + r.uri && r.name && r.mimeType + ), + sampleResources: resources.slice(0, 3).map((r: any) => ({ + uri: r.uri, + name: r.name, + mimeType: r.mimeType + })) + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Resources list method error:', data.error); + return; + } + browser.assert.ok(data.hasResult, 'Should have result'); + browser.assert.ok(!data.hasError, 'Should not have error'); + browser.assert.ok(data.hasResources, 'Should have resources array'); + browser.assert.ok(data.resourceCount > 0, 'Should have resources'); + browser.assert.ok(data.allResourcesValid, 'All resources should have required fields'); + }); + }, + + 'Should handle resources/read method': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'resources/read', + params: { uri: 'project://structure' }, + id: 'test-resources-read-1' + }); + + return { + hasResult: !!response.result, + hasError: !!response.error, + hasUri: !!response.result?.uri, + hasMimeType: !!response.result?.mimeType, + hasText: !!response.result?.text, + uri: response.result?.uri || null, + mimeType: response.result?.mimeType || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Resources read method error:', data.error); + return; + } + browser.assert.ok(data.hasResult, 'Should have result'); + browser.assert.ok(!data.hasError, 'Should not have error'); + browser.assert.ok(data.hasUri, 'Should have uri'); + browser.assert.ok(data.hasMimeType, 'Should have mimeType'); + browser.assert.ok(data.hasText, 'Should have text content'); + }); + }, + + 'Should handle server/capabilities method': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'server/capabilities', + id: 'test-capabilities-1' + }); + + const capabilities = response.result || {}; + + return { + hasResult: !!response.result, + hasError: !!response.error, + hasResources: !!capabilities.resources, + hasTools: !!capabilities.tools, + hasPrompts: capabilities.prompts !== undefined, + hasLogging: capabilities.logging !== undefined, + hasExperimental: !!capabilities.experimental, + resourcesSubscribe: capabilities.resources?.subscribe || false, + toolsListChanged: capabilities.tools?.listChanged || false, + experimentalFeatures: capabilities.experimental || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Capabilities method error:', data.error); + return; + } + browser.assert.ok(data.hasResult, 'Should have result'); + browser.assert.ok(data.hasResources, 'Should have resources capability'); + browser.assert.ok(data.hasTools, 'Should have tools capability'); + browser.assert.ok(data.resourcesSubscribe, 'Resources should support subscribe'); + browser.assert.ok(data.toolsListChanged, 'Tools should support listChanged'); + }); + }, + + 'Should handle server/stats method': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'server/stats', + id: 'test-stats-1' + }); + + const stats = response.result || {}; + + return { + hasResult: !!response.result, + hasError: !!response.error, + hasUptime: stats.uptime !== undefined, + hasTotalToolCalls: stats.totalToolCalls !== undefined, + hasTotalResourcesServed: stats.totalResourcesServed !== undefined, + hasActiveToolExecutions: stats.activeToolExecutions !== undefined, + hasErrorCount: stats.errorCount !== undefined, + hasLastActivity: !!stats.lastActivity, + stats: stats + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Stats method error:', data.error); + return; + } + browser.assert.ok(data.hasResult, 'Should have result'); + browser.assert.ok(data.hasUptime, 'Should have uptime'); + browser.assert.ok(data.hasTotalToolCalls, 'Should have total tool calls'); + browser.assert.ok(data.hasTotalResourcesServed, 'Should have total resources served'); + browser.assert.ok(data.hasActiveToolExecutions, 'Should have active tool executions count'); + browser.assert.ok(data.hasErrorCount, 'Should have error count'); + browser.assert.ok(data.hasLastActivity, 'Should have last activity timestamp'); + console.log('Server stats:', JSON.stringify(data.stats, null, 2)); + }); + }, + + 'Should handle unknown method': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const response = await aiPlugin.remixMCPServer.handleMessage({ + method: 'unknown/method', + id: 'test-unknown-1' + }); + + return { + hasResult: response.result !== undefined, + hasError: !!response.error, + errorCode: response.error?.code || null, + errorMessage: response.error?.message || null + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Unknown method handling error:', data.error); + return; + } + browser.assert.ok(data.hasError, 'Should have error for unknown method'); + browser.assert.ok(data.errorMessage?.includes('Unknown method'), 'Error message should indicate unknown method'); + }); + }, + + /** + * SERVER CAPABILITIES TESTS + */ + 'Should verify all server capabilities': function (browser: NightwatchBrowser) { + browser + .execute(function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const capabilities = aiPlugin.remixMCPServer.getCapabilities(); + + return { + capabilities, + hasResources: !!capabilities.resources, + hasTools: !!capabilities.tools, + hasPrompts: capabilities.prompts !== undefined, + hasLogging: capabilities.logging !== undefined, + hasExperimental: !!capabilities.experimental, + + // Resource capabilities + resourcesSubscribe: capabilities.resources?.subscribe || false, + resourcesListChanged: capabilities.resources?.listChanged || false, + + // Tool capabilities + toolsListChanged: capabilities.tools?.listChanged || false, + + // Experimental features + hasRemixFeatures: !!capabilities.experimental?.remix, + remixCompilation: capabilities.experimental?.remix?.compilation || false, + remixDeployment: capabilities.experimental?.remix?.deployment || false, + remixDebugging: capabilities.experimental?.remix?.debugging || false, + remixAnalysis: capabilities.experimental?.remix?.analysis || false, + remixTesting: capabilities.experimental?.remix?.testing || false, + remixGit: capabilities.experimental?.remix?.git || false + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Capabilities verification error:', data.error); + return; + } + browser.assert.ok(data.hasResources, 'Should have resources capability'); + browser.assert.ok(data.hasTools, 'Should have tools capability'); + browser.assert.ok(data.resourcesSubscribe, 'Resources should support subscribe'); + browser.assert.ok(data.resourcesListChanged, 'Resources should support listChanged'); + browser.assert.ok(data.toolsListChanged, 'Tools should support listChanged'); + browser.assert.ok(data.hasRemixFeatures, 'Should have Remix-specific features'); + browser.assert.ok(data.remixCompilation, 'Should support compilation feature'); + browser.assert.ok(data.remixDeployment, 'Should support deployment feature'); + console.log('Capabilities:', JSON.stringify(data.capabilities, null, 2)); + }); + }, + + /** + * SERVER STATISTICS & MONITORING TESTS + */ + 'Should track server statistics correctly': 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; + + // Get initial stats + const initialStats = server.stats; + + // Execute some operations + await server.handleMessage({ + method: 'tools/call', + params: { name: 'get_compiler_config', arguments: {} }, + id: 'stats-test-1' + }); + + await server.handleMessage({ + method: 'resources/read', + params: { uri: 'project://structure' }, + id: 'stats-test-2' + }); + + // Get updated stats + const updatedStats = server.stats; + + return { + initialStats, + updatedStats, + uptimeIncreased: updatedStats.uptime >= initialStats.uptime, + toolCallsTracked: updatedStats.totalToolCalls >= initialStats.totalToolCalls, + resourcesTracked: updatedStats.totalResourcesServed >= initialStats.totalResourcesServed, + lastActivityUpdated: new Date(updatedStats.lastActivity) >= new Date(initialStats.lastActivity), + hasAllStatFields: !!( + updatedStats.uptime !== undefined && + updatedStats.totalToolCalls !== undefined && + updatedStats.totalResourcesServed !== undefined && + updatedStats.activeToolExecutions !== undefined && + updatedStats.errorCount !== undefined && + updatedStats.lastActivity + ) + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Statistics tracking error:', data.error); + return; + } + browser.assert.ok(data.uptimeIncreased, 'Uptime should increase'); + browser.assert.ok(data.lastActivityUpdated, 'Last activity should be updated'); + browser.assert.ok(data.hasAllStatFields, 'Stats should have all required fields'); + console.log('Initial stats:', data.initialStats); + console.log('Updated stats:', data.updatedStats); + }); + }, + + 'Should provide cache statistics': 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; + + // Trigger some cache operations + await server.handleMessage({ + method: 'resources/read', + params: { uri: 'deployment://history' }, + id: 'cache-test-1' + }); + + await server.handleMessage({ + method: 'resources/read', + params: { uri: 'deployment://history' }, + id: 'cache-test-2' + }); + + const cacheStats = server.getCacheStats(); + + return { + hasCacheStats: !!cacheStats, + hasSize: cacheStats?.size !== undefined, + hasHitRate: cacheStats?.hitRate !== undefined, + hasEntries: !!cacheStats?.entries, + size: cacheStats?.size || 0, + hitRate: cacheStats?.hitRate || 0, + entryCount: cacheStats?.entries?.length || 0 + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Cache stats error:', data.error); + return; + } + browser.assert.ok(data.hasCacheStats, 'Should have cache statistics'); + browser.assert.ok(data.hasSize, 'Should have cache size'); + browser.assert.ok(data.hasHitRate, 'Should have cache hit rate'); + browser.assert.ok(data.hasEntries, 'Should have cache entries'); + console.log(`Cache: size=${data.size}, hitRate=${data.hitRate}, entries=${data.entryCount}`); + }); + }, + + 'Should track active tool executions': 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; + + // Execute a tool + const toolPromise = server.handleMessage({ + method: 'tools/call', + params: { name: 'get_compiler_config', arguments: {} }, + id: 'exec-test-1' + }); + + // Check active executions (should be empty after completion in our test) + await toolPromise; + + const activeExecutions = server.getActiveExecutions(); + + return { + hasMethod: typeof server.getActiveExecutions === 'function', + isArray: Array.isArray(activeExecutions), + count: activeExecutions.length, + // After completion, should be 0 + allCompleted: activeExecutions.length === 0 + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Active executions error:', data.error); + return; + } + browser.assert.ok(data.hasMethod, 'Should have getActiveExecutions method'); + browser.assert.ok(data.isArray, 'Should return array'); + browser.assert.ok(data.allCompleted, 'Should not have active executions after completion'); + }); + }, + + /** + * ERROR HANDLING TESTS + */ + 'Should handle malformed messages gracefully': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const testCases = [ + // Missing method + { id: 'test-1' }, + // Invalid method type + { method: 123, id: 'test-2' }, + // Missing required params + { method: 'tools/call', id: 'test-3' }, + // Invalid params type + { method: 'resources/read', params: 'invalid', id: 'test-4' } + ]; + + const results = []; + + for (const testCase of testCases) { + try { + const response = await aiPlugin.remixMCPServer.handleMessage(testCase as any); + results.push({ + test: testCase, + hasError: !!response.error, + handled: true + }); + } catch (error) { + results.push({ + test: testCase, + hasError: true, + handled: true, + errorMessage: error.message + }); + } + } + + return { + totalTests: testCases.length, + results, + allHandled: results.every(r => r.handled), + systemStable: true + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Malformed message handling error:', data.error); + return; + } + browser.assert.equal(data.totalTests, 4, 'Should test all malformed messages'); + browser.assert.ok(data.allHandled, 'All malformed messages should be handled'); + browser.assert.ok(data.systemStable, 'System should remain stable'); + }); + }, + + 'Should enforce tool execution timeout': function (browser: NightwatchBrowser) { + browser + .execute(function () { + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin?.remixMCPServer) { + return { error: 'RemixMCPServer not available' }; + } + + try { + const server = aiPlugin.remixMCPServer; + const config = server.config; + + return { + hasTimeout: config.toolTimeout !== undefined, + timeoutValue: config.toolTimeout || 0, + isReasonable: config.toolTimeout > 0 && config.toolTimeout <= 60000 // Between 0 and 60 seconds + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Timeout config error:', data.error); + return; + } + browser.assert.ok(data.hasTimeout, 'Should have tool timeout configured'); + browser.assert.ok(data.timeoutValue > 0, 'Timeout should be positive'); + browser.assert.ok(data.isReasonable, 'Timeout should be reasonable'); + console.log(`Tool timeout: ${data.timeoutValue}ms`); + }); + }, + + /** + * PERFORMANCE & SCALABILITY TESTS + */ + 'Should handle high concurrent load': 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 concurrentOperations = 10; + const startTime = Date.now(); + + // Create array of promises for concurrent operations + const promises = []; + for (let i = 0; i < concurrentOperations; i++) { + promises.push( + server.handleMessage({ + method: i % 2 === 0 ? 'tools/list' : 'resources/list', + id: `concurrent-${i}` + }) + ); + } + + const results = await Promise.all(promises); + const endTime = Date.now(); + const totalTime = endTime - startTime; + + return { + operationCount: concurrentOperations, + successCount: results.filter(r => r.result && !r.error).length, + totalTime, + averageTime: totalTime / concurrentOperations, + allSucceeded: results.every(r => r.result && !r.error), + performanceAcceptable: totalTime < 10000 // 10 seconds for 10 operations + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Concurrent load error:', data.error); + return; + } + browser.assert.equal(data.successCount, data.operationCount, 'All operations should succeed'); + browser.assert.ok(data.allSucceeded, 'All operations should complete successfully'); + browser.assert.ok(data.performanceAcceptable, 'Performance should be acceptable under load'); + console.log(`Concurrent load test: ${data.operationCount} operations in ${data.totalTime}ms (avg: ${data.averageTime}ms)`); + }); + }, + + 'Should verify server stability over time': 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 iterations = 5; + const results = []; + + for (let i = 0; i < iterations; i++) { + const stats = server.stats; + + // Execute some operations + await server.handleMessage({ + method: 'tools/list', + id: `stability-${i}-1` + }); + + await server.handleMessage({ + method: 'resources/list', + id: `stability-${i}-2` + }); + + results.push({ + iteration: i, + uptime: stats.uptime, + totalToolCalls: stats.totalToolCalls, + errorCount: stats.errorCount + }); + + // Small delay between iterations + await new Promise(resolve => setTimeout(resolve, 100)); + } + + return { + iterations, + results, + consistentBehavior: results.every(r => r.errorCount === results[0].errorCount), + uptimeIncreasing: results.every((r, i) => + i === 0 || r.uptime >= results[i - 1].uptime + ), + stable: true + }; + } catch (error) { + return { error: error.message }; + } + }, [], function (result) { + const data = result.value as any; + if (data.error) { + console.error('Stability test error:', data.error); + return; + } + browser.assert.equal(data.iterations, 5, 'Should complete all iterations'); + browser.assert.ok(data.uptimeIncreasing, 'Uptime should consistently increase'); + browser.assert.ok(data.stable, 'Server should remain stable'); + console.log('Stability test results:', data.results); + }); + } +}; diff --git a/apps/remix-ide-e2e/src/tests/mcp_server_connection.test.ts b/apps/remix-ide-e2e/src/tests/mcp_server_connection.test.ts new file mode 100644 index 00000000000..10736ee3ece --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/mcp_server_connection.test.ts @@ -0,0 +1,176 @@ +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="remix-ai-assistant"]') + .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_server_lifecycle.test.ts b/apps/remix-ide-e2e/src/tests/mcp_server_lifecycle.test.ts new file mode 100644 index 00000000000..d5b36946604 --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/mcp_server_lifecycle.test.ts @@ -0,0 +1,260 @@ +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="remix-ai-assistant"]') + .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.config.name, + hasVersion: !!server.config.version, + hasCapabilities: !!server.getCapabilities(), + hasToolRegistry: !!server.tools, + hasResourceProviders: !!server.resources, + }; + } + + return { + aiPluginActive: aiPlugin.aiIsActivated, + 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 = { + name: server.config.name, + capabilities: server.getCapabilities() || {}, + }; + + + const toolRegistry = server.tools; + const resourceProviders = server.resources.providers; + + const toolConfig = toolRegistry ? { + totalTools: toolRegistry.tools.size, + categories: toolRegistry.getByCategory() + } : 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_terms_modal.test.ts b/apps/remix-ide-e2e/src/tests/mcp_terms_modal.test.ts new file mode 100644 index 00000000000..fb9b37e9812 --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/mcp_terms_modal.test.ts @@ -0,0 +1,405 @@ +'use strict' + +import { NightwatchBrowser } from 'nightwatch' +import init from '../helpers/init' + +module.exports = { + '@disabled': true, + before: function (browser: NightwatchBrowser, done: VoidFunction) { + init(browser, done) + }, + + 'Should clear localStorage for MCP terms before tests': function (browser: NightwatchBrowser) { + browser + .execute(function () { + // Clear any existing MCP terms acceptance + localStorage.removeItem('remix_mcp_terms_accepted'); + return true; + }) + }, + + 'Should show MCP terms modal on first loadMCPServersFromSettings call': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + + // Clear terms acceptance to simulate first use + localStorage.removeItem('remix_mcp_terms_accepted'); + + // Call loadMCPServersFromSettings which should trigger the modal + const loadPromise = aiPlugin.loadMCPServersFromSettings(); + + // Wait a bit for the modal to appear + await new Promise(resolve => setTimeout(resolve, 500)); + + // Check if modal is visible + const modal = document.querySelector('[data-id="mcp-terms-modal"]') || document.querySelector('.modal-content'); + const modalVisible = modal !== null; + + return { + modalVisible, + modalTitle: modal?.querySelector('.modal-title')?.textContent || '', + hasAcceptButton: modal?.querySelector('.modal-ok-btn') !== null || + document.querySelector('button')?.textContent.includes('I Accept'), + hasDeclineButton: modal?.querySelector('.modal-cancel-btn') !== null || + document.querySelector('button')?.textContent.includes('I Decline') + }; + }, [], function (result) { + browser.assert.strictEqual(result.value.modalVisible, true, 'MCP terms modal should be visible'); + browser.assert.ok(result.value.modalTitle.includes('MCP'), 'Modal title should mention MCP'); + browser.assert.strictEqual(result.value.hasAcceptButton, true, 'Modal should have Accept button'); + browser.assert.strictEqual(result.value.hasDeclineButton, true, 'Modal should have Decline button'); + }); + }, + + 'Should display comprehensive terms content': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + + // Trigger the terms modal + const termsPromise = aiPlugin.checkMCPTermsAcceptance(); + + // Wait for modal to appear + await new Promise(resolve => setTimeout(resolve, 500)); + + // Extract modal content + const modalContent = document.querySelector('.modal-body')?.textContent || + document.querySelector('.modal-content')?.textContent || ''; + + return { + hasOverview: modalContent.includes('Overview'), + hasDataCollection: modalContent.includes('Data Collection'), + hasDataSharing: modalContent.includes('Data Sharing'), + hasDataSecurity: modalContent.includes('Data Security'), + hasDataRetention: modalContent.includes('Data Retention'), + hasUserRights: modalContent.includes('Your Rights'), + hasConsent: modalContent.includes('Consent'), + mentionsWorkspaceData: modalContent.includes('Workspace Data') || modalContent.includes('workspace'), + mentionsExternalServers: modalContent.includes('external servers') || modalContent.includes('External Servers'), + mentionsPrivateKeys: modalContent.includes('private keys') || modalContent.includes('Sensitive data'), + hasDisableWarning: modalContent.includes('disabled') || modalContent.includes('disable'), + contentLength: modalContent.length + }; + }, [], function (result) { + const content = result.value; + browser.assert.ok(content.hasOverview, 'Terms should include Overview section'); + browser.assert.ok(content.hasDataCollection, 'Terms should include Data Collection section'); + browser.assert.ok(content.hasDataSharing, 'Terms should include Data Sharing section'); + browser.assert.ok(content.hasDataSecurity, 'Terms should include Data Security section'); + browser.assert.ok(content.hasDataRetention, 'Terms should include Data Retention section'); + browser.assert.ok(content.hasUserRights, 'Terms should include User Rights section'); + browser.assert.ok(content.hasConsent, 'Terms should include Consent section'); + browser.assert.ok(content.mentionsWorkspaceData, 'Terms should mention workspace data'); + browser.assert.ok(content.mentionsExternalServers, 'Terms should mention external servers'); + browser.assert.ok(content.mentionsPrivateKeys, 'Terms should warn about sensitive data'); + browser.assert.ok(content.hasDisableWarning, 'Terms should explain MCP will be disabled if declined'); + browser.assert.ok(content.contentLength > 500, 'Terms content should be comprehensive (>500 chars)'); + }); + }, + + 'Should save acceptance to localStorage when user accepts': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + + // Clear localStorage first + localStorage.removeItem('remix_mcp_terms_accepted'); + + // Start the terms check + const termsPromise = aiPlugin.checkMCPTermsAcceptance(); + + // Wait for modal to appear + await new Promise(resolve => setTimeout(resolve, 500)); + + // Simulate clicking Accept button + const acceptBtn = document.querySelector('.modal-ok-btn') as HTMLButtonElement || + Array.from(document.querySelectorAll('button')).find(btn => + btn.textContent?.includes('I Accept') + ) as HTMLButtonElement; + + if (acceptBtn) { + acceptBtn.click(); + } + + // Wait for modal to process + await new Promise(resolve => setTimeout(resolve, 300)); + + // Check localStorage + const accepted = localStorage.getItem('remix_mcp_terms_accepted'); + + // Wait for promise to resolve + const result = await termsPromise; + + return { + localStorageValue: accepted, + checkResult: result, + acceptBtnFound: acceptBtn !== null + }; + }, [], function (result) { + browser.assert.ok(result.value.acceptBtnFound, 'Accept button should be found'); + browser.assert.strictEqual(result.value.localStorageValue, 'true', 'Acceptance should be saved to localStorage'); + browser.assert.strictEqual(result.value.checkResult, true, 'checkMCPTermsAcceptance should return true'); + }); + }, + + 'Should not show modal again after acceptance': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + + // Ensure terms are accepted + localStorage.setItem('remix_mcp_terms_accepted', 'true'); + + // Call checkMCPTermsAcceptance again + const result = await aiPlugin.checkMCPTermsAcceptance(); + + // Wait a bit to see if modal appears + await new Promise(resolve => setTimeout(resolve, 500)); + + // Check if modal is visible + const modal = document.querySelector('[data-id="mcp-terms-modal"]') || + document.querySelector('.modal-content'); + + return { + modalVisible: modal !== null, + checkResult: result, + localStorageValue: localStorage.getItem('remix_mcp_terms_accepted') + }; + }, [], function (result) { + browser.assert.strictEqual(result.value.localStorageValue, 'true', 'Terms acceptance should persist'); + browser.assert.strictEqual(result.value.checkResult, true, 'Should return true when already accepted'); + browser.assert.strictEqual(result.value.modalVisible, false, 'Modal should not appear when already accepted'); + }); + }, + + 'Should save decline to localStorage when user declines': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + + // Clear localStorage first + localStorage.removeItem('remix_mcp_terms_accepted'); + + // Start the terms check + const termsPromise = aiPlugin.checkMCPTermsAcceptance(); + + // Wait for modal to appear + await new Promise(resolve => setTimeout(resolve, 500)); + + // Simulate clicking Decline button + const declineBtn = document.querySelector('.modal-cancel-btn') as HTMLButtonElement || + Array.from(document.querySelectorAll('button')).find(btn => + btn.textContent?.includes('I Decline') + ) as HTMLButtonElement; + + if (declineBtn) { + declineBtn.click(); + } + + // Wait for modal to process + await new Promise(resolve => setTimeout(resolve, 300)); + + // Check localStorage + const accepted = localStorage.getItem('remix_mcp_terms_accepted'); + + // Wait for promise to resolve + const result = await termsPromise; + + return { + localStorageValue: accepted, + checkResult: result, + declineBtnFound: declineBtn !== null + }; + }, [], function (result) { + browser.assert.ok(result.value.declineBtnFound, 'Decline button should be found'); + browser.assert.strictEqual(result.value.localStorageValue, 'false', 'Decline should be saved to localStorage'); + browser.assert.strictEqual(result.value.checkResult, false, 'checkMCPTermsAcceptance should return false'); + }); + }, + + 'Should disable MCP when terms are declined': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + + // Clear localStorage and decline terms + localStorage.removeItem('remix_mcp_terms_accepted'); + + // Start loading MCP servers (which checks terms) + await aiPlugin.loadMCPServersFromSettings(); + + // Wait for processing + await new Promise(resolve => setTimeout(resolve, 500)); + + // Decline the modal if it appeared + const declineBtn = document.querySelector('.modal-cancel-btn') as HTMLButtonElement || + Array.from(document.querySelectorAll('button')).find(btn => + btn.textContent?.includes('I Decline') + ) as HTMLButtonElement; + + if (declineBtn) { + declineBtn.click(); + await new Promise(resolve => setTimeout(resolve, 300)); + } + + return { + mcpEnabled: aiPlugin.isMCPEnabled(), + mcpServersLength: aiPlugin.mcpServers.length, + localStorageValue: localStorage.getItem('remix_mcp_terms_accepted') + }; + }, [], function (result) { + browser.assert.strictEqual(result.value.mcpEnabled, false, 'MCP should be disabled when terms declined'); + browser.assert.strictEqual(result.value.mcpServersLength, 0, 'MCP servers should be empty when terms declined'); + }); + }, + + 'Should allow MCP when terms are accepted': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + + // Accept terms + localStorage.setItem('remix_mcp_terms_accepted', 'true'); + + // Load MCP servers + await aiPlugin.loadMCPServersFromSettings(); + + return { + mcpEnabled: aiPlugin.isMCPEnabled(), + mcpServersLength: aiPlugin.mcpServers.length, + hasBuiltInServer: aiPlugin.mcpServers.some(s => s.name === 'Remix IDE Server') + }; + }, [], function (result) { + browser.assert.ok(result.value.mcpServersLength > 0, 'MCP servers should be loaded when terms accepted'); + browser.assert.strictEqual(result.value.hasBuiltInServer, true, 'Built-in Remix IDE Server should be present'); + }); + }, + + 'Should check terms when calling enableMCPEnhancement': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + + // Decline terms + localStorage.setItem('remix_mcp_terms_accepted', 'false'); + + // Try to enable MCP enhancement + await aiPlugin.enableMCPEnhancement(); + + return { + mcpEnabled: aiPlugin.isMCPEnabled() + }; + }, [], function (result) { + browser.assert.strictEqual(result.value.mcpEnabled, false, 'MCP should not be enabled when terms not accepted'); + }); + }, + + 'Should enable MCP enhancement when terms are accepted': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + + // Accept terms and load servers + localStorage.setItem('remix_mcp_terms_accepted', 'true'); + await aiPlugin.loadMCPServersFromSettings(); + + // Enable MCP enhancement + await aiPlugin.enableMCPEnhancement(); + + return { + mcpEnabled: aiPlugin.isMCPEnabled(), + mcpInferencerExists: aiPlugin.mcpInferencer !== null + }; + }, [], function (result) { + browser.assert.strictEqual(result.value.mcpEnabled, true, 'MCP should be enabled when terms accepted'); + browser.assert.strictEqual(result.value.mcpInferencerExists, true, 'MCP inferencer should be initialized'); + }); + }, + + 'Should persist terms acceptance across page refreshes': function (browser: NightwatchBrowser) { + browser + .execute(function () { + // Set acceptance + localStorage.setItem('remix_mcp_terms_accepted', 'true'); + return localStorage.getItem('remix_mcp_terms_accepted'); + }, [], function (result) { + browser.assert.strictEqual(result.value, 'true', 'Terms acceptance should be stored'); + }) + .refresh() + .pause(2000) + .execute(async function () { + // After refresh, check if acceptance is still there + const accepted = localStorage.getItem('remix_mcp_terms_accepted'); + + const aiPlugin = (window as any).getRemixAIPlugin; + if (!aiPlugin) { + return { error: 'AI Plugin not available after refresh' }; + } + + // Check terms without showing modal + const termsAccepted = await aiPlugin.checkMCPTermsAcceptance(); + + return { + localStorageValue: accepted, + checkResult: termsAccepted + }; + }, [], function (result) { + if (result.value.error) { + browser.assert.fail(result.value.error); + } else { + browser.assert.strictEqual(result.value.localStorageValue, 'true', 'Terms acceptance should persist after refresh'); + browser.assert.strictEqual(result.value.checkResult, true, 'Terms check should return true after refresh'); + } + }); + }, + + 'Should handle modal close (hideFn) as decline': function (browser: NightwatchBrowser) { + browser + .execute(async function () { + const aiPlugin = (window as any).getRemixAIPlugin; + + // Clear localStorage + localStorage.removeItem('remix_mcp_terms_accepted'); + + // Start terms check + const termsPromise = aiPlugin.checkMCPTermsAcceptance(); + + // Wait for modal + await new Promise(resolve => setTimeout(resolve, 500)); + + // Try to close modal (simulate X button or Escape) + const closeBtn = document.querySelector('.modal-close') as HTMLButtonElement || + document.querySelector('[data-dismiss="modal"]') as HTMLButtonElement; + + if (closeBtn) { + closeBtn.click(); + } + + await new Promise(resolve => setTimeout(resolve, 300)); + + const result = await termsPromise; + const localStorageValue = localStorage.getItem('remix_mcp_terms_accepted'); + + return { + checkResult: result, + localStorageValue: localStorageValue, + closeBtnFound: closeBtn !== null + }; + }, [], function (result) { + // Modal close should be treated as decline + browser.assert.strictEqual(result.value.checkResult, false, 'Closing modal should return false'); + browser.assert.strictEqual(result.value.localStorageValue, 'false', 'Closing modal should save false to localStorage'); + }); + }, + + 'Cleanup - Reset MCP terms acceptance': function (browser: NightwatchBrowser) { + browser + .execute(function () { + // Clean up + localStorage.removeItem('remix_mcp_terms_accepted'); + return true; + }) + .end(); + } +} diff --git a/apps/remix-ide-e2e/src/tests/mcp_workflow_integration.test.ts b/apps/remix-ide-e2e/src/tests/mcp_workflow_integration.test.ts new file mode 100644 index 00000000000..02223cc7a0e --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/mcp_workflow_integration.test.ts @@ -0,0 +1,439 @@ +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() { + value = 100; + 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="remix-ai-assistant"]') + // Step 1: Create file through MCP if available, otherwise through UI + .addFile('contracts/WorkflowTest.sol', { content: workflowContract }) + .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; + + // Recursively search through the structure's children + const findFile = (node: any, targetName: string): any => { + if (!node) return null; + if (node.name && node.name.includes(targetName)) return node; + if (node.path && node.path.includes(targetName)) return node; + if (node.children && Array.isArray(node.children)) { + for (const child of node.children) { + const found = findFile(child, targetName); + if (found) return found; + } + } + return null; + }; + + const workflowFile = structureData?.structure ? findFile(structureData.structure, 'WorkflowTest.sol') : null; + + // 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 + .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 compilationContent = await remixClient.callTool({ + name:'solidity_compile', + arguments: {version: '0.8.30', optimize: true, runs: 200, evmVersion: 'prague', file: 'contracts/WorkflowTest.sol'} + }) + console.log('compilationContent', compilationContent) + const compilationData = compilationContent.content ? JSON.parse(compilationContent.content[0].text) : null; + + + const accountContent = await remixClient.readResource('deployment://networks'); + const accountData = accountContent.text ? JSON.parse(accountContent.text) : null; + const accounts = accountData?.configured[0]?.accounts + console.log('compilationData', compilationData) + + const deployContent = await remixClient.callTool({ + name:'deploy_contract', + arguments: { + contractName: 'WorkflowTest', + file: 'contracts/WorkflowTest.sol', + constructorArgs: Array(), + gasLimit: 3000000, + value: '0', account: accounts[0]?.address} + }) + console.log('deployContent', deployContent) + const deployData = deployContent.content ? JSON.parse(deployContent.content[0].text) : null; + console.log('deployData', deployData) + + await new Promise(resolve => setTimeout(resolve, 2000)) + + const historyContent = await remixClient.readResource('deployment://history'); + const historyData = historyContent.text ? JSON.parse(historyContent.text) : null; + console.log("historyData", historyData) + + const activeContent = await remixClient.readResource('deployment://active'); + const activeData = activeContent.text ? JSON.parse(activeContent.text) : null; + console.log("activeData", activeData) + + const transactionsContent = await remixClient.readResource('deployment://transactions'); + const transactionsData = transactionsContent.text ? JSON.parse(transactionsContent.text) : null; + console.log("transactionsData", transactionsData) + + 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 { + didCompile: !!compilationData.success, + didDeploy: !!deployData.success, + hasHistory: !!historyData && historyData.deployments.length > 0, + hasActive: !!activeData && activeData.contracts.length > 0, + userAccounts: accounts, + 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.didCompile, 'Should compile the workflowContract'); + browser.assert.ok(data.didDeploy, 'Should deploy the workflowContract'); + browser.assert.ok(data.hasHistory, 'Should capture deployment in history'); + browser.assert.ok(data.hasActive, 'Should show active deployments'); + browser.assert.ok(data.userAccounts.length >0, 'Should have multiple user accounts'); + 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 + .clickLaunchIcon('udapp') + .waitForElementPresent('*[data-id="sidePanelSwapitTitle"]') + .assert.containsText('*[data-id="sidePanelSwapitTitle"]', 'DEPLOY & RUN TRANSACTIONS') + .waitForElementPresent('*[data-id="universalDappUiContractActionWrapper"]', 60000) + .clickInstance(0) + .clickFunction('setValue - transact (not payable)', { types: 'uint256 _newValue', values: '87' }) + + .pause(3000) + .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 RemixMCPServer solidity compile tool execution via server': function (browser: NightwatchBrowser) { + browser + .addFile('contracts/RemixMCPServerTest.sol', { content: workflowContract }) + .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 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' }; + } + + const mcpClients = (aiPlugin.mcpInferencer as any).mcpClients; + const remixClient = mcpClients.get('Remix IDE Server'); + + 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) { + workflowRecoveries.push('compile_recovery'); + } + } catch (recoveryError) { + // Recovery failed + } + } + + // Test 2: Invalid resource access + try { + await remixClient.readResource('invalid://resource'); + } catch (error) { + workflowErrors.push('invalid_resource'); + + try { + const recovery = await remixClient.readResource('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-e2e/tsconfig.json b/apps/remix-ide-e2e/tsconfig.json index 47b4f1ae317..ebd47d8a1c4 100644 --- a/apps/remix-ide-e2e/tsconfig.json +++ b/apps/remix-ide-e2e/tsconfig.json @@ -6,6 +6,7 @@ "resolveJsonModule": true, "target": "es2015", "module": "commonjs", + "importHelpers": false }, "include": ["**/*.ts", "**/*.js"], "exclude": ["src/extensions"], diff --git a/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx b/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx index ef9a39ba2c3..bd8a2b025a9 100644 --- a/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx +++ b/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx @@ -3,6 +3,9 @@ import { Plugin } from '@remixproject/engine'; import { trackMatomoEvent } from '@remix-api' 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" @@ -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,259 @@ 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.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.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[] { + if (this.mcpInferencer) { + const statuses = this.mcpInferencer.getConnectionStatuses(); + return statuses; + } + + const defaultStatuses = this.mcpServers.map(server => ({ + serverName: server.name, + status: 'disconnected' as const, + lastAttempt: Date.now() + })); + return defaultStatuses; + } + + 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) { + const 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, or ensure they're enabled if they do + for (const builtInServer of builtInServers) { + const existingServer = loadedServers.find(s => s.name === builtInServer.name); + if (!existingServer) { + console.log(`[RemixAI Plugin] Adding missing built-in server: ${builtInServer.name}`); + loadedServers.push(builtInServer); + } else if (!existingServer.enabled || !existingServer.isBuiltIn) { + // Force enable and mark as built-in + console.log(`[RemixAI Plugin] Ensuring built-in server is enabled: ${builtInServer.name}`); + existingServer.enabled = true; + existingServer.isBuiltIn = true; + } + } + + 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/modified built-in servers + const originalServers = JSON.parse(savedServers); + const serversChanged = loadedServers.length !== originalServers.length || + loadedServers.some(server => { + const original = originalServers.find(s => s.name === server.name); + return !original || (server.isBuiltIn && (!original.enabled || !original.isBuiltIn)); + }); + + if (serversChanged) { + console.log(`[RemixAI Plugin] Saving corrected MCP servers to settings`); + 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`); + } + + // Initialize MCP inferencer if we have servers and it's not already initialized + if (this.mcpServers.length > 0 && !this.mcpInferencer && this.remixMCPServer) { + 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 enabled servers for status tracking + const enabledServers = this.mcpServers.filter((s: IMCPServer) => s.enabled); + if (enabledServers.length > 0) { + console.log(`[RemixAI Plugin] Connecting to ${enabledServers.length} enabled MCP servers for status tracking`); + await this.mcpInferencer.connectAllServers(); + console.log(`[RemixAI Plugin] Autostart connections completed`); + + // Emit event to notify UI that connections are ready + this.emit('mcpServersLoaded'); + } + } + } 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`); + + 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); + }); + + 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 `) + } + } } diff --git a/apps/remix-ide/src/app/tabs/compile-tab.js b/apps/remix-ide/src/app/tabs/compile-tab.js index 6663f8a5552..122d0873e49 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/remixApp.json b/apps/remix-ide/src/app/tabs/locales/en/remixApp.json index 44f48312417..f4f3beefa21 100644 --- a/apps/remix-ide/src/app/tabs/locales/en/remixApp.json +++ b/apps/remix-ide/src/app/tabs/locales/en/remixApp.json @@ -8,7 +8,7 @@ "remixApp.enterText4": "Prototyping - trying out concepts and techniques", "remixApp.enterText5": "Developing projects - Remix as your main dev tool", "remixApp.enterText6": "Production - only deployments", - "remixApp.matomoText1": "Remix features an AI coding assistant called RemixAI, that needs your approval to use.", + "remixApp.matomoText1": "Remix features an AI coding assistant called RemixAI with Model Context Protocol (MCP) integration, which enables enhanced AI capabilities by connecting to external servers and processing your workspace data. By using RemixAI with MCP, you consent to the transmission of workspace files, code context, and development data to configured MCP servers and AI providers. MCP processes smart contract code, compilation artifacts, and deployment information to provide intelligent assistance. External MCP servers (including third-party services) may retain data according to their own policies. You can disable MCP at any time through AI assistant settings. For full details on data collection, security, and your rights, please review the MCP Terms and Data Privacy policy when prompted. Your approval is required to use RemixAI with MCP features.", "remixApp.matomoText2": "We also use Matomo, an open-source analytics platform, that helps us improve your experience.", "remixApp.matomoTitle": "AI and Analytics Preferences", "remixApp.accept": "Accept", 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 dec40a82785..4ae81b09581 100644 --- a/apps/remix-ide/src/app/tabs/settings-tab.tsx +++ b/apps/remix-ide/src/app/tabs/settings-tab.tsx @@ -10,7 +10,7 @@ import { InitializationPattern, TrackingMode, MatomoState, CustomRemixApi } from 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', @@ -95,6 +95,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 78f2a255be5..bb7c2645aa9 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/assets/list.json b/apps/remix-ide/src/assets/list.json index 1e99f780891..e136bac177b 100644 --- a/apps/remix-ide/src/assets/list.json +++ b/apps/remix-ide/src/assets/list.json @@ -1066,6 +1066,18 @@ "urls": [ "dweb:/ipfs/QmXFsguaaxZj2FZmf2pGLTPDDkDD8nHX4grC4jDVugnMxv" ] + }, + { + "path": "soljson-v0.8.31-pre.1+commit.b59566f6.js", + "version": "0.8.31", + "prerelease": "pre.1", + "build": "commit.b59566f6", + "longVersion": "0.8.31-pre.1+commit.b59566f6", + "keccak256": "0x5cbab72123ec1f65e72592375e568788d88c96ffd90a1a3e9107fcd5a3b9cf87", + "sha256": "0xaf2b74e3c674c09ce89189edfaa81a0d01f1a0dce9100968e0d442de8a93b926", + "urls": [ + "dweb:/ipfs/QmafWKo2uVeEPMi1GbY2DVPreWtbA6aqXjynnn4wViA6a4" + ] } ], "releases": { diff --git a/apps/remix-ide/src/blockchain/blockchain.tsx b/apps/remix-ide/src/blockchain/blockchain.tsx index 473e4801b23..6dcdc7eece4 100644 --- a/apps/remix-ide/src/blockchain/blockchain.tsx +++ b/apps/remix-ide/src/blockchain/blockchain.tsx @@ -24,7 +24,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 } @@ -302,7 +302,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...`) @@ -545,7 +547,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) }) } @@ -606,7 +608,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 } @@ -667,7 +669,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, @@ -700,6 +702,7 @@ export class Blockchain extends Plugin { if (lookupOnly) { outputCb(returnValue) } + if (finalCb) finalCb(error, { txResult, address: _address, returnValue }) }) }, (msg) => { diff --git a/libs/endpoints-helper/src/index.ts b/libs/endpoints-helper/src/index.ts index 704f9722154..f803c89f1fc 100644 --- a/libs/endpoints-helper/src/index.ts +++ b/libs/endpoints-helper/src/index.ts @@ -1,5 +1,6 @@ type EndpointUrls = { corsProxy: string; + mcpCorsProxy: string; solidityScan: string; ipfsGateway: string; commonCorsProxy: string; @@ -17,6 +18,7 @@ type EndpointUrls = { const defaultUrls: EndpointUrls = { corsProxy: 'https://gitproxy.api.remix.live', + mcpCorsProxy: "https://mcp.api.remix.live/proxy?url=", solidityScan: 'https://solidityscan.api.remix.live', ipfsGateway: 'https://jqgt.api.remix.live', commonCorsProxy: 'https://common-corsproxy.api.remix.live', @@ -34,6 +36,7 @@ const defaultUrls: EndpointUrls = { const endpointPathMap: Record = { corsProxy: 'corsproxy', + mcpCorsProxy: 'mcpCorsProxy/proxy?url=', solidityScan: 'solidityscan', ipfsGateway: 'jqgt', commonCorsProxy: 'common-corsproxy', 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..a64d40015a3 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 = ''; @@ -56,12 +56,16 @@ export const HandleStreamResponse = async (streamResponse, cb: (streamText: stri } }; -export const HandleOpenAIResponse = async (streamResponse, cb: (streamText: string) => void, done_cb?: (result: string, thrID:string) => void) => { +export const HandleOpenAIResponse = async (aiResponse: IAIStreamResponse | any, cb: (streamText: string) => void, done_cb?: (result: string, thrID:string) => void) => { + // Handle both IAIStreamResponse format and plain response for backward compatibility + const streamResponse = aiResponse?.streamResponse || aiResponse + const tool_callback = aiResponse?.callback const reader = streamResponse.body?.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; - let threadId + let threadId: string = "" let resultText = ""; + const toolCalls: Map = new Map(); // Accumulate tool calls by index if (!reader) { // normal response, not a stream cb(streamResponse.result) @@ -90,6 +94,42 @@ export const HandleOpenAIResponse = async (streamResponse, cb: (streamText: stri const json = JSON.parse(jsonStr); threadId = json?.thread_id; + // Handle tool calls in OpenAI format - accumulate deltas + if (json.choices?.[0]?.delta?.tool_calls) { + const toolCallDeltas = json.choices[0].delta.tool_calls; + + for (const delta of toolCallDeltas) { + const index = delta.index; + + if (!toolCalls.has(index)) { + // Initialize new tool call + toolCalls.set(index, { + id: delta.id || "", + type: delta.type || "function", + function: { + name: delta.function?.name || "", + arguments: delta.function?.arguments || "" + } + }); + } else { + // Accumulate deltas + const existing = toolCalls.get(index); + if (delta.id) existing.id = delta.id; + if (delta.function?.name) existing.function.name += delta.function.name; + if (delta.function?.arguments) existing.function.arguments += delta.function.arguments; + } + } + } + + // Check if this is the finish reason for tool calls + if (json.choices?.[0]?.finish_reason === "tool_calls" && tool_callback && toolCalls.size > 0) { + console.log('OpenAI tool calls completed, calling callback with accumulated tools:', Array.from(toolCalls.values())) + const response = await tool_callback(Array.from(toolCalls.values())) + cb("\n\n"); + HandleOpenAIResponse(response, cb, done_cb) + return; + } + // Handle OpenAI "thread.message.delta" format if (json.object === "thread.message.delta" && json.delta?.content) { for (const contentItem of json.delta.content) { @@ -102,6 +142,13 @@ export const HandleOpenAIResponse = async (streamResponse, cb: (streamText: stri resultText += contentItem.text.value; } } + } else if (json.choices?.[0]?.delta?.content) { + // Handle standard OpenAI streaming format + const content = json.choices[0].delta.content; + if (typeof content === "string") { + cb(content); + resultText += content; + } } else if (json.delta?.content) { // fallback for other formats const content = json.delta.content; @@ -118,11 +165,15 @@ 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 | any, cb: (streamText: string) => void, done_cb?: (result: string, thrID:string) => void) => { + console.log('handling stream response', aiResponse) + // Handle both IAIStreamResponse format and plain response for backward compatibility + const streamResponse = aiResponse?.streamResponse || aiResponse + const tool_callback = aiResponse?.callback const reader = streamResponse.body?.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; - let threadId + let threadId: string = "" let resultText = ""; if (!reader) { // normal response, not a stream @@ -150,10 +201,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); } @@ -162,11 +223,16 @@ export const HandleMistralAIResponse = async (streamResponse, cb: (streamText: s } } -export const HandleAnthropicResponse = async (streamResponse, cb: (streamText: string) => void, done_cb?: (result: string, thrID:string) => void) => { +export const HandleAnthropicResponse = async (aiResponse: IAIStreamResponse | any, cb: (streamText: string) => void, done_cb?: (result: string, thrID:string) => void) => { + // Handle both IAIStreamResponse format and plain response for backward compatibility + const streamResponse = aiResponse?.streamResponse || aiResponse + const tool_callback = aiResponse?.callback const reader = streamResponse.body?.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; let resultText = ""; + const toolUseBlocks: Map = new Map(); + let currentBlockIndex: number = -1; if (!reader) { // normal response, not a stream cb(streamResponse.result) @@ -193,7 +259,50 @@ export const HandleAnthropicResponse = async (streamResponse, cb: (streamText: s return; } - if (json.type === "content_block_delta") { + // Handle tool use block start in Anthropic format + if (json.type === "content_block_start" && json.content_block?.type === "tool_use") { + currentBlockIndex = json.index; + toolUseBlocks.set(currentBlockIndex, { + id: json.content_block.id, + name: json.content_block.name, + input: "" + }); + console.log('Anthropic tool use started:', json.content_block) + } + + // Accumulate tool input deltas + if (json.type === "content_block_delta" && json.delta?.type === "input_json_delta") { + if (currentBlockIndex >= 0 && toolUseBlocks.has(json.index)) { + const block = toolUseBlocks.get(json.index); + block.input += json.delta.partial_json; + console.log('Anthropic tool input delta accumulated') + } + } + + // Handle tool calls when message stops for tool use + if (json.type === "message_delta" && json.delta?.stop_reason === "tool_use" && tool_callback) { + console.log('Anthropic message stopped for tool use') + + // Convert accumulated tool use blocks to tool calls format + const toolCalls = Array.from(toolUseBlocks.values()).map(block => ({ + id: block.id, + function: { + name: block.name, + arguments: block.input + } + })); + + if (toolCalls.length > 0) { + console.log('calling tools in stream:', toolCalls) + const response = await tool_callback(toolCalls) + cb("\n\n"); + HandleAnthropicResponse(response, cb, done_cb) + return; + } + } + + // Handle text content deltas + if (json.type === "content_block_delta" && json.delta?.type === "text_delta") { cb(json.delta.text); resultText += json.delta.text; } @@ -205,7 +314,10 @@ export const HandleAnthropicResponse = async (streamResponse, cb: (streamText: s } } -export const HandleOllamaResponse = async (streamResponse: any, cb: (streamText: string) => void, done_cb?: (result: string) => void, reasoning_cb?: (result: string) => void) => { +export const HandleOllamaResponse = async (aiResponse: IAIStreamResponse | any, cb: (streamText: string) => void, done_cb?: (result: string) => void, reasoning_cb?: (result: string) => void) => { + // Handle both IAIStreamResponse format and plain response for backward compatibility + const streamResponse = aiResponse?.streamResponse || aiResponse + const tool_callback = aiResponse?.callback const reader = streamResponse.body?.getReader(); const decoder = new TextDecoder("utf-8"); let resultText = ""; @@ -230,6 +342,16 @@ export const HandleOllamaResponse = async (streamResponse: any, cb: (streamText: try { const parsed = JSON.parse(line); let content = ""; + + // Handle tool calls in Ollama format + if (parsed.message?.tool_calls && tool_callback) { + console.log('calling tools in stream:', parsed.message.tool_calls) + const response = await tool_callback(parsed.message.tool_calls) + cb("\n\n"); + HandleOllamaResponse(response, cb, done_cb, reasoning_cb) + return; + } + if (parsed.message?.thinking) { reasoning_cb?.('***Thinking ...***') inThinking = true 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..04bc7671445 --- /dev/null +++ b/libs/remix-ai-core/src/inferencers/mcp/mcpInferencer.ts @@ -0,0 +1,1413 @@ +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'; +import { endpointUrls } from "@remix-endpoints-helper" + +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; + private sseEventSource?: EventSource; // For SSE transport + private wsConnection?: WebSocket; // For WebSocket transport + private httpAbortController?: AbortController; // For HTTP request cancellation + + 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') { + return await this.connectInternal(); + } else if (this.server.transport === 'http') { + return await this.connectHTTP(); + } else if (this.server.transport === 'sse') { + return await this.connectSSE(); + } else if (this.server.transport === 'websocket') { + return await this.connectWebSocket(); + } else if (this.server.transport === 'stdio') { + throw new Error(`stdio transport is not supported in browser environment. Please use http, sse, or websocket instead.`); + } else { + throw new Error(`Unknown transport type: ${this.server.transport}`); + } + + } catch (error) { + console.error(`[MCP] Failed to connect to ${this.server.name}:`, error); + this.eventEmitter.emit('error', this.server.name, error); + throw error; + } + } + + private async connectInternal(): Promise { + 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}`); + this.eventEmitter.emit('connected', this.server.name, result); + return result; + } + + private async connectHTTP(): Promise { + if (!this.server.url) { + throw new Error(`HTTP URL not specified for ${this.server.name}`); + } + + console.log(`[MCP] Connecting to HTTP MCP server at ${this.server.url}`); + this.httpAbortController = new AbortController(); + + // Send initialize request + const response = await this.sendHTTPRequest({ + jsonrpc: '2.0', + id: this.getNextRequestId(), + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: { + resources: { subscribe: true }, + sampling: {} + }, + clientInfo: { + name: 'Remix IDE', + version: '1.0.0' + } + } + }); + + if (response.error) { + throw new Error(`HTTP initialization failed: ${response.error.message}`); + } + + const result: IMCPInitializeResult = response.result; + this.connected = true; + this.capabilities = result.capabilities; + + console.log(`[MCP] Successfully connected to HTTP server ${this.server.name}`); + this.eventEmitter.emit('connected', this.server.name, result); + console.log(`[MCP] Successfully emitted event connected`); + return result; + } + + private async connectSSE(): Promise { + if (!this.server.url) { + throw new Error(`SSE URL not specified for ${this.server.name}`); + } + + console.log(`[MCP] Connecting to SSE MCP server at ${this.server.url}`); + + return new Promise((resolve, reject) => { + try { + this.sseEventSource = new EventSource(this.server.url!); + let initialized = false; + + this.sseEventSource.onmessage = (event) => { + try { + const response = JSON.parse(event.data); + + if (!initialized && response.method === 'initialize') { + const result: IMCPInitializeResult = response.result; + this.connected = true; + this.capabilities = result.capabilities; + initialized = true; + + console.log(`[MCP] Successfully connected to SSE server ${this.server.name}`); + this.eventEmitter.emit('connected', this.server.name, result); + resolve(result); + } else { + // Handle other SSE messages (resource updates, notifications, etc.) + this.handleSSEMessage(response); + } + } catch (error) { + console.error(`[MCP] Error parsing SSE message:`, error); + } + }; + + this.sseEventSource.onerror = (error) => { + console.error(`[MCP] SSE connection error:`, error); + if (!initialized) { + reject(new Error(`SSE connection failed for ${this.server.name}`)); + } + this.eventEmitter.emit('error', this.server.name, error); + }; + + // Send initialize request via POST (SSE is one-way, so we use HTTP POST for requests) + this.sendSSEInitialize().catch(reject); + + } catch (error) { + reject(error); + } + }); + } + + private async connectWebSocket(): Promise { + if (!this.server.url) { + throw new Error(`WebSocket URL not specified for ${this.server.name}`); + } + + console.log(`[MCP] Connecting to WebSocket MCP server at ${this.server.url}`); + + return new Promise((resolve, reject) => { + try { + this.wsConnection = new WebSocket(this.server.url!); + let initialized = false; + + this.wsConnection.onopen = () => { + console.log(`[MCP] WebSocket connection opened to ${this.server.name}`); + + // Send initialize message + const initMessage = { + jsonrpc: '2.0', + id: this.getNextRequestId(), + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: { + resources: { subscribe: true }, + sampling: {} + }, + clientInfo: { + name: 'Remix IDE', + version: '1.0.0' + } + } + }; + + this.wsConnection!.send(JSON.stringify(initMessage)); + }; + + this.wsConnection.onmessage = (event) => { + try { + const response = JSON.parse(event.data); + + if (!initialized && response.result) { + const result: IMCPInitializeResult = response.result; + this.connected = true; + this.capabilities = result.capabilities; + initialized = true; + + console.log(`[MCP] Successfully connected to WebSocket server ${this.server.name}`); + this.eventEmitter.emit('connected', this.server.name, result); + resolve(result); + } else { + // Handle other WebSocket messages + this.handleWebSocketMessage(response); + } + } catch (error) { + console.error(`[MCP] Error parsing WebSocket message:`, error); + } + }; + + this.wsConnection.onerror = (error) => { + console.error(`[MCP] WebSocket error:`, error); + if (!initialized) { + reject(new Error(`WebSocket connection failed for ${this.server.name}`)); + } + this.eventEmitter.emit('error', this.server.name, error); + }; + + this.wsConnection.onclose = () => { + console.log(`[MCP] WebSocket connection closed for ${this.server.name}`); + this.connected = false; + this.eventEmitter.emit('disconnected', this.server.name); + }; + + } catch (error) { + reject(error); + } + }); + } + + private async sendHTTPRequest(request: any): Promise { + const response = await fetch(endpointUrls.mcpCorsProxy + this.server.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', // Required by some MCP servers + }, + body: JSON.stringify(request), + signal: this.httpAbortController!.signal + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`[MCP] HTTP error response:`, errorText); + throw new Error(`HTTP ${response.status}: ${response.statusText} - ${errorText}`); + } + + // Check if response is SSE format (some MCP servers return SSE even for POST) + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('text/event-stream')) { + // Parse SSE response format: "event: message\ndata: {...}\n\n" + const text = await response.text(); + const dataMatch = text.match(/data: (.+)/); + if (dataMatch && dataMatch[1]) { + return JSON.parse(dataMatch[1]); + } + throw new Error('Invalid SSE response format'); + } + + return response.json(); + } + + private async sendSSEInitialize(): Promise { + // For SSE, send initialize request via HTTP POST + const initUrl = this.server.url!.replace('/sse', '/initialize'); + + // Use commonCorsProxy to bypass CORS restrictions + // The proxy expects the target URL in the 'proxy' header + console.log(`[MCP] Using CORS proxy for SSE init target: ${initUrl}`); + + await fetch(endpointUrls.mcpCorsProxy + this.server.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', // Required by some MCP servers + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: this.getNextRequestId(), + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: { + resources: { subscribe: true }, + sampling: {} + }, + clientInfo: { + name: 'Remix IDE', + version: '1.0.0' + } + } + }) + }); + } + + private handleSSEMessage(message: any): void { + console.log(`[MCP] SSE message received:`, message); + // Handle SSE notifications (resource updates, etc.) + if (message.method === 'notifications/resources/list_changed') { + this.eventEmitter.emit('resourcesChanged', this.server.name); + } else if (message.method === 'notifications/tools/list_changed') { + this.eventEmitter.emit('toolsChanged', this.server.name); + } + } + + private handleWebSocketMessage(message: any): void { + console.log(`[MCP] WebSocket message received:`, message); + // Handle WebSocket responses and notifications + if (message.method === 'notifications/resources/list_changed') { + this.eventEmitter.emit('resourcesChanged', this.server.name); + } else if (message.method === 'notifications/tools/list_changed') { + this.eventEmitter.emit('toolsChanged', this.server.name); + } + } + + 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(); + } else if (this.server.transport === 'http' && this.httpAbortController) { + this.httpAbortController.abort(); + this.httpAbortController = undefined; + } else if (this.server.transport === 'sse' && this.sseEventSource) { + this.sseEventSource.close(); + this.sseEventSource = undefined; + } else if (this.server.transport === 'websocket' && this.wsConnection) { + this.wsConnection.close(); + this.wsConnection = undefined; + } + + 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`); + } + + // Check if server supports resources capability + if (!this.capabilities?.resources) { + console.log(`[MCP] Server ${this.server.name} does not support resources capability`); + return []; + } + + console.log(`[MCP] Listing resources from ${this.server.name}...`); + + if (this.server.transport === 'internal' && this.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`); + return this.resources; + + } else if (this.server.transport === 'http') { + const response = await this.sendHTTPRequest({ + jsonrpc: '2.0', + id: this.getNextRequestId(), + 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 HTTP server`); + return this.resources; + + } else if (this.server.transport === 'websocket' && this.wsConnection) { + return new Promise((resolve, reject) => { + const requestId = this.getNextRequestId(); + + const handleMessage = (event: MessageEvent) => { + const response = JSON.parse(event.data); + if (response.id === requestId) { + this.wsConnection!.removeEventListener('message', handleMessage); + + if (response.error) { + reject(new Error(`Failed to list resources: ${response.error.message}`)); + } else { + this.resources = response.result.resources || []; + console.log(`[MCP] Found ${this.resources.length} resources from WebSocket server`); + resolve(this.resources); + } + } + }; + + this.wsConnection.addEventListener('message', handleMessage); + this.wsConnection.send(JSON.stringify({ + jsonrpc: '2.0', + id: requestId, + method: 'resources/list', + params: {} + })); + }); + + } else { + throw new Error(`SSE transport requires HTTP fallback for listing resources`); + } + } + + 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) { + 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`); + return response.result; + + } else if (this.server.transport === 'http') { + const response = await this.sendHTTPRequest({ + jsonrpc: '2.0', + id: this.getNextRequestId(), + 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 HTTP server`); + return response.result; + + } else if (this.server.transport === 'websocket' && this.wsConnection) { + return new Promise((resolve, reject) => { + const requestId = this.getNextRequestId(); + + const handleMessage = (event: MessageEvent) => { + const response = JSON.parse(event.data); + if (response.id === requestId) { + this.wsConnection!.removeEventListener('message', handleMessage); + + if (response.error) { + reject(new Error(`Failed to read resource: ${response.error.message}`)); + } else { + console.log(`[MCP] Resource read successfully from WebSocket server`); + resolve(response.result); + } + } + }; + + this.wsConnection.addEventListener('message', handleMessage); + this.wsConnection.send(JSON.stringify({ + jsonrpc: '2.0', + id: requestId, + method: 'resources/read', + params: { uri } + })); + }); + + } else { + throw new Error(`SSE transport requires HTTP fallback for reading resources`); + } + } + + 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`); + } + + // Check if server supports tools capability + if (!this.capabilities?.tools) { + console.log(`[MCP] Server ${this.server.name} does not support tools capability`); + return []; + } + + console.log(`[MCP] Listing tools from ${this.server.name}...`); + + if (this.server.transport === 'internal' && this.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 ${this.server.name}`); + return this.tools; + + } else if (this.server.transport === 'http') { + const response = await this.sendHTTPRequest({ + jsonrpc: '2.0', + id: this.getNextRequestId(), + 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 ${this.server.name}`); + return this.tools; + + } else if (this.server.transport === 'websocket' && this.wsConnection) { + return new Promise((resolve, reject) => { + const requestId = this.getNextRequestId(); + + const handleMessage = (event: MessageEvent) => { + const response = JSON.parse(event.data); + if (response.id === requestId) { + this.wsConnection!.removeEventListener('message', handleMessage); + + if (response.error) { + reject(new Error(`Failed to list tools: ${response.error.message}`)); + } else { + this.tools = response.result.tools || []; + console.log(`[MCP] Found ${this.tools.length} tools from WebSocket server`); + resolve(this.tools); + } + } + }; + + this.wsConnection.addEventListener('message', handleMessage); + this.wsConnection.send(JSON.stringify({ + jsonrpc: '2.0', + id: requestId, + method: 'tools/list', + params: {} + })); + }); + + } else { + throw new Error(`SSE transport requires HTTP fallback for listing tools`); + } + } + + 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) { + 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 if (this.server.transport === 'http') { + const response = await this.sendHTTPRequest({ + jsonrpc: '2.0', + id: this.getNextRequestId(), + 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 HTTP server`); + return response.result; + + } else if (this.server.transport === 'websocket' && this.wsConnection) { + return new Promise((resolve, reject) => { + const requestId = this.getNextRequestId(); + + const handleMessage = (event: MessageEvent) => { + const response = JSON.parse(event.data); + if (response.id === requestId) { + this.wsConnection!.removeEventListener('message', handleMessage); + + if (response.error) { + reject(new Error(`Failed to call tool: ${response.error.message}`)); + } else { + console.log(`[MCP] Tool ${toolCall.name} executed successfully on WebSocket server`); + resolve(response.result); + } + } + }; + + this.wsConnection.addEventListener('message', handleMessage); + this.wsConnection.send(JSON.stringify({ + jsonrpc: '2.0', + id: requestId, + method: 'tools/call', + params: toolCall + })); + }); + + } else { + throw new Error(`SSE transport requires HTTP fallback for calling tools`); + } + } + + 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); + } + + 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; + } + + getCapabilities(): any { + return this.capabilities; + } + + 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 + private MAX_TOOL_EXECUTIONS = 10; + + 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 + }); + + // Set up event listeners for the new client + 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); + }); + + 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(options.provider); + 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) + + // Track number of tool execution iterations + let toolExecutionCount = 0; + + const toolExecutionCallback = async (tool_calls) => { + console.log('calling tool execution callback') + + // avoid circular tooling + if (toolExecutionCount >= this.MAX_TOOL_EXECUTIONS) { + console.log(`[MCP Inferencer] Maximum tool execution limit (${this.MAX_TOOL_EXECUTIONS}) reached. Stopping further executions.`); + return { streamResponse: await super.answer(enrichedPrompt, options) }; + return; + } + + toolExecutionCount++; + console.log(`[MCP Inferencer] Tool execution iteration ${toolExecutionCount}/${this.MAX_TOOL_EXECUTIONS}`); + + // 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); + + if (options.provider === 'openai'){ + toolResults.push( { + "role": "assistant", + "tool_calls": [llmToolCall] + }) + } + + const toolResult: any = { + role: options.provider === 'anthropic' ? 'user' : 'tool', + content: result.content[0]?.text || JSON.stringify(result) + }; + + if (options.provider !== 'anthropic') { + toolResult.tool_call_id = llmToolCall.id; + } + + toolResults.push(toolResult); + } catch (error) { + console.error(`[MCP Inferencer] Tool execution failed:`, error); + + const errorResult: any = { + content: `Error: ${error.message}` + }; + + if (options.provider !== 'anthropic') { + errorResult.tool_call_id = llmToolCall.id; + } + + toolResults.push(errorResult); + } + } + + if (toolResults.length > 0) { + if (options.provider === 'mistralai'){ + toolResults.unshift({ role:'assistant', tool_calls: tool_calls }) + toolResults.unshift({ role:'user', content:enrichedPrompt }) + } else { + 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.provider); + 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); + + 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 { + const mcpToolCall = this.convertLLMToolCallToMCP(llmToolCall); + const result = await this.executeToolForLLM(mcpToolCall); + + const toolResult: any = { + content: result.content[0]?.text || JSON.stringify(result) + }; + + if (options.provider !== 'anthropic') { + toolResult.tool_call_id = llmToolCall.id; + } + + toolResults.push(toolResult); + } catch (error) { + console.error(`[MCP Inferencer] Tool execution failed:`, error); + const errorResult: any = { + content: `Error: ${error.message}` + }; + + if (options.provider !== 'anthropic') { + errorResult.tool_call_id = llmToolCall.id; + } + + toolResults.push(errorResult); + } + } + + // 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(provider?: string): Promise { + const mcpTools = await this.getAvailableToolsForLLM(); + + // Format tools based on provider + let convertedTools: any[]; + + if (provider === 'anthropic') { + // Anthropic format: direct object with name, description, input_schema + convertedTools = mcpTools.map(tool => ({ + name: tool.name, + description: tool.description, + input_schema: tool.inputSchema + })); + } else { + // OpenAI and other providers format: type + function wrapper + 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 ${provider || 'default'} LLM format`); + return convertedTools; + } + + convertLLMToolCallToMCP(llmToolCall: any): IMCPToolCall { + let parsedArguments = llmToolCall.function.arguments; + + if (typeof parsedArguments === 'string') { + const trimmed = parsedArguments.trim(); + if (trimmed === '' || trimmed === '{}') { + parsedArguments = {}; + } else { + try { + parsedArguments = JSON.parse(trimmed); + } catch (error) { + console.log(`[MCP Inferencer] Failed to parse tool arguments, using empty object:`, error); + parsedArguments = {}; + } + } + } + + return { + name: llmToolCall.function.name, + arguments: parsedArguments || {} + }; + } + + /** + * 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..2becc6c0b50 --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/RemixMCPServer.ts @@ -0,0 +1,561 @@ +/* eslint-disable no-case-declarations */ +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 { + 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 { + return await this.plugin.call('filePanel', 'getCurrentWorkspace') + } catch (error) { + return 'default'; + } + } + + /** + * Get current file + */ + private async getCurrentFile(): Promise { + try { + return await this.plugin.call('fileManager', 'getCurrentFile'); + } catch (error) { + return 'None'; + } + } + +} \ 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..882065271ff --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/handlers/CompilationHandler.ts @@ -0,0 +1,524 @@ +/** + * 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); + const 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 { + return this.createErrorResult(`Compilation failed: Workspace compilation not yet implemented. The argument file is not provided`); + } + 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 + ) + + 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(`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..dbb65989bc9 --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/handlers/DeploymentHandler.ts @@ -0,0 +1,887 @@ +/** + * 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((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 + }, + (msg) => { + // logCallback + }, + (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..ca1bc07d413 --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/handlers/FileManagementHandler.ts @@ -0,0 +1,617 @@ +/** + * 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 { + const exists = await plugin.call('fileManager', 'exists', args.path) + try { + if (!exists) {await plugin.call('fileManager', 'writeFile', args.path, "")} + + await plugin.call('fileManager', 'open', args.path) + } catch (openError) { + console.warn(`Failed to open file in editor: ${openError.message}`); + } + + await new Promise(resolve => setTimeout(resolve, 1000)) + await plugin.call('editor', 'showCustomDiff', args.path, args.content) + //await plugin.call('fileManager', 'writeFile', args.path, args.content); + + 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, ''); + await plugin.call('fileManager', 'open', args.path) + await new Promise(resolve => setTimeout(resolve, 1000)) + await plugin.call('editor', 'showCustomDiff', 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..512fd12d44f --- /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..05beb8cade7 --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/middleware/SecurityMiddleware.ts @@ -0,0 +1,472 @@ +/** + * 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, + /