Skip to content
Open
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

## [Unreleased]

### Added

- `cudly_archera_comparison`: a read-only comparison of an Archera commitment
plan (current offer and alternatives per line item, plus plan-wide
hypotheticals), with exact decimal money, unknown as null, and both Archera
disclosures. Off until the operator sets `ARCHERA_API_KEY`, `ARCHERA_ORG_ID`
and `ARCHERA_PLAN_ID`; unconfigured calls error and send no request. It
reads `https://api.archera.ai` only, never retries, and makes no purchase.

### Changed

- The purchase audit log records a new status, `unknown`, for a purchase that
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ Every other provider's purchase tool (`cudly_aws_savingsplans_purchase`, `cudly_
- A **completed** real purchase carries an `archera` block in the response: an optional underutilization-insurance offer (Archera covers the gap if committed capacity goes unused), the signup link, the enrollment window in days, and both partnership disclosures. It is attached only when the purchase actually succeeded, never to a dry run or a failed purchase, because neither started an enrollment window. Archera sponsors CUDly's development from a fraction of their insurance premiums, and CUDly works fully without it; both facts travel with the link in every response so a client rendering this payload cannot present the offer as a neutral recommendation.
- Every real purchase writes an `mcp purchase ATTEMPT` line and a matching `mcp purchase OK` / `mcp purchase FAILED` line to **stderr**, recording provider, target account, region, resource, count, term, payment option, the resulting commitment ID, and a masked idempotency token. Dry runs emit no purchase diagnostic to stderr because they spend nothing, but when auditing is enabled and the append succeeds, they are persisted in the JSONL audit log as `status: "skipped"`. Capture your MCP client's stderr if you want the diagnostic trail retained. Nothing is written to stdout, which the MCP stdio transport owns for JSON-RPC framing.

- `cudly_archera_comparison` is read-only and has no purchase path: it issues GET requests to `https://api.archera.ai` and nothing else. It is off by default. It needs `ARCHERA_API_KEY`, `ARCHERA_ORG_ID` and `ARCHERA_PLAN_ID` (UUIDs) in the server's environment, read on every call and never accepted as tool arguments; with any of them unset or blank the call returns an error naming the missing variables and sends no request. The key goes only into the `x-api-key` header, is never logged or returned, and vendor error text is not echoed. It is not gated by `CUDLY_MCP_ENABLE_REAL_PURCHASES` or the spend caps, writes no audit record, and never retries: on HTTP 429 the error reports the vendor's `Retry-After` (capped at 24 hours). Money is exact decimal text, `null` means unknown (never zero), the API states no currency, and the response carries both Archera disclosures. A comparison is a hypothetical rollup, not a bindable quote.

### What this server does NOT give you

