McpServers.ps1

function Assert-CopilotMcpConfigNotSymbolicLink {
    [CmdletBinding()]
    param()

    $configDirectory = Join-Path (Get-CopilotHome) '.copilot'
    $configPath = Join-Path $configDirectory 'mcp-config.json'
    try {
        # Inspect the link itself, including dangling links, without resolving its target.
        $config = Get-Item -LiteralPath $configPath -Force -ErrorAction Stop
    } catch [System.Management.Automation.ItemNotFoundException] {
        return
    }

    if ($config.LinkType -eq 'SymbolicLink') {
        throw "Cannot modify Copilot MCP configuration '$configPath': it is a symbolic link to '$($config.LinkTarget)'. Manage the target file directly or use the tool that manages the link. Relative targets are based at '$configDirectory'. Native 'copilot mcp add/remove' would replace the link and was not run."
    }
}

function Assert-CopilotMcpToolFilter {
    [CmdletBinding()]
    param(
        [AllowEmptyString()]
        [string]$Tools,

        [Parameter(Mandatory)]
        [string]$ParameterName
    )

    if ($null -eq $Tools -or $Tools -eq '' -or $Tools -eq '*') {
        return
    }

    $filters = $Tools.Split(',', [System.StringSplitOptions]::None)
    if ($filters.Count -eq 0 -or ($filters | Where-Object { $_ -eq '' }).Count -gt 0) {
        throw [System.ArgumentException]::new(
            "Tools must be '*', an empty string, or a comma-separated list of non-empty tool names.",
            $ParameterName)
    }

    foreach ($filter in $filters) {
        if (-not (Test-CopilotShimArgument -Value $filter -Pattern '^[A-Za-z0-9][A-Za-z0-9._:-]*$')) {
            throw [System.ArgumentException]::new(
                "Unsafe $ParameterName value. Tool names passed to the copilot CLI may only contain allow-listed characters, and Tools must be '*', an empty string, or a comma-separated list.",
                $ParameterName)
        }
    }
}

function Get-CopilotMcpServerStringField {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$Server,

        [Parameter(Mandatory)]
        [string]$Name,

        [switch]$Required,

        [switch]$AllowEmpty
    )

    if (-not $Server.Contains($Name) -or $null -eq $Server[$Name]) {
        if ($Required) {
            throw [System.IO.InvalidDataException]::new("copilot mcp list --json returned server metadata without a '$Name' field.")
        }

        return ''
    }

    $value = $Server[$Name]
    if ($value -isnot [string]) {
        throw [System.IO.InvalidDataException]::new("copilot mcp list --json returned a non-string '$Name' field.")
    }

    if (-not $AllowEmpty -and [string]::IsNullOrEmpty($value)) {
        throw [System.IO.InvalidDataException]::new("copilot mcp list --json returned an empty '$Name' field.")
    }

    $value
}

function Get-CopilotMcpServerBooleanField {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$Server,

        [Parameter(Mandatory)]
        [string]$Name
    )

    if (-not $Server.Contains($Name) -or $Server[$Name] -isnot [bool]) {
        throw [System.IO.InvalidDataException]::new("copilot mcp list --json returned server metadata without a Boolean '$Name' field.")
    }

    $Server[$Name]
}

function Get-CopilotMcpServerArgsText {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$Server
    )

    if (-not $Server.Contains('args') -or $null -eq $Server['args']) {
        return ''
    }

    $value = $Server['args']
    if ($value -is [string] -or $value -isnot [System.Collections.IEnumerable]) {
        throw [System.IO.InvalidDataException]::new("copilot mcp list --json returned a non-array 'args' field.")
    }

    $args = @($value)
    foreach ($arg in $args) {
        if ($arg -isnot [string]) {
            throw [System.IO.InvalidDataException]::new("copilot mcp list --json returned a non-string MCP argument.")
        }
    }

    $args -join ' '
}

function ConvertFrom-CopilotMcpServerListJson {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Json
    )

    if ([string]::IsNullOrWhiteSpace($Json)) {
        throw [System.IO.InvalidDataException]::new('copilot mcp list --json returned an empty response.')
    }

    try {
        $data = $Json | ConvertFrom-Json -AsHashtable -ErrorAction Stop
    } catch {
        throw [System.IO.InvalidDataException]::new("copilot mcp list --json returned invalid JSON.", $_.Exception)
    }

    if ($data -isnot [System.Collections.IDictionary] -or -not $data.Contains('mcpServers') -or $data['mcpServers'] -isnot [System.Collections.IDictionary]) {
        throw [System.IO.InvalidDataException]::new("copilot mcp list --json did not return an object with an 'mcpServers' map.")
    }

    foreach ($entry in $data['mcpServers'].GetEnumerator()) {
        if ([string]::IsNullOrEmpty($entry.Key)) {
            throw [System.IO.InvalidDataException]::new('copilot mcp list --json returned an MCP server entry without a name.')
        }

        $server = $entry.Value
        if ($server -isnot [System.Collections.IDictionary]) {
            throw [System.IO.InvalidDataException]::new("copilot mcp list --json returned non-object metadata for MCP server '$($entry.Key)'.")
        }

        [PSCustomObject]@{
            PSTypeName = 'CopilotMcpServer'
            Name       = $entry.Key
            Type       = Get-CopilotMcpServerStringField -Server $server -Name type -Required
            Command    = Get-CopilotMcpServerStringField -Server $server -Name command
            Args       = Get-CopilotMcpServerArgsText -Server $server
            Url        = Get-CopilotMcpServerStringField -Server $server -Name url
            Source     = Get-CopilotMcpServerStringField -Server $server -Name source -Required
            Enabled    = Get-CopilotMcpServerBooleanField -Server $server -Name enabled
            Tools      = Get-CopilotMcpServerStringField -Server $server -Name tools -Required -AllowEmpty
        }
    }
}

