mcp: full FastMCP server — brand-scoped tools, Google Ads, ClickHouse, recommendations

This commit is contained in:
2026-05-06 17:04:56 +00:00
parent d37ce128cb
commit 3691b59aa6
+174 -111
View File
@@ -5,7 +5,7 @@ All tools validate brand_membership before execution.
"""
import os
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from typing import Optional
@@ -13,68 +13,70 @@ from typing import Optional
import asyncpg
import clickhouse_connect
from fastmcp import FastMCP
from fastmcp.server import Context
from dotenv import load_dotenv
from health import start_health_thread
load_dotenv()
logging.basicConfig(level=logging.INFO)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("manar-mcp")
# ── Config ────────────────────────────────────────────────
CORE_DB_URL = os.environ["CORE_DB_URL"]
TENANTS_DB_URL = os.environ["TENANTS_DB_URL"]
CH_HOST = os.environ.get("CLICKHOUSE_HOST", "clickhouse")
CH_PORT = int(os.environ.get("CLICKHOUSE_PORT", 8123))
CH_DB = os.environ.get("CLICKHOUSE_DB", "manar")
MCP_PORT = int(os.environ.get("MCP_PORT", 8000))
MCP_PORT = int(os.environ.get("MCP_PORT", 8090))
# ── App State ─────────────────────────────────────────────
class AppState:
core_pool: Optional[asyncpg.Pool] = None
tenants_pool: Optional[asyncpg.Pool] = None
ch_client: Optional[any] = None
ch_client: Optional[object] = None
state = AppState()
# ── Lifespan ──────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app):
log.info("Manar MCP starting...")
state.core_pool = await asyncpg.create_pool(CORE_DB_URL, min_size=2, max_size=10)
state.tenants_pool = await asyncpg.create_pool(TENANTS_DB_URL, min_size=2, max_size=10)
state.ch_client = clickhouse_connect.get_client(host=CH_HOST, port=CH_PORT, database=CH_DB)
log.info("Manar MCP ready.")
state.ch_client = clickhouse_connect.get_client(
host=CH_HOST, port=CH_PORT, database=CH_DB,
username=os.environ.get("CLICKHOUSE_USER", "default"),
password=os.environ.get("CLICKHOUSE_PASSWORD", "")
)
log.info("Manar MCP ready — all connections established.")
yield
await state.core_pool.close()
await state.tenants_pool.close()
log.info("Manar MCP shut down.")
mcp = FastMCP("manar-mcp", lifespan=lifespan)
# ── Access Guard ──────────────────────────────────────────
# ── Access Guard ──────────────────────────────────────────────────────────────
async def assert_brand_access(brand_slug: str, actor_email: str) -> dict:
"""
Validates that actor_email has membership in brand_slug.
Super admins bypass membership check.
Returns brand row on success, raises ValueError on failure.
"""
async with state.core_pool.acquire() as conn:
# Check super admin
user = await conn.fetchrow(
"SELECT id, is_super_admin FROM users WHERE email = $1 AND is_active = true",
"SELECT id, role FROM users WHERE email = $1",
actor_email
)
if not user:
raise ValueError(f"User {actor_email} not found or inactive")
raise ValueError(f"User {actor_email} not found")
brand = await conn.fetchrow(
"SELECT id, slug, name, trust_level, is_active FROM brands WHERE slug = $1",
"SELECT id, slug, name, trust_level, status FROM brands WHERE slug = $1",
brand_slug
)
if not brand or not brand["is_active"]:
if not brand or brand["status"] != "active":
raise ValueError(f"Brand '{brand_slug}' not found or inactive")
if not user["is_super_admin"]:
if user["role"] != "super_admin":
membership = await conn.fetchrow(
"SELECT role FROM brand_memberships WHERE brand_id = $1 AND user_id = $2",
brand["id"], user["id"]
@@ -84,29 +86,22 @@ async def assert_brand_access(brand_slug: str, actor_email: str) -> dict:
return dict(brand)
# ── Health ────────────────────────────────────────────────
@mcp.resource("health://status")
async def health_status() -> str:
return "ok"
# ── Tool: list_brands ─────────────────────────────────────
# ── Tools ─────────────────────────────────────────────────────────────────────
@mcp.tool()
async def list_brands(actor_email: str) -> list[dict]:
"""
List all active brands accessible to actor_email.
Super admins see all brands. Regular users see their memberships only.
"""
"""List all active brands accessible to actor_email."""
async with state.core_pool.acquire() as conn:
user = await conn.fetchrow(
"SELECT id, is_super_admin FROM users WHERE email = $1 AND is_active = true",
"SELECT id, role FROM users WHERE email = $1",
actor_email
)
if not user:
raise ValueError(f"User {actor_email} not found")
if user["is_super_admin"]:
if user["role"] == "super_admin":
rows = await conn.fetch(
"SELECT slug, name, trust_level, timezone FROM brands WHERE is_active = true ORDER BY name"
"SELECT slug, name, trust_level, timezone FROM brands WHERE status = 'active' ORDER BY name"
)
else:
rows = await conn.fetch(
@@ -114,145 +109,128 @@ async def list_brands(actor_email: str) -> list[dict]:
FROM brands b
JOIN brand_memberships m ON m.brand_id = b.id
JOIN users u ON u.id = m.user_id
WHERE u.email = $1 AND b.is_active = true
ORDER BY b.name""",
WHERE u.email = $1 AND b.status = 'active' ORDER BY b.name""",
actor_email
)
return [dict(r) for r in rows]
# ── Tool: get_ad_accounts ─────────────────────────────────
@mcp.tool()
async def get_ad_accounts(brand_slug: str, actor_email: str) -> list[dict]:
"""
Get all ad accounts for a brand.
"""
"""Get all ad accounts for a brand."""
await assert_brand_access(brand_slug, actor_email)
schema = f"brand_{brand_slug}"
async with state.tenants_pool.acquire() as conn:
rows = await conn.fetch(
f"SELECT id, platform_slug, external_id, name, currency, is_active, last_synced_at "
f"SELECT id::text, platform_slug, external_id, name, currency, status, created_at::text "
f"FROM {schema}.ad_accounts ORDER BY platform_slug, name"
)
return [dict(r) for r in rows]
# ── Tool: fetch_google_ads_metrics ────────────────────────
@mcp.tool()
async def fetch_google_ads_metrics(
brand_slug: str,
actor_email: str,
date_from: str,
date_to: str,
breakdown: str = "campaign", # campaign | adgroup | day
brand_slug: str, actor_email: str,
date_from: str, date_to: str,
breakdown: str = "campaign"
) -> list[dict]:
"""
Fetch performance metrics from ClickHouse for a brand.
Returns aggregated impressions, clicks, cost, conversions, ROAS.
Fetch performance metrics from ClickHouse.
breakdown: campaign | adgroup | day
date_from / date_to: YYYY-MM-DD
"""
await assert_brand_access(brand_slug, actor_email)
group_fields = {
"campaign": "campaign_id, campaign_name",
"adgroup": "campaign_id, campaign_name, adgroup_id, adgroup_name",
"day": "date",
"adgroup": "campaign_id, campaign_name, ad_group_id, ad_group_name",
"day": "toDate(hour) AS date",
}.get(breakdown, "campaign_id, campaign_name")
query = f"""
SELECT
{group_fields},
SELECT {group_fields},
sum(impressions) AS impressions,
sum(clicks) AS clicks,
sum(cost_micros) AS cost_micros,
round(sum(cost_micros) / 1e6, 2) AS cost,
sum(conversions) AS conversions,
sum(conv_value) AS conv_value,
if(sum(impressions) > 0, sum(clicks) / sum(impressions), 0) AS ctr,
if(sum(clicks) > 0, sum(cost_micros) / sum(clicks), 0) AS cpc_micros,
if(sum(cost_micros) > 0,
sum(conv_value) / (sum(cost_micros) / 1000000), 0) AS roas
sum(conversion_value) AS conv_value,
round(if(sum(impressions) > 0, sum(clicks) / sum(impressions), 0), 4) AS ctr,
round(if(sum(clicks) > 0, sum(cost_micros) / 1e6 / sum(clicks), 0), 2) AS cpc,
round(if(sum(cost_micros) > 0, sum(conversion_value) / (sum(cost_micros) / 1e6), 0), 2) AS roas
FROM manar.ad_metrics_hourly
WHERE brand_id = '{brand_slug}'
AND platform = 'google_ads'
AND date >= '{date_from}'
AND date <= '{date_to}'
AND platform_slug = 'google_ads'
AND toDate(hour) >= '{date_from}'
AND toDate(hour) <= '{date_to}'
GROUP BY {group_fields}
ORDER BY cost_micros DESC
ORDER BY cost DESC
LIMIT 500
"""
result = state.ch_client.query(query)
return [dict(zip(result.column_names, row)) for row in result.result_rows]
# ── Tool: get_search_terms ────────────────────────────────
@mcp.tool()
async def get_search_terms(
brand_slug: str,
actor_email: str,
date_from: str,
date_to: str,
brand_slug: str, actor_email: str,
date_from: str, date_to: str,
min_cost: float = 0,
limit: int = 200,
limit: int = 200
) -> list[dict]:
"""
Get search term performance. Used for keyword expansion / negative keyword decisions.
"""
"""Get search term performance for keyword expansion / negative keyword decisions."""
await assert_brand_access(brand_slug, actor_email)
query = f"""
SELECT
search_term,
SELECT search_term,
sum(impressions) AS impressions,
sum(clicks) AS clicks,
sum(cost_micros) AS cost_micros,
round(sum(cost_micros) / 1e6, 2) AS cost,
sum(conversions) AS conversions,
if(sum(clicks) > 0, sum(cost_micros) / sum(clicks), 0) AS cpc_micros
round(if(sum(clicks) > 0, sum(cost_micros) / 1e6 / sum(clicks), 0), 2) AS cpc
FROM manar.search_term_performance
WHERE brand_id = '{brand_slug}'
AND date >= '{date_from}'
AND date <= '{date_to}'
AND date >= '{date_from}' AND date <= '{date_to}'
GROUP BY search_term
HAVING sum(cost_micros) >= {int(min_cost * 1_000_000)}
ORDER BY cost_micros DESC
ORDER BY cost DESC
LIMIT {limit}
"""
result = state.ch_client.query(query)
return [dict(zip(result.column_names, row)) for row in result.result_rows]
# ── Tool: get_anomalies ───────────────────────────────────
@mcp.tool()
async def get_anomalies(
brand_slug: str,
actor_email: str,
resolved: bool = False,
brand_slug: str, actor_email: str,
resolved: bool = False
) -> list[dict]:
"""
Get open anomaly events for a brand.
"""
"""Get open anomaly events for a brand."""
await assert_brand_access(brand_slug, actor_email)
resolved_filter = "IS NOT NULL" if resolved else "IS NULL"
query = f"""
SELECT brand_id, platform, entity_type, entity_id, metric,
SELECT brand_id, ad_account_id, campaign_id, metric,
detected_at, expected_value, actual_value, deviation_pct, severity
FROM manar.anomaly_events
WHERE brand_id = '{brand_slug}'
AND resolved = {1 if resolved else 0}
AND resolved_at {resolved_filter}
ORDER BY detected_at DESC
LIMIT 100
"""
result = state.ch_client.query(query)
return [dict(zip(result.column_names, row)) for row in result.result_rows]
# ── Tool: create_recommendation ──────────────────────────
@mcp.tool()
async def create_recommendation(
brand_slug: str,
actor_email: str,
rec_type: str,
title: str,
description: str,
priority: int = 2,
brand_slug: str, actor_email: str,
rec_type: str, title: str, description: str,
priority: str = "medium",
payload: dict = None,
estimated_impact: dict = None,
estimated_impact: dict = None
) -> dict:
"""
Create a recommendation in the brand's tenants DB.
rec_type: budget_increase | keyword_add | negative_add | bid_adjust | pause_campaign | etc.
Create a recommendation in the brand queue.
rec_type: budget_increase | keyword_add | negative_add | bid_adjust | pause_campaign
priority: low | medium | high | critical
"""
await assert_brand_access(brand_slug, actor_email)
schema = f"brand_{brand_slug}"
@@ -261,36 +239,121 @@ async def create_recommendation(
f"""INSERT INTO {schema}.recommendations
(type, title, description, priority, payload, estimated_impact)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)
RETURNING id, type, title, status, created_at""",
RETURNING id::text, type, title, status, created_at::text""",
rec_type, title, description, priority,
str(payload or {}), str(estimated_impact or {})
json.dumps(payload or {}),
json.dumps(estimated_impact or {})
)
return dict(row)
# ── Tool: list_recommendations ────────────────────────────
@mcp.tool()
async def list_recommendations(
brand_slug: str,
actor_email: str,
status: str = "pending",
brand_slug: str, actor_email: str,
status: str = "pending"
) -> list[dict]:
"""
List recommendations for a brand, filtered by status.
status: pending | approved | rejected | executed | vetoed
List recommendations for a brand.
status: pending | approved | rejected | executed | expired
"""
await assert_brand_access(brand_slug, actor_email)
schema = f"brand_{brand_slug}"
async with state.tenants_pool.acquire() as conn:
rows = await conn.fetch(
f"""SELECT id, type, title, description, priority, status, estimated_impact, created_at
f"""SELECT id::text, type, title, description, priority, status,
estimated_impact::text, created_at::text
FROM {schema}.recommendations
WHERE status = $1
ORDER BY priority DESC, created_at DESC""",
ORDER BY created_at DESC""",
status
)
return [dict(r) for r in rows]
# ── Entry Point ───────────────────────────────────────────
@mcp.tool()
async def approve_recommendation(
brand_slug: str, actor_email: str,
recommendation_id: str,
decision: str,
notes: str = ""
) -> dict:
"""
Approve or reject a recommendation.
decision: approved | rejected | vetoed
"""
await assert_brand_access(brand_slug, actor_email)
schema = f"brand_{brand_slug}"
async with state.tenants_pool.acquire() as conn:
await conn.execute(
f"""UPDATE {schema}.recommendations
SET status = $1, actioned_at = NOW()
WHERE id = $2::uuid""",
decision, recommendation_id
)
row = await conn.fetchrow(
f"""INSERT INTO {schema}.approvals
(recommendation_id, decision, notes)
VALUES ($1::uuid, $2, $3)
RETURNING id::text, decision, created_at::text""",
recommendation_id, decision, notes
)
return dict(row)
@mcp.tool()
async def get_day_part_performance(
brand_slug: str, actor_email: str,
campaign_id: str,
date_from: str, date_to: str
) -> list[dict]:
"""Get hourly/day-of-week performance breakdown for a campaign."""
await assert_brand_access(brand_slug, actor_email)
query = f"""
SELECT hour_of_day, day_of_week,
sum(impressions) AS impressions,
sum(clicks) AS clicks,
round(sum(cost_micros) / 1e6, 2) AS cost,
sum(conversions) AS conversions,
round(if(sum(impressions) > 0, sum(clicks) / sum(impressions), 0), 4) AS ctr
FROM manar.day_part_performance
WHERE brand_id = '{brand_slug}'
AND campaign_id = '{campaign_id}'
AND date >= '{date_from}' AND date <= '{date_to}'
GROUP BY hour_of_day, day_of_week
ORDER BY day_of_week, hour_of_day
"""
result = state.ch_client.query(query)
return [dict(zip(result.column_names, row)) for row in result.result_rows]
@mcp.tool()
async def get_geo_performance(
brand_slug: str, actor_email: str,
date_from: str, date_to: str,
campaign_id: str = ""
) -> list[dict]:
"""Get geographic performance breakdown."""
await assert_brand_access(brand_slug, actor_email)
where_campaign = f"AND campaign_id = '{campaign_id}'" if campaign_id else ""
query = f"""
SELECT country_code, region, city,
sum(impressions) AS impressions,
sum(clicks) AS clicks,
round(sum(cost_micros) / 1e6, 2) AS cost,
sum(conversions) AS conversions
FROM manar.geo_performance
WHERE brand_id = '{brand_slug}'
AND date >= '{date_from}' AND date <= '{date_to}'
{where_campaign}
GROUP BY country_code, region, city
ORDER BY cost DESC
LIMIT 200
"""
result = state.ch_client.query(query)
return [dict(zip(result.column_names, row)) for row in result.result_rows]
# ── Entry ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
start_health_thread()
mcp.run(transport="http", host="0.0.0.0", port=MCP_PORT)