workflows/default/systems/mcp/dotbot-mcp.ps1
|
#!/usr/bin/env pwsh <# .SYNOPSIS MCP Server in PowerShell with accurate date/time tools .DESCRIPTION A pure PowerShell implementation of an MCP server that exposes deterministic date and time manipulation tools via stdio transport. Tools are dynamically loaded from the tools/ directory. #> [CmdletBinding()] param() $ErrorActionPreference = 'Stop' $InformationPreference = 'SilentlyContinue' $ProgressPreference = 'SilentlyContinue' $VerbosePreference = 'SilentlyContinue' $DebugPreference = 'SilentlyContinue' $WarningPreference = 'SilentlyContinue' # Disable ANSI colors in error output $PSStyle.OutputRendering = 'PlainText' # Auto-detect project root by walking up from script location to find .git folder $script:ProjectRoot = $null $currentPath = $PSScriptRoot while ($currentPath) { if (Test-Path (Join-Path $currentPath ".git")) { $script:ProjectRoot = $currentPath break } $parent = Split-Path $currentPath -Parent if ($parent -eq $currentPath) { break } # Reached filesystem root $currentPath = $parent } if (-not $script:ProjectRoot) { [Console]::Error.WriteLine("FATAL: Could not auto-detect project root. No .git folder found in parent directories of $PSScriptRoot") exit 1 } # Also export to global scope so dot-sourced tools can access it $global:DotbotProjectRoot = $script:ProjectRoot # Initialize structured logging (console disabled — stdout is MCP protocol) $mcpControlDir = Join-Path $script:ProjectRoot ".bot\.control" $mcpLogsDir = Join-Path $mcpControlDir "logs" if (-not (Test-Path $mcpLogsDir)) { New-Item -Path $mcpLogsDir -ItemType Directory -Force | Out-Null } $dotBotLogPath = Join-Path $PSScriptRoot "..\runtime\modules\DotBotLog.psm1" if (Test-Path $dotBotLogPath) { Import-Module $dotBotLogPath -Force -DisableNameChecking Initialize-DotBotLog -LogDir $mcpLogsDir -ControlDir $mcpControlDir -ProjectRoot $script:ProjectRoot -ConsoleEnabled $false } # Diagnostic logging (stderr, separate from MCP protocol on stdout) [Console]::Error.WriteLine("Project root: $($script:ProjectRoot)") $tasksCheck = Join-Path $script:ProjectRoot ".bot\workspace\tasks" if (Test-Path $tasksCheck) { [Console]::Error.WriteLine("Tasks directory: OK ($tasksCheck)") } else { [Console]::Error.WriteLine("Tasks directory: MISSING ($tasksCheck)") } # Load helpers . "$PSScriptRoot\dotbot-mcp-helpers.ps1" # Import PowerShell YAML module for proper YAML parsing try { Import-Module powershell-yaml -ErrorAction Stop } catch { [Console]::Error.WriteLine("ERROR: powershell-yaml module not found. Install with: Install-Module -Name powershell-yaml") exit 1 } # Load server metadata $metadataPath = Join-Path $PSScriptRoot "metadata.yaml" $script:serverMetadata = Get-Content $metadataPath -Raw | ConvertFrom-Yaml # Discover and load tools $toolsPath = Join-Path $PSScriptRoot "tools" $tools = @{} $toolDirs = Get-ChildItem -Path $toolsPath -Directory foreach ($toolDirItem in $toolDirs) { $toolDir = $toolDirItem.FullName $scriptPath = Join-Path $toolDir "script.ps1" $metadataPath = Join-Path $toolDir "metadata.yaml" if ((Test-Path $scriptPath) -and (Test-Path $metadataPath)) { try { # Load tool script . $scriptPath # Load tool metadata $toolMetadata = Get-Content $metadataPath -Raw | ConvertFrom-Yaml # Store tool info $tools[$toolMetadata.name] = @{ metadata = $toolMetadata scriptPath = $scriptPath } } catch { [Console]::Error.WriteLine("ERROR: Failed to load tool from $($toolDirItem.Name): $($_.Exception.Message)") } } } # Discover workflow tools: .bot/workflows/*/tools/ $workflowsDir = Join-Path (Split-Path $PSScriptRoot -Parent) "..\workflows" if (Test-Path $workflowsDir) { Get-ChildItem -Path $workflowsDir -Directory | ForEach-Object { $wfName = $_.Name $wfToolsDir = Join-Path $_.FullName "tools" if (Test-Path $wfToolsDir) { Get-ChildItem -Path $wfToolsDir -Directory | ForEach-Object { $toolDir = $_.FullName $scriptPath = Join-Path $toolDir "script.ps1" $metadataPath = Join-Path $toolDir "metadata.yaml" if ((Test-Path $scriptPath) -and (Test-Path $metadataPath)) { try { . $scriptPath $toolMetadata = Get-Content $metadataPath -Raw | ConvertFrom-Yaml # Register tool using its metadata name as-is (no automatic workflow prefixing) # Note: name collisions across workflows are possible if tool names are not unique $registeredName = $toolMetadata.name $tools[$registeredName] = @{ metadata = $toolMetadata scriptPath = $scriptPath workflow = $wfName } [Console]::Error.WriteLine("Loaded workflow tool: $registeredName (from $wfName)") } catch { [Console]::Error.WriteLine("ERROR: Failed to load workflow tool $($_.Name) from $wfName`: $($_.Exception.Message)") } } } } } } #region MCP Handlers function Invoke-Initialize { param([hashtable]$Params) # Add project root to server info $serverInfo = @{} foreach ($key in $script:serverMetadata.serverInfo.Keys) { $serverInfo[$key] = $script:serverMetadata.serverInfo[$key] } $serverInfo.projectRoot = $script:ProjectRoot return @{ protocolVersion = $script:serverMetadata.protocolVersion capabilities = $script:serverMetadata.capabilities serverInfo = $serverInfo } } function Invoke-ListTools { $toolList = @() foreach ($toolName in $tools.Keys) { $tool = $tools[$toolName] # Accept both camelCase (inputSchema) and snake_case (input_schema) keys $inputSchema = if ($tool.metadata.inputSchema) { $tool.metadata.inputSchema } elseif ($tool.metadata.input_schema) { $tool.metadata.input_schema } else { @{ type = 'object'; properties = @{}; required = @() } } # Ensure 'required' is always an array (MCP protocol requirement) if ($inputSchema.ContainsKey('required')) { if ($inputSchema.required -isnot [array]) { # Convert non-array to array if ($null -eq $inputSchema.required) { $inputSchema.required = @() } else { $inputSchema.required = @($inputSchema.required) } } } else { # Add empty required array if missing $inputSchema.required = @() } # Add additionalProperties: false for JSON Schema 2020-12 compliance if (-not $inputSchema.ContainsKey('additionalProperties')) { $inputSchema.additionalProperties = $false } $toolList += @{ name = $tool.metadata.name description = $tool.metadata.description inputSchema = $inputSchema } } return @{ tools = $toolList } } function Invoke-CallTool { param( [string]$Name, [hashtable]$Arguments ) if (-not $tools.ContainsKey($Name)) { throw "Unknown tool: $Name" } try { # Convert tool name to function name: get_current_datetime -> Invoke-GetCurrentDateTime $parts = $Name -split '_' $capitalizedParts = foreach ($part in $parts) { $part.Substring(0,1).ToUpper() + $part.Substring(1) } $functionName = 'Invoke-' + ($capitalizedParts -join '') # Call the tool function (tools can access $script:ProjectRoot directly) $result = & $functionName -Arguments $Arguments $jsonText = $result | ConvertTo-Json -Depth 100 -Compress return @{ content = @( @{ type = 'text' text = $jsonText } ) } } catch { throw "Tool execution failed: $_" } } #endregion #region Main Loop function Start-McpServerLoop { [Console]::Error.WriteLine("PowerShell MCP Date Server starting...") [Console]::Error.WriteLine("Loaded $($tools.Count) tools") while ($true) { try { $line = [Console]::ReadLine() if ([string]::IsNullOrEmpty($line)) { continue } $request = $line | ConvertFrom-Json -AsHashtable $method = $request.method $id = $request.id $params = if ($request.params) { $request.params } else { @{} } # Handle notifications (no id) separately if ($null -eq $id -and $method -like 'notifications/*') { # Notifications don't require a response continue } $result = switch ($method) { 'initialize' { Invoke-Initialize -Params $params } 'tools/list' { Invoke-ListTools } 'tools/call' { Invoke-CallTool -Name $params.name -Arguments $(if ($params.arguments) { $params.arguments } else { @{} }) } default { if ($null -ne $id) { Write-JsonRpcError -Id $id -Code -32601 -Message "Method not found: $method" } continue } } # Only send response for requests with an id if ($null -ne $id) { $response = @{ jsonrpc = '2.0' id = $id result = $result } Write-JsonRpcResponse -Response $response } } catch { $errorMessage = $_.Exception.Message [Console]::Error.WriteLine("Error: $errorMessage") if ($null -ne $id) { Write-JsonRpcError -Id $id -Code -32603 -Message $errorMessage } } } } #endregion # Start the server Start-McpServerLoop |