function Get-CopilotMcpServer {
    <#
    .SYNOPSIS
        List configured Copilot CLI MCP servers.
    .DESCRIPTION
        Parses the JSON output of 'copilot mcp list' into typed objects with
        Name, Type, Source, Enabled, Tools, and connection details
        (Command/Args or URL). Native failures and invalid JSON fail closed and
        emit no server objects from that invocation.
    .PARAMETER Name
        Filter by server name. Supports wildcards.
    .PARAMETER Source
        Filter by source: user, workspace, plugin, or builtin.
    .EXAMPLE
        Get-CopilotMcpServer
        Lists all configured MCP servers.
    .EXAMPLE
        Get-CopilotMcpServer -Source user
        Lists only user-configured servers.
    .EXAMPLE
        Get-CopilotMcpServer Azure*
        Lists servers matching the filter.
    #>

    [OutputType('CopilotMcpServer')]
    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]$Name = '*',

        [ValidateSet('user', 'workspace', 'plugin', 'builtin')]
        [string]$Source
    )
    $discoveryErrors = @()
    $output = @(Invoke-CopilotDiscovery -Arguments 'mcp', 'list', '--json' -ErrorVariable discoveryErrors)
    if ($discoveryErrors.Count -gt 0) {
        return
    }

    $json = $output | Out-String
    if ([string]::IsNullOrWhiteSpace($json)) {
        $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
            [System.IO.InvalidDataException]::new('copilot mcp list --json returned an empty response.'),
            'CopilotMcpServerJsonInvalid', [System.Management.Automation.ErrorCategory]::InvalidData, $null))
        return
    }

    try {
        $servers = @(ConvertFrom-CopilotMcpServerListJson -Json $json)
    } catch {
        Write-Error -Exception $_.Exception -ErrorId CopilotMcpServerJsonInvalid -Category InvalidData
        return
    }

    foreach ($server in $servers) {
        if ($server.Name -notlike $Name) { continue }
        if ($Source -and $server.Source -ne $Source) { continue }
        $server
    }
}

