Tool Specs · Roster MCP Server · Companion to the PRD and MCP & OAuth writeup

Tool Catalog — Technical Deep Dive & Implementation Specs

Per-tool implementation specs for the eleven-tool catalog: exact existing v2 API mappings, the five net-new endpoints (each a thin wrapper over a production-tested stored procedure the portal already uses), MCP tool definitions with input schemas and response shapes, and the PRD divergence decisions. Written for the developer or coding agent implementing the tools and the new App.WebApi.Open endpoints. The server/OAuth half (transport, envelope, credential bridge, hosting) lives in mcp-and-oauth.html.

Author Jeff Poulton Created 2026-06-09 Updated 2026-06-10 Status Draft PRD prd.html MCP & OAuth mcp-and-oauth.html
Verified · Source code

Controllers → services → stored procedures traced in api-brand-portal, web-app-brand-portal (which internal endpoints each portal surface calls), db-brand-portal, and db-global. File:line refs throughout.

Verified · Production DB (read-only)

Schemas, SP existence, lookup IDs, live token rows checked against shard Shared18 and the Global DB on 2026-06-09.

Verified · Help docs

What each portal report actually shows customers, from the published docs.getroster.com corpus (234 articles).

1

Executive Summary

Two tools are pure pass-throughs to existing public endpoints, two need no Roster work at all (MCP-layer only), and the remaining seven are served by five new v2 Open API endpoints plus two small modifications — every one wrapping an existing, production-tested stored procedure or service the portal itself uses. No new business logic, no recomputation.

#ToolUpstream source of truthStatusBackend work required
1list_programsGET /v2/programs (+ /v2/programs/{id})EXISTSNone
2get_program_performanceThe brand's saved Program Dashboard config + UserPageComponent_GetProgramMetric (existing dispatcher SP)NEW ENDPOINTGET /v2/programs/{program_id}/performance — no new SQL
3list_campaignsGET /v2/campaigns?includeCampaignStats=trueEXISTSNone — the portal's own campaign list already calls this public endpoint
4get_campaign_performance/v2/campaigns stats + campaign overview reports (UserCampaignReport_GetByType)PARTIALAdd campaignIds filter + new GET /v2/campaigns/{campaign_id}/performance
5list_ambassadorsAzure Cognitive Search via AccountSearchService.AccountSearchV2 (portal contact search)PARTIALNew GET /v2/contacts/search (existing /v2/contacts is too thin)
6get_ambassadorGET /v2/contacts/{id} + internal GET /api/account/{id} performance dataPARTIALNew GET /v2/contacts/{contact_id}/performance
7get_sales_attribution_reportUserReport_GetSalesAttribution SP (portal Sales Attribution report)NEW ENDPOINTGET /v2/reports/sales-attribution — thin wrapper, SP fully parameterized already
8get_social_posts_reportUserSocialListeningPost_GetReportPage SP (portal Social Posts report)NEW ENDPOINTGET /v2/reports/social-posts — thin wrapper
9get_social_feed_postsUserSocialListeningPost_GetFeedPage SP (portal Social Feed)NEW ENDPOINTGET /v2/social-posts + small SP change for contact filter
10search_help_docsArchbee public search API (POST /api/public-api/docs/search)EXISTS (EXT)MCP-service-side only; no Roster API work
11get_connection_infoOAuth grant store (captured at consent from Global DB)MCP-LAYEROptional new GET /v2/me (legacy /me returns only a user ID)

Verified findings that resolve PRD open questions

Open Question 3 — EMV availability RESOLVED

EMV exists and is stored per-post, but is not exposed in the v2 API today. UserSocialListeningPostEMV (verified live on Shared18) stores per-post EMV in cents, broken down by impressions / likes / comments / shares / saves, with TotalEMVInCents as a computed column. Rates come from EmvPlatformDefault — verified live: Instagram post = $10.00 CPM, 30¢/like, $1.80/comment, $2.00/share, $1.00/save, matching the Configure EMV rates help doc exactly — with per-brand overrides in UserEmvConfig. The new report endpoints (§4, §10, §11) expose it.

Rate-limit isolation & token lifetime RESOLVED

Both verified in our favor — Roster rate limiting is already per-token, and private tokens are standard 30-year JWTs the credential bridge can mint with no special handling. Details and evidence: MCP & OAuth writeup §3.1.

2

Cross-Cutting Conventions

2.1 Upstream API envelope (existing — do not change)

All v2 Open API endpoints return (verified across App.WebApi.Open controllers):

Standard envelope
{
  "success": true,
  "message": null,
  "result": { /* payload */ }
}
Paginated payload
{
  "data": [ /* items */ ],
  "pagination": {
    "pageIndex": 1,        // 1-based
    "pageSize": 50,        // default 50, max 10,000
    "totalRecords": 1234,
    "totalPages": 25,
    "nextPageIndex": 2     // null on last page
  }
}

Sorting: sortField (per-endpoint enum) + sortDirection (Asc|Desc). Date filters: ISO 8601 (createdAtMin/createdAtMax, inclusive).

2.2 Portal deep-link paths (for portal_source)

Verified portal deep-link paths (from web-app-brand-portal/src/scripts/containers/menuV2/routes.js and AppShell.js:423,490,518):

SurfacePath
Program Dashboard/programs/{programId}/dashboard
Campaigns List/campaigns
Campaign overview (single campaign)/campaigns/{id}/analytics/overview
Sales Attribution report/reports/sales-attribution
Social Posts report/reports/social-posts
Social Feed/feed
Contact list/discover
Contact detail/account/{id}/about (tabs: about, posts, …)

2.3 MCP-layer behavior — see the MCP & OAuth writeup §2

The MCP response envelope (brand + portal_source + cursor pagination + truncated), error mapping, tool annotations (readOnlyHint: true, openWorldHint: false), and the timezone model are server-layer behavior, specified once in the MCP & OAuth writeup. The two facts tool implementers need here: dates are explicit ISO dates — Claude resolves relative ranges in the user's timezone before calling, omitted dates default to UTC last-30-days (PRD Decision 5; there is no brand-timezone field anywhere in Roster — verified) — and MCP page caps are 50 default / 200 max, well under the upstream 10,000.

