Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
703 changes: 34 additions & 669 deletions .github/workflows/README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ By participating in this project, you agree to maintain a respectful and inclusi

1. **Search existing issues** - Check if the bug has already been reported
2. **Create a detailed report** including:
- CUDly version (`./cudly --version`)
- CUDly MCP server version (`bin/cudly-mcp` has no version flag: give the release tag, or `git rev-parse --short HEAD` for a source build)
- Go version (`go version`)
- Operating system and architecture
- Cloud provider and service affected
Expand Down
6 changes: 2 additions & 4 deletions cmd/cudly-mcp/main.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
// Command cudly-mcp runs the CUDly MCP server on stdio, exposing CUDly's
// RI/SP/CUD search and purchase tools to any MCP client (e.g. Claude Code
// via ~/.claude/mcp.json). See mcp/README.md for setup and usage.
// via ~/.claude/mcp.json). See README.md for setup and usage.
//
// This binary is intentionally separate from the ri-helper CLI (cmd/main.go):
// it is a local/desktop MCP server, never a Lambda handler, and is kept out
// of iac/ and terraform/ (see mcp/README.md "Deployment model").
// This binary is a local/desktop MCP server, never a Lambda handler.
package main

import (
Expand Down
11 changes: 5 additions & 6 deletions docs/mcp-annotations-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
This repo has no existing ADR/`docs/decisions/` convention (checked at
implementation time: no `docs/adr/` or `docs/decisions/` directory), so
this rationale lives here instead of in a numbered ADR.
Context: `docs/plans/mcp/05-store.md` Phase A and `docs/plans/mcp/00-scope.md`
§6 (not checked into this repo -- local planning notes for the MCP
store-readiness workstream).
Context: the MCP store-readiness workstream (planning notes are not
checked into this repo).

## Why annotations at all

Expand All @@ -26,7 +25,7 @@ specifically:
`ReadOnlyHint bool` and `IdempotentHint bool` have no such trap (their zero
value, `false`, is already the conservative reading), but every Descriptor
still sets them explicitly via `readOnlyAnnotations`/`purchaseAnnotations`
(`mcp/tools/registry.go`) so the intent is legible at every call site rather
(`tools/registry.go`) so the intent is legible at every call site rather
than resting on an implicit zero value.

## D1 -- destructiveHint=true on every purchase tool
Expand All @@ -51,7 +50,7 @@ therefore set `DestructiveHint: boolPtr(true)`.
## A5 -- idempotentHint=false on every purchase tool, always

Every purchase tool's `IdempotentHint` is `false`, unconditionally, even
though `ExecutePurchase` (`mcp/tools/purchase.go`) already derives an
though `ExecutePurchase` (`tools/purchase.go`) already derives an
idempotency key from the request and refuses/dedupes a byte-identical retry.

Rejected alternative: `IdempotentHint=true`, on the theory that the
Expand All @@ -77,7 +76,7 @@ Rejected because:

## Per-tool annotation table

Built from `mcp/tools/registry.go`'s `readOnlyAnnotations`/
Built from `tools/registry.go`'s `readOnlyAnnotations`/
`purchaseAnnotations` helpers and each tool's `Descriptor()`. Every tool sets
`ReadOnlyHint`, `DestructiveHint`, `IdempotentHint`, and `OpenWorldHint`
explicitly; none rely on the SDK's nil-defaults-to-true behavior.
Expand Down
2 changes: 1 addition & 1 deletion server.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Package mcp wires the CUDly MCP server: it registers every tool from
// mcp/tools onto a github.com/modelcontextprotocol/go-sdk/mcp.Server and
// tools/ onto a github.com/modelcontextprotocol/go-sdk/mcp.Server and
// builds the cudly_list_commitment_actions catalog from those same tools'
// descriptors, so the live tool set and the discoverability catalog can
// never drift apart.
Expand Down
4 changes: 2 additions & 2 deletions server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func TestRegistryNonEmpty(t *testing.T) {
// without any AWS credentials configured in this test environment --
// confirming dry_run=true never reaches the real purchase path even through
// the full protocol stack, not just the Go-level unit tests in
// mcp/tools/aws_ec2_ri_test.go.
// tools/aws_ec2_ri_test.go.
func TestEndToEndSearchThenDryRunPurchase(t *testing.T) {
t.Parallel()
ctx := context.Background()
Expand Down Expand Up @@ -252,7 +252,7 @@ func TestRealPurchaseToolsDocumentMoneyImpactAndDryRun(t *testing.T) {
// TestAzureComputeRIPurchaseSchemaExcludesPartialUpfront proves the
// cudly_azure_compute_ri_purchase tool's advertised payment_option enum
// never offers partial-upfront: Azure has no partial-upfront billing plan
// (mcp/tools/azure_compute_ri.go rejects it at runtime as defense in depth),
// (tools/azure_compute_ri.go rejects it at runtime as defense in depth),
// so the schema itself must not invite a caller to pick a value the tool
// can only ever refuse. Drives the real MCP protocol (ListTools), not a
// bare Go function call, so it catches a regression in what a real client
Expand Down
2 changes: 1 addition & 1 deletion tools/aws_savingsplans.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (rec commo
// Account-level plan types (Compute/SageMaker/Database) ignore region
// entirely, so pin the canonical one rather than honoring whatever the
// caller passed. region flows into rec.Region and from there into
// idempotencyKeyFor (mcp/tools/purchase.go), so letting an IGNORED input
// idempotencyKeyFor (tools/purchase.go), so letting an IGNORED input
// through would fork purchase identity: buying a $10/hr Compute SP once
// without region and then re-issuing the identical call with
// region="eu-west-1" (a self-correction, or a retry that fills the field
Expand Down
2 changes: 1 addition & 1 deletion tools/azure_compute_ri.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec co
}

// payment_option defaults to no-upfront (matching the CLI's --payment
// default, cmd/main.go) when the caller omits it -- an omitted string
// default in the cloud-commitments-cli repo) when the caller omits it -- an omitted string
// field arrives as "" and is never confused with an explicit,
// unrecognized value (feedback_no_silent_fallbacks: the default is
// applied here, explicitly, not fabricated deeper in the stack).
Expand Down
2 changes: 1 addition & 1 deletion tools/azure_compute_ri_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ func TestAzureComputeRecommendationFromArgsAcceptsNoUpfront(t *testing.T) {

// TestAzureComputeRecommendationFromArgsDefaultsToNoUpfront proves omitting
// payment_option defaults to no-upfront (matching the CLI's --payment
// default, cmd/main.go), not to Azure's raw API default (all-upfront) and
// default in the cloud-commitments-cli repo), not to Azure's raw API default (all-upfront) and
// not to an error.
func TestAzureComputeRecommendationFromArgsDefaultsToNoUpfront(t *testing.T) {
t.Parallel()
Expand Down
5 changes: 2 additions & 3 deletions tools/enums.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Package tools implements the individual MCP tool handlers exposed by the
// CUDly MCP server (mcp/server.go), plus the shared validation and purchase
// CUDly MCP server (server.go), plus the shared validation and purchase
// harness they all build on.
package tools

Expand Down Expand Up @@ -82,8 +82,7 @@ func (t TermYears) RecommendationTerm() string {
return fmt.Sprintf("%dyr", int(t))
}

// SPType is the AWS Savings Plans product family (--include-sp-types in the
// CLI, cmd/main.go:112).
// SPType is the AWS Savings Plans product family.
type SPType string

const (
Expand Down
4 changes: 2 additions & 2 deletions tools/list_commitment_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ type listCommitmentActionsTool struct {
}

// NewListCommitmentActions builds the cudly_list_commitment_actions tool
// from descriptors -- the same slice of Descriptor values mcp/server.go
// from descriptors -- the same slice of Descriptor values server.go
// collects from every other tool's Descriptor() method, so this catalog is
// generated from the live registry rather than hand-duplicated in code or
// docs.
Expand All @@ -56,7 +56,7 @@ func NewListCommitmentActions(descriptors []Descriptor) Registration {
}

// ListCommitmentActionsDescriptor returns the static Descriptor for
// cudly_list_commitment_actions itself. It is exported so mcp/server.go can
// cudly_list_commitment_actions itself. It is exported so server.go can
// include this tool in its own catalog: the descriptors slice passed to
// NewListCommitmentActions must be assembled (and thus known) before the
// tool exists, so its own entry can't come from calling Descriptor() on an
Expand Down
9 changes: 4 additions & 5 deletions tools/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package tools
import "github.com/modelcontextprotocol/go-sdk/mcp"

// Descriptor is the source-of-truth metadata for one MCP tool. Every tool
// file builds one and both mcp/server.go (to know what to register) and
// file builds one and both server.go (to know what to register) and
// cudly_list_commitment_actions (to know what to advertise) read it, so the
// live tool set and the discoverability catalog can never drift apart --
// there is exactly one place each tool's name/description/example prompts
Expand Down Expand Up @@ -85,10 +85,9 @@ func readOnlyAnnotations(title string, openWorld bool) *mcp.ToolAnnotations {
// "purchasing is additive, not destructive" reading was considered and
// rejected), OpenWorldHint true (every purchase reaches a live cloud
// provider API), and IdempotentHint FALSE on every purchase tool, always
// (A5, 00-scope.md §6 decision register) -- even though ExecutePurchase
// derives an idempotency key from the request and dedupes an identical
// retry (mcp/tools/purchase.go), the hint can't express that conditional
// safety: idempotency_nonce is caller-supplied, so one optional string
// -- even though ExecutePurchase derives an idempotency key from the request
// and dedupes an identical retry (tools/purchase.go), the hint can't express
// that conditional safety: idempotency_nonce is caller-supplied, so one optional string
// turns an otherwise-identical "safe retry" into a second real purchase,
// and provider-side dedupe (AWS ClientToken, Azure/GCP equivalents) is
// time- and scope-bounded, not permanent. The failure asymmetry only points
Expand Down
Loading