Skip to content

feat(mcp): CUDly MCP server for RI/SP/CUD purchases across AWS, Azure, GCP - #1495

Open
cristim wants to merge 83 commits into
mainfrom
docs/mcp-server-design
Open

feat(mcp): CUDly MCP server for RI/SP/CUD purchases across AWS, Azure, GCP#1495
cristim wants to merge 83 commits into
mainfrom
docs/mcp-server-design

Conversation

@cristim

@cristim cristim commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

Implements the CUDly MCP server (#1488): a local MCP server exposing CUDly's RI/SP/CUD search and purchase surface across AWS, Azure, and GCP to any MCP client. Supersedes the design-only PR that previously occupied this slot; the design doc (docs/design/mcp-server.md) has been removed and its findings folded into code comments and the new mcp/README.md.

What landed (fully wired, real-purchase capable)

  • cudly_list_commitment_actions — catalog of every tool, generated from each tool's own descriptor so it can never drift from what's registered.
  • cudly_search_recommendations — read-only wrapper over Provider.GetRecommendationsClient().GetRecommendations().
  • cudly_aws_ec2_ri_purchase
  • cudly_aws_opensearch_ri_purchase, cudly_aws_redshift_ri_purchase, cudly_aws_memorydb_ri_purchase
  • cudly_aws_rds_ri_purchase, cudly_aws_elasticache_ri_purchase
  • cudly_aws_savingsplans_purchase
  • cudly_azure_compute_ri_purchase
  • cudly_gcp_computeengine_cud_purchase

All nine purchase tools share one safety gate (mcp/tools/purchase.go): dry_run defaults true and never contacts the cloud provider; a real purchase requires dry_run=false AND confirm=true, otherwise a structured error is returned (never a silent no-op). Every real purchase is stamped with a new common.PurchaseSourceMCP enum value (never a caller-suppliable string) and a deterministic idempotency token, so a retried identical call dedupes at the provider instead of double-buying.

Findings that changed scope from the design doc

Nothing deferred to dry-run-only

Every provider ended up real-purchase capable. Two pre-existing gaps were flagged (not fixed, out of scope) in the affected tool's description and mcp/README.md:

  • Azure's purchase body never sends a billingPlanType, so every Azure VM RI purchase uses Azure's default (upfront) billing plan regardless of the payment_option requested — it only affects the displayed cost estimate.
  • GCP Compute Engine CUDs are a vCPU+memory commitment, not an instance count — cudly_gcp_computeengine_cud_purchase takes vcpu_count/memory_gb directly.

Verification

  • go build ./..., go test ./... (6087+ tests across 41 packages), go vet ./... all green at every commit.
  • gocyclo -over 10 clean on every new file.
  • CI-pinned golangci-lint v2.10.1 (matching ci.yml, not the newer local default) run explicitly: 0 issues, repo-wide.
  • gosec clean on every commit via the pre-commit hook.
  • An end-to-end test (mcp/server_test.go) drives the real MCP protocol over an in-memory transport: connects a real client to the real server, lists tools, and calls cudly_aws_ec2_ri_purchase with dry_run omitted, proving the default-true dry-run path returns structured cost JSON with zero AWS credentials configured and zero provider calls made.

Closes #1488

Summary by CodeRabbit

  • New Features
    • Added local desktop MCP stdio server (cudly-mcp) with discoverable tools for recommendation search and preview-to-execute purchases across AWS, Azure, and GCP, plus a commitment-action catalog.
  • Documentation
    • Added setup/usage guidance, including purchase safety and real-purchase opt-in rules.
  • Bug Fixes
    • Improved Azure purchase billing-plan mapping and validation for payment options; ensured provider warnings don’t corrupt MCP protocol traffic; refined AWS recommendation region filtering and search fan-out behavior.
  • Tests
    • Expanded coverage for tool schemas, gating/confirm logic, idempotency, and end-to-end MCP search/purchase flows.

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-quarter Within the quarter impact/internal Team-internal only effort/l Weeks type/docs Documentation labels Jul 22, 2026
@cristim

cristim commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 35 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: db624471-8aab-429e-b3f5-3ee10a7aaa35

📥 Commits

Reviewing files that changed from the base of the PR and between a1d2696 and 707cd14.

📒 Files selected for processing (5)
  • mcp/README.md
  • mcp/tools/azure_compute_ri.go
  • mcp/tools/idempotency_scope_test.go
  • mcp/tools/purchase.go
  • mcp/tools/search_recommendations.go

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds a local stdio MCP server exposing cross-cloud recommendation search, commitment purchase tools, safety-gated execution, action discovery, provider integrations, Azure billing-plan mapping, tests, and setup documentation.

Changes

MCP commitment management

Layer / File(s) Summary
Shared contracts and purchase execution
mcp/tools/registry.go, mcp/tools/schema.go, mcp/tools/enums.go, mcp/tools/purchase.go, mcp/tools/list_commitment_actions.go, pkg/common/types.go
Defines tool metadata, schema refinement, strict validation, action discovery, dry-run and confirmation gates, credential scoping, deterministic idempotency, response mapping, and MCP purchase-source normalization.
Provider commitment tools and search
mcp/tools/aws_*.go, mcp/tools/azure_compute_ri.go, mcp/tools/gcp_computeengine_cud.go, mcp/tools/search_recommendations.go
Adds AWS RI/Savings Plan tools, Azure Compute RI, GCP Compute Engine CUD, and cross-provider recommendation search with provider-specific validation, filters, defaults, schemas, and credential overrides.
Server wiring and provider behavior
mcp/server.go, cmd/cudly-mcp/*, providers/aws/*, providers/azure/services/*
Registers tools on the versioned stdio server, normalizes AWS recommendation regions, keeps protocol diagnostics off stdout, and maps validated payment options to Azure reservation billing plans before side effects.
Validation, documentation, and dependencies
*_test.go, README.md, mcp/README.md, go.mod
Adds unit, protocol, integration, and provider request coverage; documents setup and safety behavior; and adds MCP SDK and JSON-schema dependencies.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MCPClient
  participant MCPServer
  participant ToolHandler
  participant ProviderClient

  MCPClient->>MCPServer: Invoke search or purchase tool
  MCPServer->>ToolHandler: Dispatch registered tool
  ToolHandler->>ToolHandler: Validate arguments and safety flags

  alt Dry-run or unconfirmed
    ToolHandler-->>MCPClient: Return preview or refusal
  else Confirmed real purchase
    ToolHandler->>ProviderClient: Resolve credentials and client
    ToolHandler->>ProviderClient: Purchase commitment with idempotency token
    ProviderClient-->>ToolHandler: Return purchase result or error
    ToolHandler-->>MCPClient: Return structured response
  end
Loading

Possibly related issues

  • #1502: Adds related Azure billing-plan wiring and preserves rejection of unsupported partial-upfront purchases.
  • #1506: Adds related Savings Plans search defaults, validation, and region-filtering behavior.
  • #1535: Relates to GCP project credential scope and idempotency behavior.

Possibly related PRs

Suggested labels: type/security

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: a CUDly MCP server for RI/SP/CUD purchases across AWS, Azure, and GCP.
Linked Issues check ✅ Passed The PR adds the MCP server, per-tool purchase/search wrappers, safety gating, cataloging, docs, and tests required by #1488.
Out of Scope Changes check ✅ Passed I don't see clear unrelated code changes; the provider, parsing, and shared-constant updates all support MCP server behavior.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/mcp-server-design

Comment @coderabbitai help to get the list of available commands.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim force-pushed the docs/mcp-server-design branch from 8b88ce3 to 6581b27 Compare July 23, 2026 00:01
@cristim cristim removed the type/docs Documentation label Jul 23, 2026
@cristim cristim changed the title docs(mcp): architecture blueprint for CUDly MCP server feat(mcp): CUDly MCP server for RI/SP/CUD purchases across AWS, Azure, GCP Jul 23, 2026
@cristim cristim added the type/feat New capability label Jul 23, 2026
@cristim

cristim commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 5

🧹 Nitpick comments (2)
mcp/tools/azure_compute_ri.go (1)

135-141: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared dry_run/confirm resolution helper. Both files reimplement the identical dryRun, confirm = true, false; if args.DryRun != nil {...}; if args.Confirm != nil {...} block for resolving optional *bool args with defaults.

  • mcp/tools/azure_compute_ri.go#L135-L141: replace with a call to a shared helper, e.g. dryRun, confirm = resolveDryRunConfirm(args.DryRun, args.Confirm).
  • mcp/tools/gcp_computeengine_cud.go#L142-L148: replace with the same shared helper call.
♻️ Proposed shared helper
// in a shared tools file, e.g. registry.go
func resolveDryRunConfirm(dryRun, confirm *bool) (bool, bool) {
	d, c := true, false
	if dryRun != nil {
		d = *dryRun
	}
	if confirm != nil {
		c = *confirm
	}
	return d, c
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/azure_compute_ri.go` around lines 135 - 141, The dryRun/confirm
default resolution is duplicated across both tool implementations. Add a shared
resolveDryRunConfirm helper in the tools package that preserves defaults of true
and false while handling optional pointers, then replace the inline blocks in
mcp/tools/azure_compute_ri.go lines 135-141 and
mcp/tools/gcp_computeengine_cud.go lines 142-148 with calls to it.
mcp/tools/aws_ec2_ri.go (1)

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Dry_run/confirm default resolution is duplicated 5 times. Each purchase tool independently re-implements "dry_run defaults to true, confirm defaults to false, resolved from *bool pointers" instead of sharing one generic helper — the root cause is that effectiveDryRunConfirm in mcp/tools/aws_ec2_ri.go is typed to ec2RIPurchaseArgs and can't be reused elsewhere.

  • mcp/tools/aws_ec2_ri.go#L143-156: generalize effectiveDryRunConfirm to take (dryRun, confirm *bool) (bool, bool) and move it to a shared file (e.g. purchase.go) so all tools can call it.
  • mcp/tools/aws_elasticache_ri.go#L145-151: replace the inline block with a call to the shared helper.
  • mcp/tools/aws_rds_ri.go#L152-158: replace the inline block with a call to the shared helper.
  • mcp/tools/aws_savingsplans.go#L162-168: replace the inline block with a call to the shared helper.
  • mcp/tools/aws_simple_ri.go#L180-186: replace the inline block with a call to the shared helper.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/aws_ec2_ri.go` at line 1, Generalize effectiveDryRunConfirm to
accept dryRun and confirm *bool, returning resolved defaults of true and false,
and move it into a shared purchase helper file. Replace the duplicated
resolution blocks in the ElastiCache, RDS, Savings Plans, and Simple RI purchase
tools with calls to this shared helper, while preserving their existing
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mcp/README.md`:
- Around line 9-13: Update the build and launch instructions in the MCP README
to use go install ./cmd/cudly-mcp instead of go build -o cudly-mcp, then
instruct users to launch cudly-mcp from PATH; apply the same change to the
referenced duplicate section.

In `@mcp/tools/aws_savingsplans.go`:
- Around line 70-79: The Register method currently permits unsupported term and
payment combinations for SPTypeDatabase; add a Database-specific validation
branch before building the recommendation that accepts only TermOneYear and
PaymentOptionNoUpfront, rejecting 3-year or upfront payment options while
preserving existing validation for other savings plan types.

In `@mcp/tools/aws_simple_ri.go`:
- Around line 91-105: Add a human-readable display-name field to the RI tool
specification and use it in simpleAWSRIPurchaseTool.Descriptor when formatting
the purchase description, while retaining t.spec.product for machine-readable
metadata such as Product. Populate display names with the correctly cased AWS
product names.

In `@mcp/tools/azure_compute_ri.go`:
- Around line 15-27: Update the Azure compute RI purchase flow using
azureComputeRIPurchaseDescription so non-default payment_option values are
rejected when dry_run=false and confirm=true. Preserve those options for dry-run
validation, and retain the existing behavior for the default upfront option
until the purchase body supports billingPlanType.

In `@mcp/tools/search_recommendations.go`:
- Around line 25-38: Add IncludeRegions and ExcludeRegions fields to
searchRecommendationsArgs with appropriate JSON/schema metadata, then update the
search recommendations handler to forward both values into
common.RecommendationParams. Preserve the existing behavior when either filter
is omitted.

---

Nitpick comments:
In `@mcp/tools/aws_ec2_ri.go`:
- Line 1: Generalize effectiveDryRunConfirm to accept dryRun and confirm *bool,
returning resolved defaults of true and false, and move it into a shared
purchase helper file. Replace the duplicated resolution blocks in the
ElastiCache, RDS, Savings Plans, and Simple RI purchase tools with calls to this
shared helper, while preserving their existing behavior.

In `@mcp/tools/azure_compute_ri.go`:
- Around line 135-141: The dryRun/confirm default resolution is duplicated
across both tool implementations. Add a shared resolveDryRunConfirm helper in
the tools package that preserves defaults of true and false while handling
optional pointers, then replace the inline blocks in
mcp/tools/azure_compute_ri.go lines 135-141 and
mcp/tools/gcp_computeengine_cud.go lines 142-148 with calls to it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 13bba9cf-49e2-462b-a91a-bc9f2d0dcc55

📥 Commits

Reviewing files that changed from the base of the PR and between 70c9a7b and 6581b27.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • go.work.sum is excluded by !**/*.sum
📒 Files selected for processing (32)
  • cmd/cudly-mcp/main.go
  • go.mod
  • mcp/README.md
  • mcp/server.go
  • mcp/server_test.go
  • mcp/tools/aws_ec2_ri.go
  • mcp/tools/aws_ec2_ri_test.go
  • mcp/tools/aws_elasticache_ri.go
  • mcp/tools/aws_elasticache_ri_test.go
  • mcp/tools/aws_rds_ri.go
  • mcp/tools/aws_rds_ri_test.go
  • mcp/tools/aws_savingsplans.go
  • mcp/tools/aws_savingsplans_test.go
  • mcp/tools/aws_simple_ri.go
  • mcp/tools/aws_simple_ri_test.go
  • mcp/tools/azure_compute_ri.go
  • mcp/tools/azure_compute_ri_test.go
  • mcp/tools/enums.go
  • mcp/tools/enums_test.go
  • mcp/tools/gcp_computeengine_cud.go
  • mcp/tools/gcp_computeengine_cud_test.go
  • mcp/tools/list_commitment_actions.go
  • mcp/tools/list_commitment_actions_test.go
  • mcp/tools/purchase.go
  • mcp/tools/purchase_test.go
  • mcp/tools/registry.go
  • mcp/tools/schema.go
  • mcp/tools/schema_test.go
  • mcp/tools/search_recommendations.go
  • mcp/tools/search_recommendations_test.go
  • pkg/common/types.go
  • pkg/common/types_test.go

Comment thread mcp/README.md
Comment thread mcp/tools/aws_savingsplans.go
Comment thread mcp/tools/aws_simple_ri.go
Comment thread mcp/tools/azure_compute_ri.go Outdated
Comment thread mcp/tools/search_recommendations.go
@cristim

cristim commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 23 minutes.

@cristim

cristim commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 5

🧹 Nitpick comments (1)
mcp/tools/aws_ec2_ri.go (1)

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared dry_run/confirm defaulting logic instead of duplicating it five times. The same dryRun, confirm = true, false; if args.DryRun != nil {...}; if args.Confirm != nil {...} pattern is re-implemented identically in every AWS purchase tool file. Since this logic gates real-money purchases, keeping it in one place reduces the risk of the copies drifting out of sync.

  • mcp/tools/aws_ec2_ri.go#L143-156: generalize effectiveDryRunConfirm into a shared helper (e.g. resolveDryRunConfirm(dryRun, confirm *bool) (bool, bool)) taking the two *bool pointers directly instead of the ec2RIPurchaseArgs struct, and call it from here.
  • mcp/tools/aws_elasticache_ri.go#L145-152: replace the inline block with a call to the shared helper.
  • mcp/tools/aws_rds_ri.go#L152-159: replace the inline block with a call to the shared helper.
  • mcp/tools/aws_savingsplans.go#L180-187: replace the inline block with a call to the shared helper.
  • mcp/tools/aws_simple_ri.go#L184-191: replace the inline block with a call to the shared helper.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/aws_ec2_ri.go` at line 1, Extract the duplicated dryRun/confirm
defaulting into a shared resolveDryRunConfirm(dryRun, confirm *bool) helper,
preserving defaults of true and false and applying non-nil overrides. Refactor
effectiveDryRunConfirm in the EC2 purchase flow to use this pointer-based
helper, then replace the equivalent inline blocks in the ElastiCache RI, RDS RI,
Savings Plans, and Simple RI purchase flows with calls to it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mcp/README.md`:
- Around line 65-67: Update the MCP client registration guidance to state that
when GOBIN is unset, users must use $(go env GOPATH)/bin/cudly-mcp; remove the
misleading implication that $(go env GOBIN)/cudly-mcp is valid in that case.
Preserve the absolute-path guidance for configurations where GOBIN is set.

In `@mcp/server.go`:
- Around line 48-55: Update the registration flow around registrations(),
descriptors, and listTool so the cudly_list_commitment_actions descriptor is
included in its own catalog. Add the catalog tool’s descriptor to descriptors
before constructing listTool, while preserving the final registration list and
existing descriptor order.

In `@mcp/tools/aws_savingsplans.go`:
- Around line 133-139: Update the EC2Instance validation in the flow containing
validateDatabaseSPConstraints to require args.InstanceFamily in addition to
args.Region, returning a validation error when it is empty before proceeding
with purchase or constraint validation. Preserve existing validation behavior
for other savings plan types.

In `@mcp/tools/azure_compute_ri.go`:
- Around line 114-121: Reject whitespace-only required inputs at the validation
boundaries: update the Azure validation in mcp/tools/azure_compute_ri.go lines
114-121 and the GCP validation in mcp/tools/gcp_computeengine_cud.go lines
98-111 to trim-check region plus VMSize or machine_type while preserving
existing errors; add regression cases for whitespace-only values in
mcp/tools/azure_compute_ri_test.go lines 43-64 and
mcp/tools/gcp_computeengine_cud_test.go lines 46-69.

In `@mcp/tools/purchase.go`:
- Around line 76-87: Populate PurchaseResponse.TermYears in both the preview and
real-purchase branches of ExecutePurchase using the requested purchase term,
ensuring term_years is present in responses when applicable; otherwise remove
TermYears and its JSON contract if the value is not intended to be exposed.

---

Nitpick comments:
In `@mcp/tools/aws_ec2_ri.go`:
- Line 1: Extract the duplicated dryRun/confirm defaulting into a shared
resolveDryRunConfirm(dryRun, confirm *bool) helper, preserving defaults of true
and false and applying non-nil overrides. Refactor effectiveDryRunConfirm in the
EC2 purchase flow to use this pointer-based helper, then replace the equivalent
inline blocks in the ElastiCache RI, RDS RI, Savings Plans, and Simple RI
purchase flows with calls to it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d6ca4bc7-4a19-41dc-849d-8311bfdb978d

📥 Commits

Reviewing files that changed from the base of the PR and between 70c9a7b and f83bc90.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • go.work.sum is excluded by !**/*.sum
📒 Files selected for processing (33)
  • README.md
  • cmd/cudly-mcp/main.go
  • go.mod
  • mcp/README.md
  • mcp/server.go
  • mcp/server_test.go
  • mcp/tools/aws_ec2_ri.go
  • mcp/tools/aws_ec2_ri_test.go
  • mcp/tools/aws_elasticache_ri.go
  • mcp/tools/aws_elasticache_ri_test.go
  • mcp/tools/aws_rds_ri.go
  • mcp/tools/aws_rds_ri_test.go
  • mcp/tools/aws_savingsplans.go
  • mcp/tools/aws_savingsplans_test.go
  • mcp/tools/aws_simple_ri.go
  • mcp/tools/aws_simple_ri_test.go
  • mcp/tools/azure_compute_ri.go
  • mcp/tools/azure_compute_ri_test.go
  • mcp/tools/enums.go
  • mcp/tools/enums_test.go
  • mcp/tools/gcp_computeengine_cud.go
  • mcp/tools/gcp_computeengine_cud_test.go
  • mcp/tools/list_commitment_actions.go
  • mcp/tools/list_commitment_actions_test.go
  • mcp/tools/purchase.go
  • mcp/tools/purchase_test.go
  • mcp/tools/registry.go
  • mcp/tools/schema.go
  • mcp/tools/schema_test.go
  • mcp/tools/search_recommendations.go
  • mcp/tools/search_recommendations_test.go
  • pkg/common/types.go
  • pkg/common/types_test.go

Comment thread mcp/README.md Outdated
Comment thread mcp/server.go
Comment thread mcp/tools/aws_savingsplans.go Outdated
Comment thread mcp/tools/azure_compute_ri.go Outdated
Comment thread mcp/tools/purchase.go
@cristim

cristim commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 34 minutes.

@cristim

cristim commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

Adversarial review follow-up (Opus pass)

An independent adversarial review of this PR turned up two defects beyond what CodeRabbit's automated pass flagged. Both are fixed in the commits pushed just now.

DEFECT 1 (HIGH, confirmed at runtime): provider factories were never registered for the MCP binary

providers/aws, providers/azure, and providers/gcp each register their factory via init() in the package root (e.g. providers/aws/provider.go:495). cmd/main.go:21-23 blank-imports all three root packages so the CLI binary gets them; cmd/cudly-mcp/main.go did not, and nothing else in the mcp package's import graph pulled them in either.

Verified with go list -deps ./cmd/cudly-mcp before the fix: the provider root packages never appear in the dependency closure. At runtime this meant provider.CreateProvider("aws"|"azure"|"gcp") always returned "provider %s not registered", so every cudly_search_recommendations call and every real purchase (dry_run=false, confirm=true) failed immediately at the ResolveClient step. Only dry-run previews worked; the server's core purpose (real purchases) was completely non-functional.

Fix: added the same three blank imports to cmd/cudly-mcp/main.go that cmd/main.go already carries.

Regression test: added cmd/cudly-mcp/main_test.go, in package main specifically, because go test ./mcp/... cannot catch this bug at all -- that test binary never pulls in cmd/cudly-mcp's import graph, so the provider registration gap is invisible from mcp/'s own tests. The new test drives a real cudly_aws_ec2_ri_purchase call with dry_run=false, confirm=true through the actual server (real provider registry, not the fake createProvider test seam used elsewhere), with ambient AWS credentials/config fully isolated via t.Setenv so it's deterministic and makes no network call either locally or in CI. It asserts the resulting error is a credentials/not-configured error, never "not registered". Confirmed this test fails pre-fix (reproducing the exact bug) and passes post-fix.

DEFECT 2 (MEDIUM): idempotency key collided across genuinely distinct repeat purchases

mcp/tools/purchase.go's idempotencyKeyFor derived the token purely from purchase parameters (provider/region/service/resource/count/term/payment/details). Two distinct, intentional purchases with identical parameters -- e.g. "buy 3 m5.large RIs now" and "buy 3 more m5.large RIs next week" -- hashed to the same token. findRIByIdempotencyToken (AWS EC2 client) treats a matching existing tag as "this is a retry of an already-completed purchase" and silently skips the second buy. The user believes they own 6 RIs; they own 3. Nothing surfaces the discrepancy.

Fix: the idempotency key now folds in a discriminator that is either an explicit caller-supplied idempotency_nonce (new optional arg on every purchase tool) or, when omitted, an automatic hourly time bucket. This preserves the original retry-safety behavior (a rapid client-side retry after a network timeout, with no nonce, still lands in the same bucket and dedupes as before) while ensuring two genuinely separate purchases made hours/days/weeks apart no longer collide by default. A caller who wants strict, long-lived dedup across an arbitrary gap can pass the same idempotency_nonce value on both calls to force a match regardless of elapsed time.

Added test coverage in mcp/tools/purchase_test.go proving: same nonce -> same key across different times; no nonce + different time buckets -> different keys; no nonce + same bucket -> same key (rapid-retry safety preserved). Updated mcp/README.md's safety-model and troubleshooting sections to describe the new behavior accurately.

Both fixes keep the rest of the money-path hygiene this PR already had intact (dry-run fail-safe, typed-enum validation, nullable cost fields, no PII in logs) -- nothing there needed to change.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@cristim: Thanks for the detailed adversarial findings and the targeted regression coverage. I’ll review the newly pushed provider-registration and idempotency changes.

🐇🔍


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1495

Timestamp: 2026-07-23T14:41:10.563Z
Learning: In this Go repository, `providers/aws`, `providers/azure`, and `providers/gcp` register provider factories through package-level `init()` functions. Executable entrypoints that call MCP tools must blank-import all three provider root packages; importing only the `mcp` package does not register them.

Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1495

Timestamp: 2026-07-23T14:41:10.563Z
Learning: For MCP purchase tools in `mcp/tools`, idempotency must distinguish intentional repeat purchases from retries: callers can supply `idempotency_nonce` for long-lived deduplication, while omitted nonces use an hourly bucket to preserve short retry safety without indefinitely deduplicating equivalent purchases.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Real GCP CUD purchases are refused unless gcp_project_id is supplied, which
is correct, but the refusal read:

  refusing real purchase: the target account could not be determined, so
  pass gcp_project_id explicitly (or set ).

Two problems. It named an EMPTY environment variable, because GCP has no
ambient scope source for CredentialScope to fall back to. And "could not be
determined" implies an ambient source exists, sending a GCP caller looking
for a variable nothing reads.

Keeping the refusal strict, rather than inventing a GCP env fallback, is
deliberate:

  - Nothing in providers/gcp reads GOOGLE_CLOUD_PROJECT or
    CLOUDSDK_CORE_PROJECT, so adding one would invent a convention the
    provider does not honor.
  - It would also be unsafe. With no project configured the provider falls
    back to getDefaultProject, which returns the FIRST ACTIVE PROJECT in the
    caller's ListProjects response -- an artifact of IAM visibility and API
    ordering, not a project anyone declared. An env-supplied scope could
    name project A while the commitment landed in project B, making the
    idempotency token assert an account it never touched. That is the
    aliasing hazard requireCredentialScope exists to remove, in a worse form.

Providers now carry a credentialScopeSource describing their tool argument
and their ambient variable, if any. GCP has none, so it gets a plain
"gcp_project_id is required" message that explains why there is no fallback;
AWS and Azure keep naming AWS_PROFILE and AZURE_SUBSCRIPTION_ID so a caller
who set the variable is not told to pass an argument they do not need. The
doc comment records the reasoning so the missing fallback is not "fixed"
later. mcp/README.md states the contract alongside the existing scope rules.

TestRequireCredentialScopeGCPMessageIsActionable pins that GCP says
"is required" and never "could not be determined", and that AWS and Azure
still name their variables. Verified failing before this change.

The schema/description half of this finding, and the sibling effective_date
and test-assertion findings, are already fixed on this branch by f8ae701,
3a65136 and 85a86ac; this commit deliberately does not duplicate them.
Their rfc3339OrNil returns *string, which distinguishes "unknown" better than
the string version drafted here, so that one is kept and mine dropped.
@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

All three CodeRabbit findings are addressed. Two were already fixed on the branch concurrently (f8ae7017e, 3a65136eb, 85a86ac54); c19cff404 adds the remaining half of #1.

1. GCP CUD purchases refused — decision: keep it strict, and it is now a stated contract

I took the strict option, and after reading the provider I'd put it more strongly than "defensible": adding a GOOGLE_CLOUD_PROJECT fallback would be actively unsafe.

When no project is configured, providers/gcp/provider.go falls back to getDefaultProject, which returns the first ACTIVE project in the caller's ListProjects response — an artifact of IAM visibility and API ordering, not a project anyone declared. So an env-supplied scope could name project A while the commitment landed in project B, making the idempotency token positively assert an account it never touched. That is the aliasing hazard requireCredentialScope exists to remove, in a worse form than the original. Spending money in "whichever project happens to be listed first" is also not a defensible default on its own merits, independent of idempotency.

The schema/description half was fixed by f8ae7017e. What was still broken is the refusal message itself:

refusing real purchase: the target account could not be determined,
so pass gcp_project_id explicitly (or set ).

Note the empty variable nameCredentialScope(args.GCPProjectID) has no env fallback, so the generic message interpolated nothing. And "could not be determined" implies an ambient source exists, sending a GCP caller hunting for a variable nothing reads.

Providers now carry a credentialScopeSource (tool argument + ambient variable, if any). GCP has none, so it gets:

refusing real purchase: gcp_project_id is required for a real gcp purchase and was not
supplied. There is no environment fallback: with no project named, the provider would
spend in whichever project happens to be listed first for your credentials, and the
idempotency token could not identify the account, so a retry might not dedupe and could
purchase twice. Dry runs do not require it

AWS and Azure still name AWS_PROFILE / AZURE_SUBSCRIPTION_ID, so a caller who set the variable isn't told to pass an argument they don't need. The doc comment records why the fallback is absent, so it isn't "fixed" later by someone adding one. mcp/README.md states the contract alongside the existing scope rules.

TestRequireCredentialScopeGCPMessageIsActionable pins that GCP says "is required" and never "could not be determined", and that AWS/Azure still name their variables. Verified failing before the fix.

2. Fabricated effective_date — fixed by 85a86ac54, mine dropped

I'd independently written a string-returning rfc3339OrEmpty. 85a86ac54's rfc3339OrNil returns *string, which makes "unknown" structurally distinct rather than leaning on omitempty, matching nonZeroCostPtr and feedback_nullable_not_zero. That's the better shape, so I kept it and dropped mine. Their tests also already assert the marshalled payload contains neither effective_date nor 0001-01-01, plus the real-timestamp case — strictly more complete than what I'd drafted, so I removed my duplicate rather than ship overlapping coverage.

3. Unguarded index/assertion — fixed by 3a65136eb

Equivalent to what I'd written; took theirs.

Verification for c19cff404

gofmt clean; 306 tests pass under -race (mcp/, cmd/cudly-mcp/); providers/aws 1212 green; golangci-lint at the CI-pinned v2.10.1 = 0 issues; gocyclo -over 10 silent.

Net diff vs the previous head is 3 files / +114 −20 — only the message fix, its test, and the README line.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Addressed the three actionables: two fixed, one dismissed with reasoning

Head is now c19cff404. Verification at the bottom.

1. mcp/tools/gcp_computeengine_cud.go -- credential-scope env fallback: NOT applied, deliberately. (f8ae7017e)

The observation is correct and the prescription is not, so this needs a real answer rather than a silent skip.

Correct: the asymmetry is real, and omitting gcp_project_id does make requireCredentialScope refuse every real GCP CUD purchase.

Why the suggested fix must not be applied: CredentialScope's contract is that its fallback names the same variable the provider factory itself consults, so that naming an account explicitly and letting it resolve ambiently derive the same idempotency token. That symmetry holds for the siblings but does not exist for GCP:

  • Azure: providers/azure/provider.go:314 genuinely does target = os.Getenv("AZURE_SUBSCRIPTION_ID").
  • AWS: the profile is the factory's own resolution input.
  • GCP: resolveGCPProjectID (providers/gcp/provider.go:151) reads only config.GCPProjectID and the deprecated config.Profile. When both are empty, NewProvider falls through to getDefaultProject, which returns the first ACTIVE project from a paginated ListProjects. Every other os.Getenv in providers/gcp is for credentials (GOOGLE_APPLICATION_CREDENTIALS, CLOUDSDK_CONFIG, APPDATA) or a parallelism knob. There is no project env lookup anywhere in the package.

So adding GOOGLE_CLOUD_PROJECT to the CredentialScope call alone would make the token name one project while the money resolves through another:

gcp_project_id omitted, GOOGLE_CLOUD_PROJECT=proj-A
  -> scope "proj-A", but factory buys in getDefaultProject's pick, say proj-B
gcp_project_id="proj-B" passed explicitly
  -> scope "proj-B", factory buys in proj-B

Two different tokens, same target account, so the provider-side dedupe misses and the second call buys again. That is precisely the aliasing hazard 01c056685 ("refuse a real purchase with no determinable account") was written to close, so applying this suggestion would revert a safety fix on the purchase path.

Fixing it "properly" by teaching the factory to read the variable too is also out of scope here: providers/gcp is shared with the CLI, the web backend and the scheduler (which makes automated purchases), and GOOGLE_CLOUD_PROJECT conventionally names the project a process runs in, not the one it should buy for, so on a hosted runtime that would silently redirect purchases. That needs owner sign-off and a separate PR.

What was shipped instead, addressing the real user-facing complaint:

  • The reasoning is documented at the call site so a later pass does not "fix" it unsafely (f8ae7017e).
  • The gcp_project_id schema description was wrong and is corrected. It advertised the field as optional; it is optional for a dry_run preview and required for a real purchase. That mis-description is what made the refusal surprising in the first place, and the model reads this string (f8ae7017e).
  • c19cff404 then made the refusal itself actionable, which is the better form of this fix. Credential-scope sources are now modelled explicitly as credentialScopeSource{arg, envVar}, so AWS carries AWS_PROFILE, Azure carries AZURE_SUBSCRIPTION_ID, and GCP's empty envVar is explicit data rather than an omitted argument. GCP now gets a plain "gcp_project_id is required" message instead of "the target account could not be determined", which previously sent callers debugging an environment fallback that never existed.

The guard fails closed with a message naming the exact argument to pass, which is the correct direction on a money path.

The safe version of this change is tracked in #1535, scoped to deciding which variable is authoritative and moving the factory and the token together, since moving either alone reopens the hazard. Discussion also recorded on the original thread.

2. mcp/tools/purchase.go -- fabricated effective_date: fixed (85a86ac54)

Confirmed and fixed. omitempty on a string only drops "", and time.Time{}.Format(time.RFC3339) yields the literal "0001-01-01T00:00:00Z", so an unpopulated PurchaseResult.Timestamp shipped a real-looking start date in the year 1 on a field a caller may key billing or renewal reminders off.

EffectiveDate is now *string, populated via a new rfc3339OrNil helper that returns nil for the zero time. This is deliberately the same shape as the existing nonZeroCostPtr, so the money and date fields now share one idiom for "unknown stays unknown".

Two regression tests. The guard asserts against the marshaled JSON, not just the Go field, because the JSON payload is what actually crosses the MCP boundary; it was confirmed to fail on the pre-fix code and pass after. A companion test pins that suppressing the zero value does not suppress a genuine timestamp.

3. cmd/cudly-mcp/main_test.go -- unguarded index and type assertion: fixed (3a65136eb)

result.Content[0].(*gosdk.TextContent) panicked on an empty slice or a non-text block, aborting the whole package's test run instead of failing readably. Now checks the slice is non-empty and uses the comma-ok form, reporting the concrete type on failure.

Nitpicks

  • Enum overrides for payment_option / term_years and the stranded doc comment in mcp/server_test.go: both already addressed in 12fe374a3.
  • Oversized test files: tracking rather than fixing in this PR. The 500-line guideline is real, but this is pre-existing repo-wide debt, not something this PR introduced: the largest offenders are cmd/multi_service_test.go (1861 lines), cmd/helpers_test.go (1456) and cmd/main_test.go (1239), none of which this PR touches. 12fe374a3 already split search_recommendations_test.go, which was the PR-adjacent one. Splitting the rest here would mean a large mechanical diff on the highest-stakes PR in the repo, which is a bad trade against reviewability. Tracked in chore(tests): split Go test files above the 500-line ceiling #1536 instead, consistent with how the same finding was handled on feat(admin): per-API-key usage stats + summary card (closes #380 scope) #1523 and alongside refactor(auth): split store_postgres.go below the 500-line ceiling (856 lines) #1531, which tracks the same 500-line ceiling for internal/auth/store_postgres.go. Nothing is suppressed: no gate, lint rule or CI check keys on file length, so this is tracked debt rather than masked debt.

Verification on c19cff404

Reproduced locally with the CI-pinned toolchain, all exit code 0:

Check Result
gofmt -l . clean (empty list)
go vet ./... clean
go build ./... ok
go test ./mcp/... ./providers/aws/... ./cmd/cudly-mcp/... 15/15 packages ok
golangci-lint v2.10.1 (CI-pinned) 0 issues.
gocyclo -over 10 -ignore "_test\.go" . no findings

The three purchase-path safety invariants were re-verified by reading the code after the rebase rather than assumed: the idempotency key still folds the credential scope in as its second component and reads no clock; requireCredentialScope still runs ahead of both the ResolveClient nil-check and the ResolveClient call, so a refusal resolves no credentials; and isRegionAgnostic still requires all three of CommitmentSavingsPlan, an empty effective region, and a recognised account-level plan type.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@cristim Thanks for the detailed resolution. The GCP decision is sound: using an environment value that the provider factory does not itself honor would make the idempotency scope diverge from the billed project and reintroduce the duplicate-purchase risk. Requiring explicit gcp_project_id for real GCP purchases is the appropriate fail-closed contract pending the coordinated provider/token design in #1535.

I’ll run a full review of c19cff404, including the updated credential-scope messaging, nullable effective_date serialization, and hardened MCP test assertion.

🐇🔍


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1495

Timestamp: 2026-07-27T21:49:04.077Z
Learning: For real GCP MCP CUD purchases in `mcp/tools/gcp_computeengine_cud.go`, `gcp_project_id` must remain explicitly required until the GCP provider factory and MCP idempotency scope share one authoritative project-resolution mechanism. Do not add a `GOOGLE_CLOUD_PROJECT` fallback solely to `CredentialScope`, because the current GCP provider does not resolve that variable and its fallback can select the first active visible project, causing the idempotency token to identify a different project from the one billed.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🧹 Nitpick comments (8)
mcp/tools/search_recommendations.go (2)

220-241: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Six sequential Cost Explorer calls on an interactive request path.

When both term_years and payment_option are omitted for an AWS reservation search, this issues 6 serialized GetRecommendations calls. Cost Explorer routinely takes seconds per call, so a fully-omitted search can run tens of seconds with no per-combo deadline — long enough for an MCP client to time out the tool call and lose the whole result set (fail-whole-search means there is no partial value to salvage). The combos are independent; running them under a bounded errgroup would cut wall time to roughly one call.

Not a blocker for merge — the fail-loud semantics documented above are the right call — but worth measuring before this lands in front of real accounts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/search_recommendations.go` around lines 220 - 241, Update
fetchSearchCombos to execute independent recClient.GetRecommendations calls
concurrently using a bounded errgroup, limiting in-flight requests to avoid
unbounded concurrency. Preserve the existing fail-whole-search behavior and
error context, while safely aggregating results from successful combos and
propagating the first error.

79-88: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the LookbackPeriod constants instead of string literals.

Every other override here derives from the typed enum; lookback_period alone hardcodes its values, so adding a window to enums.go would update ValidateLookbackPeriod while leaving the advertised schema silently stale.

♻️ Proposed fix
-		"lookback_period": {Enum: []any{"7d", "30d", "60d"}},
+		"lookback_period": {Enum: []any{
+			string(LookbackPeriod7Days),
+			string(LookbackPeriod30Days),
+			string(LookbackPeriod60Days),
+		}},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/search_recommendations.go` around lines 79 - 88, Update the
lookback_period override in the BuildInputSchema configuration to derive its
Enum values from the typed LookbackPeriod constants rather than hardcoded
duration strings. Preserve the existing 7d, 30d, and 60d options while ensuring
future enum additions stay reflected in the advertised schema.
mcp/tools/enums_test.go (1)

207-231: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a ValidateLookbackPeriod table test.

Every other validator in enums.go is pinned here, but the one with non-obvious semantics — empty is valid ("let the provider default"), while 90d/7D are not — has no coverage. That carve-out is exactly the behavior a future refactor is most likely to break.

💚 Proposed test
func TestValidateLookbackPeriod(t *testing.T) {
	t.Parallel()
	cases := []struct {
		name    string
		in      string
		want    LookbackPeriod
		wantErr bool
	}{
		{"7d", "7d", LookbackPeriod7Days, false},
		{"30d", "30d", LookbackPeriod30Days, false},
		{"60d", "60d", LookbackPeriod60Days, false},
		{"empty means provider default", "", "", false},
		{"unsupported window", "90d", "", true},
		{"case sensitive", "7D", "", true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, err := ValidateLookbackPeriod(tc.in)
			if tc.wantErr {
				assert.Error(t, err)
				return
			}
			assert.NoError(t, err)
			assert.Equal(t, tc.want, got)
		})
	}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/enums_test.go` around lines 207 - 231, Add a table-driven
TestValidateLookbackPeriod alongside TestValidateScope, covering valid 7d, 30d,
and 60d values, valid empty input returning the zero LookbackPeriod, and errors
for unsupported 90d and case-sensitive 7D inputs. Use the existing assertion
pattern and run subtests in parallel.
mcp/server_test.go (2)

44-61: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Stray doc comment attached to the wrong test.

Lines 44-49 document TestRealPurchaseToolsDocumentMoneyImpactAndDryRun (defined at line 158) but sit immediately above TestEndToEndSearchThenDryRunPurchase, so godoc/readers get two concatenated, contradictory descriptions for one function. Move that block back onto the function it describes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/server_test.go` around lines 44 - 61, Move the comment block describing
TestRealPurchaseToolsDocumentMoneyImpactAndDryRun so it immediately precedes
that function’s declaration. Leave the TestEndToEndSearchThenDryRunPurchase
comment with only its end-to-end protocol description.

63-71: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Cancel the server context so server.Run goroutines exit with the test.

ctx := context.Background() plus go server.Run(ctx, serverTransport) leaves one server goroutine (and its transport) alive for the rest of the package's test binary; this repeats in TestListCommitmentActionsIncludesItself (Lines 121-129), TestAzureComputeRIPurchaseSchemaExcludesPartialUpfront (Lines 183-191) and TestSearchRecommendationsSchemaAdvertisesTermAndPaymentEnums (Lines 235-243). cmd/cudly-mcp/main_test.go already uses a cancellable context for the same pattern.

♻️ Suggested change (apply to each of the four tests)
-	ctx := context.Background()
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/server_test.go` around lines 63 - 71, Use a cancellable context instead
of context.Background in the four tests that start server.Run, including
TestListCommitmentActionsIncludesItself,
TestAzureComputeRIPurchaseSchemaExcludesPartialUpfront, and
TestSearchRecommendationsSchemaAdvertisesTermAndPaymentEnums; defer cancel so
each server goroutine and its transport exits when the test completes.
mcp/tools/purchase.go (1)

1-14: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

File exceeds the 500-line limit (664 lines).

Splitting the audit-logging helpers (logPurchaseAttempt/logPurchaseOutcome), the idempotency-key derivation (idempotencyKeyFor + *DetailsKey), and the Archera offer into their own files in this package would bring it back under the limit without touching behavior.

As per coding guidelines, "keep files under 500 lines".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/purchase.go` around lines 1 - 14, Split purchase.go into focused
files within the tools package to keep each file under 500 lines: move the audit
helpers logPurchaseAttempt and logPurchaseOutcome, idempotency-key logic
including idempotencyKeyFor and the *DetailsKey types, and the Archera offer
implementation into separate files. Preserve all existing behavior, symbols, and
package-level access.

Source: Coding guidelines

mcp/tools/purchase_test.go (1)

1-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

File exceeds the 500-line limit (1188 lines). Consider splitting into focused test files (gate/authorization, idempotency-key, audit-logging, Archera offer).

As per coding guidelines, "keep files under 500 lines".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/purchase_test.go` around lines 1 - 18, Split purchase_test.go into
focused test files so each remains under 500 lines, grouping tests by
gate/authorization, idempotency-key, audit-logging, and Archera offer behavior.
Preserve the existing package, shared imports, fixtures, and test behavior while
moving related tests together and removing imports that are no longer used in
each resulting file.

Source: Coding guidelines

providers/azure/services/synapse/client_test.go (1)

788-853: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Both test files now well exceed the repo's 500-line guideline. providers/azure/services/synapse/client_test.go (958+ lines) and providers/aws/service_client_test.go (583+ lines) are both already past the limit, and this PR's new test additions push them further over.

  • providers/azure/services/synapse/client_test.go#L788-L853: consider splitting the billing-plan/payment-option tests (and other purchase-commitment scenarios) into a separate _billingplan_test.go (or similar) file in the same package.
  • providers/aws/service_client_test.go#L293-L583: consider extracting the applyRecommendationFilters region-filtering tests into a dedicated service_client_region_filter_test.go file.

As per coding guidelines, "**/*.{go,ts,tsx}: Follow Domain-Driven Design with bounded contexts, keep files under 500 lines, and use typed interfaces for public APIs."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/services/synapse/client_test.go` around lines 788 - 853,
Split the Synapse billing-plan/payment-option tests around
TestPurchaseCommitment_billingPlan into a same-package file such as
client_billingplan_test.go, preserving their behavior and shared test helpers.
Also move the applyRecommendationFilters region-filtering tests from
providers/aws/service_client_test.go lines 293-583 into a dedicated same-package
file such as service_client_region_filter_test.go; both original files should
retain only unrelated tests.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mcp/tools/aws_ec2_ri.go`:
- Around line 201-209: Trim credential arguments before constructing
ProviderConfig so provider authentication matches the normalized idempotency
scope. Update resolveClient in mcp/tools/aws_ec2_ri.go (201-209),
aws_elasticache_ri.go (152-160), aws_rds_ri.go (160-168), aws_simple_ri.go
(201-209), aws_savingsplans.go (299-307), azure_compute_ri.go (181-189), and
gcp_computeengine_cud.go (191-199) to pass trimmed AWSProfile,
AzureSubscriptionID, or GCPProjectID values respectively.

In `@mcp/tools/aws_savingsplans.go`:
- Around line 176-215: Canonicalize the region in the purchase flow for
account-level plan types (Compute, SageMaker, and Database) so caller-supplied
values cannot affect the purchase identity or idempotency token. Update the
region assignment around SPTypeEC2Instance and Recommendation construction to
always use savingsPlansAccountLevelRegion for non-EC2Instance plans, while
preserving the trimmed caller region for EC2Instance plans and their Details.

---

Nitpick comments:
In `@mcp/server_test.go`:
- Around line 44-61: Move the comment block describing
TestRealPurchaseToolsDocumentMoneyImpactAndDryRun so it immediately precedes
that function’s declaration. Leave the TestEndToEndSearchThenDryRunPurchase
comment with only its end-to-end protocol description.
- Around line 63-71: Use a cancellable context instead of context.Background in
the four tests that start server.Run, including
TestListCommitmentActionsIncludesItself,
TestAzureComputeRIPurchaseSchemaExcludesPartialUpfront, and
TestSearchRecommendationsSchemaAdvertisesTermAndPaymentEnums; defer cancel so
each server goroutine and its transport exits when the test completes.

In `@mcp/tools/enums_test.go`:
- Around line 207-231: Add a table-driven TestValidateLookbackPeriod alongside
TestValidateScope, covering valid 7d, 30d, and 60d values, valid empty input
returning the zero LookbackPeriod, and errors for unsupported 90d and
case-sensitive 7D inputs. Use the existing assertion pattern and run subtests in
parallel.

In `@mcp/tools/purchase_test.go`:
- Around line 1-18: Split purchase_test.go into focused test files so each
remains under 500 lines, grouping tests by gate/authorization, idempotency-key,
audit-logging, and Archera offer behavior. Preserve the existing package, shared
imports, fixtures, and test behavior while moving related tests together and
removing imports that are no longer used in each resulting file.

In `@mcp/tools/purchase.go`:
- Around line 1-14: Split purchase.go into focused files within the tools
package to keep each file under 500 lines: move the audit helpers
logPurchaseAttempt and logPurchaseOutcome, idempotency-key logic including
idempotencyKeyFor and the *DetailsKey types, and the Archera offer
implementation into separate files. Preserve all existing behavior, symbols, and
package-level access.

In `@mcp/tools/search_recommendations.go`:
- Around line 220-241: Update fetchSearchCombos to execute independent
recClient.GetRecommendations calls concurrently using a bounded errgroup,
limiting in-flight requests to avoid unbounded concurrency. Preserve the
existing fail-whole-search behavior and error context, while safely aggregating
results from successful combos and propagating the first error.
- Around line 79-88: Update the lookback_period override in the BuildInputSchema
configuration to derive its Enum values from the typed LookbackPeriod constants
rather than hardcoded duration strings. Preserve the existing 7d, 30d, and 60d
options while ensuring future enum additions stay reflected in the advertised
schema.

In `@providers/azure/services/synapse/client_test.go`:
- Around line 788-853: Split the Synapse billing-plan/payment-option tests
around TestPurchaseCommitment_billingPlan into a same-package file such as
client_billingplan_test.go, preserving their behavior and shared test helpers.
Also move the applyRecommendationFilters region-filtering tests from
providers/aws/service_client_test.go lines 293-583 into a dedicated same-package
file such as service_client_region_filter_test.go; both original files should
retain only unrelated tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 09cb8a92-fb6c-434b-b742-32ccba69ac16

📥 Commits

Reviewing files that changed from the base of the PR and between 7b18bfd and c19cff4.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • go.work.sum is excluded by !**/*.sum
📒 Files selected for processing (59)
  • README.md
  • cmd/cudly-mcp/main.go
  • cmd/cudly-mcp/main_test.go
  • cmd/multi_service_stats.go
  • go.mod
  • mcp/README.md
  • mcp/server.go
  • mcp/server_test.go
  • mcp/tools/aws_ec2_ri.go
  • mcp/tools/aws_ec2_ri_test.go
  • mcp/tools/aws_elasticache_ri.go
  • mcp/tools/aws_elasticache_ri_test.go
  • mcp/tools/aws_rds_ri.go
  • mcp/tools/aws_rds_ri_test.go
  • mcp/tools/aws_savingsplans.go
  • mcp/tools/aws_savingsplans_test.go
  • mcp/tools/aws_simple_ri.go
  • mcp/tools/aws_simple_ri_test.go
  • mcp/tools/azure_compute_ri.go
  • mcp/tools/azure_compute_ri_test.go
  • mcp/tools/enums.go
  • mcp/tools/enums_test.go
  • mcp/tools/gcp_computeengine_cud.go
  • mcp/tools/gcp_computeengine_cud_test.go
  • mcp/tools/list_commitment_actions.go
  • mcp/tools/list_commitment_actions_test.go
  • mcp/tools/purchase.go
  • mcp/tools/purchase_test.go
  • mcp/tools/registry.go
  • mcp/tools/schema.go
  • mcp/tools/schema_test.go
  • mcp/tools/search_recommendations.go
  • mcp/tools/search_recommendations_fanout_test.go
  • mcp/tools/search_recommendations_test.go
  • pkg/common/archera.go
  • pkg/common/types.go
  • pkg/common/types_test.go
  • providers/aws/recommendations/parser_ri.go
  • providers/aws/recommendations/parser_ri_test.go
  • providers/aws/recommendations/parser_sp.go
  • providers/aws/recommendations/parser_sp_test.go
  • providers/aws/service_client.go
  • providers/aws/service_client_test.go
  • providers/azure/services/cache/client.go
  • providers/azure/services/cache/client_test.go
  • providers/azure/services/compute/client.go
  • providers/azure/services/compute/client_test.go
  • providers/azure/services/cosmosdb/client.go
  • providers/azure/services/cosmosdb/client_test.go
  • providers/azure/services/database/client.go
  • providers/azure/services/database/client_test.go
  • providers/azure/services/internal/reservations/purchase.go
  • providers/azure/services/internal/reservations/purchase_test.go
  • providers/azure/services/managedredis/client.go
  • providers/azure/services/managedredis/client_test.go
  • providers/azure/services/search/client.go
  • providers/azure/services/search/client_test.go
  • providers/azure/services/synapse/client.go
  • providers/azure/services/synapse/client_test.go

Comment thread mcp/tools/aws_ec2_ri.go
Comment thread mcp/tools/aws_savingsplans.go
…ase identity

Addresses the two unresolved CodeRabbit threads on #1495. Both let an input
that should not affect purchase identity change the idempotency token, and a
forked token means ClientToken dedupe misses and the purchase runs twice.

region is documented as "ignored for account-level plan types", but for
Compute/SageMaker/Database it was still assigned to region and rec.Region and
so reached idempotencyKeyFor. Buying a $10/hr Compute SP once without region
and re-issuing the identical call with region="eu-west-1" (a self-correction,
or a retry that fills the field in) derived two different tokens and bought a
second plan. Pin the canonical account-level region for those plan types;
only EC2Instance plans are genuinely region-scoped, and validateSavingsPlanArgs
already requires a non-blank region for those. details.Region was already
correctly gated on EC2Instance; this closes the same gap for rec.Region.

Every resolveClient closure forwarded the raw credential argument to
ProviderConfig while CredentialScope trimmed the same value for the token, so
a padded " prod " named one account in the token and another (or none) in the
config that actually authenticates the purchase. Route all seven sites through
CredentialScope itself rather than an open-coded TrimSpace: it is the function
the token already uses, so the two can no longer diverge. Passing no env-var
fallback yields the trimmed explicit value, and an empty result correctly means
"use ambient credentials", which is what the token recorded anyway.

Both guards were confirmed to fail against the pre-fix code. The credential
test is table-driven across all seven resolveClient implementations rather than
one per provider: the bug was seven independent copies of the same expression,
so a guard on a subset would let a future edit reintroduce it elsewhere.
TestEC2InstanceSavingsPlanStillHonorsRegion pins the other side, so the
canonicalization cannot silently buy an EC2Instance plan in the wrong region.
@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Pushed 44b609492, addressing both unresolved threads. Both were real, and both were the same shape: an input that should not affect purchase identity was reaching the idempotency token, where a forked token means ClientToken dedupe misses and the purchase runs twice.

mcp/tools/aws_savingsplans.go — account-level region forking the token (Major). Confirmed and fixed as proposed. region is documented as "ignored for account-level plan types", but for Compute/SageMaker/Database it still reached region, rec.Region, and idempotencyKeyFor. Worth noting details.Region was already correctly gated on EC2Instance, so this closes the same gap one level up.

mcp/tools/aws_ec2_ri.go and 6 siblings — raw credentials to ProviderConfig. Confirmed and fixed at all seven sites. Rather than open-coding strings.TrimSpace seven times, I routed each through CredentialScope itself, the function the token already uses, so the two are now structurally incapable of diverging. Passing no env-var fallback yields the trimmed explicit value, and an empty result correctly means "use ambient credentials", which is exactly what the token recorded anyway.

Regression coverage. Both guards were confirmed to FAIL against the pre-fix code and pass after, rather than being written to match current behavior:

  • TestAccountLevelSavingsPlanRegionCannotForkIdempotencyToken asserts the two calls produce byte-identical tokens, so it fails on the actual defect (token divergence) and not merely on a field value.
  • TestResolveClientTrimsCredentialScope is table-driven across all seven resolveClient implementations, not one per provider. The bug was seven independent copies of the same expression, so a guard on a subset would let a future edit reintroduce it in the files it skipped.
  • TestEC2InstanceSavingsPlanStillHonorsRegion pins the other side, so the canonicalization cannot silently buy an EC2Instance plan in the wrong region.

Local gates on 44b609492: go build ./..., go test ./..., go vet, gocyclo -over 10, and golangci-lint run ./mcp/... all exit 0.

Separately, still unaddressed from the earlier pass and not mine to land: mcp/tools/purchase.go:578, where a zero result.Timestamp renders a fabricated effective_date (omitempty on a string only drops ""). Same fabricated-value class, also on the money path.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@cristim Thanks for addressing both idempotency-token divergence paths and adding focused regression coverage. I’ll review 44b609492, with particular attention to account-level Savings Plan region canonicalization, EC2Instance region preservation, and credential-scope normalization across all resolver implementations.


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1495

Timestamp: 2026-07-27T23:02:40.677Z
Learning: For AWS MCP Savings Plans purchases in `mcp/tools/aws_savingsplans.go`, `region` must be ignored and canonicalized out of both the recommendation and idempotency identity for account-level Compute, SageMaker, and Database Savings Plans. EC2Instance Savings Plans remain region-scoped and must retain the validated region so distinct regional purchases do not alias.

Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1495

Timestamp: 2026-07-27T23:02:40.677Z
Learning: For MCP purchase tool resolver configuration, normalize explicit credential/account inputs through the same `CredentialScope` logic used in idempotency-token derivation. This keeps provider configuration and token identity aligned, preventing whitespace-only or whitespace-padded credentials from creating distinct purchase identities.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mcp/tools/idempotency_scope_test.go`:
- Around line 61-77: Extend TestEC2InstanceSavingsPlanStillHonorsRegion to
generate idempotency tokens for equivalent EC2Instance savings-plan arguments in
different regions and assert the tokens differ. Keep the existing recommendation
region assertions, and use the idempotencyKeyFor path to verify region changes
are preserved rather than collapsed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 42e9ea3c-a921-4f4f-b21a-bf5d2dd4aa0b

📥 Commits

Reviewing files that changed from the base of the PR and between c19cff4 and 44b6094.

📒 Files selected for processing (8)
  • mcp/tools/aws_ec2_ri.go
  • mcp/tools/aws_elasticache_ri.go
  • mcp/tools/aws_rds_ri.go
  • mcp/tools/aws_savingsplans.go
  • mcp/tools/aws_simple_ri.go
  • mcp/tools/azure_compute_ri.go
  • mcp/tools/gcp_computeengine_cud.go
  • mcp/tools/idempotency_scope_test.go
🚧 Files skipped from review as they are similar to previous changes (7)
  • mcp/tools/azure_compute_ri.go
  • mcp/tools/aws_elasticache_ri.go
  • mcp/tools/gcp_computeengine_cud.go
  • mcp/tools/aws_rds_ri.go
  • mcp/tools/aws_ec2_ri.go
  • mcp/tools/aws_savingsplans.go
  • mcp/tools/aws_simple_ri.go

Comment thread mcp/tools/idempotency_scope_test.go
…kens

Addresses the CodeRabbit finding on 44b6094. The test's own doc comment
claimed region must still reach "the resolved region and the token" for
EC2Instance plans, but it only asserted rec.Region and details.Region, leaving
the token half of that claim untested.

That gap matters in the opposite direction from the bug the previous commit
fixed: a future change that canonicalized region for EC2Instance too would
keep the field assertions green while collapsing two genuinely different
purchases (m5 in eu-west-1 vs m5 in us-east-1) onto one token, at which point
the second, legitimately distinct purchase would dedupe away and never happen.

Derive tokens for the same plan in two regions and assert they differ.
Confirmed the assertion fails when the region is pinned for every plan type,
so it catches the over-pinning regression rather than restating what the
field assertions already cover.
@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Pushed bdb43cd30, addressing the single actionable on 44b609492.

mcp/tools/idempotency_scope_test.go — assert EC2Instance regions produce different tokens. Valid catch, and the gap was in my own test: its doc comment claimed region must reach "the resolved region and the token", but it only asserted rec.Region and details.Region, leaving the token half of that claim untested.

Worth spelling out why this matters, since it guards the opposite direction from the bug the previous commit fixed. A future change that canonicalized region for EC2Instance too would keep the field assertions green while collapsing two genuinely different purchases (m5 in eu-west-1 vs m5 in us-east-1) onto one token, at which point the second, legitimately distinct purchase would dedupe away and silently never happen.

The test now derives tokens for the same plan in two regions and asserts they differ. Confirmed it FAILS when the region is pinned for every plan type, so it catches the over-pinning regression rather than restating what the field assertions already covered.

CI on 44b609492 was green across all four workflows. Local gates on bdb43cd30: go build ./..., go test ./..., go vet, gocyclo -over 10, and golangci-lint run ./mcp/... all exit 0.

Still open and not mine to land: mcp/tools/purchase.go:578, where a zero result.Timestamp renders a fabricated effective_date (omitempty on a string only drops "").

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline mcp/tools/idempotency_scope_test.go --items all --type function --match 'TestAccountLevelSavingsPlanRegionCannotForkIdempotencyToken|TestEC2InstanceSavingsPlanStillHonorsRegion'
ast-grep outline mcp/tools/aws_savingsplans.go --items all --type function --match 'savingsPlanRecommendationFromArgs'
ast-grep outline mcp/tools/purchase.go --items all --type function --match 'idempotencyKeyFor|detailsKeyComponent|ExecutePurchase|rfc3339OrNil'

printf '\n--- idempotency test ---\n'
sed -n '1,260p' mcp/tools/idempotency_scope_test.go
printf '\n--- savings plans recommendation parsing ---\n'
rg -n -A150 -B20 'func savingsPlanRecommendationFromArgs' mcp/tools/aws_savingsplans.go
printf '\n--- token and timestamp shaping ---\n'
rg -n -A180 -B25 'func idempotencyKeyFor|func detailsKeyComponent|func rfc3339OrNil|EffectiveDate' mcp/tools/purchase.go
printf '\n--- current commit diff ---\n'
git show --format=fuller --stat --find-renames bdb43cd30
git show --format= --find-renames bdb43cd30 -- mcp/tools/idempotency_scope_test.go

Length of output: 45839


@cristim Reviewed bdb43cd30. The added assertion correctly covers the missing identity invariant: otherwise-identical EC2Instance Savings Plans in distinct regions derive different idempotency keys, while account-level plans remain canonicalized separately.

Also, the stale effective_date note no longer applies to the current head: PurchaseResponse.EffectiveDate is nullable and rfc3339OrNil omits an unset provider timestamp rather than emitting year 1.

No new actionable findings from this change.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@cristim Understood — triggering a full review of the current PR head rather than an incremental pass.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 39 minutes.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@cristim Understood — triggering a full review of the current PR head rather than an incremental pass.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 19 minutes.

@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@cristim Understood — triggering a full review of the current PR head rather than an incremental pass.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🧹 Nitpick comments (4)
mcp/tools/purchase.go (1)

462-477: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Only ComputeDetails has a value-type case; the other three silently fall through to "".

If any tool ever passes common.DatabaseDetails/CacheDetails/SavingsPlanDetails by value (as ComputeDetails apparently already is somewhere), the type switch hits default and drops every service-specific dimension from the key — a silent token collision on a money path, exactly what this function exists to prevent. Adding the value cases makes the behavior uniform.

♻️ Proposed change
 	case *common.DatabaseDetails:
 		return databaseDetailsKey(d)
+	case common.DatabaseDetails:
+		return databaseDetailsKey(&d)
 	case *common.CacheDetails:
 		return cacheDetailsKey(d)
+	case common.CacheDetails:
+		return cacheDetailsKey(&d)
 	case *common.SavingsPlanDetails:
 		return savingsPlanDetailsKey(d)
+	case common.SavingsPlanDetails:
+		return savingsPlanDetailsKey(&d)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/purchase.go` around lines 462 - 477, Update detailsKeyComponent to
handle value-type common.DatabaseDetails, common.CacheDetails, and
common.SavingsPlanDetails cases alongside their pointer cases. Pass each value
case by address to its existing key helper, preserving the current pointer
behavior and preventing service-specific dimensions from being dropped.
mcp/server_test.go (1)

44-61: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Stray doc comment: the TestRealPurchaseToolsDocumentMoneyImpactAndDryRun block is attached to TestEndToEndSearchThenDryRunPurchase.

Lines 44-49 document a different test (defined at line 158, which now has no doc comment). Move that paragraph above its own function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/server_test.go` around lines 44 - 61, Move the documentation paragraph
beginning with TestRealPurchaseToolsDocumentMoneyImpactAndDryRun above the
TestRealPurchaseToolsDocumentMoneyImpactAndDryRun function, and leave the
TestEndToEndSearchThenDryRunPurchase comment describing only the end-to-end
test. Ensure the latter test’s existing documentation remains directly attached
to its function.
mcp/tools/aws_ec2_ri.go (1)

68-78: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

platform is missing the Enum/Default schema override that tenancy/scope get.

platform has the same shape as tenancy/scope (small SDK-backed enum + documented default), but only those two are advertised via FieldOverride in the schema. An MCP client can't discover the 4 valid platform values without a round trip that fails validation.

♻️ Proposed fix
 	schema, err := BuildInputSchema[ec2RIPurchaseArgs](map[string]FieldOverride{
 		"term_years":     {Enum: []any{int(TermOneYear), int(TermThreeYear)}},
 		"payment_option": {Enum: []any{string(PaymentOptionAllUpfront), string(PaymentOptionPartialUpfront), string(PaymentOptionNoUpfront)}},
+		"platform": {Enum: []any{
+			string(ec2types.RIProductDescriptionLinuxUnix),
+			string(ec2types.RIProductDescriptionLinuxUnixAmazonVpc),
+			string(ec2types.RIProductDescriptionWindows),
+			string(ec2types.RIProductDescriptionWindowsAmazonVpc),
+		}, Default: string(ec2types.RIProductDescriptionLinuxUnix)},
 		"scope":          {Enum: []any{string(ScopeRegion), string(ScopeAvailabilityZone)}, Default: string(ScopeRegion)},
 		"tenancy":        {Enum: []any{string(TenancyDefault), string(TenancyDedicated)}, Default: string(TenancyDefault)},
 		"dry_run":        {Default: true},
 		"confirm":        {Default: false},
 	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/aws_ec2_ri.go` around lines 68 - 78, Update the BuildInputSchema
overrides for ec2RIPurchaseArgs to include platform with the SDK’s four valid
platform enum values and its documented default, matching the existing scope and
tenancy overrides. Use the existing platform constants and preserve all other
schema overrides unchanged.
mcp/tools/gcp_computeengine_cud_test.go (1)

48-73: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for the explicit-project requirement.

The PR objectives call out that GCP real purchases require an explicit project, but the invalid-args table never mutates GCPProjectID. A blank/whitespace gcp_project_id case would pin that requirement here.

💚 Suggested additional cases
 		{"invalid term", func(a *gcpComputeEngineCUDPurchaseArgs) { a.TermYears = 2 }, "invalid term_years"},
+		{"missing gcp_project_id", func(a *gcpComputeEngineCUDPurchaseArgs) { a.GCPProjectID = "" }, "gcp_project_id"},
+		{"whitespace-only gcp_project_id", func(a *gcpComputeEngineCUDPurchaseArgs) { a.GCPProjectID = "  " }, "gcp_project_id"},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/gcp_computeengine_cud_test.go` around lines 48 - 73, Add an invalid
case to TestGCPComputeEngineRecommendationFromArgsInvalid that mutates
GCPProjectID to blank or whitespace and expects the gcp_project_id-required
validation error, preserving the existing table-driven test pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mcp/tools/search_recommendations.go`:
- Around line 412-419: Update trimSearchArgsIdentifiers to trim the credential
override fields aws_profile, azure_subscription_id, and gcp_project_id before
returning args, ensuring providerConfigFromArgs receives normalized values like
the existing region fields.

In `@providers/azure/services/internal/reservations/purchase.go`:
- Around line 105-121: Update the default branch of BillingPlanForPaymentOption
so the error message describes only the actual unsupported paymentOption value;
mention partial-upfront’s lack of an Azure equivalent only when that specific
value is supplied, without attributing it to unrelated inputs.

---

Nitpick comments:
In `@mcp/server_test.go`:
- Around line 44-61: Move the documentation paragraph beginning with
TestRealPurchaseToolsDocumentMoneyImpactAndDryRun above the
TestRealPurchaseToolsDocumentMoneyImpactAndDryRun function, and leave the
TestEndToEndSearchThenDryRunPurchase comment describing only the end-to-end
test. Ensure the latter test’s existing documentation remains directly attached
to its function.

In `@mcp/tools/aws_ec2_ri.go`:
- Around line 68-78: Update the BuildInputSchema overrides for ec2RIPurchaseArgs
to include platform with the SDK’s four valid platform enum values and its
documented default, matching the existing scope and tenancy overrides. Use the
existing platform constants and preserve all other schema overrides unchanged.

In `@mcp/tools/gcp_computeengine_cud_test.go`:
- Around line 48-73: Add an invalid case to
TestGCPComputeEngineRecommendationFromArgsInvalid that mutates GCPProjectID to
blank or whitespace and expects the gcp_project_id-required validation error,
preserving the existing table-driven test pattern.

In `@mcp/tools/purchase.go`:
- Around line 462-477: Update detailsKeyComponent to handle value-type
common.DatabaseDetails, common.CacheDetails, and common.SavingsPlanDetails cases
alongside their pointer cases. Pass each value case by address to its existing
key helper, preserving the current pointer behavior and preventing
service-specific dimensions from being dropped.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: df0c93bb-dc6e-4ff4-bd3c-9ce6cc4bc7a0

📥 Commits

Reviewing files that changed from the base of the PR and between 7b18bfd and bdb43cd.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • go.work.sum is excluded by !**/*.sum
📒 Files selected for processing (60)
  • README.md
  • cmd/cudly-mcp/main.go
  • cmd/cudly-mcp/main_test.go
  • cmd/multi_service_stats.go
  • go.mod
  • mcp/README.md
  • mcp/server.go
  • mcp/server_test.go
  • mcp/tools/aws_ec2_ri.go
  • mcp/tools/aws_ec2_ri_test.go
  • mcp/tools/aws_elasticache_ri.go
  • mcp/tools/aws_elasticache_ri_test.go
  • mcp/tools/aws_rds_ri.go
  • mcp/tools/aws_rds_ri_test.go
  • mcp/tools/aws_savingsplans.go
  • mcp/tools/aws_savingsplans_test.go
  • mcp/tools/aws_simple_ri.go
  • mcp/tools/aws_simple_ri_test.go
  • mcp/tools/azure_compute_ri.go
  • mcp/tools/azure_compute_ri_test.go
  • mcp/tools/enums.go
  • mcp/tools/enums_test.go
  • mcp/tools/gcp_computeengine_cud.go
  • mcp/tools/gcp_computeengine_cud_test.go
  • mcp/tools/idempotency_scope_test.go
  • mcp/tools/list_commitment_actions.go
  • mcp/tools/list_commitment_actions_test.go
  • mcp/tools/purchase.go
  • mcp/tools/purchase_test.go
  • mcp/tools/registry.go
  • mcp/tools/schema.go
  • mcp/tools/schema_test.go
  • mcp/tools/search_recommendations.go
  • mcp/tools/search_recommendations_fanout_test.go
  • mcp/tools/search_recommendations_test.go
  • pkg/common/archera.go
  • pkg/common/types.go
  • pkg/common/types_test.go
  • providers/aws/recommendations/parser_ri.go
  • providers/aws/recommendations/parser_ri_test.go
  • providers/aws/recommendations/parser_sp.go
  • providers/aws/recommendations/parser_sp_test.go
  • providers/aws/service_client.go
  • providers/aws/service_client_test.go
  • providers/azure/services/cache/client.go
  • providers/azure/services/cache/client_test.go
  • providers/azure/services/compute/client.go
  • providers/azure/services/compute/client_test.go
  • providers/azure/services/cosmosdb/client.go
  • providers/azure/services/cosmosdb/client_test.go
  • providers/azure/services/database/client.go
  • providers/azure/services/database/client_test.go
  • providers/azure/services/internal/reservations/purchase.go
  • providers/azure/services/internal/reservations/purchase_test.go
  • providers/azure/services/managedredis/client.go
  • providers/azure/services/managedredis/client_test.go
  • providers/azure/services/search/client.go
  • providers/azure/services/search/client_test.go
  • providers/azure/services/synapse/client.go
  • providers/azure/services/synapse/client_test.go

Comment thread mcp/tools/search_recommendations.go
Comment thread providers/azure/services/internal/reservations/purchase.go
cristim added a commit that referenced this pull request Jul 28, 2026
#1504)

* fix(azure): coerce web partial-upfront to monthly, not upfront

The web/API purchase path coerced Azure partial-upfront payment options
to upfront before validation. Since PR #1495 wires Azure billingPlan
directly from the normalized payment option, that coercion silently
billed an all-upfront schedule the caller never chose. Coerce to
monthly (no-upfront, CUDly's default Azure schedule) instead, so the
rec still survives validation without flipping the billing schedule.

Closes #1503

* docs(azure): correct payment-option mechanism comment (cost split, not billingPlan)

The comment on NormalizePaymentOption/crossProviderPaymentAlias said the
web/API path wires Azure's billingPlan directly from the normalized
payment option. buildReservationBody in providers/azure/services/compute/client.go
never emits a billingPlan field for VM reservations; the payment option
actually drives the upfront-vs-monthly cost split in that file's
GetOfferingDetails. No behavior change, comment accuracy only.

* fix(api): warn on payment-option normalization coercion

validatePurchaseRecommendation silently overwrote rec.Payment with the
NormalizePaymentOption result, contradicting the doc comment that
callers are expected to WARN when a raw payment option is coerced to
its canonical form (e.g. azure partial-upfront -> monthly). Log the
transition via pkg/logging, matching the pattern already used by
scheduler.convertRecommendations, so the coercion is auditable on this
money-affecting field. The coercion itself is unchanged.

* docs(config): future-proof Azure payment-coercion comments for #1495

The coercion-rationale comments assert the normalized payment option is
"not a billingPlan request field". That is accurate on main today (the
reservation purchase body sends no billingPlan), but becomes false once
the billingPlan wiring lands (#1495/#1502), and that wiring does not
touch this file, so the comment would silently go stale on a money-path
rationale. Rephrase to state the forward-compatible fact instead: the
wiring maps only upfront/monthly tokens and hard-errors on
partial-upfront, which is exactly why normalizing to monthly here keeps
web-path Azure purchases valid.

Comment-only change; no behavior difference.

* feat(api): surface payment-option coercions in the purchase response

The web execute path silently normalizes cross-provider payment tokens
(most notably Azure partial-upfront -> monthly, #1503); until now only
operators saw the WARN log while the caller's response gave no hint the
billing schedule they requested was changed. Return the coercion to the
caller: validatePurchaseRecommendation now also yields a
PaymentAdjustment (rec index, provider, service, requested token,
applied token, reason) whenever normalization changes the token, and
executePurchase attaches the collected list to all three response
bodies (approval-pending, duplicate-collapse, direct-execute) under
payment_adjustments. The key is omitted entirely when every option was
already canonical, so existing clients are unaffected on the common
case, and the coercion policy itself (config.NormalizePaymentOption)
plus the operator WARN log are unchanged.

Tests: the guards table now cross-checks every coerced row surfaces a
matching adjustment and every canonical row surfaces none; new
field-level unit test for the Azure partial-upfront case; new
handler-level tests assert the response body carries the adjustment for
a mixed canonical+coerced batch (verified to fail with the attach
stubbed out) and omits the key for a canonical-only batch, both with
mock.AssertExpectations registered. frontend PurchaseResult gains the
matching optional payment_adjustments field.

* fix(frontend): coerce azure partial-upfront to monthly, not upfront

The Go side of #1503 was fixed to map Azure partial-upfront onto
"monthly", but normalizePaymentValue in the frontend still mapped it
onto "upfront", so the two halves of the web path disagreed.

That function decides which option the plan and purchase dropdowns
pre-select (plans.ts:1166 on plan edit, plans.ts:1562 when prefilling
from a selected commitment), so a rec carrying the legacy
partial-upfront token pre-selected "Pay Upfront". Saving from that
state submits an already-canonical "upfront", which the backend
accepts verbatim, so the backend coercion never gets a chance to run
and the user is committed to a full upfront charge they never chose.

Azure offers exactly two billing plans and the total cost is identical
either way, but the plan cannot be changed after purchase, so landing
on monthly is the only direction that cannot surprise the user with an
irreversible upfront charge.

The jest case that pinned the old expectation is updated and now
documents why monthly is required, mirroring the Go-side assertion in
TestNormalizePaymentOption. Verified failing before this change and
passing after.

Refs #1503

* docs(config): correct azure payment-coercion rationale

The doc comments justifying the partial-upfront to monthly coercion
cited providers/azure/services/compute/client.go's GetOfferingDetails
as the consumer that "drives the upfront-vs-monthly cost split".
GetOfferingDetails has no non-test callers anywhere in the repo; it
exists only to satisfy the ServiceClient interface, so the stated
mechanism does not run. On a money path a wrong rationale is worse
than none, since the next reader reasons from it.

Replace it with what actually consumes the canonical token today
(persisted on the execution, then copied into
common.Recommendation.PaymentOption in internal/purchase/execution.go)
and the billingPlan wiring arriving in #1495/#1502.

Also record the two Microsoft-documented facts the decision rests on:
upfront and monthly cost the same total, and the billing frequency
cannot be changed after purchase. Note the documented exception that
monthly is unavailable for SUSE Linux, Red Hat, Azure Red Hat
OpenShift and pre-purchase plans, where the coerced token makes Azure
reject the purchase; that loud rejection is the intended outcome.

Adds the cross-reference to the frontend mirror of this mapping so the
two stay in lockstep.

Comments only, no behaviour change.

Refs #1503

* fix(frontend): surface azure payment coercion to the user (#1503)

The backend already returns `payment_adjustments` when it normalizes a
requested payment option onto a different provider-canonical token (Azure
has exactly two billing plans, Upfront and Monthly, so an inherited
AWS-style `partial-upfront` has nowhere to land and is coerced to
monthly). The field was declared in `frontend/src/api/types.ts` and read
by nothing, so the coercion was disclosed to API clients and to the
operator WARN log but never to the web user whose billing schedule
actually changed.

Coercing instead of rejecting is only defensible if the user is told, so
this closes that gap:

- Add `formatPaymentAdjustmentNotice` next to `normalizePaymentValue`,
  the mapping it discloses. Reuses the existing `getPaymentLabel` so the
  copy shows "Partial Upfront"/"Pay Monthly" rather than raw API tokens,
  and collapses a batch to its distinct requested -> applied pairs.
- Render it as a separate non-expiring warning toast on both purchase
  submit paths (single and fan-out) so an irreversible billing-schedule
  change cannot scroll away inside a success message. The fan-out path
  collects from every fulfilled response, including buckets whose
  approval email failed, since those still created a pending execution
  carrying the coerced schedule.
- Extract the inline `payment_adjustments` element type into a named
  `PaymentAdjustment` interface so the formatter is typed against the
  same shape the API returns.

Regression coverage: the two disclosure tests in
purchase-execution-toast.test.ts fail with the wiring removed and pass
with it, exercising the real handler through to the rendered toast; a
formatter-only unit test could not prove the notice reaches the user.

* fix(api): disclose payment coercion only when the schedule changes

The #1503 disclosure fired on every raw != canonical rewrite, including
the cross-provider renames that leave the customer's cash flow untouched.
Azure spells AWS's all-upfront "upfront" and AWS's no-upfront "monthly",
so both of those rewrites are bookkeeping, not a billing change.

That mattered on the ordinary path, not an edge case: the fan-out
purchase modal builds its per-bucket Payment dropdown from
paymentOptionsFor (frontend/src/lib/purchase-compatibility.ts), whose
candidate list has no "upfront" entry. An Azure bucket the user chooses
to pay upfront therefore ALWAYS submits "all-upfront", so every ordinary
Azure upfront purchase raised a sticky "Billing schedule adjusted"
warning claiming Azure "does not offer All Upfront" and that the purchase
was applied as something else. Both claims are false, and the noise
trains users to dismiss the one notice that is real.

Classify a payment token by the cash flow it implies
(config.PaymentScheduleFor) and surface a PaymentAdjustment only when
the rewrite crosses schedules (config.PaymentCoercionChangesSchedule).
Azure partial-upfront -> monthly and GCP upfront -> monthly still
disclose; Azure all-upfront -> upfront and no-upfront -> monthly no
longer do.

The operator WARN keeps firing on every rewrite: a non-canonical token
on the wire is an upstream input bug worth auditing even when it costs
the customer nothing. Only the user-facing notice is narrowed.

Adjustment construction moves into paymentAdjustmentFor so
validatePurchaseRecommendation stays at gocyclo 10 (the pre-commit
threshold), not 11.

Regression coverage: the three rename cases assert nil adjustment plus a
still-emitted WARN, and the GCP upfront-shaped tokens assert a surfaced
adjustment. Both new tests fail against the previous raw != canonical
condition and pass after (verified by reverting the condition).
…partial-upfront

Addresses the two unresolved CodeRabbit threads on bdb43cd.

trimSearchArgsIdentifiers normalized service, region, include_regions,
exclude_regions, and account_filter but not the three credential overrides,
so providerConfigFromArgs forwarded aws_profile / azure_subscription_id /
gcp_project_id into ProviderConfig raw. A padded " my-profile " then failed
credential resolution for a reason the resulting error never mentions,
reading as "these credentials are broken" rather than "this name has a stray
space". The purchase tools already normalize the same three fields through
CredentialScope; this closes the gap on the search path.

BillingPlanForPaymentOption's default branch appended "(partial-upfront has
no azure equivalent)" to every unrecognized value, so a typo ("montly") or a
value from another provider's vocabulary ("prepaid") was diagnosed as a
partial-upfront problem the caller never had. Give partial-upfront its own
case -- it is a real payment option elsewhere in CUDly, so the caller does
need to know Azure has no equivalent -- and report anything else as simply
not recognized. This extends the same split an earlier commit already made
for the empty value, for the same reason: an error that names the wrong
cause sends whoever reads it looking in the wrong place.

A dedicated case rather than a conditional inside default, so the switch's
already-lowercased and trimmed value decides it instead of re-normalizing.

Both guards were confirmed to fail against the pre-fix code. The Azure test
also pins that "  Partial-Upfront  " still reaches the partial-upfront case
rather than falling through to the unrecognized branch.
@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Pushed a1d269686, addressing both unresolved threads from the bdb43cd30 review.

mcp/tools/search_recommendations.go — credential overrides not trimmed. Confirmed and fixed. trimSearchArgsIdentifiers normalized every other identifier in the file but not aws_profile/azure_subscription_id/gcp_project_id, so providerConfigFromArgs forwarded them raw. Trimmed at that same normalization boundary, which covers the 472-482 site you also flagged since both read from the normalized args. Worth noting this is the search-path twin of the purchase-path gap fixed in 44b609492, where the same three fields now go through CredentialScope.

providers/azure/services/internal/reservations/purchase.go — unrecognized values blamed on partial-upfront. Confirmed and fixed, though I implemented it as a dedicated case "partial-upfront" rather than a conditional inside default: the switch has already lowercased and trimmed its subject, so a separate case decides it without re-normalizing. Behaviour matches your proposal — partial-upfront keeps its specific "no azure equivalent" message because it is a real payment option elsewhere in CUDly, and everything else now reports "is not a recognized payment option". This extends the split an earlier commit already made for the empty value, for the identical reason: an error naming the wrong cause sends whoever reads it looking in the wrong place.

Regression coverage. Both guards were confirmed to FAIL against the pre-fix code and pass after. The Azure test also pins that " Partial-Upfront " still reaches the partial-upfront case rather than falling through to the unrecognized branch, so the normalization the dedicated case relies on is itself asserted.

Local gates on a1d269686: go build ./..., go test ./..., go vet, gocyclo -over 10, and golangci-lint run ./mcp/... all exit 0.

One note on the Azure submodule: golangci-lint reports pre-existing findings there (bodyclose in tests, errcheck, gocritic, and a set of cancelled/canceled misspells). None are on the lines this commit touches, and I deliberately left them alone rather than folding an unrelated cleanup into a CR-fix commit. The misspell ones in particular are not safe to autofix here: cancelled_by is a database column name in this repo, and a blanket rewrite has previously broken integration tests.

Also confirming the mcp/tools/purchase.go effective_date item I raised in earlier rounds is now resolved — EffectiveDate is a *string, so an unpopulated timestamp is omitted rather than shipped as 0001-01-01T00:00:00Z.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- commit summary and changed hunks ---'
git show --format=fuller --stat --find-renames a1d269686
git show --format= --find-renames a1d269686 -- \
  mcp/tools/search_recommendations.go \
  mcp/tools/search_recommendations_test.go \
  providers/azure/services/internal/reservations/purchase.go \
  providers/azure/services/internal/reservations/purchase_test.go

printf '%s\n' '--- relevant source outlines ---'
ast-grep outline mcp/tools/search_recommendations.go --items all --type function --match 'trimSearchArgsIdentifiers|providerConfigFromArgs'
ast-grep outline providers/azure/services/internal/reservations/purchase.go --items all --type function --match 'BillingPlanForPaymentOption'

printf '%s\n' '--- search normalization and configuration ---'
rg -n -A90 -B15 'func trimSearchArgsIdentifiers|func providerConfigFromArgs' \
  mcp/tools/search_recommendations.go

printf '%s\n' '--- Azure billing-plan mapping ---'
rg -n -A90 -B15 'func BillingPlanForPaymentOption' \
  providers/azure/services/internal/reservations/purchase.go

printf '%s\n' '--- focused regression tests ---'
rg -n -C4 'AWSProfile|AzureSubscriptionID|GCPProjectID|Credential|Partial-Upfront|partial-upfront|recognized payment option' \
  mcp/tools/search_recommendations_test.go \
  providers/azure/services/internal/reservations/purchase_test.go

Length of output: 40740


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline mcp/tools/search_recommendations.go --items all --type method,function --match 'handle'
printf '%s\n' '--- handler call order ---'
rg -n -A150 -B20 'func \(t \*searchRecommendationsTool\) handle' \
  mcp/tools/search_recommendations.go

printf '%s\n' '--- commit file list (sanity check) ---'
git diff-tree --no-commit-id --name-status -r a1d269686

printf '%s\n' '--- parent comparison for changed tests ---'
git diff --check a1d269686^ a1d269686

Length of output: 9064


@cristim Verified a1d269686.

Both reported findings are addressed:

  • searchRecommendationsTool.handle normalizes the credential overrides before validation and before providerConfigFromArgs constructs the provider configuration. The new table-driven test verifies the trimmed AWS profile, Azure subscription ID, and GCP project ID at that configuration boundary.
  • BillingPlanForPaymentOption now retains its specific normalized partial-upfront diagnostic while correctly classifying unrelated invalid values as unrecognized. The regression coverage includes padded/mixed-case Partial-Upfront.

The diff is limited to the four stated implementation/test files, and the inspected diff has no whitespace errors. No new actionable findings.


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1495

Timestamp: 2026-07-28T15:05:02.231Z
Learning: In the Go MCP recommendation-search path in `mcp/tools/search_recommendations.go`, `trimSearchArgsIdentifiers` must trim `aws_profile`, `azure_subscription_id`, and `gcp_project_id` before `providerConfigFromArgs` builds `provider.ProviderConfig`. These credential overrides select the account against which a search runs, so whitespace-padded values must not reach provider configuration.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

ARM subscription IDs are case-insensitive GUIDs, and nothing in
providers/azure canonicalizes them -- resolveAzureSubscriptionID forwards
whatever it is given straight into the request path. CredentialScope
trimmed the value but never folded its case, so the SAME subscription
spelled two ways derived two DIFFERENT idempotency tokens.

The double-spend that follows:

  1. A purchase is issued with
     azure_subscription_id="ABC12345-1234-1234-1234-1234567890AB", the
     spelling the Azure portal hands you, and times out.
  2. The model retries. This time the override is omitted, so the value
     comes from AZURE_SUBSCRIPTION_ID, which conventionally holds the
     lower-case "abc12345-...".
  3. Both calls target the same subscription, but the tokens differ, so
     reservations.FindReservationOrderByIdempotencyToken -- which matches
     on the token tag across the TENANT-wide order list -- misses the
     first order.
  4. A SECOND reservation is purchased.

This is the defect family 44b6094 fixed for untrimmed input: purchase
identity forked by a difference the provider itself does not recognize.
Whitespace was normalized there; case was not.

Both Azure uses of the subscription ID -- the idempotency scope in handle
and the ProviderConfig in resolveClient -- now go through the single
azureCredentialScope helper, so they cannot normalize it differently.
Case folding is deliberately NOT pushed into CredentialScope: AWS named
profiles are case-SENSITIVE section names in ~/.aws/config, so
lower-casing one would point a real purchase at a profile that does not
exist or at a different one that does. GCP project IDs are lower-case by
grammar. The new guard covers both directions -- two cases of one Azure
subscription must derive ONE token, two different subscriptions must keep
two -- and asserts AWS profile case is still preserved end to end, so the
Azure fix cannot leak into the AWS path. It fails on the pre-fix code.

Also in this change:

- cudly_search_recommendations no longer claims it "spends no money".
  Since d76aa7c, a reservation search that omits term_years and
  payment_option fans out to six Cost Explorer requests, and AWS bills
  those per request. The tool buys no commitment, but it is not free, and
  a model told otherwise may loop on it. The description now says both.
- The account-level Savings Plan region guard covers Database alongside
  Compute and SageMaker. It needs its own 1yr/no-upfront fixture because
  validateDatabaseSavingsPlan permits nothing else. Database is
  account-level by construction today (the branch tests for EC2Instance),
  so the case exists to stop a refactor that enumerates the account-level
  types explicitly from quietly dropping it.

Deferred to #1586: parseRecommendations logs and continues when one
recommendation detail fails to parse, so a combo whose best offer fails
quantity/cost parsing vanishes silently -- recreating within a combo the
"5 of 6 offers presented as all your options" defect the fan-out exists
to prevent. The parser is shared with the scheduler's discovery sweep,
which legitimately wants the tolerant behaviour, so the fix needs a
per-caller policy and does not belong on this branch.
@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Pushed 707cd14a0 addressing the follow-up review of a1d269686. The independent adversarial pass returned no confirmed findings, so these are the plausible items from it; three are fixed here and one is deferred with an issue.

Fixed

1. Azure subscription-ID case could fork purchase identity (double-spend). CredentialScope trimmed the credential but never folded its case, and azure_compute_ri.go folded the verbatim string into the idempotency token. ARM subscription IDs are case-insensitive GUIDs and nothing in providers/azure canonicalizes them, so:

  • a purchase issued with azure_subscription_id="ABC12345-1234-1234-1234-1234567890AB" (the spelling the portal hands you) times out;
  • the model retries with the override omitted, so the value now comes from AZURE_SUBSCRIPTION_ID as the lower-case abc12345-...;
  • same subscription, two tokens, so reservations.FindReservationOrderByIdempotencyToken (tenant-wide order list, matched on the token tag) misses the first order and a second reservation is purchased.

Same defect family as 44b6094: purchase identity forked by a difference the provider itself does not recognize. Whitespace was normalized there; case was not.

Both Azure uses of the subscription ID -- the idempotency scope in handle and the ProviderConfig in resolveClient -- now route through one azureCredentialScope helper, so they cannot normalize differently. Case folding is deliberately not pushed into CredentialScope: AWS named profiles are case-sensitive section names in ~/.aws/config, so lower-casing one would point a real purchase at a profile that does not exist or at a different one that does. GCP project IDs are lower-case by grammar.

TestAzureSubscriptionCaseCannotForkIdempotencyToken drives real purchases through the tool's own handle() (not a re-derived scope expression) and asserts both directions: two cases of one subscription derive one token, two genuinely different subscriptions keep two. Confirmed it fails on the pre-fix code (462f79c8... vs c10d89a9... for the same subscription) and passes after. TestAWSProfileCaseIsPreserved pins the boundary so the Azure fix cannot leak into the AWS path.

2. cudly_search_recommendations no longer claims it "spends no money". Since d76aa7c, a reservation search that omits term_years and payment_option fans out to six Cost Explorer requests, and AWS bills those per request. The tool buys no commitment, but it is not free, and a model told otherwise may loop on it. The description now states both, and mcp/README.md's fan-out bullet notes the request multiplication.

3. Account-level Savings Plan region guard now covers Database. It needs its own 1yr/no-upfront fixture because validateDatabaseSavingsPlan permits nothing else, which is why the shared 3yr fixture could not carry it. Database is account-level by construction today (the branch tests for EC2Instance), so the case exists to stop a refactor that enumerates the account-level types explicitly from quietly dropping it.

Deferred -- #1586

parseRecommendations (providers/aws/recommendations/parser_ri.go:30) logs to stderr and continues when one recommendation detail fails to parse. fetchSearchCombos correctly fails the whole search on a combo API error, but a combo whose best offer fails quantity/cost parsing vanishes silently, recreating within a combo the "5 of 6 offers presented as all your options" defect the fan-out exists to prevent. Newly load-bearing now that the tool promises a complete menu, but the parser is shared with the scheduler's discovery sweep, which legitimately wants the tolerant behaviour -- so the fix needs a per-caller policy and does not belong on this branch. Filed as #1586.

Gates

gate exit
go build ./... 0
go vet ./mcp/... 0
go test ./... (41 pkgs, 28 with tests) 0
gocyclo -over 10 mcp/tools/ mcp/ 0, no output
golangci-lint run ./mcp/... (v2.10.1, the CI-pinned version) 0, 0 issues.

Pre-commit hooks ran clean on the commit (no --no-verify).

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- commit summary and file list ---'
git show --format=fuller --stat --find-renames 707cd14a0
git diff-tree --no-commit-id --name-status -r 707cd14a0

printf '%s\n' '--- changed hunks ---'
git show --format= --find-renames 707cd14a0

printf '%s\n' '--- symbol outlines ---'
for file in \
  mcp/tools/azure_compute_ri.go \
  mcp/tools/azure_compute_ri_test.go \
  mcp/tools/aws_savingsplans_test.go \
  mcp/tools/search_recommendations.go
do
  if [ -f "$file" ]; then
    echo "### $file"
    ast-grep outline "$file" --items all --type function,method --view compact || true
  fi
done

printf '%s\n' '--- identity and canonicalization references ---'
rg -n -C 4 \
  'azureCredentialScope|CredentialScope\(|AzureSubscriptionID|idempotencyKeyFor|TestAzureSubscriptionCaseCannotForkIdempotencyToken|TestAWSProfileCaseIsPreserved|SPTypeDatabase|Database' \
  mcp/tools README.md mcp/README.md 2>/dev/null || true

printf '%s\n' '--- deferred issue ---'
gh issue view 1586 --repo LeanerCloud/CUDly --json number,title,state,body,url

Length of output: 112131


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Azure provider subscription resolution ---'
ast-grep outline providers/azure/provider.go --items all --type function,method --match 'resolveAzureSubscriptionID|NewProvider' --view signatures
rg -n -A90 -B20 'func resolveAzureSubscriptionID|func NewProvider|AZURE_SUBSCRIPTION_ID|AzureSubscriptionID' \
  providers/azure/provider.go

printf '%s\n' '--- Azure MCP purchase tool inventory and registration ---'
rg -n -C 3 \
  'NewAzure|ProviderAzure|AzureSubscriptionID|azure_compute_ri_purchase|cudly_azure' \
  mcp/tools mcp/server.go

printf '%s\n' '--- exact test execution path and environment use ---'
rg -n -A100 -B15 \
  'func azurePurchaseIdentity|func TestAzureSubscriptionCaseCannotForkIdempotencyToken|func \(t \*azureComputeRIPurchaseTool\) handle|func \(t \*azureComputeRIPurchaseTool\) resolveClient' \
  mcp/tools/idempotency_scope_test.go mcp/tools/azure_compute_ri.go

printf '%s\n' '--- diff integrity ---'
git diff --check 707cd14a0^ 707cd14a0

Length of output: 51206


🤖 web_search executed:

AWS Cost Explorer API pricing per request Cost Explorer API requests pricing

Length of output: 6806


@cristim Reviewed 707cd14a0; no new actionable findings.

  • azureCredentialScope correctly applies Azure-only case normalization to both the idempotency scope and ProviderConfig.AzureSubscriptionID. This covers the sole Azure MCP purchase tool without changing AWS’s case-sensitive profile semantics.
  • The Azure regression test verifies same-subscription case variants dedupe, distinct subscriptions remain distinct, and the provider configuration receives the canonical form.
  • The Database Savings Plans case uses its required 1yr/no-upfront combination and correctly extends the account-level region/idempotency invariant.
  • The search description and MCP README now accurately distinguish “does not buy a commitment” from “has no cost.” AWS documents Cost Explorer API requests as billable, including paginated requests.
  • #1586 is open with a clear strict-MCP versus tolerant-scheduler follow-up design and test direction.

🐇✅


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1495

Timestamp: 2026-07-28T16:04:28.563Z
Learning: For the Azure MCP purchase path in `mcp/tools/azure_compute_ri.go`, Azure subscription IDs must be canonicalized with Azure-specific lower-casing through `azureCredentialScope` for both `PurchaseRequest.CredentialScope` and `provider.ProviderConfig.AzureSubscriptionID`. Azure ARM subscription IDs are case-insensitive, while AWS profile names remain case-sensitive and must not be normalized this way; aligning token scope and provider configuration prevents case-only retries from bypassing Azure idempotency.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/l Weeks impact/internal Team-internal only priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(mcp): expose CUDly RI/SP purchase CLI as an MCP server for Claude

1 participant