Skip to content

Add Start-FinOpsMultitool cmdlet — interactive GUI for tenant-wide FinOps scanning - #2155

Draft
Zac larsen (z-larsen) wants to merge 79 commits into
microsoft:devfrom
z-larsen:feature/finops-multitool
Draft

Add Start-FinOpsMultitool cmdlet — interactive GUI for tenant-wide FinOps scanning#2155
Zac larsen (z-larsen) wants to merge 79 commits into
microsoft:devfrom
z-larsen:feature/finops-multitool

Conversation

@z-larsen

@z-larsen Zac larsen (z-larsen) commented May 19, 2026

Copy link
Copy Markdown

🛠️ Description

Adds the Azure FinOps Multitool to the FinOps toolkit. Discussed with Brett Wilson (@MSBrett), who suggested contributing the tool into the official toolkit.

The Multitool scans an Azure tenant for cost optimization, governance, and FinOps insights — cost trends, orphaned resources, idle VMs, tag hygiene, reservation/savings-plan utilization, AHB opportunities, budgets, anomaly alerts, and policy compliance. Analysis scans are read-only (Reader / Cost Management Reader) and ground their findings in the customer's live resource state. Four write/remediation tools (delete orphaned resource, deallocate idle VM, enable AHB, set cost allocation rule) are dry-run by default, gated by a configurable write-safety policy, and disabled unless FINOPS_WRITE_MODE is set — the server defaults to ReadOnly.

This PR delivers two interfaces over one shared scanner engine, so the same scan logic is reused everywhere:

Interface Entry point Platform Best for
TUI Invoke-FinOpsMultitool Windows, macOS, Linux Terminal-only / cross-platform, CI-friendly
MCP server Start-McpServer.ps1 Any (stdio JSON-RPC) AI agents calling scans as tools

A separate WPF GUI is maintained outside this repo; this PR contributes the TUI and MCP server only.

Shared scanner modules (modules/)

30 modular scanners (one per category): orphaned resources, idle VMs, storage tier advice, AHB, tag inventory/recommendations, policy inventory/recommendations, cost data/trend/by-tag, resource costs, reservation advice, commitment utilization, savings realized, budget status, anomaly alerts, Advisor optimization advice, billing structure, contract info, tenant hierarchy, and more.

FinOps Hub data paths

When a FinOps Hub is present, cost scans prefer the hub's Kusto database — an Azure Data Explorer / Fabric cluster (auto-discovered via Resource Graph) or a local ftklocal emulator (FINOPS_HUB_KUSTO_URI) — and push aggregation into the engine, returning only summarized results. This scales to large hubs (tens of GB / hundreds of millions of rows) without loading raw cost rows into PowerShell. The storage-export reader remains as a small-dataset convenience fallback, used only when no Kusto cluster is reachable.

MCP server + agent skills

Start-McpServer.ps1 exposes the scanners as 40 MCP tools — 36 read-only (30 scan_*, plus run_full_scan, detect_cost_data_source, get_azure_context, and other helpers) and 4 gated write/remediation tools — over the 2024-11-05 MCP protocol via stdio. .vscode/mcp.json registers the server for VS Code, and Test-McpServer.ps1 provides protocol-level unit tests.

A companion agent-skill ecosystem (src/templates/agent-skills/) teaches AI agents to use the server proactively and to route findings into the wider FinOps practice. The finops-multitool skill acts as the hub, handing off to 11 FinOps-adjacent skills: power-bi-finops, cost-allocation, azure-policy-governance, unit-economics, finops-reporting, azure-workbooks-finops, forecasting-budgeting, anomaly-investigation, focus-data-quality, sustainability-carbon, and rate-optimization-portfolio.

📦 Files added / changed

Path Purpose
Public/Start-FinOpsMultitool.ps1 Public cmdlet — launches the cross-platform TUI
Invoke-FinOpsMultitool.ps1 + FinOpsMultitool.psm1 Cross-platform TUI + module loader
Start-McpServer.ps1 MCP server — 40 tools (36 read-only + 4 gated write)
Test-McpServer.ps1 MCP protocol unit tests
modules/ 30 shared scanner modules + write/remediation modules
helpers/Get-FOHubProvider.ps1 + Invoke-FOHubKustoQuery.ps1 Scalable FinOps Hub Kusto data path (ADX / Fabric / ftklocal)
helpers/Confirm-WriteAction.ps1 Write-safety policy gate (modes, guardrails, audit log)
agent-skills/finops-multitool/ Hub skill for the MCP server
agent-skills/cost-data-source/ Cost data-source routing skill (Kusto vs storage vs API)
agent-skills/{power-bi-finops, cost-allocation, …}/ 11 FinOps-adjacent skills
.vscode/mcp.json MCP server registration for VS Code
Tests/Unit/Start-FinOpsMultitool.Tests.ps1 + FOHubProvider.Tests.ps1 Pester unit tests
docs-mslearn/.../powershell/multitool/ + docs/multitool.md Documentation (command reference, landing page, TOC, changelog)

📸 Screenshots

Screenshots are in the public repo README.

📋 Checklist

🧪 How did you test this change?

  • 🧹 Lint tests
  • 👍 PS -WhatIf / az validate
  • 🔌 Manually deployed + verified
  • 🧪 Unit tests (TUI + MCP protocol)
  • 👀 Integration tests

🐳 Deploy to test?

N/A — standalone PowerShell tooling (TUI / MCP server), not a template deployment.

🏷️ Do any of the following that apply?

  • 🚨 This is a breaking change.
  • 🐣 The change is less than 20 lines of code.