Understand these before enabling real purchases, especially in a shared or production account:
Expand Down
6 changes: 6 additions & 0 deletions annotations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ func TestToolAnnotationsValueAssertions(t *testing.T) {
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)
case d.Action == "comparison":
assert.Truef(t, d.Annotations.ReadOnlyHint, "tool %q is the Archera comparison so 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.Truef(t, *d.Annotations.OpenWorldHint, "tool %q reaches api.archera.ai so must be OpenWorldHint=true", d.Name)
assert.Falsef(t, d.RealPurchaseEnabled, "tool %q has no purchase path", 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
Expand Down
37 changes: 34 additions & 3 deletions mcpb/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@
"CUDLY_MCP_ENABLE_REAL_PURCHASES": "${user_config.enable_real_purchases}",
"CUDLY_MCP_MAX_COUNT": "${user_config.max_count}",
"CUDLY_MCP_MAX_HOURLY_COMMITMENT": "${user_config.max_hourly_commitment}",
"CUDLY_MCP_MAX_MEMORY_GB": "${user_config.max_memory_gb}"
"CUDLY_MCP_MAX_MEMORY_GB": "${user_config.max_memory_gb}",
"ARCHERA_API_KEY": "${user_config.archera_api_key}",
"ARCHERA_ORG_ID": "${user_config.archera_org_id}",
"ARCHERA_PLAN_ID": "${user_config.archera_plan_id}"
},
"platform_overrides": {
"darwin": {
Expand All @@ -38,7 +41,10 @@
"CUDLY_MCP_ENABLE_REAL_PURCHASES": "${user_config.enable_real_purchases}",
"CUDLY_MCP_MAX_COUNT": "${user_config.max_count}",
"CUDLY_MCP_MAX_HOURLY_COMMITMENT": "${user_config.max_hourly_commitment}",
"CUDLY_MCP_MAX_MEMORY_GB": "${user_config.max_memory_gb}"
"CUDLY_MCP_MAX_MEMORY_GB": "${user_config.max_memory_gb}",
"ARCHERA_API_KEY": "${user_config.archera_api_key}",
"ARCHERA_ORG_ID": "${user_config.archera_org_id}",
"ARCHERA_PLAN_ID": "${user_config.archera_plan_id}"
}
},
"linux": {
Expand All @@ -48,7 +54,10 @@
"CUDLY_MCP_ENABLE_REAL_PURCHASES": "${user_config.enable_real_purchases}",
"CUDLY_MCP_MAX_COUNT": "${user_config.max_count}",
"CUDLY_MCP_MAX_HOURLY_COMMITMENT": "${user_config.max_hourly_commitment}",
"CUDLY_MCP_MAX_MEMORY_GB": "${user_config.max_memory_gb}"
"CUDLY_MCP_MAX_MEMORY_GB": "${user_config.max_memory_gb}",
"ARCHERA_API_KEY": "${user_config.archera_api_key}",
"ARCHERA_ORG_ID": "${user_config.archera_org_id}",
"ARCHERA_PLAN_ID": "${user_config.archera_plan_id}"
}
}
}
Expand Down Expand Up @@ -82,6 +91,28 @@
"description": "Per-call ceiling on memory for a GCP CUD. Required before any real GCP CUD purchase. Applies per call, not cumulatively.",
"min": 1,
"required": false
},
"archera_api_key": {
"type": "string",
"title": "Archera API key",
"description": "Optional. Enables the read-only Archera plan comparison tool. Sent only to https://api.archera.ai. Leave empty to keep the tool disabled.",
"sensitive": true,
"required": false,
"default": ""
},
"archera_org_id": {
"type": "string",
"title": "Archera organization ID",
"description": "Optional. Archera organization UUID, used with the API key above.",
"required": false,
"default": ""
},
"archera_plan_id": {
"type": "string",
"title": "Archera commitment plan ID",
"description": "Optional. Archera commitment plan UUID to compare, used with the API key above.",
"required": false,
"default": ""
}
},
"privacy_policies": [
Expand Down
1 change: 1 addition & 0 deletions server.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func registrations() []tools.Registration {
tools.NewAWSSavingsPlansPurchaseTool(),
tools.NewAzureComputeRIPurchaseTool(),
tools.NewGCPComputeEngineCUDPurchaseTool(),
tools.NewArcheraComparisonTool(),
}
}

Expand Down
21 changes: 21 additions & 0 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,27 @@
"isRequired": false,
"isSecret": false,
"format": "string"
},
{
"name": "ARCHERA_API_KEY",
"description": "Archera API key for the read-only cudly_archera_comparison tool. Unset (the default) disables that tool: calls return an error and send no request. The key is sent only as the x-api-key header to https://api.archera.ai.",
"isRequired": false,
"isSecret": true,
"format": "string"
},
{
"name": "ARCHERA_ORG_ID",
"description": "Archera organization UUID for cudly_archera_comparison. Required together with ARCHERA_API_KEY and ARCHERA_PLAN_ID.",
"isRequired": false,
"isSecret": false,
"format": "string"
},
{
"name": "ARCHERA_PLAN_ID",
"description": "Archera commitment plan UUID for cudly_archera_comparison. Required together with ARCHERA_API_KEY and ARCHERA_ORG_ID.",
"isRequired": false,
"isSecret": false,
"format": "string"
}
]
}
Expand Down
194 changes: 194 additions & 0 deletions tools/archera_comparison.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package tools

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strings"

