diff --git a/docs/mcp-annotations-decisions.md b/docs/mcp-annotations-decisions.md new file mode 100644 index 000000000..2fe6b8340 --- /dev/null +++ b/docs/mcp-annotations-decisions.md @@ -0,0 +1,138 @@ +# MCP tool annotations -- decisions + +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). + +## Why annotations at all + +Anthropic's and OpenAI's MCP directory review criteria both treat tool +`title` and the `readOnlyHint`/`destructiveHint` annotations as a hard +submission gate, and annotation quality is the #1 rejection cause at OpenAI. +Independent of directory submission, the go-sdk's `mcp.ToolAnnotations` has a +trap that makes annotating explicitly non-optional for this server +specifically: + +> **Nil-defaults trap**: `DestructiveHint *bool` and `OpenWorldHint *bool` +> default to `true` on the wire when left nil. A `nil` `Annotations` field +> (or a struct that leaves either pointer unset) therefore publishes +> "destructive, open-world, not read-only" for every tool that omits it -- +> including the read-only search and catalog tools. Omission is not a safe +> or neutral default; it is a wrong answer for 2 of this server's 11 tools. + +`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 +than resting on an implicit zero value. + +## D1 -- destructiveHint=true on every purchase tool + +Considered and rejected: treating a purchase as "additive" (it only creates +a new reservation/commitment, never deletes or mutates existing state) and +setting `destructiveHint=false`. + +Rejected because Anthropic's review criteria require `destructiveHint` on +any tool that modifies state, not only ones that delete or overwrite it, and +state explicitly that "destructive tools always prompt" in a client UI. A +tool that commits the caller's cloud account to a multi-thousand-dollar, +multi-year financial obligation is exactly the class of action a client +should always confirm before executing, regardless of whether the technical +effect is additive. All 9 purchase tools (`cudly_aws_ec2_ri_purchase`, +`cudly_aws_rds_ri_purchase`, `cudly_aws_elasticache_ri_purchase`, +`cudly_aws_opensearch_ri_purchase`, `cudly_aws_redshift_ri_purchase`, +`cudly_aws_memorydb_ri_purchase`, `cudly_aws_savingsplans_purchase`, +`cudly_azure_compute_ri_purchase`, `cudly_gcp_computeengine_cud_purchase`) +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 +idempotency key from the request and refuses/dedupes a byte-identical retry. + +Rejected alternative: `IdempotentHint=true`, on the theory that the +server-side dedupe already makes a retry safe. + +Rejected because: + +- `idempotency_nonce` is caller-supplied and optional. Varying that one + string field turns an otherwise byte-identical request into a + deliberately distinct one, so "retrying with the same arguments has no + additional effect" is not actually true in general -- it only holds when + the caller leaves the nonce untouched, and the hint has no way to express + that condition. +- Provider-side dedupe (AWS `ClientToken`, and the Azure/GCP equivalents) is + time- and scope-bounded, not a permanent guarantee independent of this + server's own idempotency key. +- The failure asymmetry is one-sided: a wrongly-`true` hint risks an MCP + client auto-retrying a stalled/ambiguous call and buying a real + multi-thousand-dollar commitment twice; a wrongly-`false` hint costs + nothing worse than an extra confirmation round in a client UI that treats + non-idempotent tools more cautiously. Given that asymmetry, `false` is the + only defensible default across all 9 purchase tools. + +## Per-tool annotation table + +Built from `mcp/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. + +| Tool | ReadOnly | Destructive | Idempotent | OpenWorld | Title | +|---|---|---|---|---|---| +| `cudly_search_recommendations` | true | false | false | true | Search commitment recommendations | +| `cudly_list_commitment_actions` | true | false | false | **false** | List available commitment actions | +| `cudly_aws_ec2_ri_purchase` | false | true | false | true | Purchase AWS EC2 Reserved Instances | +| `cudly_aws_rds_ri_purchase` | false | true | false | true | Purchase AWS RDS Reserved Instances | +| `cudly_aws_elasticache_ri_purchase` | false | true | false | true | Purchase AWS ElastiCache Reserved Cache Nodes | +| `cudly_aws_opensearch_ri_purchase` | false | true | false | true | Purchase AWS OpenSearch Reserved Instances | +| `cudly_aws_redshift_ri_purchase` | false | true | false | true | Purchase AWS Redshift Reserved Instances | +| `cudly_aws_memorydb_ri_purchase` | false | true | false | true | Purchase AWS MemoryDB Reserved Instances | +| `cudly_aws_savingsplans_purchase` | false | true | false | true | Purchase an AWS Savings Plan | +| `cudly_azure_compute_ri_purchase` | false | true | false | true | Purchase an Azure VM Reserved Instance | +| `cudly_gcp_computeengine_cud_purchase` | false | true | false | true | Purchase a GCP Compute Engine Committed Use Discount | + +`cudly_list_commitment_actions` is the one `OpenWorldHint=false` tool in the +server: it only ever reads the in-process `Descriptor` slice `NewServer` +builds at startup, never a live cloud API, unlike +`cudly_search_recommendations` (reaches Cost Explorer/Advisor/Recommender) +and every purchase tool (reaches a live provider purchase API). + +## Enforcement + +`Descriptor.Annotations` is a plain, optional `*mcp.ToolAnnotations` field -- +nothing at the Go type level stops a new tool from leaving it nil or +building one by hand instead of calling `readOnlyAnnotations`/ +`purchaseAnnotations`, so none of this is compiler-enforced. The guarantee +is carried entirely by `mcp/annotations_test.go`, run in CI on every push: + +- `TestToolAnnotationsMatchDescriptor` is the drift test: it drives a real + `ListTools` call over the in-memory MCP transport and asserts, in both + directions, that the live `Annotations` on the wire equal each tool's + `Descriptor().Annotations` -- tool counts must match, every live tool + must have a matching `Descriptor`, and every `Descriptor` must have a + matching live tool. The bidirectional check matters because a tool + registered directly against `*gosdk.Server` outside the + `Descriptor`/`Register` contract would never appear in the descriptor + side of a one-directional comparison, and could ship unsafe or missing + annotations without this test noticing. +- `TestToolAnnotationsValueAssertions` is a table-driven sweep, derived from + `Descriptor.Action` rather than a hardcoded tool-name list (so a future + 12th purchase tool inherits the check automatically), asserting the + concrete required value of every hint per role, not just that a value is + present: `Annotations`/`DestructiveHint`/`OpenWorldHint` are never nil; + every tool whose `Action` contains `"purchase"` is `ReadOnlyHint=false`, + `DestructiveHint=true` (D1), `IdempotentHint=false` (A5), and + `OpenWorldHint=true`; the search tool (`Action == "search"`) is + `ReadOnlyHint=true`, `DestructiveHint=false`, `OpenWorldHint=true`; the + catalog tool (the one remaining role, `Action == ""`) is + `ReadOnlyHint=true`, `DestructiveHint=false`, `OpenWorldHint=false`; and + every `Title` is non-empty, differs from the tool's snake_case `Name`, and + contains no underscore. Asserting concrete values (not just "is set") + matters because `TestToolAnnotationsMatchDescriptor` alone would pass a + purchase tool that mistakenly set `IdempotentHint=true`, as long as + `Descriptor` and the live registration agreed on that same wrong value. diff --git a/mcp/annotations_test.go b/mcp/annotations_test.go new file mode 100644 index 000000000..f50ff4446 --- /dev/null +++ b/mcp/annotations_test.go @@ -0,0 +1,175 @@ +package mcp + +import ( + "context" + "reflect" + "strings" + "testing" + + gosdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/CUDly/mcp/tools" +) + +// allDescriptors returns the Descriptor for every tool this server exposes, +// including cudly_list_commitment_actions -- the same set NewServer builds, +// so tests here cover every registered tool without hand-maintaining a +// second list that could drift from the real registry. +func allDescriptors() []tools.Descriptor { + regs := registrations() + descriptors := make([]tools.Descriptor, 0, len(regs)+1) + for _, r := range regs { + descriptors = append(descriptors, r.Descriptor()) + } + descriptors = append(descriptors, tools.ListCommitmentActionsDescriptor()) + return descriptors +} + +// connectTestServer builds a real NewServer, connects a real gosdk.Client to +// it over an in-memory transport, and returns the live session -- the same +// end-to-end wiring TestEndToEndSearchThenDryRunPurchase already exercises, +// factored out here so both annotation tests below can drive an actual +// ListTools round trip instead of asserting against Descriptor alone. +func connectTestServer(t *testing.T) *gosdk.ClientSession { + t.Helper() + ctx := context.Background() + + server, err := NewServer("test") + require.NoError(t, err) + + clientTransport, serverTransport := gosdk.NewInMemoryTransports() + go func() { + _ = server.Run(ctx, serverTransport) + }() + + client := gosdk.NewClient(&gosdk.Implementation{Name: "test-client"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + return session +} + +// TestToolAnnotationsMatchDescriptor is the A9 drift test: it proves the +// Annotations the live MCP protocol advertises via ListTools are exactly +// the Annotations each tool's Descriptor() reports, for every registered +// tool -- in both directions. Descriptor.Annotations and each Register()'s +// mcp.Tool.Annotations are two independent call sites (the same duplication +// Description already has) -- nothing in the Go type system stops them from +// diverging, so this is the regression guard. The tool-count and reverse +// (live-to-descriptor) checks matter because the per-descriptor loop alone +// only proves "every descriptor has a live match" -- a tool registered +// directly against *gosdk.Server outside the Descriptor/Register contract +// (so it never appears in allDescriptors()) could still ship with unsafe or +// missing annotations and this test would stay green without them. +func TestToolAnnotationsMatchDescriptor(t *testing.T) { + t.Parallel() + ctx := context.Background() + session := connectTestServer(t) + + toolsList, err := session.ListTools(ctx, nil) + require.NoError(t, err) + + live := make(map[string]*gosdk.ToolAnnotations, len(toolsList.Tools)) + for _, tl := range toolsList.Tools { + live[tl.Name] = tl.Annotations + } + + descriptors := allDescriptors() + require.Lenf(t, toolsList.Tools, len(descriptors), + "ListTools returned %d tools but the descriptor registry has %d entries -- a tool registered "+ + "outside Descriptor/Register would only surface here, not in the per-name checks below", + len(toolsList.Tools), len(descriptors)) + + descriptorNames := make(map[string]bool, len(descriptors)) + for _, d := range descriptors { + descriptorNames[d.Name] = true + } + for name := range live { + assert.Truef(t, descriptorNames[name], + "tool %q is registered on the live server but has no matching Descriptor entry", name) + } + + for _, d := range descriptors { + liveAnnotations, ok := live[d.Name] + require.Truef(t, ok, "tool %q from the descriptor registry was not returned by ListTools", d.Name) + assert.Truef(t, reflect.DeepEqual(d.Annotations, liveAnnotations), + "tool %q: ListTools annotations %+v != Descriptor annotations %+v", d.Name, liveAnnotations, d.Annotations) + } +} + +// TestToolAnnotationsValueAssertions is the A10 value-assertion test: a +// table-driven sweep over every tool's Descriptor (not a hardcoded tool-name +// list, so a future 12th purchase tool cannot dodge it by omission) that +// checks every hint's actual value, not just that it is set. A structural +// nil-check alone would pass a purchase tool that mistakenly claims +// IdempotentHint=true or OpenWorldHint=false, or a catalog tool that claims +// OpenWorldHint=true, as long as Descriptor and the live registration agree +// on the same wrong value -- TestToolAnnotationsMatchDescriptor only proves +// the two never drift apart, not that either is correct. +// +// The three-way switch below covers every read-only/purchase role this +// server has today: a tool whose Action contains "purchase" (9 tools), the +// search tool (Action == "search", the only other role that sets Action), +// and cudly_list_commitment_actions (Action left as its zero value ""), the +// one in-process, closed-world catalog tool. Adding a role with a fourth +// annotation profile (e.g. a future audit/server-info tool) will need a new +// case here rather than falling through the default. +func TestToolAnnotationsValueAssertions(t *testing.T) { + t.Parallel() + + for _, d := range allDescriptors() { + d := d + t.Run(d.Name, func(t *testing.T) { + t.Parallel() + + require.NotNilf(t, d.Annotations, "tool %q must set Annotations explicitly: a nil Annotations "+ + "publishes destructive=true/openWorld=true/readOnly=false on the wire per the MCP spec", d.Name) + require.NotNilf(t, d.Annotations.DestructiveHint, "tool %q must set DestructiveHint explicitly "+ + "(nil defaults to true on the wire)", d.Name) + require.NotNilf(t, d.Annotations.OpenWorldHint, "tool %q must set OpenWorldHint explicitly "+ + "(nil defaults to true on the wire)", d.Name) + + switch { + case strings.Contains(d.Action, "purchase"): + assert.Falsef(t, d.Annotations.ReadOnlyHint, + "tool %q has a purchase action (%q) so must be ReadOnlyHint=false", d.Name, d.Action) + assert.Truef(t, *d.Annotations.DestructiveHint, + "tool %q has a purchase action (%q) so must be DestructiveHint=true (D1)", d.Name, d.Action) + assert.Falsef(t, d.Annotations.IdempotentHint, + "tool %q has a purchase action (%q) so must be IdempotentHint=false (A5): "+ + "idempotency_nonce is caller-supplied, so a claimed-idempotent purchase tool invites a "+ + "client to \"safely\" retry into a duplicate real purchase", d.Name, d.Action) + assert.Truef(t, *d.Annotations.OpenWorldHint, + "tool %q has a purchase action (%q) so must be OpenWorldHint=true: every purchase reaches "+ + "a live cloud provider API", d.Name, d.Action) + case d.Action == "search": + assert.Truef(t, d.Annotations.ReadOnlyHint, "tool %q is the search tool so must be ReadOnlyHint=true", d.Name) + assert.Falsef(t, *d.Annotations.DestructiveHint, + "tool %q is the search tool so must be DestructiveHint=false", d.Name) + assert.Falsef(t, d.Annotations.IdempotentHint, + "tool %q is the search tool so must be IdempotentHint=false: each search bills Cost Explorer "+ + "per request, so claiming idempotency invites a free-retry billing loop", d.Name) + assert.Truef(t, *d.Annotations.OpenWorldHint, + "tool %q is the search tool so must be OpenWorldHint=true: it reaches a live Cost Explorer/"+ + "Advisor/Recommender API", d.Name) + default: + // The one remaining role today is cudly_list_commitment_actions, the + // in-process catalog: closed-world by construction (it only ever reads + // the descriptors slice NewServer built at startup, never a live cloud API). + assert.Truef(t, d.Annotations.ReadOnlyHint, "tool %q must be ReadOnlyHint=true", d.Name) + assert.Falsef(t, *d.Annotations.DestructiveHint, "tool %q must be DestructiveHint=false", d.Name) + assert.Falsef(t, d.Annotations.IdempotentHint, "tool %q must be IdempotentHint=false", d.Name) + assert.Falsef(t, *d.Annotations.OpenWorldHint, + "tool %q is the in-process catalog tool so must be OpenWorldHint=false", d.Name) + } + + assert.NotEmptyf(t, d.Annotations.Title, "tool %q must set a human-readable Title", d.Name) + assert.NotEqualf(t, d.Name, d.Annotations.Title, + "tool %q Title must differ from the snake_case tool name", d.Name) + assert.NotContainsf(t, d.Annotations.Title, "_", + "tool %q Title %q must be human-readable prose, not a snake_case identifier", d.Name, d.Annotations.Title) + }) + } +} diff --git a/mcp/tools/aws_ec2_ri.go b/mcp/tools/aws_ec2_ri.go index 6bea3c947..5e50810da 100644 --- a/mcp/tools/aws_ec2_ri.go +++ b/mcp/tools/aws_ec2_ri.go @@ -13,6 +13,10 @@ import ( const awsEC2RIPurchaseName = "cudly_aws_ec2_ri_purchase" +const awsEC2RIPurchaseTitle = "Purchase AWS EC2 Reserved Instances" + +var awsEC2RIPurchaseAnnotations = purchaseAnnotations(awsEC2RIPurchaseTitle) + const awsEC2RIPurchaseDescription = "Purchase AWS EC2 Reserved Instances. THIS SPENDS REAL MONEY when " + "dry_run=false and confirm=true. Always call with dry_run=true first (the default) to validate your " + "parameters before committing; a dry_run response never contacts AWS and never spends money. Search first " + @@ -56,6 +60,7 @@ func (t *awsEC2RIPurchaseTool) Descriptor() Descriptor { Product: "ec2", Action: "ri_purchase", Description: awsEC2RIPurchaseDescription, + Annotations: awsEC2RIPurchaseAnnotations, RealPurchaseEnabled: true, ExamplePrompts: []string{ "Preview buying 3 m5.large 3-year no-upfront RIs in us-east-1", @@ -79,6 +84,7 @@ func (t *awsEC2RIPurchaseTool) Register(s *mcp.Server) error { mcp.AddTool(s, &mcp.Tool{ Name: awsEC2RIPurchaseName, Description: awsEC2RIPurchaseDescription, + Annotations: awsEC2RIPurchaseAnnotations, InputSchema: schema, }, t.handle) return nil diff --git a/mcp/tools/aws_ec2_ri_test.go b/mcp/tools/aws_ec2_ri_test.go index 1e0b921e1..8957ac02d 100644 --- a/mcp/tools/aws_ec2_ri_test.go +++ b/mcp/tools/aws_ec2_ri_test.go @@ -11,8 +11,6 @@ import ( "github.com/LeanerCloud/CUDly/pkg/provider" ) -func boolPtr(b bool) *bool { return &b } - func validEC2Args() ec2RIPurchaseArgs { return ec2RIPurchaseArgs{ AWSProfile: "test-profile", diff --git a/mcp/tools/aws_elasticache_ri.go b/mcp/tools/aws_elasticache_ri.go index a8b447bb9..234b9ff7c 100644 --- a/mcp/tools/aws_elasticache_ri.go +++ b/mcp/tools/aws_elasticache_ri.go @@ -12,6 +12,10 @@ import ( const awsElastiCacheRIPurchaseName = "cudly_aws_elasticache_ri_purchase" +const awsElastiCacheRIPurchaseTitle = "Purchase AWS ElastiCache Reserved Cache Nodes" + +var awsElastiCacheRIPurchaseAnnotations = purchaseAnnotations(awsElastiCacheRIPurchaseTitle) + const awsElastiCacheRIPurchaseDescription = "Purchase AWS ElastiCache Reserved Cache Nodes. THIS SPENDS REAL " + "MONEY when dry_run=false and confirm=true. Always call with dry_run=true first (the default) to validate " + "your parameters before committing; a dry_run response never contacts AWS and never spends money." @@ -49,6 +53,7 @@ func (t *awsElastiCacheRIPurchaseTool) Descriptor() Descriptor { Product: "elasticache", Action: "ri_purchase", Description: awsElastiCacheRIPurchaseDescription, + Annotations: awsElastiCacheRIPurchaseAnnotations, RealPurchaseEnabled: true, ExamplePrompts: []string{ "Preview buying 3 cache.r6g.large redis ElastiCache RIs in us-east-1 for 1 year", @@ -71,6 +76,7 @@ func (t *awsElastiCacheRIPurchaseTool) Register(s *mcp.Server) error { mcp.AddTool(s, &mcp.Tool{ Name: awsElastiCacheRIPurchaseName, Description: awsElastiCacheRIPurchaseDescription, + Annotations: awsElastiCacheRIPurchaseAnnotations, InputSchema: schema, }, t.handle) return nil diff --git a/mcp/tools/aws_rds_ri.go b/mcp/tools/aws_rds_ri.go index e44ca47a3..c1b76488a 100644 --- a/mcp/tools/aws_rds_ri.go +++ b/mcp/tools/aws_rds_ri.go @@ -12,6 +12,10 @@ import ( const awsRDSRIPurchaseName = "cudly_aws_rds_ri_purchase" +const awsRDSRIPurchaseTitle = "Purchase AWS RDS Reserved Instances" + +var awsRDSRIPurchaseAnnotations = purchaseAnnotations(awsRDSRIPurchaseTitle) + const awsRDSRIPurchaseDescription = "Purchase AWS RDS Reserved Instances. THIS SPENDS REAL MONEY when " + "dry_run=false and confirm=true. Always call with dry_run=true first (the default) to validate your " + "parameters before committing; a dry_run response never contacts AWS and never spends money." @@ -52,6 +56,7 @@ func (t *awsRDSRIPurchaseTool) Descriptor() Descriptor { Product: "rds", Action: "ri_purchase", Description: awsRDSRIPurchaseDescription, + Annotations: awsRDSRIPurchaseAnnotations, RealPurchaseEnabled: true, ExamplePrompts: []string{ "Preview buying 2 db.r6g.large multi-az postgres RDS RIs in us-east-1 for 3 years", @@ -74,6 +79,7 @@ func (t *awsRDSRIPurchaseTool) Register(s *mcp.Server) error { mcp.AddTool(s, &mcp.Tool{ Name: awsRDSRIPurchaseName, Description: awsRDSRIPurchaseDescription, + Annotations: awsRDSRIPurchaseAnnotations, InputSchema: schema, }, t.handle) return nil diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index 0504cb571..655934445 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -17,6 +17,10 @@ import ( const awsSavingsPlansPurchaseName = "cudly_aws_savingsplans_purchase" +const awsSavingsPlansPurchaseTitle = "Purchase an AWS Savings Plan" + +var awsSavingsPlansPurchaseAnnotations = purchaseAnnotations(awsSavingsPlansPurchaseTitle) + const awsSavingsPlansPurchaseDescription = "Purchase an AWS Savings Plan (Compute, EC2Instance, SageMaker, or " + "Database). THIS SPENDS REAL MONEY when dry_run=false and confirm=true. Always call with dry_run=true " + "first (the default) to validate your parameters before committing; a dry_run response never contacts AWS " + @@ -65,6 +69,7 @@ func (t *awsSavingsPlansPurchaseTool) Descriptor() Descriptor { Product: "savingsplans", Action: "purchase", Description: awsSavingsPlansPurchaseDescription, + Annotations: awsSavingsPlansPurchaseAnnotations, RealPurchaseEnabled: true, ExamplePrompts: []string{ "Preview a $10/hour Compute Savings Plan, 3-year no-upfront", @@ -89,6 +94,7 @@ func (t *awsSavingsPlansPurchaseTool) Register(s *mcp.Server) error { mcp.AddTool(s, &mcp.Tool{ Name: awsSavingsPlansPurchaseName, Description: awsSavingsPlansPurchaseDescription, + Annotations: awsSavingsPlansPurchaseAnnotations, InputSchema: schema, }, t.handle) return nil diff --git a/mcp/tools/aws_simple_ri.go b/mcp/tools/aws_simple_ri.go index d6b055cdd..0920113c9 100644 --- a/mcp/tools/aws_simple_ri.go +++ b/mcp/tools/aws_simple_ri.go @@ -93,6 +93,15 @@ func NewAWSMemoryDBRIPurchaseTool() Registration { }) } +// title returns the human-readable Title for this spec's purchase tool, +// e.g. "Purchase AWS OpenSearch Reserved Instances" -- shared between +// Descriptor() and Register() so the two can never disagree, the same +// drift protection every individually-defined purchase tool's package-level +// Title constant gets. +func (t *simpleAWSRIPurchaseTool) title() string { + return fmt.Sprintf("Purchase AWS %s Reserved Instances", t.spec.displayName) +} + func (t *simpleAWSRIPurchaseTool) Descriptor() Descriptor { return Descriptor{ Name: t.spec.name, @@ -104,6 +113,7 @@ func (t *simpleAWSRIPurchaseTool) Descriptor() Descriptor { "Always call with dry_run=true first (the default) to validate your parameters before "+ "committing; a dry_run response never contacts AWS and never spends money.", t.spec.displayName), + Annotations: purchaseAnnotations(t.title()), RealPurchaseEnabled: true, ExamplePrompts: t.spec.examplePrompts, } @@ -128,6 +138,7 @@ func (t *simpleAWSRIPurchaseTool) Register(s *mcp.Server) error { mcp.AddTool(s, &mcp.Tool{ Name: t.spec.name, Description: desc, + Annotations: purchaseAnnotations(t.title()), InputSchema: schema, }, t.handle) return nil diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index 03db1b0ca..e2a5410ed 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -13,6 +13,10 @@ import ( const azureComputeRIPurchaseName = "cudly_azure_compute_ri_purchase" +const azureComputeRIPurchaseTitle = "Purchase an Azure VM Reserved Instance" + +var azureComputeRIPurchaseAnnotations = purchaseAnnotations(azureComputeRIPurchaseTitle) + // azureComputeRIPurchaseDescription documents Azure's actual billing-plan // contract: providers/azure/services/compute/client.go's buildReservationBody // sends properties.billingPlan (armreservations.ReservationBillingPlan -- @@ -62,6 +66,7 @@ func (t *azureComputeRIPurchaseTool) Descriptor() Descriptor { Product: "compute", Action: "ri_purchase", Description: azureComputeRIPurchaseDescription, + Annotations: azureComputeRIPurchaseAnnotations, RealPurchaseEnabled: true, ExamplePrompts: []string{ "Preview buying 2 Standard_D2s_v3 Azure VM RIs in eastus for 3 years", @@ -89,6 +94,7 @@ func (t *azureComputeRIPurchaseTool) Register(s *mcp.Server) error { mcp.AddTool(s, &mcp.Tool{ Name: azureComputeRIPurchaseName, Description: azureComputeRIPurchaseDescription, + Annotations: azureComputeRIPurchaseAnnotations, InputSchema: schema, }, t.handle) return nil diff --git a/mcp/tools/gcp_computeengine_cud.go b/mcp/tools/gcp_computeengine_cud.go index 50eefef8c..6f2c09354 100644 --- a/mcp/tools/gcp_computeengine_cud.go +++ b/mcp/tools/gcp_computeengine_cud.go @@ -12,6 +12,10 @@ import ( const gcpComputeEngineCUDPurchaseName = "cudly_gcp_computeengine_cud_purchase" +const gcpComputeEngineCUDPurchaseTitle = "Purchase a GCP Compute Engine Committed Use Discount" + +var gcpComputeEngineCUDPurchaseAnnotations = purchaseAnnotations(gcpComputeEngineCUDPurchaseTitle) + const gcpComputeEngineCUDPurchaseDescription = "Purchase a GCP Compute Engine Committed Use Discount (CUD). THIS " + "SPENDS REAL MONEY when dry_run=false and confirm=true. Always call with dry_run=true first (the default) " + "to validate your parameters before committing; a dry_run response never contacts GCP and never spends " + @@ -59,6 +63,7 @@ func (t *gcpComputeEngineCUDPurchaseTool) Descriptor() Descriptor { Product: "computeengine", Action: "cud_purchase", Description: gcpComputeEngineCUDPurchaseDescription, + Annotations: gcpComputeEngineCUDPurchaseAnnotations, RealPurchaseEnabled: true, ExamplePrompts: []string{ "Preview a 3-year CUD for 8 vCPUs and 32 GB memory in us-central1", @@ -79,6 +84,7 @@ func (t *gcpComputeEngineCUDPurchaseTool) Register(s *mcp.Server) error { mcp.AddTool(s, &mcp.Tool{ Name: gcpComputeEngineCUDPurchaseName, Description: gcpComputeEngineCUDPurchaseDescription, + Annotations: gcpComputeEngineCUDPurchaseAnnotations, InputSchema: schema, }, t.handle) return nil diff --git a/mcp/tools/list_commitment_actions.go b/mcp/tools/list_commitment_actions.go index a9743124e..895f07dc3 100644 --- a/mcp/tools/list_commitment_actions.go +++ b/mcp/tools/list_commitment_actions.go @@ -8,6 +8,15 @@ import ( const listCommitmentActionsName = "cudly_list_commitment_actions" +const listCommitmentActionsTitle = "List available commitment actions" + +// listCommitmentActionsAnnotations is the one honest closed-world tool in +// this server: it only ever reads the in-process descriptors slice built at +// startup (NewServer), never a live cloud API, so openWorld=false -- unlike +// every other read-only tool here (cudly_search_recommendations), which +// reaches out to a provider. +var listCommitmentActionsAnnotations = readOnlyAnnotations(listCommitmentActionsTitle, false) + const listCommitmentActionsDescription = "List every CUDly commitment-purchase and search tool available on this " + "MCP server, including which ones can execute a REAL purchase (money-affecting) versus which are " + "search/preview-only, plus example prompts for each. This tool never spends money and takes no parameters -- " + @@ -56,6 +65,7 @@ func ListCommitmentActionsDescriptor() Descriptor { return Descriptor{ Name: listCommitmentActionsName, Description: listCommitmentActionsDescription, + Annotations: listCommitmentActionsAnnotations, ExamplePrompts: []string{ "What CUDly tools are available?", "Which purchase tools can spend real money right now?", @@ -76,6 +86,7 @@ func (t *listCommitmentActionsTool) Register(s *mcp.Server) error { mcp.AddTool(s, &mcp.Tool{ Name: listCommitmentActionsName, Description: listCommitmentActionsDescription, + Annotations: listCommitmentActionsAnnotations, InputSchema: schema, }, t.handle) return nil @@ -84,11 +95,19 @@ func (t *listCommitmentActionsTool) Register(s *mcp.Server) error { func (t *listCommitmentActionsTool) handle(_ context.Context, _ *mcp.CallToolRequest, _ listCommitmentActionsArgs) (*mcp.CallToolResult, listCommitmentActionsResult, error) { actions := make([]ActionEntry, 0, len(t.descriptors)) for _, d := range t.descriptors { - // ActionEntry's fields are identical in name, type, and order to - // Descriptor's -- only the json tags differ -- so a direct - // conversion is equivalent to (and clearer than) a field-by-field - // struct literal. - actions = append(actions, ActionEntry(d)) + // Descriptor carries fields (e.g. Annotations) that this catalog + // does not surface -- ListTools is the source of truth for MCP + // annotations, not this tool -- so ActionEntry is built field by + // field rather than by direct struct conversion. + actions = append(actions, ActionEntry{ + Name: d.Name, + Provider: d.Provider, + Product: d.Product, + Action: d.Action, + Description: d.Description, + RealPurchaseEnabled: d.RealPurchaseEnabled, + ExamplePrompts: d.ExamplePrompts, + }) } return nil, listCommitmentActionsResult{Actions: actions}, nil } diff --git a/mcp/tools/registry.go b/mcp/tools/registry.go index d661a1c0a..e1fa04059 100644 --- a/mcp/tools/registry.go +++ b/mcp/tools/registry.go @@ -21,6 +21,17 @@ type Descriptor struct { // Description is the tool's full MCP description, shared verbatim with // the live mcp.Tool registration so the two can never disagree. Description string + // Annotations are the MCP tool hints (ReadOnlyHint, DestructiveHint, + // IdempotentHint, OpenWorldHint) plus a human-readable Title, shared + // verbatim with the live mcp.Tool registration -- the same drift + // protection Description gets. Every Descriptor must set this + // explicitly via readOnlyAnnotations or purchaseAnnotations: per the MCP + // spec (see the go-sdk's ToolAnnotations doc comments), a nil + // DestructiveHint/OpenWorldHint pointer defaults to TRUE on the wire, so + // a nil Annotations field would silently publish "destructive, + // open-world, not read-only" for a tool that is neither. There is no + // safe zero value. + Annotations *mcp.ToolAnnotations // RealPurchaseEnabled reports whether this tool can execute a real, // money-spending purchase today (dry_run=false, confirm=true). false for // read-only tools and for tools shipped dry-run-only pending a @@ -39,3 +50,57 @@ type Registration interface { Descriptor() Descriptor Register(s *mcp.Server) error } + +// boolPtr returns a pointer to b. The SDK's DestructiveHint and +// OpenWorldHint are *bool specifically so a server can distinguish "false" +// from "unset" (unset defaults to true on the wire) -- a plain bool zero +// value can't express that, so every explicit hint value must go through a +// pointer. +func boolPtr(b bool) *bool { return &b } + +// readOnlyAnnotations builds the ToolAnnotations for a read-only tool: +// ReadOnlyHint true, DestructiveHint explicitly false (a read-only tool +// cannot be destructive by definition), IdempotentHint explicitly false. +// IdempotentHint is documented as "meaningful only when ReadOnlyHint == +// false", so it does not gate client behavior here, but every Descriptor +// still sets it explicitly rather than relying on the bool zero value, so a +// future spec revision that does read it for read-only tools finds an +// intentional value instead of an accidental one. openWorld distinguishes a +// tool that reaches external systems (search: true) from one that only +// consults an in-process, closed catalog (list_commitment_actions: false). +func readOnlyAnnotations(title string, openWorld bool) *mcp.ToolAnnotations { + return &mcp.ToolAnnotations{ + Title: title, + ReadOnlyHint: true, + DestructiveHint: boolPtr(false), + IdempotentHint: false, + OpenWorldHint: boolPtr(openWorld), + } +} + +// purchaseAnnotations builds the ToolAnnotations shared by every +// money-spending purchase tool: ReadOnlyHint false, DestructiveHint true (D1 +// -- Anthropic's review criteria require destructiveHint on any tool that +// modifies state, and "destructive tools always prompt"; the purist +// "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 +// 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 +// one way: a wrongly-true hint risks a client auto-retrying into a +// duplicate multi-thousand-dollar commitment, while a wrongly-false hint +// costs nothing worse than an extra confirmation prompt. +func purchaseAnnotations(title string) *mcp.ToolAnnotations { + return &mcp.ToolAnnotations{ + Title: title, + ReadOnlyHint: false, + DestructiveHint: boolPtr(true), + IdempotentHint: false, + OpenWorldHint: boolPtr(true), + } +} diff --git a/mcp/tools/search_recommendations.go b/mcp/tools/search_recommendations.go index 9ad30b31d..b162fd0a9 100644 --- a/mcp/tools/search_recommendations.go +++ b/mcp/tools/search_recommendations.go @@ -14,6 +14,19 @@ import ( const searchRecommendationsName = "cudly_search_recommendations" +const searchRecommendationsTitle = "Search commitment recommendations" + +// searchRecommendationsAnnotations is read-only/open-world (it reaches a +// live Cost Explorer/Advisor/Recommender API), but deliberately does NOT +// claim IdempotentHint -- see readOnlyAnnotations' doc comment for why the +// hint doesn't gate anything for a read-only tool per spec. The reason this +// tool still calls it out: an AWS search fans out to up to 6 Cost Explorer +// requests (searchCombos below) and AWS bills those per request, so a +// client that inferred "safe to retry freely" from a stray idempotent-style +// hint would be invited into a free-retry billing loop. openWorld=true +// because every search call reaches a live cloud provider API. +var searchRecommendationsAnnotations = readOnlyAnnotations(searchRecommendationsTitle, true) + // searchRecommendationsDescription ships in the tool schema, so the model // reads it as the contract for what calling this costs. It states the // guarantee that matters (nothing is ever bought) without claiming the call is @@ -71,6 +84,7 @@ func (t *searchRecommendationsTool) Descriptor() Descriptor { return Descriptor{ Name: searchRecommendationsName, Description: searchRecommendationsDescription, + Annotations: searchRecommendationsAnnotations, Action: "search", ExamplePrompts: []string{ "Search for AWS EC2 RI recommendations in us-east-1", @@ -102,6 +116,7 @@ func (t *searchRecommendationsTool) Register(s *mcp.Server) error { mcp.AddTool(s, &mcp.Tool{ Name: searchRecommendationsName, Description: searchRecommendationsDescription, + Annotations: searchRecommendationsAnnotations, InputSchema: schema, }, t.handle) return nil