📄 Did you update docs/changelog.md?

  • ✅ Updated changelog
  • ❌ Log not needed (small/internal change)

📖 Did you update documentation?

  • ✅ Documentation updated — FinOps Multitool reference under docs-mslearn/.../powershell/multitool/, a Jekyll landing page, overview/TOC/changelog entries, and the module README + finops-multitool / cost-data-source skills.
  • ❌ Docs not needed (small/internal change)

… GUI

Adds the Azure FinOps Multitool as a new PowerShell cmdlet in the FinOps toolkit. The Multitool is a WPF-based GUI that scans an Azure tenant for cost optimization, governance, and FinOps insights including cost trends, orphaned resources, idle VMs, tag hygiene, reservation/savings plan utilization, AHB opportunities, budgets, anomaly alerts, and policy compliance.

- Public/Start-FinOpsMultitool.ps1: thin launcher cmdlet with comment-based help

- Private/FinOpsMultitool/: full implementation (24 scanner modules, WPF GUI, Power BI template)

- Tests/Unit/Start-FinOpsMultitool.Tests.ps1: Pester unit tests

Windows-only (requires WPF support).
@z-larsen

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree company="Microsoft"

@flanakin

Copy link
Copy Markdown
Collaborator

Zac larsen (@z-larsen) This is exciting! I don't know much about the tool, but would love to learn more. Can you join us at the contributor sync next Wednesday to share?

https://aka.ms/ftk/contrib-sync

@z-larsen

Copy link
Copy Markdown
Author

Thanks, Michael! Would love to join.

Zac Larsen added 15 commits May 26, 2026 22:11
…info

- Add contract-aware cost access warning banner (EA/MCA/CSP) on Overview tab
- Add contract-specific billing tab messages when billing access unavailable
- Add MG hierarchy unavailable info node in tree view with role guidance
- Fix tag cost queries: use TagKey grouping type (not Tag/Dimension)
- Add batched TagKey+TagValue query attempt with per-tag fallback
- Clear skipSubs between batched and per-tag strategies
- Add throttle pacing (2s every 2 queries) to avoid 429s
- Add EA/MCA cost access detection in Get-CostData
- Add runspace pool for API call parallelization
@flanakin Michael Flanakin (flanakin) added this to the v15 milestone Jun 24, 2026
@microsoft-github-policy-service microsoft-github-policy-service Bot removed the Needs: Documentation 📝 PR needs a changelog entry, public docs, or internal docs before it can be closed label Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] PR Review

Summary: This is an ambitious, well-designed contribution — the write-safety gate (Resolve-WriteDecision/Confirm-WriteAction.ps1), the Kusto-first hub data path, and the MCP server's JSON-RPC handling are all solid, and every blocker from the prior maintainer review (dead entry point, permissive default write mode, Set-CostAllocationRule bypassing the gate, string-id crash, run_full_scan invoking write tools) is fixed on this branch. This pass focuses on what's still open. A second, separate review will follow on naming/packaging (module integration, "Multitool" naming) per the PR author's request — this pass is the technical/mechanical review only.

🚫 Blockers (8)

  1. Deploy-ResourceTag.ps1 / Deploy-PolicyAssignment.ps1 ship live ARM writes with zero write-safety gate, reachable after Import-Module.
  2. Get-BudgetStatus.ps1 silently loses all budget data for tenants with 50+ subscriptions.
  3. Get-PolicyRecommendations.ps1 has a malformed policy definition GUID that can never match.
  4. Read-FinOpsHubData.ps1's Parquet-reader cache uses a predictable path that skips all verification once installed.
  5. Read-FinOpsHubData.ps1 never hash/signature-verifies the downloaded Parquet.Net payload DLLs before loading them.
  6. Get-SharedCostAllocation.ps1 builds a KQL clause via unescaped string interpolation.
  7. Get-VmCostBreakdown.ps1 builds a KQL clause via unescaped string interpolation.

⚠️ Should fix (23)

Grouped by theme: test coverage gaps (4), scanner module bugs/fragility (6), documentation accuracy (7), style/naming consistency (3), TUI robustness (3).

💡 Suggestions (14)

Grouped by theme: minor security hygiene (3), additional test coverage gaps (3), code quality/duplication (4), doc/skill completeness (4).

One systemic note not tied to a single line: none of the 33 scanner modules use standard PowerShell comment-based help (.SYNOPSIS/.PARAMETER) — they use a custom banner-comment convention instead. Low priority since these are private, non-exported functions, but worth a conscious call rather than a silent drift from CLAUDE.md's "public functions must have comment-based help" convention (most of these are effectively public within the module's own surface).

# Preserves existing tags -- only adds or updates the target tag.
###########################################################################

