From c28964023f4061663c097dab0831c721705c1ea0 Mon Sep 17 00:00:00 2001 From: pipper Date: Mon, 17 Nov 2025 17:21:09 +0100 Subject: [PATCH 1/4] basic mcp middleware assertion --- .../src/inferencers/mcp/index.ts | 14 + .../src/inferencers/mcp/mcpClient.ts | 715 ++++++++++++++++++ .../src/inferencers/mcp/mcpInferencer.ts | 690 +---------------- .../src/remix-mcp-server/RemixMCPServer.ts | 165 +++- .../config/MCPConfigManager.ts | 215 ++++++ .../src/remix-mcp-server/index.ts | 36 +- .../middleware/SecurityMiddleware.ts | 182 ++++- .../middleware/ValidationMiddleware.ts | 203 +++-- .../src/remix-mcp-server/types/mcpConfig.ts | 175 +++++ 9 files changed, 1579 insertions(+), 816 deletions(-) create mode 100644 libs/remix-ai-core/src/inferencers/mcp/index.ts create mode 100644 libs/remix-ai-core/src/inferencers/mcp/mcpClient.ts create mode 100644 libs/remix-ai-core/src/remix-mcp-server/config/MCPConfigManager.ts create mode 100644 libs/remix-ai-core/src/remix-mcp-server/types/mcpConfig.ts diff --git a/libs/remix-ai-core/src/inferencers/mcp/index.ts b/libs/remix-ai-core/src/inferencers/mcp/index.ts new file mode 100644 index 00000000000..199642764a6 --- /dev/null +++ b/libs/remix-ai-core/src/inferencers/mcp/index.ts @@ -0,0 +1,14 @@ +export { MCPClient } from './mcpClient'; +export { MCPInferencer } from './mcpInferencer'; + +export type { + IMCPServer, + IMCPResource, + IMCPResourceContent, + IMCPTool, + IMCPToolCall, + IMCPToolResult, + IMCPConnectionStatus, + IMCPInitializeResult, + IEnhancedMCPProviderParams, +} from '../../types/mcp'; diff --git a/libs/remix-ai-core/src/inferencers/mcp/mcpClient.ts b/libs/remix-ai-core/src/inferencers/mcp/mcpClient.ts new file mode 100644 index 00000000000..491c8d467f5 --- /dev/null +++ b/libs/remix-ai-core/src/inferencers/mcp/mcpClient.ts @@ -0,0 +1,715 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import EventEmitter from "events"; +import { + IMCPServer, + IMCPResource, + IMCPResourceContent, + IMCPTool, + IMCPToolCall, + IMCPToolResult, + IMCPInitializeResult, +} from "../../types/mcp"; +import { RemixMCPServer } from '@remix/remix-ai-core'; +import { endpointUrls } from "@remix-endpoints-helper"; + +// Helper function to track events using MatomoManager instance +function trackMatomoEvent(category: string, action: string, name?: string) { + try { + if (typeof window !== 'undefined' && (window as any)._matomoManagerInstance) { + const matomoInstance = (window as any)._matomoManagerInstance; + if (typeof matomoInstance.trackEvent === 'function') { + matomoInstance.trackEvent(category, action, name); + } + } + } catch (error) { + // Silent fail for tracking + console.debug('Matomo tracking failed:', error); + } +} + +/** + * MCPClient - Client for connecting to and interacting with MCP servers + * Supports multiple transport types: internal, http, sse, websocket + */ +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 + private resourceListCache?: { resources: IMCPResource[], timestamp: number }; // Cache for HTTP servers + private toolListCache?: { tools: IMCPTool[], timestamp: number }; // Cache for HTTP servers + private readonly CACHE_TTL = 120000; // 120 seconds cache TTL + + constructor(server: IMCPServer, remixMCPServer?: any) { + this.server = server; + this.eventEmitter = new EventEmitter(); + this.remixMCPServer = remixMCPServer; + } + + async connect(): Promise { + try { + this.eventEmitter.emit('connecting', this.server.name); + trackMatomoEvent('ai', 'mcp_connect_attempt', `${this.server.name}|${this.server.transport}`); + + 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) { + this.eventEmitter.emit('error', this.server.name, error); + trackMatomoEvent('ai', 'mcp_connect_failed', `${this.server.name}|${error.message}`); + throw error; + } + } + + private async connectInternal(): Promise { + if (!this.remixMCPServer) { + throw new Error(`Internal RemixMCPServer not available for ${this.server.name}`); + } + + const result = await this.remixMCPServer.initialize(); + this.connected = true; + this.capabilities = result.capabilities; + this.eventEmitter.emit('connected', this.server.name, result); + trackMatomoEvent('ai', 'mcp_connect_success', `${this.server.name}|internal`); + return result; + } + + private async connectHTTP(): Promise { + if (!this.server.url) { + throw new Error(`HTTP URL not specified for ${this.server.name}`); + } + + 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; + + this.eventEmitter.emit('connected', this.server.name, result); + trackMatomoEvent('ai', 'mcp_connect_success', `${this.server.name}|http`); + return result; + } + + private async connectSSE(): Promise { + if (!this.server.url) { + throw new Error(`SSE URL not specified for ${this.server.name}`); + } + + 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; + + 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) => { + 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}`); + } + + 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; + + 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) => { + if (!initialized) { + reject(new Error(`WebSocket connection failed for ${this.server.name}`)); + } + this.eventEmitter.emit('error', this.server.name, error); + }; + + this.wsConnection.onclose = () => { + this.connected = false; + this.eventEmitter.emit('disconnected', this.server.name); + }; + + } catch (error) { + reject(error); + } + }); + } + + private async sendHTTPRequest(request: any): Promise { + const contractType = new URL(this.server.url).pathname.split('/')[2] + const response = await fetch(endpointUrls.mcpCorsProxy + '/' + contractType, { + 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(); + 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 + 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 { + // Handle SSE notifications (resource updates, etc.) + if (message.method === 'notifications/resources/list_changed') { + this.resourceListCache = undefined; + this.eventEmitter.emit('resourcesChanged', this.server.name); + } else if (message.method === 'notifications/tools/list_changed') { + this.toolListCache = undefined; + this.eventEmitter.emit('toolsChanged', this.server.name); + } + } + + private handleWebSocketMessage(message: any): void { + // Handle WebSocket responses and notifications + if (message.method === 'notifications/resources/list_changed') { + this.resourceListCache = undefined; + this.eventEmitter.emit('resourcesChanged', this.server.name); + } else if (message.method === 'notifications/tools/list_changed') { + this.toolListCache = undefined; + this.eventEmitter.emit('toolsChanged', this.server.name); + } + } + + async disconnect(): Promise { + if (this.connected) { + // 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.resourceListCache = undefined; // Clear cache on disconnect + this.toolListCache = undefined; // Clear cache on disconnect + this.eventEmitter.emit('disconnected', this.server.name); + } + } + + async listResources(): Promise { + if (!this.connected) { + throw new Error(`MCP server ${this.server.name} is not connected`); + } + + // Check if server supports resources capability + if (!this.capabilities?.resources) { + return []; + } + + 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 || []; + return this.resources; + + } else if (this.server.transport === 'http') { + // Check cache for HTTP servers + const now = Date.now(); + if (this.resourceListCache && (now - this.resourceListCache.timestamp) < this.CACHE_TTL) { + console.log(`[MCP] Using cached resource list for ${this.server.name}`); + return this.resourceListCache.resources; + } + + // Cache miss or expired, fetch from server + 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 || []; + + // Update cache + this.resourceListCache = { + resources: this.resources, + timestamp: now + }; + + 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 || []; + 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) { + throw new Error(`MCP server ${this.server.name} is not connected`); + } + + trackMatomoEvent('ai', 'mcp_resource_read', `${this.server.name}|${uri}`); + + 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}`); + } + + 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}`); + } + + 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 { + 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) { + throw new Error(`MCP server ${this.server.name} is not connected`); + } + + // Check if server supports tools capability + if (!this.capabilities?.tools) { + return []; + } + + 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 || []; + return this.tools; + + } else if (this.server.transport === 'http') { + // Check cache for HTTP servers + const now = Date.now(); + if (this.toolListCache && (now - this.toolListCache.timestamp) < this.CACHE_TTL) { + return this.toolListCache.tools; + } + + // Cache miss or expired, fetch from server + 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 || []; + + // Update cache + this.toolListCache = { + tools: this.tools, + timestamp: now + }; + + 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 || []; + 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) { + throw new Error(`MCP server ${this.server.name} is not connected`); + } + + trackMatomoEvent('ai', 'mcp_tool_call', `${this.server.name}|${toolCall.name}`); + + 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) { + trackMatomoEvent('ai', 'mcp_tool_call_failed', `${this.server.name}|${toolCall.name}|${response.error.message}`); + throw new Error(`Failed to call tool: ${response.error.message}`); + } + trackMatomoEvent('ai', 'mcp_tool_call_success', `${this.server.name}|${toolCall.name}`); + 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}`); + } + + 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 { + 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; + } + + clearResourceListCache(): void { + this.resourceListCache = undefined; + } + + clearToolListCache(): void { + this.toolListCache = undefined; + } + + clearAllCaches(): void { + this.resourceListCache = undefined; + this.toolListCache = undefined; + } + + private getNextRequestId(): number { + return this.requestId++; + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/libs/remix-ai-core/src/inferencers/mcp/mcpInferencer.ts b/libs/remix-ai-core/src/inferencers/mcp/mcpInferencer.ts index dafd2ecd4c2..a6da3672c04 100644 --- a/libs/remix-ai-core/src/inferencers/mcp/mcpInferencer.ts +++ b/libs/remix-ai-core/src/inferencers/mcp/mcpInferencer.ts @@ -2,7 +2,6 @@ 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, @@ -16,9 +15,7 @@ import { } 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" -import { text } from "stream/consumers"; +import { MCPClient } from './mcpClient'; // Helper function to track events using MatomoManager instance function trackMatomoEvent(category: string, action: string, name?: string) { @@ -35,689 +32,6 @@ function trackMatomoEvent(category: string, action: string, name?: string) { } } -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 - private resourceListCache?: { resources: IMCPResource[], timestamp: number }; // Cache for HTTP servers - private toolListCache?: { tools: IMCPTool[], timestamp: number }; // Cache for HTTP servers - private readonly CACHE_TTL = 120000; // 120 seconds cache TTL - - constructor(server: IMCPServer, remixMCPServer?: any) { - this.server = server; - this.eventEmitter = new EventEmitter(); - this.remixMCPServer = remixMCPServer; - } - - async connect(): Promise { - try { - this.eventEmitter.emit('connecting', this.server.name); - trackMatomoEvent('ai', 'mcp_connect_attempt', `${this.server.name}|${this.server.transport}`); - - 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) { - this.eventEmitter.emit('error', this.server.name, error); - trackMatomoEvent('ai', 'mcp_connect_failed', `${this.server.name}|${error.message}`); - throw error; - } - } - - private async connectInternal(): Promise { - if (!this.remixMCPServer) { - throw new Error(`Internal RemixMCPServer not available for ${this.server.name}`); - } - - const result = await this.remixMCPServer.initialize(); - this.connected = true; - this.capabilities = result.capabilities; - this.eventEmitter.emit('connected', this.server.name, result); - trackMatomoEvent('ai', 'mcp_connect_success', `${this.server.name}|internal`); - return result; - } - - private async connectHTTP(): Promise { - if (!this.server.url) { - throw new Error(`HTTP URL not specified for ${this.server.name}`); - } - - 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; - - this.eventEmitter.emit('connected', this.server.name, result); - trackMatomoEvent('ai', 'mcp_connect_success', `${this.server.name}|http`); - return result; - } - - private async connectSSE(): Promise { - if (!this.server.url) { - throw new Error(`SSE URL not specified for ${this.server.name}`); - } - - 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; - - 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) => { - 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}`); - } - - 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; - - 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) => { - if (!initialized) { - reject(new Error(`WebSocket connection failed for ${this.server.name}`)); - } - this.eventEmitter.emit('error', this.server.name, error); - }; - - this.wsConnection.onclose = () => { - this.connected = false; - this.eventEmitter.emit('disconnected', this.server.name); - }; - - } catch (error) { - reject(error); - } - }); - } - - private async sendHTTPRequest(request: any): Promise { - const contractType = new URL(this.server.url).pathname.split('/')[2] - const response = await fetch(endpointUrls.mcpCorsProxy + '/' + contractType, { - 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(); - 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 - 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 { - // Handle SSE notifications (resource updates, etc.) - if (message.method === 'notifications/resources/list_changed') { - this.resourceListCache = undefined; - this.eventEmitter.emit('resourcesChanged', this.server.name); - } else if (message.method === 'notifications/tools/list_changed') { - this.toolListCache = undefined; - this.eventEmitter.emit('toolsChanged', this.server.name); - } - } - - private handleWebSocketMessage(message: any): void { - // Handle WebSocket responses and notifications - if (message.method === 'notifications/resources/list_changed') { - this.resourceListCache = undefined; - this.eventEmitter.emit('resourcesChanged', this.server.name); - } else if (message.method === 'notifications/tools/list_changed') { - this.toolListCache = undefined; - this.eventEmitter.emit('toolsChanged', this.server.name); - } - } - - async disconnect(): Promise { - if (this.connected) { - // 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.resourceListCache = undefined; // Clear cache on disconnect - this.toolListCache = undefined; // Clear cache on disconnect - this.eventEmitter.emit('disconnected', this.server.name); - } - } - - async listResources(): Promise { - if (!this.connected) { - throw new Error(`MCP server ${this.server.name} is not connected`); - } - - // Check if server supports resources capability - if (!this.capabilities?.resources) { - return []; - } - - 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 || []; - return this.resources; - - } else if (this.server.transport === 'http') { - // Check cache for HTTP servers - const now = Date.now(); - if (this.resourceListCache && (now - this.resourceListCache.timestamp) < this.CACHE_TTL) { - console.log(`[MCP] Using cached resource list for ${this.server.name}`); - return this.resourceListCache.resources; - } - - // Cache miss or expired, fetch from server - 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 || []; - - // Update cache - this.resourceListCache = { - resources: this.resources, - timestamp: now - }; - - 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 || []; - 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) { - throw new Error(`MCP server ${this.server.name} is not connected`); - } - - trackMatomoEvent('ai', 'mcp_resource_read', `${this.server.name}|${uri}`); - - 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}`); - } - - 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}`); - } - - 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 { - 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) { - throw new Error(`MCP server ${this.server.name} is not connected`); - } - - // Check if server supports tools capability - if (!this.capabilities?.tools) { - return []; - } - - 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 || []; - return this.tools; - - } else if (this.server.transport === 'http') { - // Check cache for HTTP servers - const now = Date.now(); - if (this.toolListCache && (now - this.toolListCache.timestamp) < this.CACHE_TTL) { - return this.toolListCache.tools; - } - - // Cache miss or expired, fetch from server - 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 || []; - - // Update cache - this.toolListCache = { - tools: this.tools, - timestamp: now - }; - - 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 || []; - 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) { - throw new Error(`MCP server ${this.server.name} is not connected`); - } - - trackMatomoEvent('ai', 'mcp_tool_call', `${this.server.name}|${toolCall.name}`); - - 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) { - trackMatomoEvent('ai', 'mcp_tool_call_failed', `${this.server.name}|${toolCall.name}|${response.error.message}`); - throw new Error(`Failed to call tool: ${response.error.message}`); - } - trackMatomoEvent('ai', 'mcp_tool_call_success', `${this.server.name}|${toolCall.name}`); - 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}`); - } - - 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 { - 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; - } - - clearResourceListCache(): void { - this.resourceListCache = undefined; - } - - clearToolListCache(): void { - this.toolListCache = undefined; - } - - clearAllCaches(): void { - this.resourceListCache = undefined; - this.toolListCache = undefined; - } - - 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 @@ -1427,7 +741,7 @@ export class MCPInferencer extends RemoteInferencer implements ICompletions, IGe if (!targetServer) { throw new Error(`Tool '${toolCall.name}' not found in any connected MCP server`); } - console.log(`executing tool ${toolCall.name} from server ${targetServer}`) + console.log(`[MCPInferencer] Executing tool ${toolCall.name} from server ${targetServer}`) return this.executeTool(targetServer, toolCall); } diff --git a/libs/remix-ai-core/src/remix-mcp-server/RemixMCPServer.ts b/libs/remix-ai-core/src/remix-mcp-server/RemixMCPServer.ts index 87898448fe8..8246cbc505f 100644 --- a/libs/remix-ai-core/src/remix-mcp-server/RemixMCPServer.ts +++ b/libs/remix-ai-core/src/remix-mcp-server/RemixMCPServer.ts @@ -39,6 +39,11 @@ import { CompilationResourceProvider } from './providers/CompilationResourceProv import { DeploymentResourceProvider } from './providers/DeploymentResourceProvider'; import { TutorialsResourceProvider } from './providers/TutorialsResourceProvider'; +// Import middleware +import { SecurityMiddleware } from './middleware/SecurityMiddleware'; +import { ValidationMiddleware } from './middleware/ValidationMiddleware'; +import { MCPConfigManager } from './config/MCPConfigManager'; + /** * Main Remix MCP Server implementation */ @@ -53,6 +58,9 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { private _resourceCache: Map = new Map(); private _auditLog: AuditLogEntry[] = []; private _startTime: Date = new Date(); + private _securityMiddleware: SecurityMiddleware; + private _validationMiddleware: ValidationMiddleware; + private _configManager: MCPConfigManager; constructor(plugin, config: RemixMCPServerConfig) { super(); @@ -71,6 +79,19 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { lastActivity: new Date() }; + // Initialize config manager + this._configManager = new MCPConfigManager(this._plugin); + + // Initialize middleware with tool registry (will be updated after config is loaded) + this._securityMiddleware = new SecurityMiddleware( + this._tools as RemixToolRegistry, + this._configManager + ); + this._validationMiddleware = new ValidationMiddleware( + this._plugin, + this._configManager + ); + this.setupEventHandlers(); } @@ -97,7 +118,11 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { } get plugin(): any{ - return this.plugin + return this._plugin + } + + get configManager(): MCPConfigManager { + return this._configManager } /** @@ -107,8 +132,26 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { try { this.setState(ServerState.STARTING); - await this.initializeDefaultTools(); + // Load configuration from workspace .mcp.config.json + try { + await this._configManager.loadConfig(); + console.log('[RemixMCPServer] MCP configuration loaded and connected to middlewares'); + console.log('[RemixMCPServer] Configuration summary:', this._configManager.getConfigSummary()); + + // Verify middleware connection + const securityConfig = this._configManager.getSecurityConfig(); + const validationConfig = this._configManager.getValidationConfig(); + console.log('[RemixMCPServer] Middlewares connected:'); + console.log(` - SecurityMiddleware: using ${securityConfig.excludeTools?.length || 0} excluded tools, ${securityConfig.allowTools?.length || 0} allowed tools`); + console.log(` - ValidationMiddleware: strictMode=${validationConfig.strictMode}, ${Object.keys(validationConfig.toolValidation || {}).length} tool-specific rules`); + + // Start polling for config file changes (every 5 seconds) + this._configManager.startPolling(5000); + } catch (error) { + console.log(`[RemixMCPServer] Failed to load MCP config: ${error.message}, using defaults`); + } + await this.initializeDefaultTools(); await this.initializeDefaultResourceProviders(); this.setupCleanupIntervals(); @@ -120,7 +163,7 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { 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}` + instructions: `Remix IDE MCP Server initialized. Available tools: ${this._tools.list().length}, Resource providers: ${this._resources.list().length}. Configuration loaded from workspace.` }; this.setState(ServerState.RUNNING); @@ -146,6 +189,9 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { async stop(): Promise { this.setState(ServerState.STOPPING); + // Stop config polling + this._configManager.stopPolling(); + // Cancel active tool executions for (const [id, execution] of this._activeExecutions) { execution.status = 'failed'; @@ -253,7 +299,7 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { } /** - * Execute a tool + * Execute a tool with security and validation middleware */ private async executeTool(call: IMCPToolCall): Promise { const executionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; @@ -266,7 +312,7 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { status: 'running', context: { workspace: await this.getCurrentWorkspace(), - user: 'default', // TODO: Get actual user + user: 'remixIDE', // TODO: Get actual user permissions: ["*"] // TODO: Get actual permissions } }; @@ -275,12 +321,53 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { 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}`); + const context = { + workspace: execution.context.workspace, + currentFile: await this.getCurrentFile(), + permissions: execution.context.permissions, + timestamp: Date.now(), + requestId: executionId + }; + + // STEP 1: Security Validation (uses MCPConfigManager for dynamic config) + console.log(`[RemixMCPServer] Step 1: Security validation for tool '${call.name}' (using MCPConfigManager)`); + const securityResult = await this._securityMiddleware.validateToolCall(call, context, this._plugin); + + if (!securityResult.allowed) { + console.log(`[RemixMCPServer] Security validation FAILED for tool '${call.name}': ${securityResult.reason}`); + throw new Error(`Security validation failed: ${securityResult.reason}`); + } + console.log(`[RemixMCPServer] Security validation PASSED for tool '${call.name}'`); + + // STEP 2: Input Validation (uses MCPConfigManager for dynamic config) + console.log(`[RemixMCPServer] Step 2: Input validation for tool '${call.name}' (using MCPConfigManager)`); + const toolDefinition = this._tools.get(call.name); + const inputSchema = toolDefinition?.inputSchema; + + const validationResult = await this._validationMiddleware.validateToolCall( + call, + inputSchema, + context, + this._plugin + ); + + if (!validationResult.valid) { + const errorMessages = validationResult.errors.map(e => e.message).join(', '); + console.log(`[RemixMCPServer] Input validation FAILED for tool '${call.name}': ${errorMessages}`); + throw new Error(`Input validation failed: ${errorMessages}`); + } + + // Log warnings if any + if (validationResult.warnings.length > 0) { + const warnings = validationResult.warnings.map(w => w.message).join(', '); + console.log(`[RemixMCPServer] Input validation warnings for tool '${call.name}': ${warnings}`); + } else { + console.log(`[RemixMCPServer] Input validation PASSED for tool '${call.name}'`); } + // STEP 3: Tool Execution + console.log(`[RemixMCPServer] Step 3: Executing tool '${call.name}'`); + // Set timeout const timeout = this._config.toolTimeout || 30000; const timeoutPromise = new Promise((_, reject) => { @@ -288,14 +375,7 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { }); // 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 toolPromise = this._tools.execute(call, context, this._plugin); const result = await Promise.race([toolPromise, timeoutPromise]); // Update execution status @@ -303,6 +383,7 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { execution.endTime = new Date(); this._stats.totalToolCalls++; + console.log(`[RemixMCPServer] Tool '${call.name}' executed successfully`); this.emit('tool-executed', execution); return result; @@ -312,8 +393,12 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { execution.endTime = new Date(); this._stats.errorCount++; + console.log(`[RemixMCPServer] Tool '${call.name}' execution FAILED: ${error.message}`); this.emit('tool-executed', execution); - throw error; + return { + isError:true, + content: [{ type: 'text', text:error.message }] + } } finally { this._activeExecutions.delete(executionId); } @@ -401,6 +486,39 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { } } + /** + * Reload MCP configuration from workspace + */ + async reloadConfig(): Promise { + try { + console.log('[RemixMCPServer] Reloading MCP configuration...'); + const mcpConfig = await this._configManager.reloadConfig(); + console.log('[RemixMCPServer] Configuration reloaded successfully'); + console.log('[RemixMCPServer] Configuration summary:', this._configManager.getConfigSummary()); + this.emit('config-reloaded', mcpConfig); + } catch (error) { + console.log(`[RemixMCPServer] Failed to reload config: ${error.message}`); + throw error; + } + } + + getMCPConfig() { + return this._configManager.getConfig(); + } + + updateMCPConfig(partialConfig: Partial): void { + this._configManager.updateConfig(partialConfig); + console.log('[RemixMCPServer] Configuration updated at runtime'); + this.emit('config-updated', this._configManager.getConfig()); + } + + /** + * Check if config polling is active + */ + isConfigPollingActive(): boolean { + return this._configManager.isPolling(); + } + /** * Set server state */ @@ -496,9 +614,6 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { } } - /** - * Setup cleanup intervals - */ private setupCleanupIntervals(): void { setInterval(() => { const now = Date.now(); @@ -516,20 +631,14 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { }, 300000); } - /** - * Get current workspace - */ private async getCurrentWorkspace(): Promise { try { return await this.plugin.call('filePanel', 'getCurrentWorkspace') } catch (error) { - return 'default'; + return ''; } } - /** - * Get current file - */ private async getCurrentFile(): Promise { try { return await this.plugin.call('fileManager', 'getCurrentFile'); diff --git a/libs/remix-ai-core/src/remix-mcp-server/config/MCPConfigManager.ts b/libs/remix-ai-core/src/remix-mcp-server/config/MCPConfigManager.ts new file mode 100644 index 00000000000..56b3bd46def --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/config/MCPConfigManager.ts @@ -0,0 +1,215 @@ +/** + * MCP Configuration Manager + * Loads and manages .mcp.config.json configuration + */ + +import { Plugin } from '@remixproject/engine'; +import { MCPConfig, defaultMCPConfig, minimalMCPConfig } from '../types/mcpConfig'; + +export class MCPConfigManager { + private config: MCPConfig; + private plugin: Plugin; + private configPath: string = 'artifacts/.mcp.config.json'; + private pollingInterval?: NodeJS.Timeout; + + constructor(plugin: Plugin) { + this.plugin = plugin; + this.config = defaultMCPConfig; + } + + async loadConfig(): Promise { + try { + + const exists = await this.plugin.call('fileManager', 'exists', this.configPath); + + if (exists) { + const configContent = await this.plugin.call('fileManager', 'readFile', this.configPath); + const userConfig = JSON.parse(configContent); + // Merge with defaults + this.config = this.mergeConfig(defaultMCPConfig, userConfig); + } else { + this.config = minimalMCPConfig; + // Create default config file + await this.plugin.call('fileManager', 'writeFile', this.configPath, JSON.stringify(this.config, null, 2)); + } + + return this.config; + } catch (error) { + this.config = defaultMCPConfig; + return this.config; + } + } + + async saveConfig(config: MCPConfig): Promise { + try { + const configContent = JSON.stringify(config, null, 2); + + await this.plugin.call('fileManager', 'writeFile', this.configPath, configContent); + this.config = config; + + console.log(`[MCPConfigManager] Config saved to: ${this.configPath}`); + } catch (error) { + console.error(`[MCPConfigManager] Error saving config: ${error.message}`); + throw error; + } + } + + async createDefaultConfig(): Promise { + try { + + const exists = await this.plugin.call('fileManager', 'exists', this.configPath); + if (exists) { + console.log('[MCPConfigManager] Config file already exists, skipping creation'); + return; + } + + await this.saveConfig(defaultMCPConfig); + console.log('[MCPConfigManager] Default config file created'); + } catch (error) { + console.error(`[MCPConfigManager] Error creating default config: ${error.message}`); + throw error; + } + } + + getConfig(): MCPConfig { + return this.config; + } + + getSecurityConfig() { + return this.config.security; + } + + getValidationConfig() { + return this.config.validation; + } + + getResourceConfig() { + return this.config.resources; + } + + updateConfig(partialConfig: Partial): void { + this.config = this.mergeConfig(this.config, partialConfig); + console.log('[MCPConfigManager] Config updated at runtime'); + } + + isToolAllowed(toolName: string): boolean { + const { excludeTools, allowTools } = this.config.security; + + if (excludeTools && excludeTools.includes(toolName)) { + return false; + } + + if (allowTools && allowTools.length > 0) { + return allowTools.includes(toolName); + } + + return true; + } + + isPathAllowed(path: string): boolean { + const { blockedPaths, allowedPaths } = this.config.security; + + if (blockedPaths) { + for (const blocked of blockedPaths) { + if (path.includes(blocked)) { + return false; + } + } + } + + // If allowedPaths is set, only allow paths matching patterns + if (allowedPaths && allowedPaths.length > 0) { + let allowed = false; + for (const allowedPattern of allowedPaths) { + if (path.includes(allowedPattern) || this.matchPattern(path, allowedPattern)) { + allowed = true; + break; + } + } + return allowed; + } + + // Otherwise, allow by default + return true; + } + + private matchPattern(str: string, pattern: string): boolean { + // Convert glob pattern to regex + const regexPattern = pattern + .replace(/\./g, '\\.') + .replace(/\*/g, '.*') + .replace(/\?/g, '.'); + + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(str); + } + + private mergeConfig(base: any, override: any): any { + const result = { ...base }; + + for (const key in override) { + if (override[key] !== undefined) { + if (typeof override[key] === 'object' && !Array.isArray(override[key]) && override[key] !== null) { + result[key] = this.mergeConfig(base[key] || {}, override[key]); + } else { + result[key] = override[key]; + } + } + } + + return result; + } + + async reloadConfig(): Promise { + return this.loadConfig(); + } + + /** + * Get configuration summary for logging + */ + getConfigSummary(): string { + const config = this.getConfig(); + return JSON.stringify({ + version: config.version, + security: { + excludeTools: config.security.excludeTools?.length || 0, + allowTools: config.security.allowTools?.length || 0, + rateLimitEnabled: config.security.rateLimit?.enabled || false, + maxRequestsPerMinute: config.security.rateLimit?.requestsPerMinute || config.security.maxRequestsPerMinute + }, + validation: { + strictMode: config.validation.strictMode, + schemasEnabled: config.validation.validateSchemas, + toolValidationRules: Object.keys(config.validation.toolValidation || {}).length + }, + resources: { + cacheEnabled: config.resources?.enableCache || false, + cacheTTL: config.resources?.cacheTTL || 0 + } + }, null, 2); + } + + startPolling(intervalMs: number = 10000): void { + if (this.pollingInterval) { + return; + } + + this.pollingInterval = setInterval(async () => { + try { + await this.reloadConfig(); + } catch (error) { + } + }, intervalMs); + } + + stopPolling(): void { + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = undefined; + } + } + + isPolling(): boolean { + return !!this.pollingInterval; + } +} diff --git a/libs/remix-ai-core/src/remix-mcp-server/index.ts b/libs/remix-ai-core/src/remix-mcp-server/index.ts index 8e0efd957eb..853b7fdd160 100644 --- a/libs/remix-ai-core/src/remix-mcp-server/index.ts +++ b/libs/remix-ai-core/src/remix-mcp-server/index.ts @@ -6,10 +6,16 @@ // 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'; + +// Configuration +export { MCPConfigManager } from './config/MCPConfigManager'; +export type { + MCPConfig, + MCPSecurityConfig, + MCPValidationConfig, + MCPResourceConfig, +} from './types/mcpConfig'; +export { defaultMCPConfig } from './types/mcpConfig'; // Tool Handlers export { createFileManagementTools } from './handlers/FileManagementHandler'; @@ -25,22 +31,14 @@ export { DeploymentResourceProvider } from './providers/DeploymentResourceProvid export { TutorialsResourceProvider } from './providers/TutorialsResourceProvider'; // Middleware -export { - SecurityMiddleware, - defaultSecurityConfig -} from './middleware/SecurityMiddleware'; export type { - SecurityConfig, SecurityValidationResult, + SecurityMiddleware, AuditLogEntry } from './middleware/SecurityMiddleware'; -export { - ValidationMiddleware, - defaultValidationConfig -} from './middleware/ValidationMiddleware'; export type { - ValidationConfig, + ValidationMiddleware, ValidationResult, ValidationError, ValidationWarning @@ -69,8 +67,8 @@ export async function createRemixMCPServer( options: { enableSecurity?: boolean; enableValidation?: boolean; - securityConfig?: SecurityConfig; - validationConfig?: ValidationConfig; + securityConfig?: any; + validationConfig?: any; customTools?: any[]; customProviders?: any[]; } = {}, @@ -78,8 +76,6 @@ export async function createRemixMCPServer( const { enableSecurity = true, enableValidation = true, - securityConfig = defaultSecurityConfig, - validationConfig = defaultValidationConfig, customTools = [], customProviders = [] } = options; @@ -95,8 +91,8 @@ export async function createRemixMCPServer( resourceCacheTTL: 5000, enableResourceCache: false, security: enableSecurity ? { - enablePermissions: securityConfig.requirePermissions, - enableAuditLog: securityConfig.enableAuditLog, + enablePermissions: true, + enableAuditLog: true, allowedFilePatterns: [], blockedFilePatterns: [] } : undefined, 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 index 05beb8cade7..69bb4464319 100644 --- a/libs/remix-ai-core/src/remix-mcp-server/middleware/SecurityMiddleware.ts +++ b/libs/remix-ai-core/src/remix-mcp-server/middleware/SecurityMiddleware.ts @@ -5,16 +5,9 @@ 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; -} +import { RemixToolRegistry } from '../registry/RemixToolRegistry'; +import { MCPSecurityConfig } from '../types/mcpConfig'; +import { MCPConfigManager } from '../config/MCPConfigManager'; export interface SecurityValidationResult { allowed: boolean; @@ -40,8 +33,26 @@ export class SecurityMiddleware { private rateLimitTracker = new Map(); private auditLog: AuditLogEntry[] = []; private blockedIPs = new Set(); + private toolRegistry?: RemixToolRegistry; + private configManager?: MCPConfigManager; + private config: MCPSecurityConfig; + + constructor(toolRegistry?: RemixToolRegistry, configManager?: MCPConfigManager) { + this.toolRegistry = toolRegistry; + this.configManager = configManager; + + this.config = configManager.getSecurityConfig() as MCPSecurityConfig; + } - constructor(private config: SecurityConfig) {} + /** + * Get current security config (refreshes from ConfigManager if available) + */ + private getConfig(): MCPSecurityConfig { + if (this.configManager) { + return this.configManager.getSecurityConfig(); + } + return this.config; + } /** * Validate a tool call before execution @@ -52,8 +63,16 @@ export class SecurityMiddleware { plugin: Plugin ): Promise { const startTime = Date.now(); + const config = this.getConfig(); try { + // Check if tool is allowed (exclude/allow lists) + const toolAllowedResult = this.checkToolAllowed(call.name); + if (!toolAllowedResult.allowed) { + this.logAudit(call, context, 'blocked', toolAllowedResult.reason, startTime, 'high'); + return toolAllowedResult; + } + // Rate limiting check const rateLimitResult = this.checkRateLimit(context); if (!rateLimitResult.allowed) { @@ -102,6 +121,48 @@ export class SecurityMiddleware { } } + /** + * Check if tool is allowed based on exclude/allow lists + */ + private checkToolAllowed(toolName: string): SecurityValidationResult { + const config = this.getConfig(); + + // Use ConfigManager if available + if (this.configManager) { + const allowed = this.configManager.isToolAllowed(toolName); + if (!allowed) { + return { + allowed: false, + reason: `Tool '${toolName}' is not allowed by configuration`, + risk: 'high' + }; + } + return { allowed: true, risk: 'low' }; + } + + // Check exclude list + if (config.excludeTools && config.excludeTools.includes(toolName)) { + return { + allowed: false, + reason: `Tool '${toolName}' is excluded by security configuration`, + risk: 'high' + }; + } + + // Check allow list (if set, only allow tools in the list) + if (config.allowTools && config.allowTools.length > 0) { + if (!config.allowTools.includes(toolName)) { + return { + allowed: false, + reason: `Tool '${toolName}' is not in the allowed tools list`, + risk: 'high' + }; + } + } + + return { allowed: true, risk: 'low' }; + } + /** * Wrap tool execution with security monitoring */ @@ -149,20 +210,28 @@ export class SecurityMiddleware { * Check rate limiting for user/session */ private checkRateLimit(context: ToolExecutionContext): SecurityValidationResult { + const config = this.getConfig(); const identifier = context.userId || context.sessionId || 'anonymous'; const now = Date.now(); const resetTime = Math.floor(now / 60000) * 60000 + 60000; // Next minute + // Check if rate limiting is disabled + if (config.rateLimit && !config.rateLimit.enabled) { + return { allowed: true, risk: 'low' }; + } + + const maxRequests = config.rateLimit?.requestsPerMinute || config.maxRequestsPerMinute; + 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) { + if (userLimit.count >= maxRequests) { return { allowed: false, - reason: `Rate limit exceeded: ${userLimit.count}/${this.config.maxRequestsPerMinute} requests per minute`, + reason: `Rate limit exceeded: ${userLimit.count}/${maxRequests} requests per minute`, risk: 'medium' }; } @@ -298,6 +367,8 @@ export class SecurityMiddleware { * Validate file path for security issues */ private validateFilePath(path: string): SecurityValidationResult { + const config = this.getConfig(); + // Check for path traversal attacks if (path.includes('..') || path.includes('~')) { return { @@ -316,8 +387,21 @@ export class SecurityMiddleware { }; } + // Use ConfigManager if available + if (this.configManager) { + const allowed = this.configManager.isPathAllowed(path); + if (!allowed) { + return { + allowed: false, + reason: 'Path not allowed by configuration', + risk: 'high' + }; + } + return { allowed: true, risk: 'low' }; + } + // Check blocked paths - for (const blockedPath of this.config.blockedPaths) { + for (const blockedPath of config.blockedPaths) { if (path.includes(blockedPath)) { return { allowed: false, @@ -327,6 +411,24 @@ export class SecurityMiddleware { } } + // Check allowed paths (if set, only allow paths matching patterns) + if (config.allowedPaths && config.allowedPaths.length > 0) { + let pathAllowed = false; + for (const allowedPattern of config.allowedPaths) { + if (path.includes(allowedPattern) || this.matchPattern(path, allowedPattern)) { + pathAllowed = true; + break; + } + } + if (!pathAllowed) { + return { + allowed: false, + reason: 'Path not in allowed paths list', + risk: 'high' + }; + } + } + // Check for system files const systemFiles = ['.env', '.git', 'node_modules', '.ssh', 'id_rsa']; for (const systemFile of systemFiles) { @@ -342,6 +444,22 @@ export class SecurityMiddleware { return { allowed: true, risk: 'low' }; } + /** + * Match a string against a pattern (supports wildcards) + */ + private matchPattern(str: string, pattern: string): boolean { + // Convert glob pattern to regex + const regexPattern = pattern + .replace(/\./g, '\\.') + .replace(/\*\*/g, '___DOUBLESTAR___') + .replace(/\*/g, '[^/]*') + .replace(/___DOUBLESTAR___/g, '.*') + .replace(/\?/g, '.'); + + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(str); + } + private validateInputSanitization(call: IMCPToolCall): SecurityValidationResult { const args = call.arguments || {}; @@ -386,21 +504,21 @@ export class SecurityMiddleware { } /** - * Get required permissions for a tool (placeholder) + * Get required permissions for a tool from the registry + * Returns ['*'] (all permissions) if no specific permissions are defined */ private getRequiredPermissions(toolName: string): string[] { - // This would typically come from the tool registry - // TODO update the tools - const toolPermissions: Record = { - 'file_write': ['file:write'], - 'file_delete': ['file:delete'], - 'deploy_contract': ['deploy:contract'], - 'call_contract': ['contract:interact'], - 'debug_start': ['debug:start'], - 'solidity_compile': ['compile:solidity'] - }; + if (this.toolRegistry) { + const toolDefinition = this.toolRegistry.get(toolName); + if (toolDefinition && toolDefinition.permissions && toolDefinition.permissions.length > 0) { + console.log(`[SecurityMiddleware] Tool '${toolName}' requires permissions:`, toolDefinition.permissions); + return toolDefinition.permissions; + } + } - return toolPermissions[toolName] || []; + // If no permissions found, grant all permissions (wildcard) + console.log(`[SecurityMiddleware] Tool '${toolName}' has no specific permissions defined, granting all permissions (*)`); + return ['*']; } /** @@ -459,14 +577,4 @@ export class SecurityMiddleware { isIPBlocked(ip: string): boolean { return this.blockedIPs.has(ip); } -} - -export const defaultSecurityConfig: SecurityConfig = { - maxRequestsPerMinute: 60, - maxFileSize: 1024 * 1024 * 2, // 2MB - allowedFileTypes: ['sol', 'js', 'ts', 'json', 'md', 'txt', 'toml', 'yaml', 'yml'], - blockedPaths: ['.env', '.git', 'node_modules', '.ssh', 'private', 'secret'], - requirePermissions: true, - enableAuditLog: true, - maxExecutionTime: 30000 // 30 seconds -}; \ No newline at end of file +} \ No newline at end of file diff --git a/libs/remix-ai-core/src/remix-mcp-server/middleware/ValidationMiddleware.ts b/libs/remix-ai-core/src/remix-mcp-server/middleware/ValidationMiddleware.ts index c812a11b495..8821b64babb 100644 --- a/libs/remix-ai-core/src/remix-mcp-server/middleware/ValidationMiddleware.ts +++ b/libs/remix-ai-core/src/remix-mcp-server/middleware/ValidationMiddleware.ts @@ -5,15 +5,8 @@ import { Plugin } from '@remixproject/engine'; import { IMCPToolCall, IMCPToolResult } from '../../types/mcp'; import { ToolExecutionContext } from '../types/mcpTools'; - -export interface ValidationConfig { - validateSchemas: boolean; - validateTypes: boolean; - validateRanges: boolean; - validateFormats: boolean; - strictMode: boolean; - customValidators: Map ValidationResult>; -} +import { MCPValidationConfig } from '../types/mcpConfig'; +import { MCPConfigManager } from '../config/MCPConfigManager'; export interface ValidationResult { valid: boolean; @@ -41,9 +34,24 @@ export interface ValidationWarning { * Validation middleware for MCP tool calls */ export class ValidationMiddleware { - private _plugin - constructor(private config: ValidationConfig, plugin) { - this._plugin = plugin + private _plugin: Plugin; + private configManager?: MCPConfigManager; + private config: MCPValidationConfig; + + constructor(plugin: Plugin, configManager?: MCPConfigManager) { + this._plugin = plugin; + this.configManager = configManager; + this.config = configManager.getValidationConfig() as MCPValidationConfig; + } + + /** + * Get current validation config (refreshes from ConfigManager if available) + */ + private getConfig(): MCPValidationConfig { + if (this.configManager) { + return this.configManager.getValidationConfig(); + } + return this.config; } /** @@ -55,6 +63,7 @@ export class ValidationMiddleware { context: ToolExecutionContext, plugin: Plugin ): Promise { + const config = this.getConfig(); const result: ValidationResult = { valid: true, errors: [], @@ -65,23 +74,26 @@ export class ValidationMiddleware { // Validate basic call structure this.validateCallStructure(call, result); + // Tool-specific validation (required/forbidden fields from config) + this.validateToolSpecificRules(call, result); + // Validate against input schema - if (this.config.validateSchemas && inputSchema) { + if (config.validateSchemas && inputSchema) { this.validateSchema(call.arguments || {}, inputSchema, result); } // Validate argument types - if (this.config.validateTypes) { + if (config.validateTypes) { this.validateArgumentTypes(call.arguments || {}, inputSchema, result); } // Validate ranges and constraints - if (this.config.validateRanges) { + if (config.validateRanges) { this.validateRanges(call.arguments || {}, inputSchema, result); } // Validate formats (emails, URLs, addresses, etc.) - if (this.config.validateFormats) { + if (config.validateFormats) { this.validateFormats(call.arguments || {}, inputSchema, result); } @@ -105,6 +117,60 @@ export class ValidationMiddleware { return result; } + /** + * Validate tool-specific rules from configuration + */ + private validateToolSpecificRules(call: IMCPToolCall, result: ValidationResult): void { + const config = this.getConfig(); + const toolConfig = config.toolValidation?.[call.name]; + + if (!toolConfig) return; + + const args = call.arguments || {}; + + // Check required fields + if (toolConfig.requiredFields) { + for (const requiredField of toolConfig.requiredFields) { + if (!(requiredField in args)) { + result.errors.push({ + field: requiredField, + code: 'REQUIRED_FIELD', + message: `Required field '${requiredField}' is missing (configured for tool '${call.name}')` + }); + } + } + } + + // Check forbidden fields + if (toolConfig.forbiddenFields) { + for (const forbiddenField of toolConfig.forbiddenFields) { + if (forbiddenField in args) { + result.errors.push({ + field: forbiddenField, + code: 'FORBIDDEN_FIELD', + message: `Forbidden field '${forbiddenField}' is present (configured for tool '${call.name}')` + }); + } + } + } + + // Check custom patterns + if (toolConfig.patterns) { + for (const [fieldName, pattern] of Object.entries(toolConfig.patterns)) { + if (fieldName in args && typeof args[fieldName] === 'string') { + const regex = new RegExp(pattern); + if (!regex.test(args[fieldName])) { + result.errors.push({ + field: fieldName, + code: 'PATTERN_MISMATCH', + message: `Field '${fieldName}' does not match required pattern (configured for tool '${call.name}')` + }); + } + } + } + } + } + /** * Validate basic call structure */ @@ -488,13 +554,6 @@ export class ValidationMiddleware { plugin: Plugin, result: ValidationResult ): Promise { - const customValidator = this.config.customValidators.get(call.name); - if (customValidator) { - const customResult = customValidator(call.arguments); - result.errors.push(...customResult.errors); - result.warnings.push(...customResult.warnings); - } - // Tool-specific validations switch (call.name) { case 'file_write': @@ -525,6 +584,9 @@ export class ValidationMiddleware { plugin: Plugin, result: ValidationResult ): Promise { + const config = this.getConfig(); + const networkOpsConfig = config.networkOperations; + // Validate workspace state if (this.requiresWorkspace(call.name)) { // TODO: Check if workspace is properly initialized @@ -539,10 +601,33 @@ export class ValidationMiddleware { message: 'Ensure contracts are compiled before deployment', suggestion: 'Run compilation first' }); + + // Validate network is allowed + const args = call.arguments || {}; + if (networkOpsConfig?.allowedNetworks && args.network) { + if (!networkOpsConfig.allowedNetworks.includes(args.network)) { + result.errors.push({ + field: 'network', + code: 'NETWORK_NOT_ALLOWED', + message: `Network '${args.network}' is not in allowed networks list` + }); + } + } + + // Check gas limit + if (networkOpsConfig?.maxGasLimit && args.gasLimit) { + if (args.gasLimit > networkOpsConfig.maxGasLimit) { + result.errors.push({ + field: 'gasLimit', + code: 'GAS_LIMIT_EXCEEDED', + message: `Gas limit ${args.gasLimit} exceeds maximum allowed (${networkOpsConfig.maxGasLimit})` + }); + } + } } // Validate network connectivity for mainnet operations - if (this.isMainnetOperation(call)) { + if (networkOpsConfig?.warnOnMainnet && this.isMainnetOperation(call)) { result.warnings.push({ field: 'network', code: 'MAINNET_WARNING', @@ -558,6 +643,9 @@ export class ValidationMiddleware { private async validateFileWrite(args: any, plugin: Plugin, result: ValidationResult): Promise { if (!args.path) return; + const config = this.getConfig(); + const fileOpsConfig = config.fileOperations; + try { // Check if path is writable const parentPath = args.path.substring(0, args.path.lastIndexOf('/')); @@ -573,16 +661,42 @@ export class ValidationMiddleware { } } - // Check file size - if (args.content && args.content.length > 10 * 1024 * 1024) { // 10MB - result.warnings.push({ + // Check file extension against allowed list + if (fileOpsConfig?.allowedExtensions && args.path) { + fileOpsConfig.allowedExtensions.includes('*') + const extension = args.path.split('.').pop()?.toLowerCase(); + if (extension && !fileOpsConfig.allowedExtensions.includes(extension)) { + result.errors.push({ + field: 'path', + code: 'INVALID_EXTENSION', + message: `File extension '.${extension}' is not allowed by configuration` + }); + } + } + + // Check file size with config max + const maxSize = fileOpsConfig?.maxFileSize || 10 * 1024 * 1024; + if (args.content && args.content.length > maxSize) { + result.errors.push({ field: 'content', - code: 'LARGE_FILE', - message: 'File content is very large', - suggestion: 'Consider breaking into smaller files' + code: 'FILE_TOO_LARGE', + message: `File content exceeds maximum size (${maxSize} bytes)`, }); } + // Check blocked patterns + if (fileOpsConfig?.blockedPatterns) { + for (const pattern of fileOpsConfig.blockedPatterns) { + if (this.matchPattern(args.path, pattern)) { + result.errors.push({ + field: 'path', + code: 'BLOCKED_PATTERN', + message: `File path matches blocked pattern: ${pattern}` + }); + } + } + } + } catch (error) { result.warnings.push({ field: 'path', @@ -593,6 +707,21 @@ export class ValidationMiddleware { } } + /** + * Match a string against a pattern (supports wildcards) + */ + private matchPattern(str: string, pattern: string): boolean { + const regexPattern = pattern + .replace(/\./g, '\\.') + .replace(/\*\*/g, '___DOUBLESTAR___') + .replace(/\*/g, '[^/]*') + .replace(/___DOUBLESTAR___/g, '.*') + .replace(/\?/g, '.'); + + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(str); + } + /** * Validate contract deployment */ @@ -724,16 +853,4 @@ export class ValidationMiddleware { const args = call.arguments || {}; return args.network === 'mainnet' || args.network === '1' || args.chainId === 1; } -} - -/** - * Default validation configuration - */ -export const defaultValidationConfig: ValidationConfig = { - validateSchemas: true, - validateTypes: true, - validateRanges: true, - validateFormats: true, - strictMode: false, - customValidators: new Map() -}; \ No newline at end of file +} \ No newline at end of file diff --git a/libs/remix-ai-core/src/remix-mcp-server/types/mcpConfig.ts b/libs/remix-ai-core/src/remix-mcp-server/types/mcpConfig.ts new file mode 100644 index 00000000000..4c2e34b1c78 --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/types/mcpConfig.ts @@ -0,0 +1,175 @@ +export interface MCPSecurityConfig { + maxRequestsPerMinute?: number; + maxFileSize?: number; + allowedFileTypes?: string[]; + blockedPaths?: string[]; + allowedPaths?: string[]; + requirePermissions?: boolean; + enableAuditLog?: boolean; + maxExecutionTime?: number; + excludeTools?: string[]; + allowTools?: string[]; + permissions?: { + defaultPermissions: string[]; + roles?: { + [roleName: string]: string[]; + }; + }; + rateLimit?: { + enabled: boolean; + requestsPerMinute: number; + burstAllowance?: number; + }; +} + +export interface MCPValidationConfig { + validateSchemas?: boolean; + validateTypes?: boolean; + validateRanges?: boolean; + validateFormats?: boolean; + strictMode: boolean; + toolValidation?: { + [toolName: string]: { + requiredFields?: string[]; + forbiddenFields?: string[]; + patterns?: { + [fieldName: string]: string; // regex pattern + }; + }; + }; + + fileOperations?: { + maxFileSize?: number; + allowedExtensions?: string[]; + blockedPatterns?: string[]; + }; + + networkOperations?: { + allowedNetworks?: string[]; + warnOnMainnet?: boolean; + maxGasLimit?: number; + }; +} + +export interface MCPResourceConfig { + enableCache: boolean; + cacheTTL: number; + excludeResources?: string[]; + allowResources?: string[]; + accessPatterns?: { + allowedPatterns?: string[]; + blockedPatterns?: string[]; + }; +} + +export interface MCPConfig { + version: string; + security: MCPSecurityConfig; + validation: MCPValidationConfig; + resources?: MCPResourceConfig; + features?: { + compilation?: boolean; + deployment?: boolean; + debugging?: boolean; + analysis?: boolean; + testing?: boolean; + git?: boolean; + }; + logging?: { + level: 'debug' | 'info' | 'warn' | 'error'; + console: boolean; + logFile?: string; + }; +} + +export const defaultMCPConfig: MCPConfig = { + version: '1.0.0', + security: { + allowedFileTypes: ['sol', 'js', 'ts', 'json', 'md', 'txt', 'toml', 'yaml', 'yml'], + blockedPaths: ['.env', '.git', 'node_modules', '.ssh', 'private', 'secret'], + allowedPaths: [], + maxExecutionTime: 30000, + excludeTools: [], + allowTools: [], + permissions: { + defaultPermissions: ['*'] + }, + rateLimit: { + enabled: true, + requestsPerMinute: 60, + burstAllowance: 10 + } + }, + validation: { + validateSchemas: true, + validateTypes: true, + validateRanges: true, + validateFormats: true, + strictMode: false, + toolValidation: {}, + fileOperations: { + maxFileSize: 10 * 1024 * 1024, // 10MB + allowedExtensions: ['sol', 'js', 'ts', 'json', 'md', 'txt', 'toml', 'yaml', 'yml'], + blockedPatterns: ['**/node_modules/**', '**/.git/**'] + }, + networkOperations: { + allowedNetworks: ['sepolia', 'goerli', 'localhost', 'vm'], + warnOnMainnet: true, + maxGasLimit: 15000000 + } + }, + resources: { + enableCache: true, + cacheTTL: 300000, // 5 minutes + excludeResources: [], + allowResources: [], + accessPatterns: { + allowedPatterns: [], + blockedPatterns: [] + } + }, + features: { + compilation: true, + deployment: true, + debugging: true, + analysis: true, + testing: true, + git: true + }, + logging: { + level: 'info', + console: true + } +} + +export const minimalMCPConfig: MCPConfig = { + version: '1.0.0', + security: { + allowedFileTypes: ['*'], + blockedPaths: ['.env', '.git', 'node_modules', '.ssh', 'private', 'secret'], + requirePermissions: true, + enableAuditLog: true, + }, + validation: { + strictMode: false, + fileOperations: { + maxFileSize: 10 * 1024 * 1024, // 10MB + allowedExtensions: ['*'], + blockedPatterns: ['**/node_modules/**', '**/.git/**'] + }, + networkOperations: { + allowedNetworks: ['sepolia', 'goerli', 'localhost', 'vm'], + warnOnMainnet: true, + maxGasLimit: 15000000 + } + }, + features: { + compilation: true, + deployment: true, + debugging: true, + analysis: true, + testing: true, + git: true + }, +}; + From cd3b870f9630c16c1cfb9dd41044d1e387da6287 Mon Sep 17 00:00:00 2001 From: pipper Date: Mon, 8 Dec 2025 12:12:25 +0100 Subject: [PATCH 2/4] adding base middleware --- .../src/app/plugins/remixAIPlugin.tsx | 9 +- .../src/inferencers/mcp/mcpInferencer.ts | 3 +- .../src/remix-mcp-server/RemixMCPServer.ts | 2 +- .../config/MCPConfigManager.ts | 7 +- .../middleware/BaseMiddleware.ts | 109 +++++++++ .../middleware/SecurityMiddleware.ts | 219 ++++++------------ .../middleware/ValidationMiddleware.ts | 54 ++--- .../src/remix-mcp-server/types/mcpConfig.ts | 6 +- 8 files changed, 204 insertions(+), 205 deletions(-) create mode 100644 libs/remix-ai-core/src/remix-mcp-server/middleware/BaseMiddleware.ts diff --git a/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx b/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx index c1d82061864..0b36f39ee1a 100644 --- a/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx +++ b/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx @@ -8,6 +8,8 @@ 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" +import { QueryParams } from '@remix-project/remix-lib' + type chatRequestBufferT = { [key in keyof T]: T[key] } @@ -92,7 +94,12 @@ export class RemixAIPlugin extends Plugin { (window as any).getRemixAIPlugin = this // initialize the remix MCP server - this.remixMCPServer = await createRemixMCPServer(this) + const qp = new QueryParams() + const hasFlag = qp.exists('experimental') + if (hasFlag) { + this.remixMCPServer = await createRemixMCPServer(this) + } + return true } diff --git a/libs/remix-ai-core/src/inferencers/mcp/mcpInferencer.ts b/libs/remix-ai-core/src/inferencers/mcp/mcpInferencer.ts index 20423f60b4e..e8881f5a0a0 100644 --- a/libs/remix-ai-core/src/inferencers/mcp/mcpInferencer.ts +++ b/libs/remix-ai-core/src/inferencers/mcp/mcpInferencer.ts @@ -777,12 +777,11 @@ ${toolsList}`, const result = await this.executeTool(targetServer, innerToolCall); return result }, - 30000 // 30 second timeout + 50000 // 50 second timeout ); // Execute the code const result = await codeExecutor.execute(code); - console.log(`[MCP Code Mode] inner tool executed with result`, result); // Convert code execution result to MCP tool result format if (result.success) { diff --git a/libs/remix-ai-core/src/remix-mcp-server/RemixMCPServer.ts b/libs/remix-ai-core/src/remix-mcp-server/RemixMCPServer.ts index 8246cbc505f..5967e3344d6 100644 --- a/libs/remix-ai-core/src/remix-mcp-server/RemixMCPServer.ts +++ b/libs/remix-ai-core/src/remix-mcp-server/RemixMCPServer.ts @@ -142,7 +142,7 @@ export class RemixMCPServer extends EventEmitter implements IRemixMCPServer { const securityConfig = this._configManager.getSecurityConfig(); const validationConfig = this._configManager.getValidationConfig(); console.log('[RemixMCPServer] Middlewares connected:'); - console.log(` - SecurityMiddleware: using ${securityConfig.excludeTools?.length || 0} excluded tools, ${securityConfig.allowTools?.length || 0} allowed tools`); + console.log(` - SecurityMiddleware: using ${securityConfig.excludeTools?.length || 0} excluded tools`); console.log(` - ValidationMiddleware: strictMode=${validationConfig.strictMode}, ${Object.keys(validationConfig.toolValidation || {}).length} tool-specific rules`); // Start polling for config file changes (every 5 seconds) diff --git a/libs/remix-ai-core/src/remix-mcp-server/config/MCPConfigManager.ts b/libs/remix-ai-core/src/remix-mcp-server/config/MCPConfigManager.ts index 56b3bd46def..5af0d513745 100644 --- a/libs/remix-ai-core/src/remix-mcp-server/config/MCPConfigManager.ts +++ b/libs/remix-ai-core/src/remix-mcp-server/config/MCPConfigManager.ts @@ -93,16 +93,12 @@ export class MCPConfigManager { } isToolAllowed(toolName: string): boolean { - const { excludeTools, allowTools } = this.config.security; + const { excludeTools } = this.config.security; if (excludeTools && excludeTools.includes(toolName)) { return false; } - if (allowTools && allowTools.length > 0) { - return allowTools.includes(toolName); - } - return true; } @@ -173,7 +169,6 @@ export class MCPConfigManager { version: config.version, security: { excludeTools: config.security.excludeTools?.length || 0, - allowTools: config.security.allowTools?.length || 0, rateLimitEnabled: config.security.rateLimit?.enabled || false, maxRequestsPerMinute: config.security.rateLimit?.requestsPerMinute || config.security.maxRequestsPerMinute }, diff --git a/libs/remix-ai-core/src/remix-mcp-server/middleware/BaseMiddleware.ts b/libs/remix-ai-core/src/remix-mcp-server/middleware/BaseMiddleware.ts new file mode 100644 index 00000000000..80f9c985dbe --- /dev/null +++ b/libs/remix-ai-core/src/remix-mcp-server/middleware/BaseMiddleware.ts @@ -0,0 +1,109 @@ +/** + * Base Middleware class with shared utilities + * + * This base class provides common functionality used by both SecurityMiddleware + * and ValidationMiddleware to eliminate code duplication. + */ + +import { MCPConfigManager } from '../config/MCPConfigManager'; + +export abstract class BaseMiddleware { + protected configManager?: MCPConfigManager; + + constructor(configManager?: MCPConfigManager) { + this.configManager = configManager; + } + + /** + * Match a string against a pattern (supports wildcards) + * Shared between SecurityMiddleware and ValidationMiddleware + * + * Pattern syntax: + * - * matches any characters except / + * - ** matches any characters including / + * - ? matches a single character + * + * @example + * matchPattern('src/file.ts', 'src/*.ts') // true + * matchPattern('src/sub/file.ts', 'src/**\/*.ts') // true + * matchPattern('test.js', 'test.?s') // true + */ + protected matchPattern(str: string, pattern: string): boolean { + // Convert glob pattern to regex + const regexPattern = pattern + .replace(/\./g, '\\.') // Escape dots + .replace(/\*\*/g, '___DOUBLESTAR___') // Temporarily replace ** + .replace(/\*/g, '[^/]*') // * matches anything except / + .replace(/___DOUBLESTAR___/g, '.*') // ** matches anything including / + .replace(/\?/g, '.'); // ? matches single character + + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(str); + } + + /** + * Check if a value is likely code content (to avoid false positives in validation) + * Code content should not trigger injection warnings for patterns like require(), eval(), etc. + * + * This helps differentiate between: + * - Malicious input: eval(userInput) in a parameter + * - Legitimate code: 'function test() { eval("x"); }' in file content + */ + protected isLikelyCodeContent(value: string): boolean { + // Check for common code patterns that indicate this is actual source code + const codeIndicators = [ + /^pragma solidity/, // Solidity contract + /^\/\*[\s\S]*\*\//, // Block comment at start + /^\/\//, // Line comment at start + /function\s+\w+\s*\(/, // Function declarations + /contract\s+\w+/, // Contract declarations + /import\s+.*from/, // Import statements + /\n\s*function\s+/, // Function on new line + /\n\s*contract\s+/, // Contract on new line + ]; + + // If it contains multiple code indicators, it's likely source code + const matchCount = codeIndicators.filter(pattern => pattern.test(value)).length; + return matchCount >= 2 || value.length > 500; // Long content is likely code + } + + /** + * Check if a string contains potentially dangerous patterns + * Returns the pattern that matched, or null if safe + * + * IMPORTANT: This should only be used for user inputs, not for file content + * that might legitimately contain these patterns as code. + */ + protected findDangerousPattern(value: string, context: 'input' | 'code' = 'input'): RegExp | null { + // If this is code content, be much more lenient + if (context === 'code' || this.isLikelyCodeContent(value)) { + // Only check for actual command injection patterns in code + const severePatterns = [ + /;\s*rm\s+-rf\s+\//, // Dangerous rm commands + /&&\s*rm\s+-rf\s+\//, // Chained dangerous commands + /\|\s*rm\s+-rf\s+\//, // Piped dangerous commands + ]; + + for (const pattern of severePatterns) { + if (pattern.test(value)) return pattern; + } + return null; + } + + // For user inputs, be more strict + const dangerousPatterns = [ + /;\s*rm\s/, // rm commands + /&&\s*rm\s/, // Chained rm + /\|\s*rm\s/, // Piped rm + />\s*\/dev\//, // Redirect to devices + /curl\s.*\|/, // Piped curl (potential malware download) + /wget\s.*\|/, // Piped wget (potential malware download) + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(value)) return pattern; + } + + return null; + } +} 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 index 69bb4464319..b30c2b21b2b 100644 --- a/libs/remix-ai-core/src/remix-mcp-server/middleware/SecurityMiddleware.ts +++ b/libs/remix-ai-core/src/remix-mcp-server/middleware/SecurityMiddleware.ts @@ -3,11 +3,12 @@ */ import { Plugin } from '@remixproject/engine'; -import { IMCPToolCall, IMCPToolResult } from '../../types/mcp'; +import { IMCPToolCall } from '../../types/mcp'; import { ToolExecutionContext } from '../types/mcpTools'; import { RemixToolRegistry } from '../registry/RemixToolRegistry'; import { MCPSecurityConfig } from '../types/mcpConfig'; import { MCPConfigManager } from '../config/MCPConfigManager'; +import { BaseMiddleware } from './BaseMiddleware'; export interface SecurityValidationResult { allowed: boolean; @@ -29,24 +30,25 @@ export interface AuditLogEntry { /** * Security middleware for validating and securing MCP tool calls */ -export class SecurityMiddleware { +export class SecurityMiddleware extends BaseMiddleware { private rateLimitTracker = new Map(); private auditLog: AuditLogEntry[] = []; - private blockedIPs = new Set(); private toolRegistry?: RemixToolRegistry; - private configManager?: MCPConfigManager; private config: MCPSecurityConfig; + private rateLimitCleanupInterval?: NodeJS.Timeout; constructor(toolRegistry?: RemixToolRegistry, configManager?: MCPConfigManager) { + super(configManager); this.toolRegistry = toolRegistry; - this.configManager = configManager; this.config = configManager.getSecurityConfig() as MCPSecurityConfig; + + // Setup periodic cleanup of rate limit tracker (every 5 minutes) + this.rateLimitCleanupInterval = setInterval(() => { + this.cleanupRateLimitTracker(); + }, 300000); } - /** - * Get current security config (refreshes from ConfigManager if available) - */ private getConfig(): MCPSecurityConfig { if (this.configManager) { return this.configManager.getSecurityConfig(); @@ -54,9 +56,6 @@ export class SecurityMiddleware { return this.config; } - /** - * Validate a tool call before execution - */ async validateToolCall( call: IMCPToolCall, context: ToolExecutionContext, @@ -121,9 +120,6 @@ export class SecurityMiddleware { } } - /** - * Check if tool is allowed based on exclude/allow lists - */ private checkToolAllowed(toolName: string): SecurityValidationResult { const config = this.getConfig(); @@ -140,7 +136,6 @@ export class SecurityMiddleware { return { allowed: true, risk: 'low' }; } - // Check exclude list if (config.excludeTools && config.excludeTools.includes(toolName)) { return { allowed: false, @@ -149,66 +144,9 @@ export class SecurityMiddleware { }; } - // Check allow list (if set, only allow tools in the list) - if (config.allowTools && config.allowTools.length > 0) { - if (!config.allowTools.includes(toolName)) { - return { - allowed: false, - reason: `Tool '${toolName}' is not in the allowed tools list`, - risk: 'high' - }; - } - } - return { allowed: true, risk: 'low' }; } - /** - * 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 config = this.getConfig(); const identifier = context.userId || context.sessionId || 'anonymous'; @@ -271,32 +209,30 @@ export class SecurityMiddleware { /** * Validate tool arguments for security issues + * + * IMPORTANT: For file operations (file_write, file_create), we treat 'content' + * arguments as code, not user input, to avoid false positives for legitimate + * code patterns like require(), eval(), etc. */ private async validateArguments(call: IMCPToolCall, plugin: Plugin): Promise { const args = call.arguments || {}; + console.log('validateArguments', args) - // Check for potentially dangerous patterns - const dangerousPatterns = [ - /eval\s*\(/i, - /function\s*\(/i, - /javascript:/i, - /