"github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/LeanerCloud/cloud-commitments-go/pkg/insurance"
)

const (
archeraComparisonName = "cudly_archera_comparison"
archeraComparisonTitle = "Archera commitment plan comparison (read-only)"

// EnvArcheraAPIKey, EnvArcheraOrgID and EnvArcheraPlanID configure the
// Archera comparison tool. They are read from the server's environment on
// every call and never from tool arguments, which the model controls.
EnvArcheraAPIKey = "ARCHERA_API_KEY" // #nosec G101 -- environment variable name, not a credential
EnvArcheraOrgID = "ARCHERA_ORG_ID"
EnvArcheraPlanID = "ARCHERA_PLAN_ID"

// archeraMaxResultBytes bounds the marshaled result; beyond it the call
// fails and the caller narrows with line_item_ids.
archeraMaxResultBytes = 512 << 10
archeraMaxLineItemIDs = 200
archeraMaxPayments = 3
)

var archeraComparisonAnnotations = readOnlyAnnotations(archeraComparisonTitle, true)

const archeraComparisonDescription = "Read-only comparison of an Archera commitment plan: per line item, the " +
"current offer and the vendor's alternatives, plus plan-wide hypothetical rollups for contract term and " +
"payment option combinations. Makes no purchase and enrolls nothing. Disabled until the operator sets " +
"ARCHERA_API_KEY, ARCHERA_ORG_ID and ARCHERA_PLAN_ID in the server's environment; unconfigured, a call " +
"returns an error and sends no request. Reads https://api.archera.ai only. Money is exact decimal text " +
"or null (null means unknown, never zero); monthly figures are 730-hour rates with the Archera premium " +
"already included; upfront figures are one-time amounts. The API states no currency. Not a bindable " +
"quote. The response carries both partnership disclosures. No retries: on HTTP 429 the error states " +
"the vendor's Retry-After, wait that long before calling again."

type archeraComparisonArgs struct {
LineItemIDs []string `json:"line_item_ids,omitempty" jsonschema:"restrict the comparison to these Archera line item UUIDs; omit for all selected line items"`
ContractTerms []string `json:"contract_terms,omitempty" jsonschema:"target contract terms for the hypothetical rollups; omit for every distinct candidate term"`
PaymentOptions []string `json:"payment_options,omitempty" jsonschema:"payment options for the hypothetical rollups; omit for no_upfront only"`
}

type archeraComparisonTool struct {
// httpClient is nil in production, which selects the library's
// SSRF-hardened client. Tests inject a RoundTripper. The Archera key and
// client are deliberately never fields: both live in call locals only.
httpClient *http.Client
}

// NewArcheraComparisonTool builds the cudly_archera_comparison tool.
func NewArcheraComparisonTool() Registration { return &archeraComparisonTool{} }

func (t *archeraComparisonTool) Descriptor() Descriptor {
return Descriptor{
Name: archeraComparisonName,
Description: archeraComparisonDescription,
Annotations: archeraComparisonAnnotations,
Product: "insurance",
Action: "comparison",
ExamplePrompts: []string{
"Compare my Archera commitment plan against one-year and three-year alternatives",
"Show the Archera plan comparison for these line items with all_upfront payment",
},
}
}

