Skip to content
Draft
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
1 change: 1 addition & 0 deletions adcp/schemas/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
'TestControllerStore', 'StateTransition', 'SimulateDeliveryParams',
'ReportedSpend', 'SimulateBudgetParams', 'SimulationResult',
'TestControllerError',
'ForcedDirectiveSuccess', # pending upstream schema in adcp#3104 — revisit on bundle bump past 3.0.0
# Collection domain (from types.go)
'CollectionList', 'CollectionListFilters', 'BaseCollectionSource',
'DistributionID', 'ContentRating', 'ResolvedCollection',
Expand Down
1 change: 1 addition & 0 deletions adcp/schemas/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
'TestControllerStore', 'StateTransition', 'SimulateDeliveryParams',
'ReportedSpend', 'SimulateBudgetParams', 'SimulationResult',
'TestControllerError',
'ForcedDirectiveSuccess', # pending upstream schema in adcp#3104 — revisit on bundle bump past 3.0.0
# inputs (agent-specific helpers, schemas are inline in request schemas)
'EmptyInput', 'PackageInput', 'AccountInput', 'GovernanceAccountInput',
'CreativeInput', 'CatalogInput', 'EventSourceInput', 'DestinationInput',
Expand Down
101 changes: 101 additions & 0 deletions adcp/testcontroller.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,31 @@ import (
// TestControllerStore is the seller-side interface for comply_test_controller.
// Implement the methods for each scenario you support.
// Unimplemented (nil) methods mean that scenario is excluded from list_scenarios.
//
// Sandbox must be true. RegisterTestController panics at startup if false.
// This tool MUST NOT be registered in production.
type TestControllerStore struct {
// Sandbox must be set to true. RegisterTestController panics if false — this
// tool MUST NOT be registered in production. Gate registration on a sandbox
// flag in your agent config.
Sandbox bool
ForceAccountStatus func(accountID, status string) (*StateTransition, error)
ForceMediaBuyStatus func(mediaBuyID, status string, rejectionReason string) (*StateTransition, error)
ForceCreativeStatus func(creativeID, status string, rejectionReason string) (*StateTransition, error)
ForceSessionStatus func(sessionID, status string, terminationReason string) (*StateTransition, error)
SimulateDelivery func(mediaBuyID string, params SimulateDeliveryParams) (*SimulationResult, error)
SimulateBudgetSpend func(params SimulateBudgetParams) (*SimulationResult, error)
// ForceCreateMediaBuyArm registers a single-shot directive (per adcp#3104) that
// drives the next create_media_buy call into the specified arm.
// Implementations MUST scope the directive to the authenticated principal and
// clear it after consumption. MUST NOT log the raw params at any log level.
ForceCreateMediaBuyArm func(arm, taskID, message string) (*ForcedDirectiveSuccess, error)
// ForceTaskCompletion resolves a submitted task to completed (per adcp#3138).
// Implementations MUST scope task_id to the authenticated principal (cross-account
// replays return NOT_FOUND), handle identical-params idempotency, and return
// INVALID_TRANSITION for diverging-params replays against a terminal task.
// MUST NOT log the raw result payload at any log level.
ForceTaskCompletion func(taskID string, result json.RawMessage) (*StateTransitionSuccess, error)
}

// StateTransition is returned by force_* scenarios.
Expand All @@ -27,6 +45,14 @@ type StateTransition struct {
CurrentState string `json:"current_state"`
}

// ForcedDirectiveSuccess is returned by force_create_media_buy_arm (adcp#3104).
type ForcedDirectiveSuccess struct {
Success bool `json:"success"`
Arm string `json:"arm"`
TaskID string `json:"task_id,omitempty"`
Message string `json:"message,omitempty"`
}

// SimulateDeliveryParams contains delivery simulation parameters.
type SimulateDeliveryParams struct {
Impressions int `json:"impressions,omitempty"`
Expand Down Expand Up @@ -86,7 +112,11 @@ type listScenariosResponse struct {
// RegisterTestController adds the comply_test_controller tool to an MCP server.
// This tool allows arbitrary state mutations for compliance testing and MUST NOT
// be registered in production. Gate registration on a sandbox flag in your agent.
// Panics if store.Sandbox is false.
func RegisterTestController(server *mcp.Server, store *TestControllerStore) {
if !store.Sandbox {
panic("adcp: RegisterTestController requires TestControllerStore.Sandbox = true — this tool MUST NOT be registered in production")
}
AddTool(server, "comply_test_controller",
"Triggers seller-side state transitions for compliance testing. Sandbox only.",
func(ctx context.Context, req *mcp.CallToolRequest, input controllerInput) (*mcp.CallToolResult, any, error) {
Expand Down Expand Up @@ -118,6 +148,10 @@ func handleTestController(store *TestControllerStore, input controllerInput) (*m
return handleSimulateDelivery(store, input.Params)
case "simulate_budget_spend":
return handleSimulateBudget(store, input.Params)
case "force_create_media_buy_arm":
return handleForceCreateMediaBuyArm(store, input.Params)
case "force_task_completion":
return handleForceTaskCompletion(store, input.Params)
default:
return controllerErr("UNKNOWN_SCENARIO", "Unrecognized scenario name", "")
}
Expand Down Expand Up @@ -217,6 +251,67 @@ func handleSimulateBudget(store *TestControllerStore, params map[string]any) (*m
return wrapSimResult(result, err)
}

func handleForceCreateMediaBuyArm(store *TestControllerStore, params map[string]any) (*mcp.CallToolResult, any, error) {
if store.ForceCreateMediaBuyArm == nil {
return controllerErr("UNKNOWN_SCENARIO", "Scenario not supported: force_create_media_buy_arm", "")
}
arm, _ := params["arm"].(string)
if arm == "" {
return controllerErr("INVALID_PARAMS", "force_create_media_buy_arm requires params.arm", "")
}
if arm != TaskStatusSubmitted && arm != TaskStatusInputRequired {
return controllerErr("INVALID_PARAMS", "force_create_media_buy_arm params.arm must be 'submitted' or 'input-required'", "")
}
taskID, _ := params["task_id"].(string)
message, _ := params["message"].(string)
if arm == TaskStatusSubmitted && taskID == "" {
return controllerErr("INVALID_PARAMS", "force_create_media_buy_arm requires params.task_id when arm=submitted", "")
}
if len(taskID) > 128 {
return controllerErr("INVALID_PARAMS", "force_create_media_buy_arm params.task_id must be ≤128 bytes", "")
}
if len(message) > 2000 {
return controllerErr("INVALID_PARAMS", "force_create_media_buy_arm params.message must be ≤2000 bytes", "")
}
result, err := store.ForceCreateMediaBuyArm(arm, taskID, message)
if err != nil {
if tce, ok := err.(*TestControllerError); ok {
return controllerErr(tce.Code, tce.Message, tce.CurrentState)
}
return controllerErr("INTERNAL_ERROR", "An unexpected error occurred in the test controller store", "")
}
return controllerOK(result)
}

func handleForceTaskCompletion(store *TestControllerStore, params map[string]any) (*mcp.CallToolResult, any, error) {
if store.ForceTaskCompletion == nil {
return controllerErr("UNKNOWN_SCENARIO", "Scenario not supported: force_task_completion", "")
}
taskID, _ := params["task_id"].(string)
if taskID == "" {
return controllerErr("INVALID_PARAMS", "force_task_completion requires params.task_id", "")
}
if len(taskID) > 128 {
return controllerErr("INVALID_PARAMS", "force_task_completion params.task_id must be ≤128 bytes", "")
}
resultObj, ok := params["result"].(map[string]any)
if !ok || len(resultObj) == 0 {
return controllerErr("INVALID_PARAMS", "force_task_completion requires params.result to be a non-empty object", "")
}
raw, _ := json.Marshal(resultObj)
if len(raw) > 256*1024 {
return controllerErr("INVALID_PARAMS", "force_task_completion params.result encoded size exceeds 256 KB limit", "")
}
result, err := store.ForceTaskCompletion(taskID, json.RawMessage(raw))
if err != nil {
if tce, ok := err.(*TestControllerError); ok {
return controllerErr(tce.Code, tce.Message, tce.CurrentState)
}
return controllerErr("INTERNAL_ERROR", "An unexpected error occurred in the test controller store", "")
}
return controllerOK(result)
}

func listScenarios(store *TestControllerStore) []string {
var scenarios []string
if store.ForceAccountStatus != nil {
Expand All @@ -237,6 +332,12 @@ func listScenarios(store *TestControllerStore) []string {
if store.SimulateBudgetSpend != nil {
scenarios = append(scenarios, "simulate_budget_spend")
}
if store.ForceCreateMediaBuyArm != nil {
scenarios = append(scenarios, "force_create_media_buy_arm")
}
if store.ForceTaskCompletion != nil {
scenarios = append(scenarios, "force_task_completion")
}
return scenarios
}

Expand Down
179 changes: 179 additions & 0 deletions adcp/testcontroller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,182 @@ func TestControllerErrorHandling(t *testing.T) {

assert.Equal(t, "NOT_FOUND", resp.Error)
}

func TestForceCreateMediaBuyArm_Submitted(t *testing.T) {
store := &TestControllerStore{
ForceCreateMediaBuyArm: func(arm, taskID, message string) (*ForcedDirectiveSuccess, error) {
return &ForcedDirectiveSuccess{Success: true, Arm: arm, TaskID: taskID}, nil
},
}
result, _, _ := handleTestController(store, controllerInput{
Scenario: "force_create_media_buy_arm",
Params: map[string]any{"arm": "submitted", "task_id": "task-abc"},
})
require.False(t, result.IsError, "expected success")

var resp ForcedDirectiveSuccess
text := result.Content[0].(*mcp.TextContent).Text
require.NoError(t, json.Unmarshal([]byte(text), &resp))

assert.True(t, resp.Success)
assert.Equal(t, "submitted", resp.Arm)
assert.Equal(t, "task-abc", resp.TaskID)
}

func TestForceCreateMediaBuyArm_InputRequired(t *testing.T) {
store := &TestControllerStore{
ForceCreateMediaBuyArm: func(arm, taskID, message string) (*ForcedDirectiveSuccess, error) {
return &ForcedDirectiveSuccess{Success: true, Arm: arm, Message: message}, nil
},
}
result, _, _ := handleTestController(store, controllerInput{
Scenario: "force_create_media_buy_arm",
Params: map[string]any{"arm": "input-required", "message": "needs clarification"},
})
require.False(t, result.IsError, "expected success")

var resp ForcedDirectiveSuccess
text := result.Content[0].(*mcp.TextContent).Text
require.NoError(t, json.Unmarshal([]byte(text), &resp))

assert.Equal(t, "input-required", resp.Arm)
assert.Equal(t, "needs clarification", resp.Message)
}

func TestForceCreateMediaBuyArm_InvalidParams(t *testing.T) {
store := &TestControllerStore{
ForceCreateMediaBuyArm: func(arm, taskID, message string) (*ForcedDirectiveSuccess, error) {
return &ForcedDirectiveSuccess{Success: true, Arm: arm}, nil
},
}

cases := []struct {
name string
params map[string]any
code string
}{
{"missing arm", map[string]any{}, "INVALID_PARAMS"},
{"invalid arm", map[string]any{"arm": "unknown"}, "INVALID_PARAMS"},
{"submitted without task_id", map[string]any{"arm": "submitted"}, "INVALID_PARAMS"},
{"task_id too long", map[string]any{"arm": "submitted", "task_id": string(make([]byte, 129))}, "INVALID_PARAMS"},
{"message too long", map[string]any{"arm": "input-required", "message": string(make([]byte, 2001))}, "INVALID_PARAMS"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result, _, _ := handleTestController(store, controllerInput{
Scenario: "force_create_media_buy_arm",
Params: tc.params,
})
require.True(t, result.IsError, "expected error")
var resp controllerErrorResponse
require.NoError(t, json.Unmarshal([]byte(result.Content[0].(*mcp.TextContent).Text), &resp))
assert.Equal(t, tc.code, resp.Error)
})
}
}

func TestForceTaskCompletion_Valid(t *testing.T) {
store := &TestControllerStore{
ForceTaskCompletion: func(taskID string, result json.RawMessage) (*StateTransitionSuccess, error) {
return &StateTransitionSuccess{Success: true, PreviousState: "submitted", CurrentState: "completed"}, nil
},
}
result, _, _ := handleTestController(store, controllerInput{
Scenario: "force_task_completion",
Params: map[string]any{"task_id": "t-1", "result": map[string]any{"status": "ok"}},
})
require.False(t, result.IsError, "expected success")

var resp StateTransitionSuccess
text := result.Content[0].(*mcp.TextContent).Text
require.NoError(t, json.Unmarshal([]byte(text), &resp))

assert.True(t, resp.Success)
assert.Equal(t, "submitted", resp.PreviousState)
assert.Equal(t, "completed", resp.CurrentState)
}

func TestForceTaskCompletion_InvalidParams(t *testing.T) {
store := &TestControllerStore{
ForceTaskCompletion: func(taskID string, result json.RawMessage) (*StateTransitionSuccess, error) {
return &StateTransitionSuccess{Success: true}, nil
},
}

cases := []struct {
name string
params map[string]any
}{
{"missing task_id", map[string]any{"result": map[string]any{"x": 1}}},
{"task_id too long", map[string]any{"task_id": string(make([]byte, 129)), "result": map[string]any{"x": 1}}},
{"missing result", map[string]any{"task_id": "t-1"}},
{"empty result", map[string]any{"task_id": "t-1", "result": map[string]any{}}},
{"result not object", map[string]any{"task_id": "t-1", "result": "not-an-object"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result, _, _ := handleTestController(store, controllerInput{
Scenario: "force_task_completion",
Params: tc.params,
})
require.True(t, result.IsError, "expected error")
var resp controllerErrorResponse
require.NoError(t, json.Unmarshal([]byte(result.Content[0].(*mcp.TextContent).Text), &resp))
assert.Equal(t, "INVALID_PARAMS", resp.Error)
})
}
}

func TestForceTaskCompletion_NotFound(t *testing.T) {
store := &TestControllerStore{
ForceTaskCompletion: func(taskID string, result json.RawMessage) (*StateTransitionSuccess, error) {
return nil, &TestControllerError{Code: "NOT_FOUND", Message: "task not found for this account"}
},
}
result, _, _ := handleTestController(store, controllerInput{
Scenario: "force_task_completion",
Params: map[string]any{"task_id": "other-account-task", "result": map[string]any{"x": 1}},
})
require.True(t, result.IsError)
var resp controllerErrorResponse
require.NoError(t, json.Unmarshal([]byte(result.Content[0].(*mcp.TextContent).Text), &resp))
assert.Equal(t, "NOT_FOUND", resp.Error)
}

func TestForceTaskCompletion_InvalidTransition(t *testing.T) {
store := &TestControllerStore{
ForceTaskCompletion: func(taskID string, result json.RawMessage) (*StateTransitionSuccess, error) {
return nil, &TestControllerError{Code: "INVALID_TRANSITION", Message: "task already completed with different result", CurrentState: "completed"}
},
}
result, _, _ := handleTestController(store, controllerInput{
Scenario: "force_task_completion",
Params: map[string]any{"task_id": "t-done", "result": map[string]any{"new": "payload"}},
})
require.True(t, result.IsError)
var resp controllerErrorResponse
require.NoError(t, json.Unmarshal([]byte(result.Content[0].(*mcp.TextContent).Text), &resp))
assert.Equal(t, "INVALID_TRANSITION", resp.Error)
assert.Equal(t, "completed", resp.CurrentState)
}

func TestRegisterTestController_SandboxGuard(t *testing.T) {
server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.0"}, nil)
store := &TestControllerStore{Sandbox: false}
assert.Panics(t, func() { RegisterTestController(server, store) }, "expected panic when Sandbox=false")
}

func TestListScenarios_IncludesNewScenarios(t *testing.T) {
store := &TestControllerStore{
ForceCreateMediaBuyArm: func(arm, taskID, message string) (*ForcedDirectiveSuccess, error) { return nil, nil },
ForceTaskCompletion: func(taskID string, result json.RawMessage) (*StateTransitionSuccess, error) { return nil, nil },
}
result, _, _ := handleTestController(store, controllerInput{Scenario: "list_scenarios"})
require.False(t, result.IsError)

var resp listScenariosResponse
require.NoError(t, json.Unmarshal([]byte(result.Content[0].(*mcp.TextContent).Text), &resp))

assert.Contains(t, resp.Scenarios, "force_create_media_buy_arm")
assert.Contains(t, resp.Scenarios, "force_task_completion")
}
Loading