Functions/GenXdev.AI/Start-GenXdevMCPServer.ps1
function Start-GenXdevMCPServer { <# .SYNOPSIS Starts the GenXdev MCP server that exposes PowerShell cmdlets as tools. .DESCRIPTION This function starts an HTTP server that implements the Model Context Protocol (MCP) server pattern, exposing PowerShell cmdlets as callable tools. The server provides endpoints for listing available tools and executing them, similar to the TypeScript example but using PowerShell's ExposedCmdLets functionality. .PARAMETER Port The port on which the server will listen. Default is 2175. .PARAMETER ExposedCmdLets Array of PowerShell command definitions to expose as tools. .PARAMETER NoConfirmationToolFunctionNames Array of command names that can execute without user confirmation. .PARAMETER StopExisting Stop any existing server running on the specified port. .PARAMETER MaxOutputLength Maximum length of tool output in characters. Output exceeding this length will be trimmed with a warning message. Default is 75000 characters (100KB). .EXAMPLE Start-GenXdevMCPServer -Port 2175 .EXAMPLE $exposedCmdlets = @( [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = "Get-Process" Description = "Get running processes" AllowedParams = @("Name", "Id") Confirm = $false } ) Start-GenXdevMCPServer -Port 2175 -ExposedCmdLets $exposedCmdlets #> [CmdletBinding()] param ( [int]$Port = 2175, [GenXdev.Helpers.ExposedCmdletDefinition[]]$ExposedCmdLets = @(), [string[]]$NoConfirmationToolFunctionNames = @(), [switch]$StopExisting, [int]$MaxOutputLength = 75000 ) # Helper functions (nested to avoid module scope exposure) function HandleGenXdevMCPServerSSERequest { param( [System.Net.HttpListenerRequest]$Request, [System.Net.HttpListenerResponse]$Response, [hashtable[]]$Functions ) try { Microsoft.PowerShell.Utility\Write-Host 'Establishing SSE connection for legacy transport' -ForegroundColor Yellow # Set SSE headers $Response.ContentType = 'text/event-stream' $Response.Headers.Add('Cache-Control', 'no-cache') $Response.Headers.Add('Access-Control-Allow-Origin', '*') $Response.Headers.Add('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') $Response.Headers.Add('Access-Control-Allow-Headers', 'Content-Type, Last-Event-ID') # Don't set Content-Length for SSE $Response.SendChunked = $true # Send initial endpoint event for legacy SSE transport $endpointEvent = @{ method = 'notifications/message' params = @{ endpoint = '/mcp' } } # Fix: Use deeper JSON serialization depth $sseData = "event: endpoint`ndata: $($endpointEvent | Microsoft.PowerShell.Utility\ConvertTo-Json -Compress -Depth 10)`n`n" $sseBytes = [System.Text.Encoding]::UTF8.GetBytes($sseData) $Response.OutputStream.Write($sseBytes, 0, $sseBytes.Length) $Response.OutputStream.Flush() # Send initialization capabilities $initEvent = @{ jsonrpc = '2.0' method = 'notifications/initialized' params = @{ protocolVersion = '2024-11-05' capabilities = @{ tools = @{ listChanged = $true } } serverInfo = @{ name = 'GenXdev-PowerShell-MCP-Server' version = '1.202.2025' } } } # Fix: Use deeper JSON serialization depth $initSseData = "event: message`ndata: $($initEvent | Microsoft.PowerShell.Utility\ConvertTo-Json -Compress -Depth 10)`n`n" $initSseBytes = [System.Text.Encoding]::UTF8.GetBytes($initSseData) $Response.OutputStream.Write($initSseBytes, 0, $initSseBytes.Length) $Response.OutputStream.Flush() Microsoft.PowerShell.Utility\Write-Host 'SSE connection established, keeping alive...' -ForegroundColor Green # Fix: Reduce heartbeat frequency and add connection validation $heartbeatCount = 0 while ($Response.OutputStream.CanWrite -and $listener.IsListening) { try { # Check if client is still connected before sleeping if (-not $Response.OutputStream.CanWrite) { Microsoft.PowerShell.Utility\Write-Host 'Client disconnected, ending SSE session' -ForegroundColor Yellow break } Microsoft.PowerShell.Utility\Start-Sleep -Seconds 10 # Reduced from 30 to 10 seconds $heartbeatCount++ # Send heartbeat with better error handling $heartbeat = "event: heartbeat`ndata: {`"timestamp`": `"$(Microsoft.PowerShell.Utility\Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffZ')`", `"count`": $heartbeatCount}`n`n" $heartbeatBytes = [System.Text.Encoding]::UTF8.GetBytes($heartbeat) # Check connection before writing if ($Response.OutputStream.CanWrite) { $Response.OutputStream.Write($heartbeatBytes, 0, $heartbeatBytes.Length) $Response.OutputStream.Flush() # Reduced heartbeat logging frequency if ($heartbeatCount % 6 -eq 0) { # Log every 6th heartbeat (every minute) Microsoft.PowerShell.Utility\Write-Host "SSE heartbeat #$heartbeatCount" -ForegroundColor DarkGray } } else { Microsoft.PowerShell.Utility\Write-Host 'Cannot write to stream, client disconnected' -ForegroundColor Yellow break } } catch [System.ObjectDisposedException] { Microsoft.PowerShell.Utility\Write-Host 'SSE connection disposed by client' -ForegroundColor Yellow break } catch [System.Net.HttpListenerException] { Microsoft.PowerShell.Utility\Write-Host "SSE connection closed by client: $($_.Exception.Message)" -ForegroundColor Yellow break } catch { Microsoft.PowerShell.Utility\Write-Host "SSE connection error: $($_.Exception.Message)" -ForegroundColor Yellow break } } } catch { Microsoft.PowerShell.Utility\Write-Error "SSE connection error: $($_.Exception.Message)" } finally { try { if ($Response -and -not $Response.OutputStream.CanWrite) { $Response.Close() } } catch { # Ignore close errors } } } function HandleGenXdevMCPServerMCPRequest { param( [object]$Request, [hashtable[]]$Functions, [GenXdev.Helpers.ExposedCmdletDefinition[]]$ExposedCmdLets, [string[]]$NoConfirmationToolFunctionNames, [int]$MaxOutputLength = 75000 ) try { # Handle different MCP methods switch ($Request.method) { 'initialize' { $response = @{ jsonrpc = '2.0' id = $Request.id result = @{ protocolVersion = '2024-11-05' capabilities = @{ tools = @{ listChanged = $true } } serverInfo = @{ name = 'GenXdev-PowerShell-MCP-Server' version = '1.202.2025' } } } return $response | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10 -Compress -ErrorAction SilentlyContinue } 'initialized' { # Just acknowledge the initialized notification return '' } 'tools/list' { $tools = @() foreach ($func in $Functions) { $tool = @{ name = $func.function.name description = $func.function.description inputSchema = @{ type = 'object' properties = $func.function.parameters.properties required = $func.function.parameters.required } } $tools += $tool } $response = @{ jsonrpc = '2.0' id = $Request.id result = @{ tools = $tools } } return $response | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10 -Compress } 'tools/call' { if (-not $Request.params -or -not $Request.params.name) { throw 'Invalid request: missing tool name' } $toolName = $Request.params.name $arguments = $Request.params.arguments Microsoft.PowerShell.Utility\Write-Host " Executing tool: $toolName" -ForegroundColor Yellow if ($arguments) { Microsoft.PowerShell.Utility\Write-Host " With arguments: $($arguments | Microsoft.PowerShell.Utility\ConvertTo-Json -Compress)" -ForegroundColor DarkGray } # Create tool call object $toolCall = @{ function = @{ name = $toolName arguments = if ($arguments) { ($arguments | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10 -Compress) } else { '{}' } } } # Execute the tool call with defensive error handling to prevent prompts try { # Temporarily redirect stdin to prevent any prompts $originalIn = [Console]::In try { $null = [Console]::SetIn([System.IO.TextReader]::Null) # Execute the tool call directly (no jobs to avoid serialization issues) $invocationResult = GenXdev.AI\Invoke-CommandFromToolCall ` -ToolCall:$toolCall ` -Functions:$Functions ` -ExposedCmdLets:$ExposedCmdLets ` -NoConfirmationToolFunctionNames:$NoConfirmationToolFunctionNames | Microsoft.PowerShell.Utility\Select-Object -First 1 } finally { # Always restore original stdin [Console]::SetIn($originalIn) } if (-not $invocationResult) { throw 'Tool execution failed to return result' } } catch { # If tool execution fails, return error instead of prompting Microsoft.PowerShell.Utility\Write-Host " Tool execution failed: $($_.Exception.Message)" -ForegroundColor Red $response = @{ jsonrpc = '2.0' id = $Request.id error = @{ code = -32603 message = "Tool execution failed: $($_.Exception.Message)" } } return $response | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10 -Compress } if ($invocationResult.CommandExposed) { # Check if the cmdlet is configured to return text only $isTextOnlyOutput = $invocationResult.ExposedCmdLet -and $invocationResult.ExposedCmdLet.OutputText -eq $true # Process output if ($isTextOnlyOutput) { # For text-only output, convert everything to string first using Out-String $outputText = "$(($invocationResult.Output | Microsoft.PowerShell.Utility\Out-String))".Trim() } else { # For structured output, preserve object types but convert non-strings to text $outputText = "$($invocationResult.Output | Microsoft.PowerShell.Core\ForEach-Object { if ($_ -is [string]) { $_ } else { $_ | Microsoft.PowerShell.Utility\Out-String } })".Trim() } # For text-only output, respect MaxOutputLength and don't serialize to JSON if ($isTextOnlyOutput) { # Trim output if it exceeds MaxOutputLength if ($outputText.Length -gt $MaxOutputLength) { $originalLength = $outputText.Length $trimMessage = "TRIMMED OUTPUT (check parameter use!) invalid json on purpose, AI Agent: don't retry same function without check parameters! >>" $maxContentLength = $MaxOutputLength - $trimMessage.Length $outputText = $trimMessage + $outputText.Substring(0, $maxContentLength) Microsoft.PowerShell.Utility\Write-Verbose "Tool '$toolName' output was trimmed from $originalLength to $MaxOutputLength characters" } # If output is still empty, provide more informative message if ([string]::IsNullOrWhiteSpace($outputText)) { $outputText = "Command '$toolName' executed successfully but produced no output." } Microsoft.PowerShell.Utility\Write-Host " Tool output ($($outputText.Length) chars):" -ForegroundColor Green Microsoft.PowerShell.Utility\Write-Verbose "Full tool output:`n$outputText" if ($outputText.Length -lt 200) { Microsoft.PowerShell.Utility\Write-Host "$outputText" -ForegroundColor DarkCyan } else { Microsoft.PowerShell.Utility\Write-Host "First 100 chars: $($outputText.Substring(0, 100))..." -ForegroundColor DarkCyan Microsoft.PowerShell.Utility\Write-Host 'Use -Verbose to see full output' -ForegroundColor DarkGray } $response = @{ jsonrpc = '2.0' id = $Request.id result = @{ content = @( @{ type = 'text' text = $outputText } ) } } } else { # For non-text output, serialize to JSON with length limiting # If output is still empty, provide more informative message if ([string]::IsNullOrWhiteSpace($outputText)) { $outputText = "Command '$toolName' executed successfully but produced no output." } Microsoft.PowerShell.Utility\Write-Host " Tool output ($($outputText.Length) chars):" -ForegroundColor Green Microsoft.PowerShell.Utility\Write-Verbose "Full tool output:`n$outputText" if ($outputText.Length -lt 200) { Microsoft.PowerShell.Utility\Write-Host "$outputText" -ForegroundColor DarkCyan } else { Microsoft.PowerShell.Utility\Write-Host "First 100 chars: $($outputText.Substring(0, 100))..." -ForegroundColor DarkCyan Microsoft.PowerShell.Utility\Write-Host 'Use -Verbose to see full output' -ForegroundColor DarkGray } # Try to return structured data as JSON text with smart depth reduction try { # Start with the specified depth and progressively reduce if too long $targetDepth = $invocationResult.ExposedCmdLet.JsonDepth ?? 10 $parsedOutput = $null $finalDepth = $targetDepth # Try progressively smaller depths until it fits or we reach minimum depth of 2 $foundValidDepth = $false while ($finalDepth -ge 2) { $parsedOutput = $invocationResult.Output | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth $finalDepth -Compress if ($parsedOutput.Length -le $MaxOutputLength) { # Success! It fits at this depth $foundValidDepth = $true if ($finalDepth -lt $targetDepth) { Microsoft.PowerShell.Utility\Write-Verbose "Tool '$toolName' JSON output reduced from depth $targetDepth to $finalDepth to fit $MaxOutputLength character limit" } break } $finalDepth-- } # If we found a depth that works, use it if ($foundValidDepth) { $response = @{ jsonrpc = '2.0' id = $Request.id result = @{ content = @( @{ type = 'text' text = $parsedOutput } ) } } } else { # Even at depth 2 it's too long, so trim it $originalLength = $parsedOutput.Length $trimMessage = "TRIMMED JSON OUTPUT (reduced to depth $finalDepth, still too large!) incomplete json data, AI Agent: don't retry same function without checking parameters! >>" $maxContentLength = $MaxOutputLength - $trimMessage.Length $trimmedJson = $trimMessage + $parsedOutput.Substring(0, $maxContentLength) Microsoft.PowerShell.Utility\Write-Verbose "Tool '$toolName' JSON output was reduced to minimum depth $finalDepth and trimmed from $originalLength to $MaxOutputLength characters" $response = @{ jsonrpc = '2.0' id = $Request.id result = @{ content = @( @{ type = 'text' text = $trimmedJson } ) } } } } catch { # If JSON conversion fails, fall back to text with trimming if ($outputText.Length -gt $MaxOutputLength) { $originalLength = $outputText.Length $trimMessage = "TRIMMED OUTPUT (check parameter use!) invalid json on purpose, AI Agent: don't retry same function without check parameters! >>" $maxContentLength = $MaxOutputLength - $trimMessage.Length $outputText = $trimMessage + $outputText.Substring(0, $maxContentLength) Microsoft.PowerShell.Utility\Write-Verbose "Tool '$toolName' fallback output was trimmed from $originalLength to $MaxOutputLength characters" } $response = @{ jsonrpc = '2.0' id = $Request.id result = @{ content = @( @{ type = 'text' text = $outputText } ) } } } } } else { Microsoft.PowerShell.Utility\Write-Host " Tool execution failed: $($invocationResult.Reason)" -ForegroundColor Red $response = @{ jsonrpc = '2.0' id = $Request.id error = @{ code = -32603 message = "Tool execution failed: $($invocationResult.Reason)" } } } return $response | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10 } default { $response = @{ jsonrpc = '2.0' id = $Request.id error = @{ code = -32601 message = "Method not found: $($Request.method)" } } return $response | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10 } } } catch { $errorResponse = @{ jsonrpc = '2.0' id = $Request.id error = @{ code = -32603 message = "Internal error: $($_.Exception.Message)" } } return $errorResponse | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10 } } function HandleGenXdevMCPServerListToolsRequest { param( [hashtable[]]$Functions ) $tools = @() foreach ($func in $Functions) { $tool = @{ name = $func.function.name description = $func.function.description inputSchema = @{ type = 'object' properties = $func.function.parameters.properties required = $func.function.parameters.required } } $tools += $tool } $response = @{ tools = $tools } return $response | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10 } function HandleGenXdevMCPServerToolRequest { param( [object]$Request, [hashtable[]]$Functions, [GenXdev.Helpers.ExposedCmdletDefinition[]]$ExposedCmdLets, [string[]]$NoConfirmationToolFunctionNames, [int]$MaxOutputLength = 75000 ) try { # Validate request structure if (-not $Request.params -or -not $Request.params.name) { throw 'Invalid request: missing tool name' } $toolName = $Request.params.name $arguments = $Request.params.arguments # Create tool call object in the exact same format as Invoke-LLMQuery # Arguments should be a JSON string, not a PowerShell object $toolCall = @{ function = @{ name = $toolName arguments = if ($arguments) { ($arguments | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10) } else { '{}' } } } # Execute the tool call with defensive error handling to prevent prompts try { # Temporarily redirect stdin to prevent any prompts $originalIn = [Console]::In try { $null = [Console]::SetIn([System.IO.TextReader]::Null) # Execute the tool call directly (no jobs to avoid serialization issues) $invocationResult = GenXdev.AI\Invoke-CommandFromToolCall ` -ToolCall:$toolCall ` -Functions:$Functions ` -ExposedCmdLets:$ExposedCmdLets ` -NoConfirmationToolFunctionNames:$NoConfirmationToolFunctionNames | Microsoft.PowerShell.Utility\Select-Object -First 1 } finally { # Always restore original stdin [Console]::SetIn($originalIn) } if (-not $invocationResult) { throw 'Tool execution failed to return result' } } catch { # If tool execution fails, return error instead of prompting $response = @{ content = @( @{ type = 'text' text = "Error executing tool '$toolName': $($_.Exception.Message)" } ) isError = $true } return $response | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10 } if ($invocationResult.CommandExposed) { # Check if the cmdlet is configured to return text only $isTextOnlyOutput = $invocationResult.ExposedCmdLet -and $invocationResult.ExposedCmdLet.OutputText -eq $true # Process output exactly like Invoke-LLMQuery does (line 1560) if ($isTextOnlyOutput) { # For text-only output, convert everything to string first using Out-String $outputText = "$($invocationResult.Output | Microsoft.PowerShell.Utility\Out-String)".Trim() } else { # For structured output, preserve object types but convert non-strings to text $outputText = "$($invocationResult.Output | Microsoft.PowerShell.Core\ForEach-Object { if ($_ -is [string]) { $_ } else { $_ | Microsoft.PowerShell.Utility\Out-String } })".Trim() } # For text-only output, respect MaxOutputLength and don't serialize to JSON if ($isTextOnlyOutput) { # Trim output if it exceeds MaxOutputLength if ($outputText.Length -gt $MaxOutputLength) { $originalLength = $outputText.Length $trimMessage = "TRIMMED OUTPUT (check parameter use!) invalid json on purpose, AI Agent: don't retry same function without check parameters! >>" $maxContentLength = $MaxOutputLength - $trimMessage.Length $outputText = $trimMessage + $outputText.Substring(0, $maxContentLength) Microsoft.PowerShell.Utility\Write-Verbose "Tool '$toolName' output was trimmed from $originalLength to $MaxOutputLength characters" } # If output is still empty, provide more informative message if ([string]::IsNullOrWhiteSpace($outputText)) { $outputText = "Command '$toolName' executed successfully but produced no output." } $response = @{ content = @( @{ type = 'text' text = $outputText } ) } } else { # For non-text output, serialize to JSON with length limiting # If output is still empty, provide more informative message if ([string]::IsNullOrWhiteSpace($outputText)) { $outputText = "Command '$toolName' executed successfully but produced no output." } # Try to return structured data as JSON text with smart depth reduction try { # Start with the specified depth and progressively reduce if too long $targetDepth = $invocationResult.ExposedCmdLet.JsonDepth ?? 10 $parsedOutput = $null $finalDepth = $targetDepth # Try progressively smaller depths until it fits or we reach minimum depth of 2 $foundValidDepth = $false while ($finalDepth -ge 2) { $parsedOutput = $invocationResult.Output | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth $finalDepth -Compress if ($parsedOutput.Length -le $MaxOutputLength) { # Success! It fits at this depth $foundValidDepth = $true if ($finalDepth -lt $targetDepth) { Microsoft.PowerShell.Utility\Write-Verbose "Tool '$toolName' JSON output reduced from depth $targetDepth to $finalDepth to fit $MaxOutputLength character limit" } break } $finalDepth-- } # If we found a depth that works, use it if ($foundValidDepth) { $response = @{ content = @( @{ type = 'text' text = $parsedOutput } ) } } else { # Even at depth 2 it's too long, so trim it $originalLength = $parsedOutput.Length $trimMessage = "TRIMMED JSON OUTPUT (reduced to depth $finalDepth, still too large!) incomplete json data, AI Agent: don't retry same function without checking parameters! >>" $maxContentLength = $MaxOutputLength - $trimMessage.Length $trimmedJson = $trimMessage + $parsedOutput.Substring(0, $maxContentLength) Microsoft.PowerShell.Utility\Write-Verbose "Tool '$toolName' JSON output was reduced to minimum depth $finalDepth and trimmed from $originalLength to $MaxOutputLength characters" $response = @{ content = @( @{ type = 'text' text = $trimmedJson } ) } } } catch { # If JSON conversion fails, fall back to text with trimming if ($outputText.Length -gt $MaxOutputLength) { $originalLength = $outputText.Length $trimMessage = "TRIMMED OUTPUT (check parameter use!) invalid json on purpose, AI Agent: don't retry same function without check parameters! >>" $maxContentLength = $MaxOutputLength - $trimMessage.Length $outputText = $trimMessage + $outputText.Substring(0, $maxContentLength) Microsoft.PowerShell.Utility\Write-Verbose "Tool '$toolName' fallback output was trimmed from $originalLength to $MaxOutputLength characters" } $response = @{ content = @( @{ type = 'text' text = $outputText } ) } } } } else { $response = @{ content = @( @{ type = 'text' text = "Error: $($invocationResult.Reason)" } ) isError = $true } } return $response | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10 } catch { $errorResponse = @{ content = @( @{ type = 'text' text = "Error executing tool: $($_.Exception.Message)" } ) isError = $true } return $errorResponse | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10 } } # Default exposed cmdlets if none provided if ($ExposedCmdLets.Count -eq 0) { $ExposedCmdLets = @( [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-GenXDevCmdlets' Description = "Gets GenXdev PowerShell modules with their cmdlets and aliases, allow it to take a few seconds or more. Don't invoke this function without parameters, that would be too much data. Wildcards allowed like * and ?" AllowedParams = @( 'CmdletName=string', 'BaseModuleName=string', 'NoLocal', 'OnlyPublished', 'FromScripts', 'OnlyReturnModuleNames' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() OutputText = $false JsonDepth = 5 Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Import-GenXdevModules' Description = 'Reloads all GenXdev PowerShell modules to reflect source code changes in the MCP interface. Use this after making any changes to GenXdev module source code to ensure those changes are available through the MCP server without restarting it.' AllowedParams = @( ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 2 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-Help' Description = 'Gets help information for PowerShell commands. Use to understand cmdlet syntax, parameters, and examples for GenXdev functions.' AllowedParams = @( 'Name=string' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @(@{ Name = 'Full' Value = $true }) JsonDepth = 4 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Add-TodoLineToREADME' Description = 'Adds a TODO item to the README.md file in the current project. Great for tracking development tasks and project management.' AllowedParams = @( 'Line=string' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 2 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Add-IssueLineToREADME' Description = 'Adds an issue or bug report to the README.md file in the current project. Useful for tracking bugs and problems.' AllowedParams = @( 'Line=string' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 2 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Add-FeatureLineToREADME' Description = 'Adds a feature request or enhancement to the README.md file in the current project. Great for tracking feature development.' AllowedParams = @( 'Line=string' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 2 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Now' Description = 'Gets the current date and time in various formats. Simple utility for timestamps and time-based operations.' AllowedParams = @( ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 1 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Invoke-WebbrowserEvaluation' Description = 'Executes JavaScript in browser tabs using Chrome DevTools Protocol. Incredibly powerful for web automation, testing, and debugging.' AllowedParams = @( 'Scripts=string' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 4 OutputText = $false Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-WebbrowserTabDomNodes' Description = 'Gets DOM elements from browser tabs using CSS selectors. Essential for web scraping, testing, and content analysis.' AllowedParams = @( 'QuerySelector=string', 'ModifyScript' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 4 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-GitChangedFiles' Description = 'Gets files that have been changed in a Git repository. Essential for code review, commit preparation, and change tracking.' AllowedParams = @( ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 3 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-GenXdevPreference' Description = "Use this for preferences storage, use Get-GenXdevPreferenceNames to retreive previous ones you've set." AllowedParams = @( 'Name', 'DefaultValue', 'SessionOnly', 'ClearSession', 'SkipSession' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @( [GenXdev.Helpers.ExposedForcedCmdLetParameter]@{ Name = 'PreferencesDatabasePath' Value = "$ENV:LOCALAPPDATA\GenXdev.Powershell\MCPPreferences.db" } ) JsonDepth = 2 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Invoke-SQLiteQuery' Description = 'Executes SQL queries against SQLite databases. Powerful database interaction tool for data analysis and management.' AllowedParams = @( 'Queries=string', 'ConnectionString', 'DatabaseFilePath', 'SqlParameters', 'IsolationLevel' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 4 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-SQLiteTables' Description = 'Gets table information from SQLite databases. Essential for database schema exploration and analysis.' AllowedParams = @( 'ConnectionString', 'DatabaseFilePath' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 3 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-ClipboardFiles' Description = 'Gets file paths from the Windows clipboard when files are copied. Essential for file management automation.' AllowedParams = @( ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 2 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Set-ClipboardFiles' Description = 'Sets files in the Windows clipboard for copy/paste operations. Powerful file management automation tool.' AllowedParams = @( 'InputObject=string' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 2 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-KnownFolderPath' Description = 'Gets Windows known folder paths (Documents, Desktop, etc.). Essential for cross-user compatible file operations.' AllowedParams = @( 'KnownFolder=string' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 2 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Search-SpotifyAndPlay' Description = 'Performs a Spotify search and plays the first found item. Searches Spotify using the provided query string and automatically plays the first matching item on the currently active Spotify device.' AllowedParams = @( 'Queries=string', 'SearchType=string' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 3 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-SQLiteSchema' Description = 'Retrieves the complete schema information from a SQLite database, including tables, views, indexes and triggers.' AllowedParams = @( 'ConnectionString', 'DatabaseFilePath' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 4 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-SQLiteTableData' Description = 'Retrieves data from a SQLite database table with optional record limiting.' AllowedParams = @( 'ConnectionString', 'DatabaseFilePath', 'TableName', 'Count' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 4 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-SQLiteTableColumnData' Description = 'Retrieves data from a specific column in a SQLite database table.' AllowedParams = @( 'ConnectionString', 'DatabaseFilePath', 'TableName', 'ColumnName', 'Count' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 4 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-SQLiteTableSchema' Description = 'Retrieves the schema information for a specified SQLite table.' AllowedParams = @( 'ConnectionString', 'DatabaseFilePath', 'TableName' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 4 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-SQLiteViewData' Description = 'Retrieves data from a SQLite database view with optional record limiting.' AllowedParams = @( 'ConnectionString', 'DatabaseFilePath', 'ViewName', 'Count' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 4 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-SQLiteViewColumnData' Description = 'Retrieves column data from a SQLite view with optional record limiting.' AllowedParams = @( 'ConnectionString', 'DatabaseFilePath', 'ViewName', 'ColumnName', 'Count' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 4 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-SQLiteViewSchema' Description = 'Retrieves the SQL schema definition for a SQLite view.' AllowedParams = @( 'ConnectionString', 'DatabaseFilePath', 'ViewName' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 4 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-SQLiteViews' Description = 'Retrieves a list of views from a SQLite database.' AllowedParams = @( 'ConnectionString', 'DatabaseFilePath' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 3 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'New-SQLiteDatabase' Description = 'Creates a new SQLite database file at the specified path.' AllowedParams = @( 'DatabaseFilePath' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 2 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Invoke-SQLiteStudio' Description = 'Launches SQLite Studio database management application.' AllowedParams = @( 'DatabaseFilePath' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 2 OutputText = $true Confirm = $false }, [GenXdev.Helpers.ExposedCmdletDefinition]@{ Name = 'Get-HometoolsData' Description = 'Queries the hometools database storage table for specific data entries.' AllowedParams = @( 'Queries=string', 'Count=integer', 'OldestFirst', 'ByKey' ) DontShowDuringConfirmationParamNames = @() ForcedParams = @() JsonDepth = 4 OutputText = $true Confirm = $false } ) } # Stop existing server if requested if ($StopExisting) { GenXdev.AI\Stop-GenXdevMCPServer -Port $Port } Microsoft.PowerShell.Utility\Write-Host "Starting GenXdev MCP server on port $Port..." -ForegroundColor Green Microsoft.PowerShell.Utility\Write-Host "Exposed cmdlets: $($ExposedCmdLets.Name -join ', ')" -ForegroundColor Cyan Microsoft.PowerShell.Utility\Write-Host "Max output length: $MaxOutputLength characters ($([math]::Round($MaxOutputLength / 1024, 1))KB)" -ForegroundColor Cyan # Convert cmdlets to function definitions $functions = GenXdev.AI\ConvertTo-LMStudioFunctionDefinition -ExposedCmdLets $ExposedCmdLets # Create HTTP listener $listener = Microsoft.PowerShell.Utility\New-Object System.Net.HttpListener $listener.Prefixes.Add("http://localhost:$Port/") while (-not [Console]::KeyAvailable) { try { $listener.Start() Microsoft.PowerShell.Utility\Write-Host "Server started successfully at http://localhost:$Port" -ForegroundColor Green Microsoft.PowerShell.Utility\Write-Host 'Available endpoints:' -ForegroundColor Yellow Microsoft.PowerShell.Utility\Write-Host ' GET /mcp - List tools (normal HTTP)' -ForegroundColor Gray Microsoft.PowerShell.Utility\Write-Host ' POST /mcp - MCP HTTP transport (VS Code)' -ForegroundColor Gray Microsoft.PowerShell.Utility\Write-Host ' GET|POST /mcp/list-tools - List available tools' -ForegroundColor Gray Microsoft.PowerShell.Utility\Write-Host ' POST /mcp/call-tool - Execute a tool' -ForegroundColor Gray Microsoft.PowerShell.Utility\Write-Host ' GET /sse - SSE connection for legacy transport' -ForegroundColor Gray Microsoft.PowerShell.Utility\Write-Host ' POST /messages - Legacy SSE transport POST endpoint' -ForegroundColor Gray Microsoft.PowerShell.Utility\Write-Host 'Press Ctrl+C to stop the server' -ForegroundColor Yellow break } catch { Microsoft.PowerShell.Utility\Write-Verbose "Failed to start server: $($_.Exception.Message)" sleep 2 } } # Store server info globally for stop function $Global:GenXdevMCPServer = @{ Listener = $listener Port = $Port Functions = $functions ExposedCmdLets = $ExposedCmdLets NoConfirmationToolFunctionNames = $NoConfirmationToolFunctionNames MaxOutputLength = $MaxOutputLength } # Main server loop while (-not [Console]::KeyAvailable) { try { while ($listener.IsListening -and (-not [Console]::KeyAvailable)) { $context = $listener.GetContext() $request = $context.Request $response = $context.Response try { # Add separator line for non-routine requests $isRoutineRequest = ($request.HttpMethod -eq 'GET' -and $request.Url.AbsolutePath -eq '/mcp') if (-not $isRoutineRequest) { Microsoft.PowerShell.Utility\Write-Host '──────────────────────────────────────────────────────────────────────────────────' -ForegroundColor DarkGray } # Set CORS headers - SECURITY: Only allow localhost origins to prevent CSRF attacks $origin = $request.Headers['Origin'] $allowedOrigins = @( 'http://localhost', 'https://localhost', 'http://127.0.0.1', 'https://127.0.0.1', 'http://[::1]', 'https://[::1]' ) # Check if origin is from localhost/127.0.0.1 (any port) $isAllowedOrigin = $false if ($origin) { foreach ($allowedOrigin in $allowedOrigins) { if ($origin.StartsWith($allowedOrigin)) { $isAllowedOrigin = $true break } } } if ($isAllowedOrigin -or -not $origin) { $response.Headers.Add('Access-Control-Allow-Origin',($origin ? $origin : '*' )) $response.Headers.Add('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') $response.Headers.Add('Access-Control-Allow-Headers', 'Content-Type') } # Handle preflight requests - only if CORS headers were set (origin allowed) if ($request.HttpMethod -eq 'OPTIONS') { if ($isAllowedOrigin -or -not $origin) { $response.StatusCode = 200 } else { $response.StatusCode = 403 # Forbidden for non-localhost origins } $response.Close() continue } # Security check: Block requests from non-localhost origins if ($origin -and -not $isAllowedOrigin) { Microsoft.PowerShell.Utility\Write-Verbose "Blocked request from unauthorized origin: $origin" $response.StatusCode = 403 $response.Close() continue } # Read request body $requestBody = '' if ($request.HasEntityBody) { $reader = Microsoft.PowerShell.Utility\New-Object System.IO.StreamReader($request.InputStream) $requestBody = $reader.ReadToEnd() $reader.Close() } # Parse JSON request $jsonRequest = $null if ($requestBody) { try { $jsonRequest = $requestBody | Microsoft.PowerShell.Utility\ConvertFrom-Json # Only log detailed JSON for non-/mcp endpoints if ($request.Url.AbsolutePath -ne '/mcp') { Microsoft.PowerShell.Utility\Write-Host "Parsed JSON request: $($jsonRequest | Microsoft.PowerShell.Utility\ConvertTo-Json -Compress)" -ForegroundColor Magenta } } catch { Microsoft.PowerShell.Utility\Write-Verbose "Invalid JSON in request: $($_.Exception.Message)" Microsoft.PowerShell.Utility\Write-Host "Raw request body: $requestBody" -ForegroundColor Red } } # Route requests $responseJson = '' $statusCode = 200 # Add concise logging (skip routine GET /mcp requests) if (-not $isRoutineRequest) { Microsoft.PowerShell.Utility\Write-Host "Request: $($request.HttpMethod) $($request.Url.AbsolutePath)" -ForegroundColor Cyan } # Normalize path by removing trailing slash for consistent routing $normalizedPath = $request.Url.AbsolutePath.TrimEnd('/') if ([string]::IsNullOrEmpty($normalizedPath)) { $normalizedPath = '/' } switch ($normalizedPath) { '/mcp' { if ($request.HttpMethod -eq 'GET') { # All GET requests to /mcp return normal HTTP (list tools) $responseJson = HandleGenXdevMCPServerListToolsRequest -Functions $functions } elseif ($request.HttpMethod -eq 'POST') { # Handle Streamable HTTP transport if ($jsonRequest) { Microsoft.PowerShell.Utility\Write-Host "MCP Request: $($jsonRequest.method)" -ForegroundColor Cyan if ($jsonRequest.method -eq 'tools/call') { Microsoft.PowerShell.Utility\Write-Host " Tool: $($jsonRequest.params.name)" -ForegroundColor Green if ($jsonRequest.params.arguments) { Microsoft.PowerShell.Utility\Write-Host " Arguments: $($jsonRequest.params.arguments | Microsoft.PowerShell.Utility\ConvertTo-Json -Compress)" -ForegroundColor Gray } } $responseJson = HandleGenXdevMCPServerMCPRequest -Request $jsonRequest -Functions $functions -ExposedCmdLets $ExposedCmdLets -NoConfirmationToolFunctionNames $NoConfirmationToolFunctionNames -MaxOutputLength $MaxOutputLength if ($jsonRequest.method -eq 'tools/call') { Microsoft.PowerShell.Utility\Write-Host " Response Length: $($responseJson.Length) chars" -ForegroundColor Gray } } else { Microsoft.PowerShell.Utility\Write-Error 'No valid JSON request received for POST to /mcp' $statusCode = 400 $responseJson = @{ error = @{ code = -32700 message = 'Parse error - Invalid JSON' } } | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 50 } } else { $statusCode = 405 $responseJson = @{ error = @{ code = -32601 message = 'Method not allowed' } } | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 50 } } '/mcp/list-tools' { Microsoft.PowerShell.Utility\Write-Host 'Handling list-tools request' -ForegroundColor Cyan $responseJson = HandleGenXdevMCPServerListToolsRequest -Functions $functions } '/sse' { # Dedicated SSE endpoint for legacy transport if ($request.HttpMethod -eq 'GET') { Microsoft.PowerShell.Utility\Write-Host 'Handling dedicated SSE GET request' -ForegroundColor Yellow HandleGenXdevMCPServerSSERequest -Request $request -Response $response -Functions $functions continue # SSE response handles its own connection, continue to next request } else { $statusCode = 405 $responseJson = @{ error = @{ code = -32601 message = 'Method not allowed - SSE endpoint only supports GET' } } | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 50 } } '/mcp/call-tool' { Microsoft.PowerShell.Utility\Write-Host "Tool Call: $($jsonRequest.params.name)" -ForegroundColor Green if ($jsonRequest.params.arguments) { Microsoft.PowerShell.Utility\Write-Host "Arguments: $($jsonRequest.params.arguments | Microsoft.PowerShell.Utility\ConvertTo-Json -Compress)" -ForegroundColor Gray } $responseJson = HandleGenXdevMCPServerToolRequest -Request $jsonRequest -Functions $functions -ExposedCmdLets $ExposedCmdLets -NoConfirmationToolFunctionNames $NoConfirmationToolFunctionNames -MaxOutputLength $MaxOutputLength Microsoft.PowerShell.Utility\Write-Host "Tool Response Length: $($responseJson.Length) chars" -ForegroundColor Gray } '/messages' { # Handle legacy SSE transport POST messages Microsoft.PowerShell.Utility\Write-Host "Legacy SSE Message: $($jsonRequest.method)" -ForegroundColor Yellow $responseJson = HandleGenXdevMCPServerMCPRequest -Request $jsonRequest -Functions $functions -ExposedCmdLets $ExposedCmdLets -NoConfirmationToolFunctionNames $NoConfirmationToolFunctionNames -MaxOutputLength $MaxOutputLength } default { $statusCode = 404 $responseJson = @{ error = @{ code = -32601 message = 'Method not found' } } | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 50 } } # Send response $response.StatusCode = $statusCode $response.ContentType = 'application/json' $responseBytes = [System.Text.Encoding]::UTF8.GetBytes($responseJson) $response.ContentLength64 = $responseBytes.Length $response.OutputStream.Write($responseBytes, 0, $responseBytes.Length) $response.Close() } catch { # Handle request-specific exceptions without stopping the server Microsoft.PowerShell.Utility\Write-Verbose "Request handling error: $($_.Exception.Message)" try { if (-not $response.HeadersSent) { $response.StatusCode = 500 $response.ContentType = 'application/json' $errorJson = @{ error = @{ code = -32603 message = 'Internal server error' } } | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 10 $errorBytes = [System.Text.Encoding]::UTF8.GetBytes($errorJson) $response.ContentLength64 = $errorBytes.Length $response.OutputStream.Write($errorBytes, 0, $errorBytes.Length) } $response.Close() } catch { # Ignore errors when trying to send error response } } } } catch [System.Net.HttpListenerException] { if ($_.Exception.ErrorCode -ne 995) { # 995 = WSA_OPERATION_ABORTED (normal shutdown) Microsoft.PowerShell.Utility\Write-Error "Server error: $($_.Exception.Message)" } } catch { Microsoft.PowerShell.Utility\Write-Error "Unexpected error: $($_.Exception.Message)" } finally { if ($listener.IsListening) { $listener.Stop() } $listener.Close() Microsoft.PowerShell.Utility\Write-Host 'Server stopped.' -ForegroundColor Yellow } } } function Stop-GenXdevMCPServer { <# .SYNOPSIS Stops the GenXdev MCP server. .DESCRIPTION This function stops the GenXdev MCP server if it's running. .PARAMETER Port The port of the server to stop. If not specified, stops the globally tracked server. .EXAMPLE Stop-GenXdevMCPServer -Port 2175 #> [CmdletBinding()] param ( [int]$Port ) if ($Global:GenXdevMCPServer -and $Global:GenXdevMCPServer.Listener) { if (-not $Port -or $Global:GenXdevMCPServer.Port -eq $Port) { Microsoft.PowerShell.Utility\Write-Host "Stopping GenXdev MCP server on port $($Global:GenXdevMCPServer.Port)..." -ForegroundColor Yellow $Global:GenXdevMCPServer.Listener.Stop() $Global:GenXdevMCPServer.Listener.Close() $Global:GenXdevMCPServer = $null Microsoft.PowerShell.Utility\Write-Host 'Server stopped.' -ForegroundColor Green } } else { Microsoft.PowerShell.Utility\Write-Host 'No server is currently running.' -ForegroundColor Gray } } |