function Deploy-ResourceTag {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 🚫 Blocker

Deploy-ResourceTag issues a real ARM PATCH (line ~55) with no dry-run, no -Apply switch, no ConfirmationToken, and no call to Resolve-WriteDecision/Confirm-WriteAction.ps1 — unlike every other mutating tool in this PR (Remove-OrphanedResource, Stop-IdleVm, Enable-HybridBenefit, Set-CostAllocationRule), which all route through that single write-safety gate.

FinOpsMultitool.psm1 has no manifest/Export-ModuleMember, so Deploy-ResourceTag and Remove-ResourceTag become directly callable in any session that does Import-Module FinOpsMultitool.psm1 — which is exactly what Start-FinOpsMultitool/Invoke-FinOpsMultitool and Start-McpServer.ps1 both do. Neither the TUI menu nor the MCP tool list currently calls these functions, so there's no reachable path today — but they ship fully wired and load automatically, contradicting the PR's core safety claim ("every mutating tool routes through the gate") and creating a live foot-gun for anyone who later wires a menu item or MCP tool to them without remembering to add the gate.

Either route this through Resolve-WriteDecision like the other four write tools, or remove it from this PR until it's wired up with the same safety story.

# Uses ARM REST API PUT to create policy assignments.
###########################################################################

function Deploy-PolicyAssignment {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 🚫 Blocker

Same issue as Deploy-ResourceTag.ps1: Deploy-PolicyAssignment (PUT, line ~99) and Remove-PolicyAssignment (DELETE, line ~144) perform real ARM mutations with no Resolve-WriteDecision call, no -Apply gate, and no confirmation token — bypassing the write-safety architecture entirely. They're dot-sourced unconditionally by FinOpsMultitool.psm1:82 and become callable in any session that imports the module (both TUI and MCP server do). Not currently reachable via the TUI menu or an MCP tool definition, but shipping live/ungated ARM-mutating code in a PR whose entire safety narrative is "every write goes through the gate" is a real inconsistency — please gate these the same way, or drop them from this PR.

$budgetPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/budgets?api-version=2023-05-01"
$resp = Invoke-AzRestMethodWithRetry -Path $budgetPath -Method GET
if ($resp.StatusCode -eq 200) {
$budgets = ($resp.Content | ConvertFrom-Json).value

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 🚫 Blocker

$budgets starts as a [System.Collections.Generic.List[PSCustomObject]] (line 29), but for tenants with more than 50 subscriptions this line reassigns it to a plain array from the sampling response: $budgets = ($resp.Content | ConvertFrom-Json).value. The main collection loop later calls [void]$budgets.Add(...) (line ~148), which throws on a plain array/$null — and that exception is silently swallowed by the surrounding try/catch, counted as subsWithoutBudget.

Net effect: for any tenant with 50+ subscriptions, budget data comes back silently empty even when budgets exist. Use a distinct variable name for the sampling-response value (e.g. $sampleBudgetsResp) so it doesn't clobber the accumulator.

)
}
[PSCustomObject]@{
PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/ea3f2387-9b95-492a-a190-fcbef5-37f7'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 🚫 Blocker

The policy definition ID for "Inherit a tag from the resource group if missing" has an extra hyphen: ea3f2387-9b95-492a-a190-fcbef5-37f7. Splitting on - gives segments [8,4,4,4,6,4] instead of the valid GUID shape [8,4,4,4,12] — it's 35 characters, not 36. This recommendation can never match a real policy assignment's definition ID, so it will always be reported "missing" even when the built-in policy is actually assigned, and the ID can't be used to deploy it. Please verify the correct GUID against Get-AzPolicyDefinition -Builtin | Where-Object DisplayName -eq 'Inherit a tag from the resource group if missing' and fix the typo.

if ($loaded) { return $true }

$parquetDir = Join-Path ([System.IO.Path]::GetTempPath()) 'FinOpsMultitool-Parquet'
$markerFile = Join-Path $parquetDir '.installed'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 🚫 Blocker

The Parquet-reader install cache lives at a fixed, predictable path ($env:TEMP/FinOpsMultitool-Parquet). Once the .installed marker file exists there, subsequent runs skip nuget.exe entirely and jump straight to Import-ParquetAssemblies — no Authenticode check, no hash check, nothing (lines 52-61). On a shared/multi-tenant temp directory (e.g. /tmp on Linux/macOS, or any host an attacker has prior local access to), someone who can write to that predictable path before the victim's first run can plant malicious DLLs plus a .installed marker and have them Add-Type-loaded into the victim's PowerShell process with zero verification on every subsequent run. Consider a per-user, randomized, or ACL'd cache location, and/or re-verifying on every load rather than trusting a marker file alone.

$rows = $null
$cols = $null

switch ($mod.Fn) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 💡 Suggestion

This ~450-line switch ($mod.Fn) block (result-table formatting) and a similarly large one further down (contextual guidance text) each independently enumerate all ~26 scan modules, duplicating per-module knowledge in two places. Combined with the picker/runner/export logic all living as nested functions inside one 2441-line top-level function, this is a maintainability concern worth addressing in a follow-up — e.g. colocating format/guidance metadata with each Get-*.ps1 module, or splitting the renderer and guidance engine into their own files.

$results = Invoke-SelectedScans -Modules $finalModules -Subscriptions $subs -TenantId $tenantId -DataSource $dataSource

# Step 5: Summary + export
$global:FinOpsResults = Show-ResultsSummary -Results $results -Modules $finalModules -ExportPath $OutputPath -Subscriptions $subs

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 💡 Suggestion

$global:FinOpsResults pollutes global scope. Likely intentional for interactive drill-down after the TUI exits (documented a few lines up), but worth a one-line comment noting the trade-off, or a more collision-resistant name (e.g. $global:FinOpsMultitoolResults).

$exportDir = $ExportPath
}
else {
$defaultPath = Join-Path (Get-Location) 'FinOpsResults'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 💡 Suggestion

-OutputPath (and the interactively-typed export path here) flows directly into Test-Path/New-Item/Join-Path with no normalization or traversal check. Low risk — this is a local interactive tool acting on a path the user themselves typed for their own machine, no untrusted/remote input reaches it — but a Resolve-Path-based sanity check would be a defensive-programming nicety. Separately, the doc at start-finopsmultitool.md line 40 says -OutputPath "defaults to the tool's working folder," but the actual behavior is an interactive [E] Export [Enter] Skip prompt when omitted — if the user doesn't press E, nothing is exported at all; if they do, the suggested default is <current directory>/FinOpsResults, not simply "the working folder."


Pick the narrowest tool that answers the question. Use `run_full_scan` only for a broad assessment.

| Intent | Tool | Category |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 💡 Suggestion

This intent-routing table lists only 21 of the 36 read-only tools — it omits scan_budget_history, scan_unit_economics, scan_vm_cost_breakdown, scan_allocate_shared_cost, scan_billing_account, scan_usage_allocation, scan_ai_workloads, scan_legacy_resources, scan_carbon, scan_macc_commitment, get_azure_context, generate_powerbi_template, connect_powerbi_to_hub, and explore_finops_kpis. Since this table is exactly how an AI agent decides which tool to call for a given intent, the missing rows mean the agent may not discover those 14 tools exist for their matching questions. Worth filling out for completeness.

4. **Alert thresholds** — set multiple (e.g. 50/80/100% actual, plus a forecasted-to-exceed alert). Forecasted alerts warn *before* the overrun.
5. **Actions** — wire alerts to an action group (email/Teams/webhook). For automated response, trigger off the budget alert, not a manual check.

## Variance analysis (budget vs actual)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 💡 Suggestion

This variance-analysis section doesn't reference scan_budget_history ("monthly budget vs actual history") despite it being the tool purpose-built for exactly this analysis — only scan_budget_status/scan_cost_trend are cited. Worth adding a pointer to it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] PR Review

Summary: This is an ambitious, well-designed contribution — the write-safety gate (Resolve-WriteDecision/Confirm-WriteAction.ps1), the Kusto-first hub data path, and the MCP server's JSON-RPC handling are all solid, and every blocker from the prior maintainer review (dead entry point, permissive default write mode, Set-CostAllocationRule bypassing the gate, string-id crash, run_full_scan invoking write tools) is fixed on this branch. This pass focuses on what's still open. A second, separate review will follow on naming/packaging (module integration, "Multitool" naming) per the PR author's request — this pass is the technical/mechanical review only.

🚫 Blockers (8)

  1. Deploy-ResourceTag.ps1 / Deploy-PolicyAssignment.ps1 ship live ARM writes with zero write-safety gate, reachable after Import-Module.
  2. Get-BudgetStatus.ps1 silently loses all budget data for tenants with 50+ subscriptions.
  3. Get-PolicyRecommendations.ps1 has a malformed policy definition GUID that can never match.
  4. Read-FinOpsHubData.ps1's Parquet-reader cache uses a predictable path that skips all verification once installed.
  5. Read-FinOpsHubData.ps1 never hash/signature-verifies the downloaded Parquet.Net payload DLLs before loading them.
  6. Get-SharedCostAllocation.ps1 builds a KQL clause via unescaped string interpolation.
  7. Get-VmCostBreakdown.ps1 builds a KQL clause via unescaped string interpolation.

⚠️ Should fix (23)

Grouped by theme: test coverage gaps (4), scanner module bugs/fragility (6), documentation accuracy (7), style/naming consistency (3), TUI robustness (3).

💡 Suggestions (14)

Grouped by theme: minor security hygiene (3), additional test coverage gaps (3), code quality/duplication (4), doc/skill completeness (4).

One systemic note not tied to a single line: none of the 33 scanner modules use standard PowerShell comment-based help (.SYNOPSIS/.PARAMETER) — they use a custom banner-comment convention instead. Low priority since these are private, non-exported functions, but worth a conscious call rather than a silent drift from CLAUDE.md's "public functions must have comment-based help" convention (most of these are effectively public within the module's own surface).

# Preserves existing tags -- only adds or updates the target tag.
###########################################################################

function Deploy-ResourceTag {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 🚫 Blocker

Deploy-ResourceTag issues a real ARM PATCH (line ~55) with no dry-run, no -Apply switch, no ConfirmationToken, and no call to Resolve-WriteDecision/Confirm-WriteAction.ps1 — unlike every other mutating tool in this PR (Remove-OrphanedResource, Stop-IdleVm, Enable-HybridBenefit, Set-CostAllocationRule), which all route through that single write-safety gate.

FinOpsMultitool.psm1 has no manifest/Export-ModuleMember, so Deploy-ResourceTag and Remove-ResourceTag become directly callable in any session that does Import-Module FinOpsMultitool.psm1 — which is exactly what Start-FinOpsMultitool/Invoke-FinOpsMultitool and Start-McpServer.ps1 both do. Neither the TUI menu nor the MCP tool list currently calls these functions, so there's no reachable path today — but they ship fully wired and load automatically, contradicting the PR's core safety claim ("every mutating tool routes through the gate") and creating a live foot-gun for anyone who later wires a menu item or MCP tool to them without remembering to add the gate.

Either route this through Resolve-WriteDecision like the other four write tools, or remove it from this PR until it's wired up with the same safety story.

# Uses ARM REST API PUT to create policy assignments.
###########################################################################

function Deploy-PolicyAssignment {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 🚫 Blocker

Same issue as Deploy-ResourceTag.ps1: Deploy-PolicyAssignment (PUT, line ~99) and Remove-PolicyAssignment (DELETE, line ~144) perform real ARM mutations with no Resolve-WriteDecision call, no -Apply gate, and no confirmation token — bypassing the write-safety architecture entirely. They're dot-sourced unconditionally by FinOpsMultitool.psm1:82 and become callable in any session that imports the module (both TUI and MCP server do). Not currently reachable via the TUI menu or an MCP tool definition, but shipping live/ungated ARM-mutating code in a PR whose entire safety narrative is "every write goes through the gate" is a real inconsistency — please gate these the same way, or drop them from this PR.

$budgetPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/budgets?api-version=2023-05-01"
$resp = Invoke-AzRestMethodWithRetry -Path $budgetPath -Method GET
if ($resp.StatusCode -eq 200) {
$budgets = ($resp.Content | ConvertFrom-Json).value

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 🚫 Blocker

$budgets starts as a [System.Collections.Generic.List[PSCustomObject]] (line 29), but for tenants with more than 50 subscriptions this line reassigns it to a plain array from the sampling response: $budgets = ($resp.Content | ConvertFrom-Json).value. The main collection loop later calls [void]$budgets.Add(...) (line ~148), which throws on a plain array/$null — and that exception is silently swallowed by the surrounding try/catch, counted as subsWithoutBudget.

Net effect: for any tenant with 50+ subscriptions, budget data comes back silently empty even when budgets exist. Use a distinct variable name for the sampling-response value (e.g. $sampleBudgetsResp) so it doesn't clobber the accumulator.

)
}
[PSCustomObject]@{
PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/ea3f2387-9b95-492a-a190-fcbef5-37f7'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 🚫 Blocker

The policy definition ID for "Inherit a tag from the resource group if missing" has an extra hyphen: ea3f2387-9b95-492a-a190-fcbef5-37f7. Splitting on - gives segments [8,4,4,4,6,4] instead of the valid GUID shape [8,4,4,4,12] — it's 35 characters, not 36. This recommendation can never match a real policy assignment's definition ID, so it will always be reported "missing" even when the built-in policy is actually assigned, and the ID can't be used to deploy it. Please verify the correct GUID against Get-AzPolicyDefinition -Builtin | Where-Object DisplayName -eq 'Inherit a tag from the resource group if missing' and fix the typo.

if ($loaded) { return $true }

$parquetDir = Join-Path ([System.IO.Path]::GetTempPath()) 'FinOpsMultitool-Parquet'
$markerFile = Join-Path $parquetDir '.installed'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 🚫 Blocker

The Parquet-reader install cache lives at a fixed, predictable path ($env:TEMP/FinOpsMultitool-Parquet). Once the .installed marker file exists there, subsequent runs skip nuget.exe entirely and jump straight to Import-ParquetAssemblies — no Authenticode check, no hash check, nothing (lines 52-61). On a shared/multi-tenant temp directory (e.g. /tmp on Linux/macOS, or any host an attacker has prior local access to), someone who can write to that predictable path before the victim's first run can plant malicious DLLs plus a .installed marker and have them Add-Type-loaded into the victim's PowerShell process with zero verification on every subsequent run. Consider a per-user, randomized, or ACL'd cache location, and/or re-verifying on every load rather than trusting a marker file alone.

$rows = $null
$cols = $null

switch ($mod.Fn) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 💡 Suggestion

This ~450-line switch ($mod.Fn) block (result-table formatting) and a similarly large one further down (contextual guidance text) each independently enumerate all ~26 scan modules, duplicating per-module knowledge in two places. Combined with the picker/runner/export logic all living as nested functions inside one 2441-line top-level function, this is a maintainability concern worth addressing in a follow-up — e.g. colocating format/guidance metadata with each Get-*.ps1 module, or splitting the renderer and guidance engine into their own files.

$results = Invoke-SelectedScans -Modules $finalModules -Subscriptions $subs -TenantId $tenantId -DataSource $dataSource

# Step 5: Summary + export
$global:FinOpsResults = Show-ResultsSummary -Results $results -Modules $finalModules -ExportPath $OutputPath -Subscriptions $subs

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 💡 Suggestion

$global:FinOpsResults pollutes global scope. Likely intentional for interactive drill-down after the TUI exits (documented a few lines up), but worth a one-line comment noting the trade-off, or a more collision-resistant name (e.g. $global:FinOpsMultitoolResults).

$exportDir = $ExportPath
}
else {
$defaultPath = Join-Path (Get-Location) 'FinOpsResults'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 💡 Suggestion

-OutputPath (and the interactively-typed export path here) flows directly into Test-Path/New-Item/Join-Path with no normalization or traversal check. Low risk — this is a local interactive tool acting on a path the user themselves typed for their own machine, no untrusted/remote input reaches it — but a Resolve-Path-based sanity check would be a defensive-programming nicety. Separately, the doc at start-finopsmultitool.md line 40 says -OutputPath "defaults to the tool's working folder," but the actual behavior is an interactive [E] Export [Enter] Skip prompt when omitted — if the user doesn't press E, nothing is exported at all; if they do, the suggested default is <current directory>/FinOpsResults, not simply "the working folder."


Pick the narrowest tool that answers the question. Use `run_full_scan` only for a broad assessment.

| Intent | Tool | Category |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 💡 Suggestion

This intent-routing table lists only 21 of the 36 read-only tools — it omits scan_budget_history, scan_unit_economics, scan_vm_cost_breakdown, scan_allocate_shared_cost, scan_billing_account, scan_usage_allocation, scan_ai_workloads, scan_legacy_resources, scan_carbon, scan_macc_commitment, get_azure_context, generate_powerbi_template, connect_powerbi_to_hub, and explore_finops_kpis. Since this table is exactly how an AI agent decides which tool to call for a given intent, the missing rows mean the agent may not discover those 14 tools exist for their matching questions. Worth filling out for completeness.

4. **Alert thresholds** — set multiple (e.g. 50/80/100% actual, plus a forecasted-to-exceed alert). Forecasted alerts warn *before* the overrun.
5. **Actions** — wire alerts to an action group (email/Teams/webhook). For automated response, trigger off the budget alert, not a manual check.

## Variance analysis (budget vs actual)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] 💡 Suggestion

This variance-analysis section doesn't reference scan_budget_history ("monthly budget vs actual history") despite it being the tool purpose-built for exactly this analysis — only scan_budget_status/scan_cost_trend are cited. Worth adding a pointer to it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] PR Review — packaging & ecosystem alignment

Summary: Separate pass from the earlier technical review, focused on how this fits the existing FinOps toolkit PowerShell module rather than code correctness. Two items below; more (versioning sync, shared .vscode/mcp.json scope) are being discussed with the author directly rather than posted here, to avoid overwhelming a first release with polish items that can land in a fast-follow.

⚠️ Should fix (2)

  1. The ~40 scan/write functions aren't real module cmdlets — they're private and only reachable through the TUI menu or a separately-launched MCP server process.
  2. A nested LICENSE file with a personal copyright holder shouldn't exist inside a Microsoft OSS repo.

@@ -0,0 +1,94 @@
# Copyright (c) Microsoft Corporation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] ⚠️ Should fix

All 18 existing public cmdlets in this module (Get-FinOpsCostExport, New-FinOpsCostExport, etc.) are eagerly loaded and exported by src/powershell/FinOpsToolkit.psm1 via its flat Private/*.ps1 / Public/*.ps1 glob. This file bypasses that entirely: Public/Start-FinOpsMultitool.ps1 manually dot-sources Invoke-FinOpsMultitool.ps1 at call time, which lazy-loads this whole subsystem — none of the ~40 scan/write functions here are ever exported or visible to Get-Command -Module FinOpsToolkit. They're only reachable through the TUI menu or by separately launching Start-McpServer.ps1 as its own process.

For a toolkit whose whole existing story is "install one module, get named Verb-FinOps*Noun cmdlets you can script directly," that's a real inconsistency, not just style. It also means these functions don't follow the -FinOps noun-prefix convention used everywhere else (Get-OrphanedResources vs. the established Get-FinOpsCostExport pattern) — worth fixing together, since promoting these to public cmdlets later would need the rename anyway to avoid generic-name collisions with other modules (e.g. Get-BillingAccount, Get-BudgetStatus are exactly the kind of generic nouns the FinOps prefix exists to avoid).

Suggest at least the read-only scan functions become real Get-FinOps* public cmdlets that the TUI and MCP server both call as thin wrappers, matching how the rest of the module works — rather than a self-contained subsystem bolted on behind one entry point.

@@ -0,0 +1,21 @@
MIT License

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] ⚠️ Should fix

This file asserts "Copyright (c) 2026 Zac Larsen" — a different copyright holder than the repo's top-level LICENSE ("Copyright (c) Microsoft Corporation"), which already covers every other file in this module. A nested per-subsystem LICENSE with a personal copyright holder inside a Microsoft OSS repo is confusing at best and a real licensing-clarity problem at worst, once this code is merged and distributed as part of the FinOpsToolkit package.

This looks like a carry-over from the original standalone repo (linked in the PR description) rather than an intentional choice — 45 of the 53 .ps1 files under this folder are also missing the standard # Copyright (c) Microsoft Corporation. / # Licensed under the MIT License. header that every other file in the module (including 8 files within this same PR) carries, and 12 files still have a personal # Author: Zac Larsen banner instead. Recommend deleting this LICENSE file and bringing the header convention in line with the rest of the module — the top-level repo LICENSE already covers contributions under the MIT terms (and the CLA was signed on this PR).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] PR Review — packaging & ecosystem alignment (2/2)

One more from the same packaging/alignment pass.

# =====================================================================
$MCP_VERSION = '2024-11-05'
$SERVER_NAME = 'finops-multitool'
$SERVER_VERSION = '1.3.0'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude Code] ⚠️ Should fix

$SERVER_VERSION = '1.3.0' is a hardcoded, independent version number, disconnected from the toolkit's own release cycle (currently heading to 15.0.0 per the changelog entry in this PR). Every other major component (src/optimization-engine, src/templates/finops-workbooks, src/templates/finops-alerts/modules, src/templates/finops-hub/modules/fx) has a ftkver.txt that src/scripts/Update-Version.ps1 finds via a recursive Get-ChildItem and overwrites on every release — it's opt-in by file existence, so a missing ftkver.txt here just means this never gets touched. Without one, $SERVER_VERSION will silently drift from the actual toolkit version forever (an MCP client will keep seeing "1.3.0" no matter what FinOpsToolkit release it's actually running against). Add a ftkver.txt under src/powershell/Private/FinOpsMultitool/ and have Start-McpServer.ps1 read it instead of hardcoding the string, so it stays in sync automatically.

@MSBrett Brett Wilson (MSBrett) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes. A post-submission rubber-duck pass independently rechecked all 11 findings against live head 5f5deaa8; none were withdrawn. Final severity is 0 Critical, 7 High, 4 Medium. The snapshot finding is High rather than Critical because the unsafe private-function path is not currently exposed through the TUI, and the subscription finding is financial-scope correctness rather than an RBAC boundary violation. Merge blockers remain: missing snapshot orphan validation, cross-subscription scope contamination, broken macOS Kusto authentication, silent Hub-to-API fallback with incorrect provenance, unusable nested CSV exports, invalid SecureString bearer-token handling, inaccurate realized-savings calculations, and a Hub-provider suite that fails Pester 6 discovery. Please address the inline findings, align the documentation and PR description with the current head, resolve the base conflicts, and rerun validation.

'Microsoft.Compute/disks' = @{ api = '2023-04-02'; label = 'Managed disk'; inUseProps = @('managedBy', 'diskState'); kind = 'attachment' }
'Microsoft.Network/publicIPAddresses' = @{ api = '2023-09-01'; label = 'Public IP address'; inUseProps = @('ipConfiguration', 'natGateway'); kind = 'attachment' }
'Microsoft.Network/networkInterfaces' = @{ api = '2023-09-01'; label = 'Network interface'; inUseProps = @('virtualMachine', 'privateEndpoint'); kind = 'attachment' }
'Microsoft.Compute/snapshots' = @{ api = '2023-04-02'; label = 'Disk snapshot'; inUseProps = @(); kind = 'backup' }

@MSBrett Brett Wilson (MSBrett) Aug 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: This snapshot entry has no in-use properties, so the execution-time orphan loop evaluates nothing and always passes. The latest commit only blocks an unparseable GET response; it does not establish that a valid recovery point is orphaned. This path is not reachable through the current TUI and the MCP server was removed, so it is a latent defect requiring a direct private-function call rather than a current UI-triggerable delete. Please remove snapshots from this generic path or require fresh source, age, retention/protection, and scan-evidence validation before any remediation surface exposes it.

if ($kustoProvider) {
Write-Host ""
Write-Host " Querying FinOps Hub Kusto database ($($kustoProvider.Mode))..." -ForegroundColor Green
$cs = Get-FOHubCostSummary -Provider $kustoProvider

@MSBrett Brett Wilson (MSBrett) Aug 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High (functional correctness): The selected IDs are computed above, but none of the three Hub preload calls receives -SubscriptionIds $subIdsForDisco. In addition, -RestrictToSelected is declared by the cost summary, trend, and resource-cost scanners but is never passed at any call site. A one-subscription UAT consequently returned 25 Hub subscriptions and cross-subscription resources. This stays within the caller’s RBAC, but it contaminates reports labeled as single-subscription. Pass the selected scope through every Hub, storage, and management-group cost path.

$tok = (Get-AzAccessToken -ResourceUrl $ResourceUrl).Token
if ($tok -is [securestring]) {
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($tok)
try { [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: SecureStringToBSTR returns UTF-16 data, but PtrToStringAuto is platform-dependent. On macOS this converted a live 2,612-character JWT into a one-character string, causing every remote Kusto query to return 401. Decode this pointer with Marshal.PtrToStringBSTR.

if (-not ($rc -is [System.Collections.IDictionary] -and $rc.Contains('Error') -and $rc.Error)) { $hubResourceCosts = $rc }
$ct = Get-FOHubCostByTag -Provider $kustoProvider
if (-not ($ct -is [System.Collections.IDictionary] -and $ct.Contains('Error') -and $ct.Error)) { $hubCostByTag = $ct }
Write-Host " Hub data summarized in-engine (no rows loaded). Forecast is not included; choose API source for live forecast." -ForegroundColor DarkGray

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: This success message is unconditional even when all three provider calls returned errors and their results were discarded. The scan then falls through to Cost Management APIs while the UI still labels the source as FinOps Hub. Preserve and surface the provider error, require an explicit fallback decision, and update result provenance whenever the provider changes.

if ($data -and @($data).Count -gt 0) {
$safeName = $mod.Fn -replace '[^a-zA-Z0-9\-]', ''
$csvPath = Join-Path $exportDir "$safeName.csv"
$data | Export-Csv -Path $csvPath -NoTypeInformation

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Several scan contracts return wrappers containing nested hashtables or arrays. Exporting the wrapper directly produces cells such as System.Collections.Hashtable and System.Object[]; the cost-summary export even turns subscription IDs into columns. Define a tabular projection for each scan contract before CSV export.

$sub = if ($subNameById.ContainsKey($sid)) { $subNameById[$sid] } else { $sid }
}
if ($pm -match 'Reservation') {
$ri += $cost * 0.4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Multiplying amortized committed cost by a fixed discount percentage does not calculate realized savings. For example, a paid cost of $100 at a 40% discount implies about $66.67 savings, not $40, and real discounts vary by SKU, term, region, and agreement. Use benefit-utilization/savings data or compare against the matching PAYG benchmark; otherwise label this explicitly as a heuristic rather than realized savings.

}

if ($PreselectedId) {
$sub = Get-AzSubscription -SubscriptionId $PreselectedId -ErrorAction SilentlyContinue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: A supplied -SubscriptionId is resolved only after the tenant picker, and this lookup omits -TenantId. In UAT it attempted tokens across unrelated tenants and emitted repeated conditional-access/MFA warnings. Resolve the subscription and tenant first, set that context, and bypass both pickers when the caller already supplied the scope.


& "$PSScriptRoot/../Initialize-Tests.ps1"

BeforeAll {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Initialize-Tests.ps1, invoked above, already declares a root-level BeforeAll. Pester 6 rejects this second root-level block during discovery, so none of the Hub-provider tests runs. Combine the setup into one supported BeforeAll or move the module import into the existing initialization block.

Write-Host ""
Write-Host " ↑↓ Navigate │ Enter = Select tenant │ Q = Stay in current" -ForegroundColor DarkGray

$tKey = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: The public cmdlet has no non-RawUI path. Cursor repainting plus RawUI.ReadKey makes focus and selection state unreliable for screen readers and can hang or throw in remoting, redirected, and CI hosts. Add a numbered line-oriented accessible mode, explicit noninteractive parameters, and an early RawUI capability check.

Comment thread docs/multitool.md
</div>
<div class="ftk-tile">
<div>🛡️ Write-safety policy</div>
<div>Optional remediation tools are dry-run by default and gated by a configurable write-safety policy. The server is read-only out of the box.</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: The current head no longer contains the MCP server, yet this page still describes a server and its write-safety mode. Other public help also advertises Excel/JSON/Power BI exports, TUI remediation, and Windows PowerShell 5.1 behavior that the reviewed head does not provide. Please reconcile the public docs, Learn content, module README, agent skill, and PR description with the code that will actually ship.

@microsoft-github-policy-service microsoft-github-policy-service Bot added Needs: Attention 👋 Issue or PR needs to be reviewed by the author or it will be closed due to no activity and removed Needs: Review 👀 PR that is ready to be reviewed labels Aug 21, 2026
Finding 6 (High): Get-AzAccessToken now returns Token as a SecureString, so
interpolating it produced "Bearer System.Security.SecureString" and every
metric query returned 401. The empty catch turned that into zero findings
rather than an error. Get-IdleVMs, Get-StorageTierAdvice, and
Get-AIWorkloadMetrics now use Get-PlainAccessToken, and the AI scanner
surfaces a token failure instead of swallowing it.

Finding 3 (High): Get-PlainAccessToken used PtrToStringAuto, which picks the
platform default encoding and truncated the JWT to one character on macOS.
A BSTR is always UTF-16, so decode with PtrToStringBSTR.

Finding 1 (High): Microsoft.Compute/snapshots declared inUseProps = @(), so
the orphan verification loop iterated zero times and passed vacuously. Whether
a snapshot is safe to delete depends on retention and backup policy, which
this tool does not evaluate, and the delete is irreversible. Removed snapshots
from the deletable allow-list.

Finding 2 (High): the selected subscription scope was computed but never
applied. Passed -SubscriptionIds to the three Hub Kusto queries, added
-RestrictToSelected in the generic scan dispatcher for the scanners that
declare it, and added -SubscriptionIds plus a shared row-subscription
resolver to the Hub storage reader, which had no scope mechanism at all.

Finding 4 (High): provider errors were discarded and the UI printed
"Hub data summarized in-engine" unconditionally, then fell back to the Cost
Management API while still labelling results as Hub. Errors are now surfaced
per query and a total failure states that results are not from the Hub.

Finding 9 (Medium): Initialize-Tests.ps1 already declares a root-level
BeforeAll, and Pester 6 rejects a second one during discovery, so the Hub
provider tests would not run in CI (CI installs Pester unpinned). Moved the
module import into the Describe block.

Also replaced MCP tool names and apply=true syntax in user-facing messages
with the PowerShell equivalents, since the MCP server was removed.
@microsoft-github-policy-service microsoft-github-policy-service Bot added Needs: Review 👀 PR that is ready to be reviewed and removed Needs: Attention 👋 Issue or PR needs to be reviewed by the author or it will be closed due to no activity labels Aug 21, 2026
Zac Larsen added 3 commits August 21, 2026 12:59
Savings was computed as amortized committed cost multiplied by a flat
discount percentage, which measures a share of what was paid rather than
the gap up to pay-as-you-go. $100 paid at a 40% discount implies $66.67
saved, not $40.

Savings is now paid * d / (1 - d). The assumed rates are named constants
with the derivation documented alongside them.

Real discounts vary by SKU, term, region, and agreement, so the RI and
savings plan figures remain an estimate. The result now carries IsEstimate
and EstimateBasis, the module header no longer claims measured savings, and
the terminal UI prints the basis under the breakdown.
…s (review finding 8)

A supplied -SubscriptionId was resolved only after the tenant picker had
already run, and the lookup omitted -TenantId. Get-AzSubscription without a
tenant probes every accessible tenant, so a scoped run emitted repeated
conditional-access/MFA warnings for tenants that refuse.

Resolution now happens immediately after the connection check: the current
context tenant is tried first, falling back to an explicit per-tenant lookup
only if that misses. On success the context is set to that subscription and
tenant and both pickers are bypassed, which is what an explicitly scoped
invocation should do.

Also picks up a formatter pass that split "} catch {" across lines in
Get-SavingsRealized.ps1 to match the surrounding style.
…review finding 5)

Exports piped the raw scan wrapper straight to Export-Csv, so collection and
hashtable properties landed as "System.Object[]" and
"System.Collections.Hashtable" cells, and the cost summary - a hashtable keyed
by subscription - turned every subscription id into a column.

Adds ConvertTo-FinOpsExportRows, which projects a result to flat rows:
explicit projections for the cost summary and cost-by-tag contracts, a
key/value shape for other dictionaries, and for wrapper objects the single
collection property (falling back to a preferred name when several exist) so
scans that follow the existing pattern export correctly without a case of
their own. Summary-only contracts export their scalar properties as one row.

Every projection then passes through ConvertTo-FinOpsExportCell, which
guarantees scalar cells regardless of contract - dictionaries and arrays are
rendered as delimited text rather than type names.

Verified against a wrapper-plus-array result, a subscription-keyed hashtable,
a nested tag map, and a summary-only object: no object-type cells in any.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs: Review 👀 PR that is ready to be reviewed Tool: PowerShell PowerShell scripts and automation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants