Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
10 changes: 4 additions & 6 deletions adcp/v3/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
// ErrorOptions configures an AdCP error response.
type ErrorOptions struct {
Message string
Recovery string // "retry", "revise", "contact_support", "terminal"
Recovery string // "transient", "correctable", "terminal"
Field string
Suggestion string
RetryAfter int
Expand Down Expand Up @@ -109,13 +109,11 @@ func Errorf(code string, opts ErrorOptions) (*mcp.CallToolResult, any, error) {

func defaultRecovery(code string) string {
switch code {
case "RATE_LIMITED":
return "retry"
case "RATE_LIMITED", "SERVICE_UNAVAILABLE":
Comment thread
garvitkaushik-123 marked this conversation as resolved.
Resolved
Comment thread
garvitkaushik-123 marked this conversation as resolved.
Resolved
return "transient"
case "BUDGET_TOO_LOW", "INVALID_REQUEST", "MISSING_FIELD", "INVALID_FIELD",
"ACCOUNT_NOT_FOUND", "TERMS_REJECTED":
return "revise"
case "INTERNAL_ERROR", "SERVICE_UNAVAILABLE":
return "contact_support"
return "correctable"
default:
return "terminal"
}
Expand Down
26 changes: 26 additions & 0 deletions adcp/v3/idempotency/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,3 +509,29 @@ func SessionScope(sessionIDField string) ScopeFn {
return EncodeScope("principal", principal, "session", sid), nil
}
}

// ContextIDScope scopes keys to (principal, context_id). Use for A2A
// transports where context_id is the conversation identifier and keys
// must be unique within a conversation.
func ContextIDScope(ctx context.Context, payload []byte) (string, error) {
principal := PrincipalFromContext(ctx)
if principal == "" {
return "", errors.New("idempotency: principal missing from context")
}
var m map[string]json.RawMessage
if err := json.Unmarshal(payload, &m); err != nil {
return "", fmt.Errorf("idempotency: decode request for context_id scope: %w", err)
}
raw, ok := m["context_id"]
if !ok {
return "principal:" + principal, nil
}
var cid string
if err := json.Unmarshal(raw, &cid); err != nil {
return "", fmt.Errorf("idempotency: context_id is not a string")
}
if cid == "" {
return "principal:" + principal, nil
}
return "principal:" + principal + ":ctx:" + cid, nil
Comment thread
garvitkaushik-123 marked this conversation as resolved.
Resolved
}
27 changes: 27 additions & 0 deletions adcp/v3/idempotent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -826,3 +826,30 @@ func TestOutcomeUnknownClassification(t *testing.T) {
})
}
}

func TestRegisterDeduplicatesReportingTools(t *testing.T) {
var calls int32
cs := newRegisteredSession(t, baseTestConfig(Config{
SyncReportingStatus: func(context.Context, *SyncReportingStatusRequest) ([]ReportingStatusResult, error) {
atomic.AddInt32(&calls, 1)
return []ReportingStatusResult{}, nil
},
SyncReportingReceipts: func(context.Context, *SyncReportingReceiptsRequest) ([]ReportingReceiptResult, error) {
atomic.AddInt32(&calls, 1)
return []ReportingReceiptResult{}, nil
},
}))
for _, tool := range []string{"sync_reporting_status", "sync_reporting_receipts"} {
t.Run(tool, func(t *testing.T) {
atomic.StoreInt32(&calls, 0)
key := idempotency.Generate()
callSession(t, cs, tool, map[string]any{"idempotency_key": key})
replay := callSession(t, cs, tool, map[string]any{"idempotency_key": key})
assert.Equal(t, true, replay["replayed"])
assert.EqualValues(t, 1, atomic.LoadInt32(&calls))

missing := callSession(t, cs, tool, map[string]any{})
assert.Equal(t, "INVALID_REQUEST", adcpErrorOf(t, missing)["code"])
})
}
}
44 changes: 44 additions & 0 deletions adcp/v3/inputs.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,50 @@ import "encoding/json"
// EmptyInput is the input type for tools that accept no parameters (e.g. get_adcp_capabilities).
type EmptyInput struct{}

// Nullable encodes a JSON field with three states: absent, explicit null,
// or a value. Use as *Nullable[T] with `omitempty` in struct fields:
// - nil pointer: field omitted from JSON (absent/inherit)
// - &Nullable[T]{Null: true}: encodes as JSON null (clear)
// - NullableValue(v): encodes as v's JSON representation (replace)
type Nullable[T any] struct {
Value T
Null bool
}

// NullableValue returns a pointer to a Nullable carrying v.
func NullableValue[T any](v T) *Nullable[T] {
return &Nullable[T]{Value: v}
}

// NullableNull returns a pointer to a Nullable encoding explicit null.
func NullableNull[T any]() *Nullable[T] {
return &Nullable[T]{Null: true}
}

func (n Nullable[T]) MarshalJSON() ([]byte, error) {
if n.Null {
return []byte("null"), nil
}
return json.Marshal(n.Value)
}

func (n *Nullable[T]) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
n.Null = true
return nil
}
return json.Unmarshal(data, &n.Value)
}

// ClearField returns a value that marshals as JSON null when stored in an
// `any` field with omitempty. Use it on TargetingInput dimensions to clear
// an inherited targeting dimension:
//
// input.GeoCountries = adcp.ClearField() // sends "geo_countries": null
func ClearField() any {
return (*struct{})(nil)
}

// Bool returns a pointer to v for optional boolean request fields.
func Bool(v bool) *bool {
return &v
Expand Down
14 changes: 14 additions & 0 deletions adcp/v3/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,20 @@ func ListCollectionListsResponse(lists []CollectionList, pagination *PaginationR
return buildResult(fmt.Sprintf("Found %d collection lists", len(lists)), out), out, nil
}

// --- Reporting ---

// SyncReportingStatusResponseData builds a sync_reporting_status response.
func SyncReportingStatusResponseData(results []ReportingStatusResult) (*mcp.CallToolResult, any, error) {
out := map[string]any{"status": "completed", "results": results}
return buildResult(fmt.Sprintf("Recorded %d reporting status results", len(results)), out), out, nil
}

// SyncReportingReceiptsResponseData builds a sync_reporting_receipts response.
func SyncReportingReceiptsResponseData(results []ReportingReceiptResult) (*mcp.CallToolResult, any, error) {
out := map[string]any{"status": "completed", "results": results}
return buildResult(fmt.Sprintf("Recorded %d reporting receipt results", len(results)), out), out, nil
}

// --- Generic ---

// Result builds a generic tool response with StructuredContent.
Expand Down
13 changes: 13 additions & 0 deletions adcp/v3/schemas/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@
# oneOf schemas — hand-writing flattens the union into a single struct with
# all variant fields.
'PricingOption', 'Deployment', 'PublisherPropertySelector',
'CanonicalMediaBuyAction',
'ReportingConsumerStatus', 'ReportingConsumerStatusPeriod',
'ReportingStatusResult',
'ReportingReceipt', 'ReportingAdjustmentReceipt', 'ReportingReceiptResult',
'OptimizationGoal',
'OptimizationGoalCostPerTarget', 'OptimizationGoalThresholdRateTarget',
'OptimizationGoalPerAdSpendTarget', 'OptimizationGoalMaximizeValueTarget',
Expand Down Expand Up @@ -148,6 +152,11 @@
"media-buy/build-creative-response.json",
"media-buy/refine-proposals-request.json",
"media-buy/refine-proposals-response.json",
# Reporting
"media-buy/sync-reporting-status-request.json",
"media-buy/sync-reporting-status-response.json",
"media-buy/sync-reporting-receipts-request.json",
"media-buy/sync-reporting-receipts-response.json",
# Creative
"creative/sync-creatives-request.json",
"creative/sync-creatives-response.json",
Expand Down Expand Up @@ -2101,6 +2110,10 @@
('RightsConstraint', 'attestation_refs'),
('RightsConstraint', 'disclosure'),
('SyncCreativesRequest', 'assignment_operations'),
('SyncReportingReceiptsRequest', 'adcp_major_version'),
('SyncReportingReceiptsResponse', 'results'),
('SyncReportingStatusRequest', 'adcp_major_version'),
('SyncReportingStatusResponse', 'results'),
('Targeting', 'demographics'),
('Targeting', 'geo_places'),
('Targeting', 'geo_places_exclude'),
Expand Down
4 changes: 4 additions & 0 deletions adcp/v3/schemas/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@
# OptimizationGoal is hand-flattened from a oneOf and preserves unknown
# top-level keys for replacement-update round trips.
'OptimizationGoal',
# Reporting types hand-written from core schemas
'ReportingConsumerStatus', 'ReportingConsumerStatusPeriod',
'ReportingStatusResult', 'ReportingReceipt',
'ReportingAdjustmentReceipt', 'ReportingReceiptResult',
}