function Register-CopilotMcpServer {
    <#
    .SYNOPSIS
        Add an MCP server to the Copilot CLI user configuration.
    .DESCRIPTION
        Wraps 'copilot mcp add' with typed parameters for stdio and HTTP/SSE servers.
        Refuses to invoke the native command when ~/.copilot/mcp-config.json is a
        symbolic link, including relative, chained, and dangling links, because
        the native command would replace the link. Manage the target file directly
        or use the tool that manages the link. Regular files retain native CLI
        behavior and validation. WhatIf only previews the operation.
    .PARAMETER Name
        The server name.
    .PARAMETER Transport
        The transport type: stdio, http, or sse. Defaults to stdio.
    .PARAMETER Command
        The command to run for stdio servers.
    .PARAMETER ArgumentList
        Arguments for the stdio command.
    .PARAMETER Url
        The URL for http/sse servers.
    .PARAMETER Env
        Environment variables as KEY=VALUE strings.
    .PARAMETER Header
        HTTP headers for remote servers.
    .PARAMETER Tools
        Tool filter: '*' for all, an empty string for none, or a comma-separated
        list of tool names.
    .PARAMETER TimeoutMilliseconds
        Timeout in milliseconds. Must be between 1 and 4294967295.
    .EXAMPLE
        Register-CopilotMcpServer -Name context7 -Transport http -Url https://mcp.context7.com/mcp
        Adds a remote HTTP MCP server.
    .EXAMPLE
        Register-CopilotMcpServer -Name myserver -Command npx -ArgumentList '-y', '@my/mcp-server'
        Adds a local stdio MCP server.
    .EXAMPLE
        Register-CopilotMcpServer -Name docs -Command npx -ArgumentList '-y', 'docs-mcp' -Tools '' -TimeoutMilliseconds 30000
        Adds a local MCP server with no exposed tools and a 30-second timeout.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Position = 0, Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,

        [ValidateSet('stdio', 'http', 'sse')]
        [string]$Transport = 'stdio',

        [string]$Command,

        [string[]]$ArgumentList,

        [string]$Url,

        [string[]]$Env,

        [string[]]$Header,

        [AllowEmptyString()]
        [string]$Tools,

        [Alias('Timeout')]
        [ValidateRange(1, [uint32]::MaxValue)]
        [uint32]$TimeoutMilliseconds
    )
    Assert-CopilotShimArgument -Value $Name -ParameterName 'Name' -Pattern '^[A-Za-z0-9][A-Za-z0-9._-]*$'
    if ($Command) { Assert-CopilotShimTextArgument -Value $Command -ParameterName 'Command' }
    if ($ArgumentList) {
        foreach ($argument in $ArgumentList) {
            Assert-CopilotShimTextArgument -Value $argument -ParameterName 'ArgumentList'
        }
    }
    if ($Url) { Assert-CopilotShimTextArgument -Value $Url -ParameterName 'Url' }
    if ($Env) {
        foreach ($entry in $Env) {
            Assert-CopilotShimTextArgument -Value $entry -ParameterName 'Env'
        }
    }
    if ($Header) {
        foreach ($entry in $Header) {
            Assert-CopilotShimTextArgument -Value $entry -ParameterName 'Header'
        }
    }
    if ($PSBoundParameters.ContainsKey('Tools')) {
        Assert-CopilotMcpToolFilter -Tools $Tools -ParameterName 'Tools'
    }

    $copilotExe = Resolve-CliExe -Name copilot
    if ($PSCmdlet.ShouldProcess($Name, 'copilot mcp add')) {
        $addArgs = @('mcp', 'add', '--transport', $Transport)
        if ($Env) { foreach ($e in $Env) { $addArgs += '--env', $e } }
        if ($Header) { foreach ($h in $Header) { $addArgs += '--header', $h } }
        if ($PSBoundParameters.ContainsKey('Tools')) {
            # An empty native argument can disappear when copilot resolves to a .cmd shim.
            if ($Tools -eq '') { $addArgs += '--tools=' } else { $addArgs += '--tools', $Tools }
        }
        if ($PSBoundParameters.ContainsKey('TimeoutMilliseconds')) { $addArgs += '--timeout', "$TimeoutMilliseconds" }
        $addArgs += $Name
        if ($Transport -eq 'stdio') {
            $addArgs += '--'
            if ($Command) { $addArgs += $Command }
            if ($ArgumentList) { $addArgs += $ArgumentList }
        } else {
            if ($Url) { $addArgs += $Url }
        }
        Assert-CopilotMcpConfigNotSymbolicLink
        & $copilotExe @addArgs 2>&1
        if ($LASTEXITCODE -ne 0) {
            Write-Error "Failed to add MCP server: $Name"
        }
    }
}

function Unregister-CopilotMcpServer {
    <#
    .SYNOPSIS
        Remove an MCP server from the Copilot CLI configuration.
    .DESCRIPTION
        Removes a server by name. Accepts pipeline input from Get-CopilotMcpServer.
        Refuses to invoke the native command when ~/.copilot/mcp-config.json is a
        symbolic link, including relative, chained, and dangling links, because
        the native command would replace the link. Manage the target file directly
        or use the tool that manages the link. The check applies to each pipeline
        item. Regular files retain native CLI behavior and validation. WhatIf only
        previews the operation.
    .PARAMETER InputObject
        A CopilotMcpServer object from Get-CopilotMcpServer.
    .PARAMETER Name
        The server name to remove.
    .EXAMPLE
        Unregister-CopilotMcpServer -Name old-server
        Removes the specified MCP server.
    .EXAMPLE
        Get-CopilotMcpServer old* | Unregister-CopilotMcpServer
        Removes servers matching the filter.
    #>

    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'ByObject')]
    param(
        [Parameter(ParameterSetName = 'ByObject', ValueFromPipeline, Mandatory)]
        [PSObject]$InputObject,

        [Parameter(ParameterSetName = 'ByName', Position = 0, Mandatory)]
        [string]$Name
    )
    process {
        $removeName = if ($PSCmdlet.ParameterSetName -eq 'ByName') { $Name } else { $InputObject.Name }
        Assert-CopilotShimArgument -Value $removeName -ParameterName 'Name' -Pattern '^[A-Za-z0-9][A-Za-z0-9._-]*$'

        $copilotExe = Resolve-CliExe -Name copilot
        if ($PSCmdlet.ShouldProcess($removeName, 'copilot mcp remove')) {
            Assert-CopilotMcpConfigNotSymbolicLink
            & $copilotExe mcp remove $removeName 2>&1
            if ($LASTEXITCODE -ne 0) {
                Write-Error "Failed to remove MCP server: $removeName"
            }
        }
    }
}