diff --git a/adcp/schemas/generate.py b/adcp/schemas/generate.py index 511a39d6..6d24a474 100644 --- a/adcp/schemas/generate.py +++ b/adcp/schemas/generate.py @@ -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', diff --git a/adcp/schemas/lint.py b/adcp/schemas/lint.py index 1dd12bd4..1127b66a 100644 --- a/adcp/schemas/lint.py +++ b/adcp/schemas/lint.py @@ -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', diff --git a/adcp/testcontroller.go b/adcp/testcontroller.go index bc861ee1..2880c977 100644 --- a/adcp/testcontroller.go +++ b/adcp/testcontroller.go @@ -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. @@ -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"` @@ -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) { @@ -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", "") } @@ -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 { @@ -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 } diff --git a/adcp/testcontroller_test.go b/adcp/testcontroller_test.go index 591ba647..12a87023 100644 --- a/adcp/testcontroller_test.go +++ b/adcp/testcontroller_test.go @@ -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") +}