# Map hand-written Go type name → schema path (relative to schemas/).
Expand Down
32 changes: 32 additions & 0 deletions adcp/v3/seller.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,34 @@ func Register(server *mcp.Server, cfg Config) {
})
}

// --- Reporting tools ---

if cfg.SyncReportingStatus != nil {
addSellerTool(server, idem, "sync_reporting_status", "Submit consumer reporting status",
func(ctx context.Context, req *mcp.CallToolRequest, input SyncReportingStatusRequest) (*mcp.CallToolResult, any, error) {
results, err := cfg.SyncReportingStatus(ctx, &input)
if err != nil {
result, out, e := mutationErrorResult(idem, err)
return attachContext(result, input.Context), out, e
}
result, out, err := SyncReportingStatusResponseData(results)
return attachContext(result, input.Context), out, err
})
}

if cfg.SyncReportingReceipts != nil {
addSellerTool(server, idem, "sync_reporting_receipts", "Submit consumer reporting receipts",
func(ctx context.Context, req *mcp.CallToolRequest, input SyncReportingReceiptsRequest) (*mcp.CallToolResult, any, error) {
results, err := cfg.SyncReportingReceipts(ctx, &input)
if err != nil {
result, out, e := mutationErrorResult(idem, err)
return attachContext(result, input.Context), out, e
}
result, out, err := SyncReportingReceiptsResponseData(results)
return attachContext(result, input.Context), out, err
})
}

// --- Creative tools ---

if cfg.ListCreativeFormats != nil {
Expand Down Expand Up @@ -512,6 +540,10 @@ type Config struct {
GetSignals func(ctx context.Context, req *GetSignalsRequest) ([]Signal, error)
ActivateSignal func(ctx context.Context, req *ActivateSignalRequest) ([]Deployment, error)

// --- Reporting ---
SyncReportingStatus func(ctx context.Context, req *SyncReportingStatusRequest) ([]ReportingStatusResult, error)
SyncReportingReceipts func(ctx context.Context, req *SyncReportingReceiptsRequest) ([]ReportingReceiptResult, error)

// --- Collection ---
CreateCollectionList func(ctx context.Context, req *CreateCollectionListRequest) (*CreateCollectionListResult, error)
GetCollectionList func(ctx context.Context, req *GetCollectionListRequest) (*GetCollectionListResult, error)
Expand Down
6 changes: 3 additions & 3 deletions adcp/v3/seller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func TestBuildCapabilitiesDefaults(t *testing.T) {
require.NotNil(t, caps.ADCP)
assert.Equal(t, 86400, caps.ADCP.Idempotency.ReplayTTLSeconds)
assert.Equal(t, []int{3}, caps.ADCP.MajorVersions)
assert.Equal(t, []string{"3.0", "3.1", "3.2-rc.1"}, caps.ADCP.SupportedVersions)
assert.Equal(t, []string{"3.0", "3.1", "3.2-rc.1", "3.2-rc.3"}, caps.ADCP.SupportedVersions)
assert.Equal(t, "3.1", caps.AdcpVersion)
assert.Equal(t, 3, caps.AdcpMajorVersion)
assert.Contains(t, caps.SupportedProtocols, "media_buy")
Expand Down Expand Up @@ -181,7 +181,7 @@ func TestCapabilitiesResponseWireShape(t *testing.T) {
idem, ok := adcp["idempotency"].(map[string]any)
require.True(t, ok, "adcp.idempotency must be present as an object (required in 3.0)")
assert.EqualValues(t, 86400, idem["replay_ttl_seconds"])
assert.Equal(t, []any{"3.0", "3.1", "3.2-rc.1"}, adcp["supported_versions"])
assert.Equal(t, []any{"3.0", "3.1", "3.2-rc.1", "3.2-rc.3"}, adcp["supported_versions"])
assert.Equal(t, "3.1", wire["adcp_version"])
assert.EqualValues(t, 3, wire["adcp_major_version"])

Expand Down Expand Up @@ -392,7 +392,7 @@ func TestRegisteredCapabilitiesUsesDefaultVersion(t *testing.T) {
assert.EqualValues(t, 3, wire["adcp_major_version"])
adcpBlock, ok := wire["adcp"].(map[string]any)
require.True(t, ok)
assert.Equal(t, []any{"3.0", "3.1", "3.2-rc.1"}, adcpBlock["supported_versions"])
assert.Equal(t, []any{"3.0", "3.1", "3.2-rc.1", "3.2-rc.3"}, adcpBlock["supported_versions"])
}

func TestRegisteredCapabilitiesFiltersProtocols(t *testing.T) {
Expand Down
Loading
Loading