feat(mcp): expose generate_content_brief tool that triggers the brief endpoint#109
Merged
Conversation
… endpoint Adds the third content-opportunity MCP tool (after list_content_opportunities and get_content_opportunity in #74), plus the parallel REST endpoint at POST /api/mcp/content-opportunities/[id]/brief. Auth boundary follows the existing cron / trigger-tracking pattern: the web MCP layer authenticates the ans_ API key, does the org-ownership check via supabaseAdmin *first*, and only then proxies to a new internal aeo-server endpoint guarded by CRON_SECRET. A wrong-org or missing id returns 404 before any outbound call, so no LLM cost or data leak for ids the caller doesn't own. The MCP path always passes force=true to bypass the brief-already-exists early-return on the dashboard route — MCP callers explicitly want a fresh brief; cached reads stay on get_content_opportunity. Server side: - server/src/routes/content.js: extract generateBriefForOpportunity(id, { force, model }) as the shared core; existing POST /api/content/:id/brief keeps its current cache-on-existing-brief behavior by calling it without force - server/src/server.js: new POST /api/internal/content/:id/brief mounted before the session-JWT middleware, CRON_SECRET-authenticated, always delegates to the shared core Web side: - web/src/lib/mcp/data.ts: generateBriefFor(auth, opportunityId) — wrong-org → null → 404, otherwise POSTs to the internal endpoint with Authorization: Bearer $CRON_SECRET (same pattern as /api/cron/daily-tracking) - web/src/lib/mcp/server.ts: registers generate_content_brief with a strong usage guard in the description (LLM cost + always re-generates) - web/src/app/api/mcp/content-opportunities/[id]/brief/route.ts: POST handler sharing the same generateBriefFor data function Closes #66.
gkhngyk
added a commit
that referenced
this pull request
May 26, 2026
…ions Closes the content-opportunity MCP loop (after #74 list/get and #109's generate_brief) by letting MCP-driven flows move an opportunity between new / sent / in_progress / done / dismissed without a dashboard round-trip. Pattern follows #109: ownership-check-first in the web data layer via the same brands!inner join. A wrong-org or missing id returns null (→ 404) with no UPDATE issued, so an attacker can't probe ids and can't flip rows they don't own. The shared CONTENT_OPPORTUNITY_STATUSES tuple is reused by both the zod enum (MCP) and the REST handler's manual validation, so the surfaces can't drift. - web/src/lib/mcp/data.ts: updateOpportunityStatusFor(auth, id, status) + exported CONTENT_OPPORTUNITY_STATUSES tuple + ContentOpportunityStatus type - web/src/lib/mcp/server.ts: registers update_opportunity_status with zod enum + per-status description so the model knows valid values without guessing - web/src/app/api/mcp/content-opportunities/[id]/status/route.ts: PATCH handler sharing the same data function; manual enum validation returns 400 with the valid list Closes #67.
gkhngyk
added a commit
that referenced
this pull request
May 26, 2026
…ions (#110) Closes the content-opportunity MCP loop (after #74 list/get and #109's generate_brief) by letting MCP-driven flows move an opportunity between new / sent / in_progress / done / dismissed without a dashboard round-trip. Pattern follows #109: ownership-check-first in the web data layer via the same brands!inner join. A wrong-org or missing id returns null (→ 404) with no UPDATE issued, so an attacker can't probe ids and can't flip rows they don't own. The shared CONTENT_OPPORTUNITY_STATUSES tuple is reused by both the zod enum (MCP) and the REST handler's manual validation, so the surfaces can't drift. - web/src/lib/mcp/data.ts: updateOpportunityStatusFor(auth, id, status) + exported CONTENT_OPPORTUNITY_STATUSES tuple + ContentOpportunityStatus type - web/src/lib/mcp/server.ts: registers update_opportunity_status with zod enum + per-status description so the model knows valid values without guessing - web/src/app/api/mcp/content-opportunities/[id]/status/route.ts: PATCH handler sharing the same data function; manual enum validation returns 400 with the valid list Closes #67.
gkhngyk
added a commit
that referenced
this pull request
May 28, 2026
…) + REST endpoint (#116) Adds the third A-group MCP read tool from the assistant-readiness list, after the content-opportunity tools shipped in #74 / #109 / #110. Combines two existing RPCs (competitor_aggregates and share_of_voice_aggregates, both DB-side aggregated since #114) so the tool answers "how do I compare to my competitors?" and "who's gaining share of voice?" in a single round trip. Ownership-check first via supabaseAdmin (wrong-org brand → null → 404, no RPC fire). Snapshot shape — deltas are out of scope for V1 and consistent with the existing get_visibility_summary / list_topics / list_prompts shape; a caller that wants a delta issues a second call with an earlier window. - web/src/lib/mcp/data.ts: getCompetitorComparisonFor(auth, params) — parallel RPC calls, computes brand + per-competitor averages (when-present semantics matching the UI), and per-(model_used, platform) SoV split. Uses the same competitor display-name fallback pattern as the existing competitor logic (name ?? competitor_id) so unnamed competitors don't collide. - web/src/lib/mcp/server.ts: registers the get_competitor_comparison tool with a description that covers both questions the tool answers and the snapshot-vs-delta caveat. - web/src/app/api/mcp/competitor-comparison/route.ts: GET handler sharing the same data fn; query params mirror the MCP tool args. Verified end-to-end against the largest brand in the cloud project (5054 prompt result appearances, 7 competitors, 10 distinct model/ platform combos) — returned shape matches the documented interface, SoV totals + per-platform breakdown reconcile with the existing UI. Closes #98.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements #66 to the locked spec. Third (and final read+action) content-opportunity MCP tool, after `list_content_opportunities` and `get_content_opportunity` in #74.
What
Auth boundary (per spec)
The web MCP layer authenticates the `ans_` API key via the existing `authenticateMcpRequest`, then `generateBriefFor` in `web/src/lib/mcp/data.ts` does the org-ownership check via `supabaseAdmin` first:
```ts
const { data: ownership } = await supabaseAdmin
.from('content_opportunities')
.select('id, brands!inner(organization_id)')
.eq('id', opportunityId)
.eq('brands.organization_id', auth.organizationId)
.maybeSingle();
if (!ownership) return null;
```
Only after ownership passes does it proxy to the internal aeo-server endpoint with `Authorization: Bearer $CRON_SECRET` (same pattern as `/api/cron/daily-tracking`). The `ans_` API key never leaves the web layer, and the aeo-server never has to validate it. A wrong-org or missing id returns 404 with no outbound call — no LLM cost, no data leak.
Re-generate vs cached read
The MCP path always passes `force: true` to skip the existing dashboard early-return (`if (opportunity.brief) return res.json({ brief })`). MCP callers explicitly want a fresh brief; cached reads belong on `get_content_opportunity`. The dashboard `POST /api/content/:id/brief` route keeps its current cache-on-existing-brief behavior because the shared core defaults to `force: false`.
Files
How verified
Local smoke test against running backend + web + local Supabase, with an `ans_` API key and a demo content opportunity from `supabase/seed.sql`:
Out of scope (per #66)
Closes #66.