-
Notifications
You must be signed in to change notification settings - Fork 48
Expand file tree
/
Copy pathmain.py
More file actions
366 lines (272 loc) · 13.4 KB
/
Copy pathmain.py
File metadata and controls
366 lines (272 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
import logging
from typing import Any
from fastmcp import FastMCP
from fastmcp.server.context import Context
from rich.console import Console
from sqlalchemy import select
from src.adapters.mock_creative_engine import MockCreativeEngine
from src.core.exceptions import AdCPAuthenticationError
from src.core.transport_helpers import resolve_identity_from_context
logger = logging.getLogger(__name__)
# Database models
# Other imports
from src.core.config_loader import (
get_current_tenant,
load_config,
set_current_tenant,
)
from src.core.database.database import init_db
from src.core.database.database_session import get_db_session
from src.core.database.models import Product as ModelProduct
from src.core.database.models import (
WorkflowStep,
)
# Schema models (explicit imports to avoid collisions)
# Schema adapters (wrapping generated schemas)
from src.core.schemas import (
Creative,
CreativeAssignment,
CreativeStatus,
Error, # noqa: F401 - Required for MCP protocol error handling (regression test PR #332)
Product,
)
# Initialize Rich console
console = Console()
# Backward compatibility alias for deprecated Task model
# The workflow system now uses WorkflowStep exclusively
Task = WorkflowStep
# --- Helper Functions ---
# --- Helper Functions ---
# Helper functions moved to src/core/helpers/ modules and imported above
# --- Authentication ---
# Auth functions moved to src/core/auth.py and imported above
# --- Initialization ---
# NOTE: Database initialization moved to startup script to avoid import-time failures
# The run_all_services.py script handles database initialization before starting the MCP server
# Try to load config, but use defaults if no tenant context available
try:
config = load_config()
except (RuntimeError, Exception) as e:
# Use minimal config for test environments or when DB is unavailable
# This handles both "No tenant context set" and database connection errors
if "No tenant context" in str(e) or "connection" in str(e).lower() or "operational" in str(e).lower():
config = {
"creative_engine": {},
"dry_run": False,
"adapters": {"mock": {"enabled": True}},
"ad_server": {"adapter": "mock", "enabled": True},
}
else:
raise
from contextlib import asynccontextmanager
def _background_schedulers_enabled() -> bool:
"""Whether to start the background schedulers on app startup.
``ADCP_RUN_BACKGROUND_SCHEDULERS`` is a **test-only** knob that defaults to
ENABLED (schedulers run in production unless the value is exactly ``"false"``,
read at runtime). The test harness sets it to ``false`` because the schedulers
run a batch immediately on startup, on the *real* wall clock, and mutate
media-buy status rows — which silently rewrites the rows a test just seeded
(e.g. promoting a seeded ``pending_start`` buy to ``active`` before the
assertion runs). It is NOT an operator control: disabling it in production
stops automatic pending->active->completed status transitions and delivery
webhooks, so an accidental disable is logged at WARNING (below) to make it
visible in production logs.
"""
import os
return os.getenv("ADCP_RUN_BACKGROUND_SCHEDULERS", "true").lower() != "false"
# Lifespan context manager for FastMCP startup/shutdown
@asynccontextmanager
async def lifespan_context(app):
"""Handle application startup and shutdown."""
schedulers_enabled = _background_schedulers_enabled()
if not schedulers_enabled:
# WARNING, not INFO: this is a test-only knob (see
# _background_schedulers_enabled). If it is ever set in production the
# status/webhook schedulers do not run — surface that loudly.
logger.warning(
"Background schedulers DISABLED via ADCP_RUN_BACKGROUND_SCHEDULERS=false — "
"media-buy status transitions and delivery webhooks will NOT run. "
"This is a test-only knob; unset it in production."
)
if schedulers_enabled:
# Startup: Initialize delivery webhook scheduler
from src.services.delivery_webhook_scheduler import start_delivery_webhook_scheduler
logger.info("Starting delivery webhook scheduler...")
try:
await start_delivery_webhook_scheduler()
logger.info("✅ Delivery webhook scheduler started")
except Exception as e:
logger.error(f"Failed to start delivery webhook scheduler: {e}", exc_info=True)
# Startup: Initialize media buy status scheduler
from src.services.media_buy_status_scheduler import start_media_buy_status_scheduler
logger.info("Starting media buy status scheduler...")
try:
await start_media_buy_status_scheduler()
logger.info("✅ Media buy status scheduler started")
except Exception as e:
logger.error(f"Failed to start media buy status scheduler: {e}", exc_info=True)
yield
if not schedulers_enabled:
return
# Shutdown: Stop media buy status scheduler
from src.services.media_buy_status_scheduler import stop_media_buy_status_scheduler
logger.info("Stopping media buy status scheduler...")
try:
await stop_media_buy_status_scheduler()
logger.info("✅ Media buy status scheduler stopped")
except Exception as e:
logger.error(f"Failed to stop media buy status scheduler: {e}", exc_info=True)
# Shutdown: Stop delivery webhook scheduler
from src.services.delivery_webhook_scheduler import stop_delivery_webhook_scheduler
logger.info("Stopping delivery webhook scheduler...")
try:
await stop_delivery_webhook_scheduler()
logger.info("✅ Delivery webhook scheduler stopped")
except Exception as e:
logger.error(f"Failed to stop delivery webhook scheduler: {e}", exc_info=True)
mcp = FastMCP(
name="AdCPSalesAgent",
# Sessions enabled for HTTP context (tenant detection via headers)
# Note: stateless_http is now configured at runtime via run() or global settings
lifespan=lifespan_context,
)
# Centralized identity resolution — runs before every tool call.
# Tools read identity via ctx.get_state('identity') instead of calling
# resolve_identity_from_context() directly.
from src.core.mcp_auth_middleware import MCPAuthMiddleware
from src.core.mcp_compat_middleware import RequestCompatMiddleware
mcp.add_middleware(MCPAuthMiddleware())
mcp.add_middleware(RequestCompatMiddleware())
# Initialize creative engine with minimal config (will be tenant-specific later)
creative_engine_config: dict[str, Any] = {}
creative_engine = MockCreativeEngine(creative_engine_config)
# Removed get_task_from_db - replaced by workflow-based system
# --- In-Memory State ---
creative_assignments: dict[str, dict[str, list[str]]] = {}
creative_statuses: dict[str, CreativeStatus] = {}
product_catalog: list[Product] = []
creative_library: dict[str, Creative] = {} # creative_id -> Creative
creative_assignments_v2: dict[str, CreativeAssignment] = {} # assignment_id -> CreativeAssignment
# REMOVED: human_tasks dictionary - now using direct database queries only
# Authentication cache removed - FastMCP v2.11.0+ properly forwards headers
# Import audit logger for later use
# Import context manager for workflow steps
from src.core.context_manager import ContextManager
context_mgr = ContextManager()
# --- Adapter Configuration ---
# Get adapter from config, fallback to mock
SELECTED_ADAPTER = ((config.get("ad_server", {}).get("adapter") or "mock") if config else "mock").lower()
AVAILABLE_ADAPTERS = ["mock", "gam", "kevel", "triton", "triton_digital"]
# --- In-Memory State (already initialized above, just adding context_map) ---
context_map: dict[str, str] = {} # Maps context_id to media_buy_id
# --- Dry Run Mode ---
DRY_RUN_MODE = config.get("dry_run", False)
if DRY_RUN_MODE:
console.print("[bold yellow]🏃 DRY RUN MODE ENABLED - Adapter calls will be logged[/bold yellow]")
# Display selected adapter
if SELECTED_ADAPTER not in AVAILABLE_ADAPTERS:
console.print(f"[bold red]❌ Invalid adapter '{SELECTED_ADAPTER}'. Using 'mock' instead.[/bold red]")
SELECTED_ADAPTER = "mock"
console.print(f"[bold cyan]🔌 Using adapter: {SELECTED_ADAPTER.upper()}[/bold cyan]")
# --- Creative Conversion Helper ---
# Creative helper functions moved to src/core/helpers.py and imported above
# --- Security Helper ---
# --- Activity Feed Helper ---
# --- MCP Tools (Full Implementation) ---
# Unified update tools
# --- Admin Tools ---
# --- Human-in-the-Loop Task Queue Tools ---
# DEPRECATED workflow functions moved to src/core/helpers/workflow_helpers.py and imported above
# Removed get_pending_workflows - replaced by admin dashboard workflow views
# Removed assign_task - assignment handled through admin UI workflow management
# Dry run logs are now handled by the adapters themselves
def get_product_catalog(tenant_id: str | None = None) -> list[Product]:
"""Get products for the current tenant.
Uses shared convert_product_model_to_schema() to ensure consistent
conversion logic across all product catalog providers.
"""
from sqlalchemy.orm import selectinload
from src.core.product_conversion import convert_product_model_to_schema
if tenant_id is None:
tenant = get_current_tenant()
tenant_id = tenant["tenant_id"]
with get_db_session() as session:
stmt = select(ModelProduct).filter_by(tenant_id=tenant_id).options(selectinload(ModelProduct.pricing_options))
products = session.scalars(stmt).all()
loaded_products = []
for product in products:
loaded_products.append(convert_product_model_to_schema(product))
# convert_product_model_to_schema returns LibraryProduct,
# which our Product extends - safe cast at runtime
return loaded_products
# Creative macro support is now simplified to a single creative_macro string
# that AEE can provide as a third type of provided_signal.
# Ad servers like GAM can inject this string into creatives.
if __name__ == "__main__":
init_db(exit_on_error=True) # Exit on error when run as main
# Server is now run via run_server.py script
# Always add health check endpoint
# --- Strategy and Simulation Control ---
from src.core.strategy import StrategyManager
def get_strategy_manager(context: Context | None) -> StrategyManager:
"""Get strategy manager for current context."""
identity = resolve_identity_from_context(context, require_valid_token=True, protocol="mcp")
if not identity or not identity.tenant_id:
raise AdCPAuthenticationError("No tenant configuration found")
if identity.tenant and isinstance(identity.tenant, dict):
set_current_tenant(identity.tenant)
else:
tenant_config = get_current_tenant()
if not tenant_config:
raise AdCPAuthenticationError("No tenant configuration found")
return StrategyManager(tenant_id=identity.tenant_id, principal_id=identity.principal_id)
# Health/debug routes moved to src/routes/health.py (FastAPI migration).
# Admin and landing routes moved to src/app.py (FastAPI migration).
# Task management tools extracted to src/core/tools/task_management.py.
# Import MCP tools from separate modules and register with MCP manually.
# Tool descriptions and ToolAnnotations are imported from the AdCP SDK at
# registration time. Our tools are a subset of the SDK's 57 — matching tools
# get agent-facing descriptions and annotations (readOnlyHint, destructiveHint,
# idempotentHint). Non-matching tools keep their existing docstrings.
from adcp.server.mcp_tools import ADCP_TOOL_DEFINITIONS
from mcp.types import ToolAnnotations
from src.core.tool_error_logging import with_error_logging
from src.core.tools.accounts import list_accounts, sync_accounts
from src.core.tools.capabilities import get_adcp_capabilities
from src.core.tools.creative_formats import list_creative_formats
from src.core.tools.creatives import list_creatives, sync_creatives
from src.core.tools.media_buy_create import create_media_buy
from src.core.tools.media_buy_delivery import get_media_buy_delivery
from src.core.tools.media_buy_list import get_media_buys
from src.core.tools.media_buy_update import update_media_buy
from src.core.tools.performance import update_performance_index
from src.core.tools.products import get_products
from src.core.tools.properties import list_authorized_properties
from src.core.tools.task_management import complete_task, get_task, list_tasks
_sdk_tool_defs = {td["name"]: td for td in ADCP_TOOL_DEFINITIONS}
def _register_tool(fn: Any) -> None:
"""Register an MCP tool with SDK description and annotations when available."""
tool_name = fn.__name__
sdk_def = _sdk_tool_defs.get(tool_name)
kwargs: dict[str, Any] = {}
if sdk_def:
kwargs["description"] = sdk_def["description"]
if sdk_def.get("annotations"):
kwargs["annotations"] = ToolAnnotations(**sdk_def["annotations"])
mcp.tool(**kwargs)(with_error_logging(fn))
_register_tool(list_accounts)
_register_tool(sync_accounts)
_register_tool(get_adcp_capabilities)
_register_tool(get_products)
_register_tool(list_creative_formats)
_register_tool(sync_creatives)
_register_tool(list_creatives)
_register_tool(list_authorized_properties)
_register_tool(create_media_buy)
_register_tool(update_media_buy)
_register_tool(get_media_buy_delivery)
_register_tool(get_media_buys)
_register_tool(update_performance_index)
_register_tool(list_tasks)
_register_tool(get_task)
_register_tool(complete_task)