Skip to content

Commit 74f2fb7

Browse files
Sbussisoclaude
andcommitted
fix(mcp): fall back to live Clerk lookup when cached org_plan is stale
The MCP path was reading org_plan from a Setting populated only by the subscription.created/updated/active webhook. When that webhook never fires (org upgraded before the handler shipped, delivery failed, or the Clerk plan slug doesn't match the literal "pro" key in RATE_LIMITS), paid orgs get blocked with "MCP requires a Pro or Business plan" even though their JWT-based dashboard correctly shows them as Pro. Fix: add resolve_org_plan() in app/core/plans.py that: - Returns the cached Setting immediately when it's a known paid plan (fast path, zero API calls) - Falls back to clerk.organizations.get_billing_subscription() to read the live subscription state, then writes the corrected slug back to the Setting so future calls hit the fast path - Throttles live re-checks to once per 60s per org so a non-paid caller hammering MCP can't drive Clerk API spend - Survives Clerk API failures by returning the cached value (or free_org) Use the resolver in two places: - mcp/server.py _resolve_org() — the auth gate that was rejecting legitimate Pro orgs - get_plan_limits_for_org() in plans.py — same drift can affect node registration and any other API-key-authed endpoint reading plan limits Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 69b8d3b commit 74f2fb7

2 files changed

Lines changed: 87 additions & 8 deletions

File tree

backend/app/core/plans.py

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
- business (Business — $49/mo)
88
"""
99

10+
import logging
11+
import time
12+
13+
logger = logging.getLogger(__name__)
14+
1015
PLAN_LIMITS = {
1116
"free_org": {
1217
"max_cameras": 2,
@@ -22,22 +27,92 @@
2227
},
2328
}
2429

30+
# Slugs we trust without re-checking against Clerk.
31+
PAID_PLAN_SLUGS = frozenset({"pro", "business"})
32+
33+
# Min seconds between consecutive live Clerk lookups for the same org.
34+
# Prevents non-paid callers from generating excessive Clerk API traffic
35+
# when they hit the MCP gate repeatedly.
36+
_RESOLVE_THROTTLE_SECONDS = 60.0
37+
_last_resolve_at: dict[str, float] = {}
38+
2539

2640
def get_plan_limits(plan: str) -> dict:
2741
"""Return the limits dict for a plan slug. Falls back to free tier."""
2842
return PLAN_LIMITS.get(plan, PLAN_LIMITS["free_org"])
2943

3044

45+
def resolve_org_plan(db, org_id: str) -> str:
46+
"""Return the current plan slug for an org, with a Clerk fallback.
47+
48+
Read order:
49+
1. Cached `Setting(org_plan)` — populated by the Clerk webhook
50+
handler in app/api/webhooks.py. If the value is a recognized
51+
paid plan, return immediately (fast path, no API call).
52+
2. Live `clerk.organizations.get_billing_subscription()` — fixes
53+
orgs whose subscription webhook never fired (e.g. they
54+
upgraded before the handler shipped, delivery failed, or the
55+
dashboard plan name produced a slug that didn't match a key
56+
in PLAN_LIMITS). The fresh slug is written back to the
57+
Setting so future calls hit the fast path.
58+
59+
Live lookups for the same org are throttled to once per 60 seconds
60+
so a free-tier caller hammering MCP can't drive Clerk API spend.
61+
"""
62+
from app.models.models import Setting
63+
from app.core.clerk import clerk
64+
65+
cached = Setting.get(db, org_id, "org_plan", "")
66+
if cached in PAID_PLAN_SLUGS:
67+
return cached
68+
69+
# Throttle live re-checks per org.
70+
now = time.monotonic()
71+
if now - _last_resolve_at.get(org_id, 0.0) < _RESOLVE_THROTTLE_SECONDS:
72+
return cached or "free_org"
73+
_last_resolve_at[org_id] = now
74+
75+
try:
76+
sub = clerk.organizations.get_billing_subscription(organization_id=org_id)
77+
except Exception:
78+
logger.warning(
79+
"Live Clerk plan lookup failed for org %s — returning cached value %r",
80+
org_id, cached, exc_info=True,
81+
)
82+
return cached or "free_org"
83+
84+
# Mirror the webhook handler's logic: take the first active item's plan slug.
85+
live_slug = "free_org"
86+
for item in (sub.subscription_items or []):
87+
status = getattr(item, "status", None)
88+
plan = getattr(item, "plan", None)
89+
if status == "active" and plan and getattr(plan, "slug", None):
90+
live_slug = plan.slug
91+
break
92+
93+
if live_slug != cached:
94+
Setting.set(db, org_id, "org_plan", live_slug)
95+
try:
96+
db.commit()
97+
logger.info(
98+
"Resolved org %s plan from Clerk: cached=%r → live=%r",
99+
org_id, cached, live_slug,
100+
)
101+
except Exception:
102+
db.rollback()
103+
logger.exception("Failed to persist resolved plan for org %s", org_id)
104+
105+
return live_slug
106+
107+
31108
def get_plan_limits_for_org(db, org_id: str) -> dict:
32109
"""Look up an org's plan from the database and return its limits.
33110
34111
Used by endpoints that authenticate via API key (e.g. node registration)
35-
where JWT claims are not available. The plan is stored as a Setting
36-
by the Clerk webhook handler whenever a subscription changes.
112+
where JWT claims are not available. Falls back to a live Clerk lookup
113+
if the cached Setting is stale or missing — see ``resolve_org_plan``.
37114
"""
38-
from app.models.models import Setting
39-
40-
plan = Setting.get(db, org_id, "org_plan", "free_org")
115+
plan = resolve_org_plan(db, org_id)
41116
limits = get_plan_limits(plan)
42117
# Attach the plan slug so callers can show a display name
43118
return {**limits, "_plan": plan}

backend/app/mcp/server.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,11 @@ def _resolve_org(headers: dict | None) -> tuple[str, Session]:
137137
db.close()
138138
raise ToolError("Unauthorized: invalid or revoked API key")
139139

140-
# Look up org plan and enforce access + rate limit
141-
plan = Setting.get(db, mcp_key.org_id, "org_plan", "free_org")
140+
# Look up org plan and enforce access + rate limit. Uses the
141+
# resolver so orgs whose subscription webhook never landed
142+
# still get checked against the live Clerk subscription state.
143+
from app.core.plans import resolve_org_plan
144+
plan = resolve_org_plan(db, mcp_key.org_id)
142145
limit = RATE_LIMITS.get(plan)
143146
if limit is None:
144147
db.close()
@@ -649,7 +652,8 @@ def get_system_status() -> dict:
649652
online_cameras = sum(1 for c in cameras if c.effective_status != "offline")
650653
online_nodes = sum(1 for n in nodes if n.effective_status not in ("offline", "pending"))
651654

652-
plan = Setting.get(db, org_id, "org_plan", "free_org")
655+
from app.core.plans import resolve_org_plan
656+
plan = resolve_org_plan(db, org_id)
653657

654658
return {
655659
"org_id": org_id,

0 commit comments

Comments
 (0)