Skip to content

feat(mcp): expose generate_content_brief tool that triggers the brief endpoint#109

Merged
gkhngyk merged 1 commit into
mainfrom
feat/mcp-generate-content-brief
May 26, 2026
Merged

feat(mcp): expose generate_content_brief tool that triggers the brief endpoint#109
gkhngyk merged 1 commit into
mainfrom
feat/mcp-generate-content-brief

Conversation

@gkhngyk

@gkhngyk gkhngyk commented May 26, 2026

Copy link
Copy Markdown
Contributor

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

  • New MCP tool: `generate_content_brief(opportunity_id)` — fires the existing aeo-server LLM brief flow and returns `{ id, brief, generated_at, regenerated }`
  • New REST endpoint: `POST /api/mcp/content-opportunities/[id]/brief` — same data layer, same ownership guarantee
  • New internal aeo-server endpoint: `POST /api/internal/content/:id/brief` — CRON_SECRET-guarded, mounted before `Middleware.decodeToken` (cron / trigger-tracking pattern)

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

File Change
`server/src/routes/content.js` Extract `generateBriefForOpportunity(id, { force, model })` as shared core; existing dashboard route delegates to it without force
`server/src/server.js` New `POST /api/internal/content/:id/brief` mounted before session-JWT middleware, CRON_SECRET auth, always delegates to shared core
`web/src/lib/mcp/data.ts` New `generateBriefFor(auth, opportunityId)` — ownership check → internal endpoint proxy
`web/src/lib/mcp/server.ts` Registers `generate_content_brief` tool with LLM-cost guard rail in description
`web/src/app/api/mcp/content-opportunities/[id]/brief/route.ts` New POST route sharing the same data function

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`:

  • ✅ Wrong-org `opportunity_id` → 404, no upstream call (server logs show no `[internal] content brief` line)
  • ✅ Missing/invalid `opportunity_id` → 404, no upstream call
  • ✅ Valid `opportunity_id` → returns `{ id, brief, generated_at, regenerated: true }`, `content_opportunities.brief` and `updated_at` are refreshed
  • ✅ Second call on same id → re-generates, new `generated_at`
  • ✅ Missing/wrong API key → 401 from `authenticateMcpRequest`
  • ✅ `yarn typecheck` + `yarn format:check` clean

Out of scope (per #66)

  • Streaming the brief output (single-shot for now)
  • Auto-setting opportunity status to `in_progress` on brief generation (separate `update_opportunity_status` tool, future B-group issue)

Closes #66.

… 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 gkhngyk merged commit 006a8a4 into main May 26, 2026
4 checks passed
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.
gkhngyk added a commit that referenced this pull request May 31, 2026
Bundles the in-product Agent epic (#120, #121), the BYOK rollout on
cloud, the MCP toolkit expansion (#109, #110, #116, #117, #118), the
insights performance fix (#114), and the invite-accept flow rebuild
(#127, #129, #130) into a tagged release.

See CHANGELOG.md for the full notes.
gkhngyk added a commit that referenced this pull request May 31, 2026
Bundles the in-product Agent epic (#120, #121), the BYOK rollout on
cloud, the MCP toolkit expansion (#109, #110, #116, #117, #118), the
insights performance fix (#114), and the invite-accept flow rebuild
(#127, #129, #130) into a tagged release.

See CHANGELOG.md for the full notes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(mcp): expose generate_content_brief tool that triggers the brief endpoint

1 participant