|
| 1 | +function Get-CippMcpToolList { |
| 2 | + <# |
| 3 | + .SYNOPSIS |
| 4 | + Projects the CIPP OpenAPI spec into the read-only MCP tool list. |
| 5 | + .DESCRIPTION |
| 6 | + Returns every operation whose x-cipp-role ends in '.Read' (never '.ReadWrite') as an |
| 7 | + MCP tool definition: name (the API endpoint), description, inputSchema (JSON Schema |
| 8 | + built from the operation's query parameters / request body with $ref inlined), and |
| 9 | + read-only annotations. Cached per worker; pass -Force to rebuild. Not an entrypoint. |
| 10 | + The spec is consumed as nested hashtables (Get-CippMcpSpec uses -AsHashtable). |
| 11 | + .FUNCTIONALITY |
| 12 | + Internal |
| 13 | + #> |
| 14 | + [CmdletBinding()] |
| 15 | + param([switch]$Force) |
| 16 | + |
| 17 | + if ($script:CippMcpToolListCache -and -not $Force) { |
| 18 | + return $script:CippMcpToolListCache |
| 19 | + } |
| 20 | + |
| 21 | + $Spec = Get-CippMcpSpec |
| 22 | + $Tools = [System.Collections.Generic.List[object]]::new() |
| 23 | + |
| 24 | + foreach ($PathEntry in $Spec['paths'].GetEnumerator()) { |
| 25 | + $Endpoint = $PathEntry.Key -replace '^/api/', '' |
| 26 | + |
| 27 | + # Never expose the MCP transport itself as a tool. |
| 28 | + if ($Endpoint -eq 'ExecMcp') { continue } |
| 29 | + |
| 30 | + foreach ($MethodEntry in $PathEntry.Value.GetEnumerator()) { |
| 31 | + $Method = [string]$MethodEntry.Key |
| 32 | + if ($Method -notin @('get', 'post')) { continue } |
| 33 | + |
| 34 | + $Op = $MethodEntry.Value |
| 35 | + $Role = $Op['x-cipp-role'] |
| 36 | + |
| 37 | + # Read-only surface only. |
| 38 | + if (-not $Role -or $Role -notmatch '\.Read$') { continue } |
| 39 | + |
| 40 | + # Defensive backstop: never expose an endpoint whose name implies a mutation, |
| 41 | + # even if its x-cipp-role is mislabeled '.Read' (e.g. AddTestReport, EditIntunePolicy). |
| 42 | + if ($Endpoint -match '^(Add|Set|Remove|Delete|Edit|New|Update|Disable|Enable|Reset|Revoke|Push|Clear|Start|Stop|Rename|Move|Copy)') { continue } |
| 43 | + |
| 44 | + $Properties = [ordered]@{} |
| 45 | + $RequiredList = [System.Collections.Generic.List[string]]::new() |
| 46 | + |
| 47 | + # Query / path parameters. |
| 48 | + foreach ($ParamRaw in @($Op['parameters'])) { |
| 49 | + if (-not $ParamRaw) { continue } |
| 50 | + $Param = Resolve-CippMcpNode -Node $ParamRaw -Spec $Spec |
| 51 | + if ($Param['in'] -notin @('query', 'path')) { continue } |
| 52 | + $Schema = if ($Param['schema']) { $Param['schema'] } else { @{ type = 'string' } } |
| 53 | + $Properties[[string]$Param['name']] = $Schema |
| 54 | + if ($Param['required']) { $RequiredList.Add([string]$Param['name']) } |
| 55 | + } |
| 56 | + |
| 57 | + # Request body (uncommon for reads; included for completeness). |
| 58 | + if ($Op['requestBody'] -and $Op['requestBody']['content'] -and $Op['requestBody']['content']['application/json']) { |
| 59 | + $BodySchema = Resolve-CippMcpNode -Node $Op['requestBody']['content']['application/json']['schema'] -Spec $Spec |
| 60 | + if ($BodySchema -and $BodySchema['properties']) { |
| 61 | + foreach ($BodyProp in $BodySchema['properties'].GetEnumerator()) { |
| 62 | + $Properties[[string]$BodyProp.Key] = $BodyProp.Value |
| 63 | + } |
| 64 | + foreach ($Req in @($BodySchema['required'])) { if ($Req) { $RequiredList.Add([string]$Req) } } |
| 65 | + } |
| 66 | + } |
| 67 | + |
| 68 | + $InputSchema = [ordered]@{ |
| 69 | + type = 'object' |
| 70 | + properties = $Properties |
| 71 | + } |
| 72 | + if ($RequiredList.Count -gt 0) { |
| 73 | + $InputSchema['required'] = @($RequiredList | Select-Object -Unique) |
| 74 | + } |
| 75 | + |
| 76 | + $Tools.Add([ordered]@{ |
| 77 | + name = $Endpoint |
| 78 | + description = Get-CippMcpDescription -Operation $Op |
| 79 | + inputSchema = $InputSchema |
| 80 | + annotations = [ordered]@{ title = $Endpoint; readOnlyHint = $true } |
| 81 | + }) |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + $script:CippMcpToolListCache = $Tools |
| 86 | + return $Tools |
| 87 | +} |
| 88 | + |
| 89 | +function Resolve-CippMcpNode { |
| 90 | + # Deep-resolves a parsed OpenAPI node (hashtable/array/scalar), inlining any $ref. Internal helper. |
| 91 | + param($Node, $Spec, [int]$Depth = 0, [string[]]$Seen = @()) |
| 92 | + |
| 93 | + if ($null -eq $Node) { return $null } |
| 94 | + if ($Depth -gt 15) { return @{ type = 'object' } } |
| 95 | + if ($Node -is [string] -or $Node -is [valuetype]) { return $Node } |
| 96 | + |
| 97 | + if ($Node -is [System.Collections.IDictionary]) { |
| 98 | + if ($Node.Contains('$ref')) { |
| 99 | + $Ref = [string]$Node['$ref'] |
| 100 | + if ($Seen -contains $Ref) { return [ordered]@{ type = 'object'; description = 'recursive reference omitted' } } |
| 101 | + $Target = Resolve-CippMcpRef -Ref $Ref -Spec $Spec |
| 102 | + return Resolve-CippMcpNode -Node $Target -Spec $Spec -Depth ($Depth + 1) -Seen ($Seen + $Ref) |
| 103 | + } |
| 104 | + $Out = [ordered]@{} |
| 105 | + foreach ($Entry in $Node.GetEnumerator()) { |
| 106 | + if ($Entry.Key -eq '$ref') { continue } |
| 107 | + $Out[[string]$Entry.Key] = Resolve-CippMcpNode -Node $Entry.Value -Spec $Spec -Depth ($Depth + 1) -Seen $Seen |
| 108 | + } |
| 109 | + return $Out |
| 110 | + } |
| 111 | + |
| 112 | + if ($Node -is [System.Collections.IEnumerable]) { |
| 113 | + return @($Node | ForEach-Object { Resolve-CippMcpNode -Node $_ -Spec $Spec -Depth ($Depth + 1) -Seen $Seen }) |
| 114 | + } |
| 115 | + |
| 116 | + return $Node |
| 117 | +} |
| 118 | + |
| 119 | +function Resolve-CippMcpRef { |
| 120 | + # Resolves a JSON pointer like '#/components/parameters/tenantFilter' against the spec. Internal helper. |
| 121 | + param([string]$Ref, $Spec) |
| 122 | + |
| 123 | + $Segments = $Ref.TrimStart('#') -split '/' | Where-Object { $_ -ne '' } |
| 124 | + $Node = $Spec |
| 125 | + foreach ($Seg in $Segments) { |
| 126 | + $Key = $Seg -replace '~1', '/' -replace '~0', '~' |
| 127 | + if ($Node -is [System.Collections.IDictionary] -and $Node.Contains($Key)) { |
| 128 | + $Node = $Node[$Key] |
| 129 | + } else { |
| 130 | + return $null |
| 131 | + } |
| 132 | + } |
| 133 | + return $Node |
| 134 | +} |
| 135 | + |
| 136 | +function Get-CippMcpDescription { |
| 137 | + # Cleans the operation description (strips leaked PowerShell help) and prefixes the tag. Internal helper. |
| 138 | + param($Operation) |
| 139 | + |
| 140 | + $Desc = [string]$Operation['description'] |
| 141 | + $Desc = $Desc -replace '(?s)\s*#>.*$', '' |
| 142 | + $Desc = $Desc -replace '(?s)\[CmdletBinding.*$', '' |
| 143 | + $Desc = $Desc.Trim() |
| 144 | + if ([string]::IsNullOrWhiteSpace($Desc)) { $Desc = [string]$Operation['summary'] } |
| 145 | + |
| 146 | + $Tag = @($Operation['tags'])[0] |
| 147 | + if ($Tag -and $Tag -ne 'Uncategorized') { $Desc = "[$Tag] $Desc" } |
| 148 | + return $Desc |
| 149 | +} |
0 commit comments