func (t *archeraComparisonTool) Register(s *mcp.Server) error {
schema, err := BuildInputSchema[archeraComparisonArgs](nil)
if err != nil {
return err
}
terms := insurance.ContractTerms()
termEnum := make([]any, len(terms))
for i, v := range terms {
termEnum[i] = v
}
payEnum := []any{
string(insurance.PaymentNoUpfront), string(insurance.PaymentPartialUpfront), string(insurance.PaymentAllUpfront),
}
limit := func(field string, max int, enum []any) {
p := schema.Properties[field]
n := max
p.MaxItems = &n
if enum != nil {
p.Items.Enum = enum
}
}
limit("line_item_ids", archeraMaxLineItemIDs, nil)
limit("contract_terms", len(terms), termEnum)
limit("payment_options", archeraMaxPayments, payEnum)
mcp.AddTool(s, &mcp.Tool{
Name: archeraComparisonName,
Description: archeraComparisonDescription,
Annotations: archeraComparisonAnnotations,
InputSchema: schema,
}, t.handle)
return nil
}

// archeraConfig reads the operator's configuration from the environment at
// call time. Missing names are reported, never values.
func archeraConfig() (cfg insurance.Config, planID string, err error) {
var missing []string
get := func(name string) string {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
missing = append(missing, name)
}
return v
}
cfg.APIKey = get(EnvArcheraAPIKey)
cfg.OrgID = get(EnvArcheraOrgID)
planID = get(EnvArcheraPlanID)
if len(missing) > 0 {
return insurance.Config{}, "", fmt.Errorf(
"archera comparison is not configured: set %s, %s and %s in the server environment (missing: %s)",
EnvArcheraAPIKey, EnvArcheraOrgID, EnvArcheraPlanID, strings.Join(missing, ", "))
}
return cfg, planID, nil
}

func (t *archeraComparisonTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args archeraComparisonArgs) (*mcp.CallToolResult, archeraComparisonDTO, error) {
cfg, planID, err := archeraConfig()
if err != nil {
return nil, archeraComparisonDTO{}, err
}
key := cfg.APIKey
client, err := insurance.NewClient(cfg, t.httpClient)
if err != nil {
return nil, archeraComparisonDTO{}, archeraError(err, key)
}
req := insurance.ComparisonRequest{PlanID: planID, LineItemIDs: args.LineItemIDs, ContractTerms: args.ContractTerms}
for _, p := range args.PaymentOptions {
req.PaymentOptions = append(req.PaymentOptions, insurance.PaymentOption(p))
}
cmp, err := client.Comparison(ctx, req)
if err != nil {
return nil, archeraComparisonDTO{}, archeraError(err, key)
}
dto, err := buildArcheraComparison(cmp)
if err != nil {
return nil, archeraComparisonDTO{}, err
}
b, err := json.Marshal(dto)
if err != nil {
return nil, archeraComparisonDTO{}, fmt.Errorf("marshal archera comparison: %w", err)
}
if err := archeraCheckSize(len(b)); err != nil {
return nil, archeraComparisonDTO{}, err
}
return nil, *dto, nil
}

// archeraError reports a vendor failure by status and Retry-After only: the
// vendor message is dropped. A context error is returned unchanged. Any other
// error (decode, transport, request validation) can quote vendor values
// uncapped, so it is key-masked, stripped of control characters and capped at
// 256 bytes (the go library does not bound it yet: go#336).
func archeraError(err error, key string) error {
var he *insurance.HTTPError
if errors.As(err, &he) {
retry := "retry after: not given"
if he.RetryAfter > 0 {
retry = fmt.Sprintf("retry after %ds", int64(he.RetryAfter.Seconds()))
}
return fmt.Errorf("archera request failed: HTTP %d; %s", he.StatusCode, retry)
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
msg := err.Error()
if key != "" {
msg = strings.ReplaceAll(msg, key, "[redacted]")
}
return errors.New(archeraCleanString(msg))
}

// archeraCheckSize fails loud when the marshaled result exceeds the cap.
func archeraCheckSize(n int) error {
if n > archeraMaxResultBytes {
return fmt.Errorf("archera comparison result is %d bytes, over the %d byte limit: narrow it with line_item_ids", n, archeraMaxResultBytes)
}
return nil
}
Loading
Loading