Tool 1

list_programs

EXISTING API
Portal surface: Programs list · /programs  ·  Upstream: GET /v2/programs (ProgramsController.cs)
MCP tool definition
{
  "name": "list_programs",
  "title": "List ambassador programs",
  "description": "Lists the brand's ambassador programs with
    member and applicant counts. Use this first to resolve a
    program name (e.g. 'my VIP program') to a program_id for
    other tools. Cheap to call. Set include_details to also
    fetch one program's application form, smart link, discount
    rules, and referral commission settings.",
  "annotations": { "readOnlyHint": true },
  "inputSchema": {
    "type": "object",
    "properties": {
      "status": { "type": "string",
        "enum": ["active","archived","all"],
        "default": "active" },
      "include_details_for_program_id": { "type": "integer",
        "description": "Optional. Fetch extended
         join-requirement details for this one program." }
    }
  }
}
Response (1:1 from OpenApiProgram / -Extended)
{
  "programs": [{
    "program_id": 42, "name": "VIP Ambassadors",
    "key": "vip", "status": "active",
    "ambassadors_count": 312,
    "pending_applicants_count": 18,
    "nominated_applicants_count": 4,
    "rejected_applicants_count": 51,
    "created_at": "2024-03-01T00:00:00Z",
    "updated_at": "2026-06-01T00:00:00Z"
  }],
  "program_details": {   // only when requested;
                         // from GET /v2/programs/{id}
    "application_form": { … },
    "smart_link": { … },
    "personal_discount_rule_id": 7,
    "shareable_discount_rule_id": 9,
    "referral_commissions": { … },
    "referral_points": { … }
  }
}

Implementation notes: status maps to the upstream status array (Active = lookup 7, Archived = 49; both when all). Drop color/icon fields (UI noise for an LLM). No backend work.

Tool 2

get_program_performance

NEW V2 ENDPOINT
Portal surface: Program Dashboard · /programs/{programId}/dashboard
PRD NOTE #2The Program Dashboard is a customizable widget canvas, not a fixed report DECIDED 2026-06-10

The layout is saved per brand, per program — verified: UserPage rows are fetched by AccessToUserId (the brand) with LinkedObjectId = the program (UserPage_GetByAccessUserId.sql; UserPage.sql) — so every team member at the brand sees the same dashboard (help doc; ProgramDashboard.js + DraggableDashboardContainer.js, default range "Last 30 days").

Decided (Jeff, 2026-06-10): the tool mirrors the brand's saved dashboard configuration for that program — so the output matches exactly what the brand sees when they log into the Program Dashboard: (1) resolve the program's saved UserPage + UserPageComponent rows (each carries Settings — metric type, viewBy — and GroupId → section labels); (2) if the brand never customized the dashboard, fall back to the default template component set (UserPage_GetTemplate / UserPageComponentDefaults — the same fallback the portal renders); (3) execute the existing dispatcher SP UserPageComponent_GetProgramMetric(@ComponentId, …) per component — no new SQL at all; (4) return one entry per dashboard card, labeled with metric key and section name, in dashboard order.

Trade-off (by design): if the brand removed a widget (e.g. EMV), that metric is not returned for the program — the tool answers exactly what the dashboard shows. The tool description states this so Claude routes uncovered metric questions to the report tools instead.

How the dashboard actually computes (verified end-to-end)

GET /api/user/page/component/{componentId}/data    UserPageApiController.cs:64-80
  → UserPageService.GetPageComponentData()         UserPageService.cs:199-314
  → UserPageData.GetProgramMetric()                UserPageData.cs:538-583
  → EXEC dbo.UserPageComponent_GetProgramMetric    1,738-line dispatcher SP — verified on Shared18
      → fn_CalculateComponentData_ProgramMetric*   per-metric table functions — verified on Shared18

The dispatcher is coupled to a saved dashboard component (@ComponentId VARCHAR(30) — it reads the component's saved Settings for metric type and defaults). The metric functions underneath are clean, reusable, and take (@BrandUserId, @ProgramIds, @StartDate DATETIMEOFFSET, @EndDate, @DateGrouping, …).

Metric keyFunctionDefinition
applicants…ProgramMetricApplicantsDistinct accounts entering PENDING status in UserProgramAccountStatusHistory in range
members…ProgramMetricMemberCountDistinct approved members per date snapshot (via fn_UserProgramAccount_GetAccountRange); supports new-vs-existing split
first_time_logins…ProgramMetricFirstLoginsDistinct accounts whose UserListAccount.RegistrationDate (Consumer-App list = first portal access) falls in range
post_mentions…ProgramMetricPostMentionsCount of UserSocialListeningPost rows (excl. web-link type, excl. manual adds) by program members in range
post_engagements…ProgramMetricPostEngagementsSum of likes + comments + shares + saves on those posts
post_impressions…ProgramMetricPostImpressionsSum of TotalImpressions
emv…ProgramMetricPostEMVSUM(UserSocialListeningPostEMV.TotalEMVInCents)/100 (dollars)
points_earned…ProgramMetricPointsEarnedPoints awarded in range
referred_revenue, referred_orders…RevenueGenerated_ByRevenueTypeAttributed order revenue / counts for program members
(same pattern)campaigns-completed, campaigns-joined, actions-completed, post-reach, milestones-unlocked, link-clicks, new-customers, personal-revenue, rewards-earned, referral-points, referral-commissions — dispatcher cases in UserPageComponent_GetProgramMetric.sql:109-1461

Each function returns a time series + comparison: [{Date, Value}] grouped by an auto-selected DateGrouping (span ≤1 day → finest grain; coarser as the span grows; dispatcher lines 77-94), plus Total, PrevCount (parallel preceding period of equal length: @DeltaStartDate = @StartDate − (EndDate−StartDate)), and Delta (% change).

New endpoint to build — GET /v2/programs/{program_id}/performance

New controller ProgramPerformanceController in App.WebApi.Open/Controllers/Community/. Params: startDate (DateTimeOffset, default 30 days ago — matches dashboard default), endDate (default now), metrics (optional filter — return only the dashboard cards matching these metric keys).

Implementation approach (decided — saved-dashboard mirror): service loads the brand's saved page + components for the program (default-template fallback), then calls the existing UserPageComponent_GetProgramMetric per component via UserPageData.GetProgramMetric (UserPageData.cs:538-583) — the exact path the portal uses. No new SP needed; the endpoint is controller + service composition + DTOs. The earlier alternative (a new SP over the fn_CalculateComponentData_* functions returning a fixed standard catalog regardless of dashboard config) is shelved unless parity-with-dashboard proves too narrow in beta.

MCP tool definition
{
  "name": "get_program_performance",
  "title": "Get program performance (Program Dashboard)",
  "description": "Returns the portal's Program Dashboard for
    one program over a date range — the same widgets the
    brand has configured on that program's dashboard
    (typically: applicants, members, first-time portal
    logins, post mentions, engagements, impressions, earned
    media value, points, referred revenue, and more). Each
    metric includes a time series, the period total, and the
    change vs the preceding period of equal length. Defaults
    to the last 30 days, matching the dashboard. Note: the
    set of metrics returned mirrors the brand's dashboard
    configuration; if a metric isn't on their dashboard, use
    the report tools instead (e.g. get_social_posts_report
    for EMV). Resolve relative date ranges in the user's
    timezone before calling. Use list_programs first to
    resolve the program_id.",
  "annotations": { "readOnlyHint": true },
  "inputSchema": {
    "type": "object",
    "required": ["program_id"],
    "properties": {
      "program_id": { "type": "integer" },
      "start_date": { "type": "string", "format": "date" },
      "end_date":   { "type": "string", "format": "date" },
      "metrics": { "type": "array", "items": { "type":
        "string", "enum": ["applicants","members",
        "first_time_logins","post_mentions",
        "post_engagements","post_impressions","emv",
        "points_earned","referred_revenue","referred_orders",
        "link_clicks","new_customers","personal_revenue",
        "campaigns_joined","campaigns_completed",
        "actions_completed","post_reach",
        "milestones_unlocked","rewards_earned",
        "referral_points","referral_commissions"] },
        "description": "Optional filter — only return
         dashboard cards matching these keys; omit for
         the full dashboard" }
    }
  }
}
Response
{
  "program": { "program_id": 42,
               "name": "VIP Ambassadors" },
  "date_range": { "start": "2026-05-10",
                  "end": "2026-06-09",
                  "grouping": "week" },
  "dashboard_cards": [
    {
      "section": "Program Health",
      "metric": "post_mentions",
      "total": 184,
      "previous_period_total": 150,
      "delta_pct": 22.7,
      "series": [
        { "date": "2026-05-10", "value": 41 },
        { "date": "2026-05-17", "value": 48 }
      ]
    },
    {
      "section": "Social Engagement",
      "metric": "emv",
      "total": 12450.30,
      "previous_period_total": 9100.00,
      "delta_pct": 36.8,
      "series": [ /* … */ ],
      "unit": "USD"
    }
    /* … one entry per card on the brand's saved
       dashboard, in dashboard order … */
  ]
}
Tool 3

list_campaigns

EXISTING API
Upstream: GET /v2/campaigns · CampaignController.cs:28-73 (verified directly)

The Brand Portal's own campaigns page calls this exact public endpoint (web-app-brand-portal/src/scripts/containers/campaigns/actions.js:196-237 uses getRosterApiAxios()GET campaigns), so parity is automatic.

{
  "name": "list_campaigns",
  "title": "List campaigns",
  "description": "Lists the brand's campaigns with status, dates, and participant counts (invited,
    joined, completed, participation rate). Filter by status or search by name. Use this to resolve
    a campaign name to a campaign_id for get_campaign_performance.",
  "annotations": { "readOnlyHint": true },
  "inputSchema": {
    "type": "object",
    "properties": {
      "query":  { "type": "string", "description": "Search by campaign name/title" },
      "status": { "type": "array", "items": { "type": "string",
                  "enum": ["draft","planned","published","active","completed","archived"] } },
      "include_stats": { "type": "boolean", "default": true },
      "page": { "type": "integer", "default": 1 },
      "page_size": { "type": "integer", "default": 50, "maximum": 200 }
    }
  }
}

Response: pass-through of the fields above (snake_cased). No backend work.

Tool 4

get_campaign_performance

PARTIAL — 1 FILTER + 1 NEW ENDPOINT
Portal surfaces: Campaigns List · /campaigns  +  per-campaign overview · /campaigns/{id}/analytics/overview · help doc
PRD NOTE #3Naming DECIDED 2026-06-10

The portal also has a separate "Campaign Performance Dashboard" (/dashboards/campaign-performance) — an all-campaigns rollup. Per Jeff: do not use or reference it anywhere in this project (it is confusing and effectively unused). This tool is built exclusively from the Campaigns List and the individual campaign overview pages; portal_source.surface reads "Campaign overview" with the deep link /campaigns/{id}/analytics/overview.

What the campaign overview page actually calls (verified)

CampaignAnalyticsContainerOverview.js fetches GET /api/user/report/no-cache?typeId=…&userCampaignId=… (campaigns/analytics/actions.js:245-275) for these report types (Overview.js:26-32, 67-73), all dispatched through SP UserCampaignReport_GetByType (verified on Shared18): REPORT_CAMPAIGN_ANALYTICS_TOTAL_CONTENT · TOTAL_REACH · TOTAL_ENGAGEMENT · CONTENT_BY_NETWORK · REACH_BY_NETWORK · CAMPAIGN_STATUS (the added → emails sent/opened → joined → completed funnel) · PERFORMANCE_BY_NETWORK — plus GET /api/campaign/{id}/ambassador-stats, top posts via GET /api/social-listening/campaign-posts, and participants via the public GET /v2/campaigns/{id}/participants.

What exists vs. what's missing

PRD-listed datumSource todayPublic?
Participants, completion/milestone stats/v2/campaigns?includeCampaignStats=true + /v2/campaigns/{id}/participants + /v2/campaign/{id}/rewardsYES
Posts generated, reach, engagement rateOpenApiCampaignStatsYES
Overview funnel + reach/engagement/content-by-network (the REPORT_CAMPAIGN_ANALYTICS_* set above)UserCampaignReport_GetByType SP via internal GET /api/user/report/no-cache (UserReportApiController.cs:66-74); funnel tables AccountMessage/AccountMessageStatNO
Campaign EMVNot surfaced per-campaign anywhere today; computable as SUM(UserSocialListeningPostEMV.TotalEMVInCents) over the campaign's posts (UserCampaignPost join — same join UserSocialListeningPost_GetReportPage uses for its campaign filter)NO
Attributed revenue per campaignDoes not exist as a portal surface. The campaign overview shows no revenue; sales attribution is ambassador-level, with no campaign linkage in UserReport_GetSalesAttributionNO
PRD NOTE #4Drop "attributed revenue" from this tool in Phase 1 CONFIRMED 2026-06-10

Confirmed by Jeff (2026-06-10): "campaign attributed revenue is not real." There is no existing query that attributes revenue to a campaign, so exposing it would violate the PRD's own trust rule ("data MUST come from the same data paths the portal reports use — never recomputed"). If campaign revenue becomes a requirement, it's a product feature (define attribution window + membership rules) before it's an API feature.

Backend work

MCP tool definition
{
  "name": "get_campaign_performance",
  "title": "Get campaign performance
            (campaign overview)",
  "description": "Returns one campaign's full performance,
    matching the portal's campaign overview page: invite
    funnel (added, emails sent/opened, joined, completed,
    participation and completion rates), content generated
    (posts, stories, uploads with likes/comments/views),
    reach and engagement by network, earned media value, and
    reward fulfillment counts. Use list_campaigns to resolve
    the campaign_id. Note: Roster does not attribute order
    revenue to campaigns; for revenue questions use
    get_sales_attribution_report.",
  "annotations": { "readOnlyHint": true },
  "inputSchema": {
    "type": "object",
    "required": ["campaign_id"],
    "properties": {
      "campaign_id": { "type": "integer" }
    }
  }
}
Response
{
  "campaign": { "campaign_id": 88,
    "name": "Summer Launch", "status": "active",
    "start_at": "2026-05-01", "end_at": "2026-07-01" },
  "funnel": {
    "added": 120, "emails_sent": 118,
    "emails_opened": 96, "joined": 74,
    "completed": 31,
    "participation_rate": 61.7,
    "completion_rate": 41.9
  },
  "content": {
    "posts": 64, "stories": 12, "uploads": 9,
    "likes": 8120, "comments": 644,
    "video_views": 19500
  },
  "social": {
    "follower_reach": 410000,
    "engagement_rate": 4.2,
    "emv": 9120.50
  },
  "rewards": {
    "needs_approval": 3,
    "needs_fulfillment": 5
  }
}
Tool 5

list_ambassadors

NEW SEARCH ENDPOINT
Portal surface: contact list · /discover

Why the existing endpoint isn't enough

GET /v2/contacts (ContactsController.cs:42-66) filters only by contactIds | email | instagramHandle | referralLinkId — no name query, no program/tag/status filter, no join-date range, no sorting, and the OpenApiContact DTO carries no performance fields. The PRD's leaderboard and export use cases need the portal's search.

What the portal actually uses (verified)

POST /api/account/account-searchV2AccountSearchService.AccountSearchV2 (AccountSearchService.cs:644-692) → Azure Cognitive Search (index fed from SQL view vw_AccountSearchAll_V4 via change tracking). The index already contains everything the tool needs (field list at AccountSearchService.cs:2305-2370): identity + phone, per-network handles/followers/engagement, tags, custom properties, programs + status + added date, TotalSpent/OrdersCount, TotalReferralValue/TotalReferralOrders/LastReferralDate, MentionsTotalCount/LastMentionDate, campaigns, points, LastLoginDate, referral links and discount codes.

PRD NOTE #5sort: emv is not implementable in Phase 1 ACCEPTED 2026-06-10

The PRD's list_ambassadors spec lists sort: (referral_revenue | emv | posts | joined_date), but per-contact EMV is not a field in the search index (EMV lives per-post in UserSocialListeningPostEMV), so the contact search physically cannot rank by it. Supportable sorts: referral_revenue (TotalReferralValue), posts (MentionsTotalCount), joined_date (DateAdded/ProgramAddedDate), followers, engagement, total_spent.

The use case is still covered: "ambassadors ranked by EMV" = get_social_posts_report with sort: emv (per-ambassador EMV over a date range). Adding a lifetime-EMV aggregate to vw_AccountSearchAll_V4 + the index remains a possible later enhancement (view + index schema + re-index).

Backend work — new GET /v2/contacts/search

New controller in App.WebApi.Open/Controllers/Community/ wrapping AccountSearchService.AccountSearchV2 with a fixed, documented column set. Params: query (name/email free text), programIds[], programStatus, tags[], joinedAtMin/Max, sortField + sortDirection, paging.

MCP tool definition
{
  "name": "list_ambassadors",
  "title": "Search ambassadors",
  "description": "Paginated search over the brand's
    ambassadors/contacts — the same search the portal's
    contact list uses. Filter by name/email text, program,
    tag, program status, or join-date range; sort by
    lifetime referral revenue, post mentions, joined date,
    followers, or engagement to build leaderboards. Returns
    full contact rows: email, phone, social handles with
    follower counts, tags, custom properties, program
    memberships, lifetime referral revenue and order counts,
    points, and last portal login. For one contact's full
    performance detail use get_ambassador.",
  "annotations": { "readOnlyHint": true },
  "inputSchema": {
    "type": "object",
    "properties": {
      "query": { "type": "string" },
      "program_id": { "type": "integer" },
      "status": { "type": "string", "enum":
        ["member","applicant","rejected","none"] },
      "tag": { "type": "string" },
      "joined_after":  { "type": "string", "format": "date" },
      "joined_before": { "type": "string", "format": "date" },
      "sort": { "type": "string", "enum":
        ["referral_revenue","posts","joined_date",
         "followers","engagement","total_spent"],
        "default": "referral_revenue" },
      "sort_direction": { "type": "string",
        "enum": ["asc","desc"], "default": "desc" },
      "page": { "type": "integer", "default": 1 },
      "page_size": { "type": "integer",
        "default": 50, "maximum": 200 }
    }
  }
}
Response row
{
  "contact_id": 9001,
  "first_name": "Stephanie", "last_name": "Lee",
  "email": "s@ex.com",
  "phone": "+1-555-0100",
  "socials": [{
    "network": "instagram", "handle": "steph.lee",
    "followers": 24800, "engagement_rate": 3.1
  }],
  "tags": ["vip", "ugc-approved"],
  "custom_properties": { "shoe_size": "8" },
  "programs": [{
    "program_id": 42, "name": "VIP Ambassadors",
    "status": "member", "joined_at": "2025-02-11"
  }],
  "lifetime_referral_revenue": 18250.00,
  "lifetime_referral_orders": 61,
  "last_referral_at": "2026-06-02",
  "post_mentions_total": 87,
  "last_mention_at": "2026-06-05",
  "total_points": 1240,
  "last_portal_login_at": "2026-06-01",
  "referral_link": "https://brand.com/?rstr=abc123",
  "discount_codes": ["STEPH20"]
}

Lifetime referral revenue semantics (so support can answer "why doesn't this match X"): computed in vw_AccountSearchAll_V4:485-527 as the UNION of (a) orders linked via the ambassador's assigned discount codes and (b) orders linked via referral-link tracking (UserCodeTrackingUsage), excluding cancelled/refunded orders, using SubtotalPrice in the brand's currency.

Tool 6

get_ambassador

EXISTING PROFILE + NEW PERFORMANCE ENDPOINT
Portal surface: contact detail page · /account/{id}/about

Existing piece

GET /v2/contacts/{contact_id} (ContactsController.cs:24-36) already returns the profile: name, email, phoneNumber, DOB, shipping address, PayPal/Venmo, per-network handle strings, tags, single program object, sharableCodes, referralLink + referralLinkIds, contactOwnerEmail, lifeTimePoints, dateAdded, termsAgreementDate, lastAccessedPortalDate (OpenApiContact.cs). Custom properties via GET /v2/contacts/{id}/properties.

Missing piece → new GET /v2/contacts/{contact_id}/performance

The portal detail page gets its performance numbers from internal GET /api/account/{accountId} (AccountApiController.cs:100-107AccountService.GetAccount), whose Account model carries (Account.cs:17-323): TotalReferralValue, TotalReferralOrders, LastReferralDate, Customer.{OrdersCount, TotalSpent, MostRecentOrderDate, MostRecentOrderValue} (personal orders), MentionsTotalCount, campaign participation fields, TotalPoints, ActionsCompleted, LastLoginDate, social profiles with stats.

BlockSource (all existing)
Referral performanceAccount.TotalReferralValue/TotalReferralOrders/LastReferralDate (same view the search index uses)
Personal ordersAccount.Customer (OrdersCount, TotalSpent, most-recent order)
CommissionsSUM(AccountReward.RewardAmount) where RewardTypeId IN (710 commission, 560 manual referred) and status ≠ rejected, grouped by status (pending/approved/paid) — exactly the reward-type/status logic UserReport_GetSalesAttribution.sql:402-460 uses
Posts + engagementTotals from UserSocialListeningPost_GetByAccount (verified SP; powers the contact's Posts tab via GET /api/social-listening/account-posts, SocialListeningApiController.cs:82-105) incl. per-post EMV
Rewards earned/redeemedAccountReward counts by RewardStatusId (same data /v2/rewards?ambassador= already exposes — reuse AccountRewardService.GetAccountRewards)
Referral links & codesAlready on OpenApiContact
MCP tool definition
{
  "name": "get_ambassador",
  "title": "Get ambassador profile & performance",
  "description": "Returns one ambassador's full profile and
    performance, mirroring the portal's contact detail page:
    profile (email, phone, socials with followers, tags,
    custom properties, program memberships), lifetime
    referral revenue and orders, personal orders, commission
    totals by status, post counts with engagement and EMV,
    rewards earned/redeemed, referral links and discount
    codes, and last activity dates. Look up by contact_id
    or email.",
  "annotations": { "readOnlyHint": true },
  "inputSchema": {
    "type": "object",
    "properties": {
      "contact_id": { "type": "integer" },
      "email": { "type": "string", "format": "email",
                 "description": "Alternative to contact_id" }
    }
  }
}
// Email lookup: resolve via existing
// GET /v2/contacts?email= first, then fetch by id.
Response
{
  "profile": { /* GET /v2/contacts/{id} fields
                  + custom_properties */ },
  "performance": {
    "referral": {
      "lifetime_revenue": 18250.00,
      "lifetime_orders": 61,
      "last_referral_at": "2026-06-02"
    },
    "personal_orders": {
      "count": 9, "total_spent": 1120.00,
      "most_recent_at": "2026-05-20"
    },
    "commissions": {
      "pending": 240.00, "approved": 1180.00,
      "paid": 9320.00, "currency": "USD"
    },
    "social": {
      "posts": 87, "engagements": 14230,
      "impressions": 410200, "emv": 6210.40
    },
    "rewards": {
      "earned": 22, "fulfilled": 19,
      "not_redeemed": 2
    },
    "last_activity": {
      "last_portal_login_at": "2026-06-01",
      "last_post_at": "2026-06-05"
    }
  }
}
Tool 7

get_sales_attribution_report

NEW V2 ENDPOINT — THIN WRAPPER
Portal surface: Reports → Sales Attribution · /reports/sales-attribution · help doc

Verified data path (the wrapper target)

GET /api/user/report/sales-attribution             UserReportApiController.cs:133-144
  → UserReportService.GetSalesAttributionReport()  UserReportService.cs:184-250
  → UserReportData.GetSalesAttributionReport()     UserReportData.cs:79-117
  → EXEC dbo.UserReport_GetSalesAttribution        verified on Shared18; cmd timeout 300s

The SP is already fully parameterized — the new endpoint is a pure pass-through: @AccountIdsJson, @ProgramIdsJson, @AccountOwnerUserIdsJson, @AttributionMethodIdsJson, @TagIdsJson, @StartDate, @EndDate, @PageIndex, @PageSize, @SortBy, @SortDirection, @AdjustRevenue.

Row semantics (verified in SP + live lookups)

Why not the existing /v2/attributions? It exists (rich row-level attributions + a summary block — AttributionController.cs:58-120, OpenApiAttributionSummary) but it is order-grained, not the per-ambassador rollup the portal report shows, and its summary fields don't match the report's totals. For report parity, wrap the report SP. (/v2/attributions remains the right citation for "show me the underlying orders" follow-ups.)

PRD NOTEattribution_type (referred | personal) param PRD UPDATED 2026-06-10

The PRD originally specced this param. The portal report always shows both column families per row; its "Attribution Method" filter is the 870-874 method list. So the tool exposes attribution_methods (named values) and keeps both referred and personal columns in every row — that is what the portal does. The PRD now reflects this.

New endpoint — GET /v2/reports/sales-attribution

New controller ReportsController in App.WebApi.Open/Controllers/Reports/. Params: ambassadorIds[], programIds[], contactOwnerIds[], attributionMethods[] (enum → ids), tagIds[], fromDate, toDate, paging, sortField/sortDirection. Response DTO mirrors SalesAttributionReport (App.Domain/User/Reports/SalesAttributionReport.cs): totals{…} + rows[].

MCP tool definition
{
  "name": "get_sales_attribution_report",
  "title": "Sales Attribution report",
  "description": "The portal's Sales Attribution report: one
    row per ambassador with activity in the date range — link
    clicks, new customers, referred orders and referred
    revenue (order total after discounts, before
    tax/shipping), commissions and points earned, personal
    orders and personal-order revenue — plus grand totals.
    Filter by program, ambassador, tag, or attribution
    method; sort by any metric for leaderboards. Defaults to
    the last 30 days. This is the source of truth for
    ambassador revenue questions and exec reporting.",
  "annotations": { "readOnlyHint": true },
  "inputSchema": {
    "type": "object",
    "properties": {
      "start_date": { "type": "string", "format": "date" },
      "end_date":   { "type": "string", "format": "date" },
      "program_id": { "type": "integer" },
      "contact_id": { "type": "integer" },
      "tag": { "type": "string" },
      "attribution_methods": { "type": "array",
        "items": { "type": "string", "enum":
          ["emailAddress","rewardCode","referralLink",
           "discountCode","recurringOrder"] } },
      "sort": { "type": "string", "enum":
        ["total_clicks","new_customers","referred_orders",
         "referred_revenue","personal_orders",
         "personal_order_revenue"],
        "default": "referred_revenue" },
      "sort_direction": { "type": "string",
        "enum": ["asc","desc"], "default": "desc" },
      "page": { "type": "integer", "default": 1 },
      "page_size": { "type": "integer",
        "default": 50, "maximum": 200 }
    }
  }
}
Response
{
  "totals": {
    "total_clicks": 4210,
    "new_customers": 96,
    "referred_revenue": 88120.50,
    "personal_order_revenue": 7240.00,
    "total_revenue": 95360.50,
    "row_count": 143
  },
  "rows": [{
    "ambassador": {
      "contact_id": 9001,
      "name": "Stephanie Lee",
      "email": "s@ex.com"
    },
    "total_clicks": 221,
    "new_customers": 4,
    "referred_orders": 8,
    "referred_revenue": 5230.00,
    "referral_commissions": 523.00,
    "referral_points": 0,
    "personal_orders": 2,
    "personal_order_revenue": 567.00,
    "shareable_codes": "STEPH20",
    "referral_link":
      "https://brand.com/?rstr=abc123",
    "currency": "USD",
    "tags": "vip"
  }]
}
Tool 8

get_social_posts_report

NEW V2 ENDPOINT — THIN WRAPPER
Portal surface: Reports → Social Posts · /reports/social-posts · help doc
PRD NOTEThis report is per-ambassador, not per-post

The PRD describes "per-post rows", but the portal report aggregates one row per ambassador over the range (verified in the SP and in the help-doc walkthrough: "Stephanie has created 13 posts, this is her reach for those 13 posts…"). Recommendation: keep portal parity (per-ambassador rollup + grand totals) — per-post listing is exactly what get_social_feed_posts provides, so nothing is lost.

Verified data path

GET /api/user/report/social-posts                  UserReportApiController.cs:180
  → UserReportService
  → EXEC dbo.UserSocialListeningPost_GetReportPage read in full; verified on Shared18

New endpoint — GET /v2/reports/social-posts

In the new ReportsController. Pure pass-through to the SP. Expose filters: ambassadorIds[], programIds[], networks[] (instagram|facebook|tiktok|x), campaignIds[], tagIds[], fromDate, toDate, paging, sorting. (Age/country/gender filters exist in the SP; skip them in v1 — low LLM utility, more enum surface.)

MCP tool definition
{
  "name": "get_social_posts_report",
  "title": "Social Posts report",
  "description": "The portal's Social Posts report: one row
    per ambassador who posted in the date range, with post
    and story counts, reach, impressions, likes, comments,
    shares, saves, engagement rate, and earned media value
    (EMV) — plus grand totals across all ambassadors. Filter
    by program, campaign, network, tag, or a single
    ambassador; sort by any metric (e.g. EMV) for content
    leaderboards. Defaults to the last 30 days. For
    individual post URLs and captions use
    get_social_feed_posts.",
  "annotations": { "readOnlyHint": true },
  "inputSchema": {
    "type": "object",
    "properties": {
      "start_date": { "type": "string", "format": "date" },
      "end_date":   { "type": "string", "format": "date" },
      "platform": { "type": "array", "items": {
        "type": "string", "enum":
        ["instagram","facebook","tiktok","x"] } },
      "program_id":  { "type": "integer" },
      "campaign_id": { "type": "integer" },
      "contact_id":  { "type": "integer" },
      "tag": { "type": "string" },
      "sort": { "type": "string", "enum":
        ["posts","stories","reach","impressions","likes",
         "comments","shares","saves","emv",
         "engagement_rate"],
        "default": "posts" },
      "sort_direction": { "type": "string",
        "enum": ["asc","desc"], "default": "desc" },
      "page": { "type": "integer", "default": 1 },
      "page_size": { "type": "integer",
        "default": 50, "maximum": 200 }
    }
  }
}
Response
{
  "totals": {
    "posts": 100, "stories": 0,
    "reach": 1300000, "impressions": 1520000,
    "emv": 48210.00, "engagement": 91200,
    "engagement_rate": 4.4,
    "ambassador_count": 37
  },
  "rows": [{
    "ambassador": {
      "contact_id": 9001,
      "name": "Stephanie Lee",
      "email": "s@ex.com"
    },
    "posts": 13, "stories": 2,
    "reach": 182000, "impressions": 210000,
    "likes": 8120, "comments": 644,
    "shares": 230, "saves": 410,
    "emv": 6210.40, "engagement": 9404,
    "engagement_rate": 5.2
  }]
}
Tool 9

get_social_feed_posts

NEW V2 ENDPOINT
Portal surface: Social Feed · /feed · help doc

Verified data path

POST /api/user/feed/social/page                    UserFeedApiController.cs:16-37
  → UserSocialFeedService.GetUserSocialFeedPage()  UserSocialFeedService.cs:23-43
  → UserSocialFeedData.GetUserSocialFeedPage()     UserSocialFeedData.cs:31-86
  → EXEC dbo.UserSocialListeningPost_GetFeedPage   verified on Shared18
Gap for the PRD's contact_id filter

GetFeedPage has no account filter. Two options:

(a — recommended) add nullable @AccountIdsJson to UserSocialListeningPost_GetFeedPage (one extra EXISTS predicate, pattern already present in GetReportPage); or
(b) when contact_id is supplied, route to UserSocialListeningPost_GetByAccount (the contact-detail Posts tab SP) instead — works today but has a slightly different filter/sort surface (no program filter; sorts: date / likes / comments / engagement).

New endpoint — GET /v2/social-posts

New controller SocialPostsController in App.WebApi.Open/Controllers/Community/note: distinct from /v2/community/posts, which is community-forum posts, not social listening. Params: networks[], programIds[], contactId, fromDate, toDate, sort (engagement|followers|post_date), paging. Each row exposes post id, ambassador (id/name), program, network, post type (post/story/reel), postDate, caption (Content), permalink, media URLs, engagement metrics (likes/comments/shares/saves/video views), impressions/reach, and the EMV breakdown.

MCP tool definition
{
  "name": "get_social_feed_posts",
  "title": "Social Feed — recent ambassador posts",
  "description": "Recent individual posts where ambassadors
    mentioned the brand, matching the portal's Social Feed:
    post URL and media, caption, platform, post type
    (post/story/reel), ambassador, program, posted date,
    likes, comments, shares, saves, video views,
    impressions, and earned media value per post. Filter by
    platform, program, ambassador, or date range; sort by
    engagement to find top-performing content, or by date
    for 'what are my ambassadors posting right now?'.
    Defaults to the last 30 days, newest first.",
  "annotations": { "readOnlyHint": true },
  "inputSchema": {
    "type": "object",
    "properties": {
      "start_date": { "type": "string", "format": "date" },
      "end_date":   { "type": "string", "format": "date" },
      "platform": { "type": "array", "items": {
        "type": "string", "enum":
        ["instagram","facebook","tiktok","x"] } },
      "program_id": { "type": "integer" },
      "contact_id": { "type": "integer" },
      "sort": { "type": "string", "enum":
        ["post_date","engagement","followers"],
        "default": "post_date" },
      "page": { "type": "integer", "default": 1 },
      "page_size": { "type": "integer",
        "default": 25, "maximum": 100 }
    }
  }
}
// Smaller default/max page size:
// rows are heavy — captions + media URLs.
Response row
{
  "post_id": 555123,
  "platform": "instagram",
  "post_type": "reel",
  "ambassador": {
    "contact_id": 9001,
    "name": "Stephanie Lee"
  },
  "program": {
    "program_id": 42,
    "name": "VIP Ambassadors"
  },
  "posted_at": "2026-06-05T18:22:00Z",
  "caption": "Loving my new @acmeoutdoor pack…",
  "permalink":
    "https://www.instagram.com/reel/abc/",
  "media": [{
    "type": "video_thumbnail",
    "url": "https://…"
  }],
  "metrics": {
    "likes": 1820, "comments": 96,
    "shares": 41, "saves": 88,
    "video_views": 21400,
    "impressions": 48200, "reach": 39800,
    "engagement": 2045
  },
  "emv": {
    "total": 812.40, "impressions": 482.00,
    "likes": 54.60, "comments": 172.80,
    "shares": 82.00, "saves": 21.00
  }
}
12

Tools 10 & 11 — MCP-service-side only

Tool 10

search_help_docs

EXTERNAL API EXISTS

Corpus: 234 published articles at docs.getroster.com; canonical URLs in each article's frontmatter (docs/brand-portal-docs/*.txt).

Archbee has a public search API — verified in the CLI source this workspace already ships (roster-vendor-tools/archbee/src/archbee_cli/client.py:185-197):

POST {base_url}/api/public-api/docs/search
Authorization: Bearer {ARCHBEE_API_KEY}
Body: { "query": "smart links" }
→ { "data": { "docs": [ … ] } }
MCP tool definition
{
  "name": "search_help_docs",
  "title": "Search Roster help docs",
  "description": "Keyword search over Roster's published
    help documentation (docs.getroster.com). Returns article
    titles, canonical URLs, and excerpts. Use for how-to and
    troubleshooting questions ('why isn't my discount code
    tracking?', 'how do I set up smart links?') — cite the
    URL so the user can read the full article.",
  "annotations": { "readOnlyHint": true,
                   "openWorldHint": false },
  "inputSchema": {
    "type": "object", "required": ["query"],
    "properties": {
      "query": { "type": "string" },
      "max_results": { "type": "integer",
        "default": 5, "maximum": 10 }
    }
  }
}
Response
{
  "results": [{
    "title": "Sales attribution report",
    "url": "https://docs.getroster.com/
            sales-attribution-report",
    "excerpt": "…rolls up key metrics such
                as link clicks…"
  }]
}
Tool 11

get_connection_info

GRANT STORE + OPTIONAL /v2/me

Primary source: the OAuth service's own grant record, captured at consent — no Roster API call needed per invocation. (Schemas verified in db-global/GlobalDB/dbo/Tables/ApiSession.sql, SystemBrand.sql, SystemUserDomain.sql, SystemUser.sql.)

FieldCaptured from (at consent)
brand.nameGlobal SystemBrand.Name (FK BrandUserId = the brand's SystemUser)
brand.domainGlobal SystemUserDomain.Domain where Active=1 AND [Default]=1
authorized_byGlobal SystemUser.{FirstName, LastName, Email} of the logging-in user
scopeConstant read-only (Phase 1)
granted_atGrant row create date

Optional live-check enrichment — new GET /v2/me. The only whoami today is legacy GET /me (LegacyAuthController.cs:10-27), which returns just { UserId } and is hidden from docs. A proper v2 endpoint returning { brandName, brandDomain, tokenCreatedAt, accessType } lets the tool double as the PRD's "cheap connectivity check" (a 401 here = revoked bridge token → surface re-auth). Low effort; recommended but not blocking — the tool can ship on grant-store data alone with a ping to any cheap endpoint (e.g. /v2/programs?pageSize=1).

MCP tool definition
{
  "name": "get_connection_info",
  "title": "Show connection info",
  "description": "Returns which Roster brand this Claude
    connection is bound to (name and portal domain), who
    authorized it, the access scope (read-only), and when
    it was granted. Use when the user asks 'which brand is
    this?' or to verify the connection is alive. Takes no
    parameters.",
  "annotations": { "readOnlyHint": true },
  "inputSchema": { "type": "object", "properties": {} }
}
Response
{
  "brand": {
    "name": "Acme Outdoor",
    "domain": "acme"
  },
  "authorized_by": {
    "name": "Jane Smith",
    "email": "jane@acme.com"
  },
  "scope": "read-only",
  "granted_at": "2026-06-09T17:00:00Z",
  "connection_healthy": true
}
13

Net-New v2 Endpoint Workstream (REST API scope)

All in repos/api-brand-portal, project src/App.WebApi.Open (+ App.OpenApi.Domain DTOs, App.DomainService/App.DomainRepository where noted, SPs in repos/db-brand-portal). Every endpoint: [ApiVersion("2")], standard auth filter, RateLimitAttribute, Swagger docs — follow the existing controller pattern (e.g. AttributionController.cs).

#EndpointWraps (existing, verified)New code surfaceSize
E1GET /v2/programs/{program_id}/performanceSaved dashboard config (UserPage/UserPageComponent, default-template fallback) + existing UserPageComponent_GetProgramMetric per componentController + service composition + DTOs — no new SQLS–M
E2campaignIds filter on GET /v2/campaignsUserCampaign_GetStats_V2Param plumb-through (request DTO → UserCampaignStatsRequest → SP may already accept it — check @CampaignIdsJson)XS–S
E3GET /v2/campaigns/{campaign_id}/performanceUserCampaignReport_GetByType + GetCampaignsStats + EMV join (UserCampaignPostUserSocialListeningPostEMV)Controller + service composition + 1 small query + DTOsM
E4GET /v2/contacts/searchAccountSearchService.AccountSearchV2 (Azure Cognitive Search)Controller + request/response DTOs; map fixed column setS–M
E5GET /v2/contacts/{contact_id}/performanceAccountService.GetAccount + AccountReward sums + UserSocialListeningPost_GetByAccount totalsController + service composition + DTOsM
E6GET /v2/reports/sales-attributionUserReportService.GetSalesAttributionReportUserReport_GetSalesAttributionController + DTOs only (service exists, fully parameterized)S
E7GET /v2/reports/social-postsUserSocialListeningPost_GetReportPageController + repo method + DTOs (service wrapper exists internally)S
E8GET /v2/social-postsUserSocialListeningPost_GetFeedPage (+ optional @AccountIdsJson param)Controller + DTOs + 1-predicate SP changeS–M
E9GET /v2/me (optional)ApiSession + Global SystemBrand/SystemUserDomainController onlyXS

Suggested commit slicing: one PR per endpoint; E6/E7 first (smallest, immediately unblock the two highest-value report tools), then E1, E4, E5, E3+E2, E8, E9.

Parity test per endpoint (required by PRD): for a seeded test brand and fixed date range, assert the endpoint's figures equal the portal's — implement as integration tests that call the internal endpoint and the new open endpoint with the same params and diff the numbers.

14

PRD Divergences — Decisions (all resolved 2026-06-10, Jeff)

#FindingDecision (Jeff, 2026-06-10)
1No brand-timezone field exists anywhere (§2.3)DECIDED Follow the platform pattern: UTC storage, client-side resolution. Claude (which knows the user's timezone) resolves relative ranges to explicit ISO dates before calling; tools default to UTC last-30-days when dates are omitted. Nothing stored at consent.
2Program Dashboard is a customizable widget canvas, saved per brand per program (§4)DECIDED Mirror the brand's saved dashboard config: the endpoint loads the program's UserPage/UserPageComponent rows (default-template fallback) and runs the existing dispatcher SP per card — output matches exactly what the brand sees in the portal. No new SQL.
3"Campaign Performance Dashboard" (/dashboards/campaign-performance) is a separate all-campaigns surface (§6)DECIDED Do not use or reference the Campaign Performance Dashboard at all. The tool is built from the Campaigns List (/campaigns) + the per-campaign overview (/campaigns/{id}/analytics/overview) only.
4Per-campaign attributed revenue does not exist in any portal surface or query (§6)CONFIRMED Not real. Dropped from get_campaign_performance Phase 1; tool description points revenue questions to get_sales_attribution_report.
5Social Posts report is per-ambassador, not per-post (§10); per-contact EMV sort isn't in the search index (§7)ACCEPTED get_social_posts_report = per-ambassador rollup (per-post need is covered by get_social_feed_posts). Remove emv from the PRD's list_ambassadors sort list — the EMV-leaderboard use case is served by get_social_posts_report with sort: emv.