Buttercup MCP Server
Domain: connects marketing/campaign tooling (Claude, internal agent orchestrators) to a multi-brand tool manufacturer's product catalog, dealer network, DAM, and campaign platform.
Transport
Streamable HTTP (the current MCP spec, not the deprecated HTTP+SSE variant), deployed behind the company's API gateway. Reasoning: this server is a shared, always-on resource hit by multiple clients (Claude Desktop for brand managers, an internal campaign-ops agent, a nightly reporting job) — not a local dev tool — so stdio was never in the running. I kept SSE as a fallback transport for one legacy internal client that hadn't upgraded its MCP SDK yet, but Streamable HTTP is primary because it supports plain request/response for simple tool calls and only upgrades to a stream when a tool (like a bulk asset export) needs to push incremental progress.
Auth
OAuth 2.1 with PKCE, using the MCP Authorization spec's pattern of treating the MCP server as a resource server, not an identity provider:
- Human sessions (brand manager using Claude Desktop) authenticate against the company's Azure AD tenant; Buttercup validates the JWT audience/issuer and maps AD groups to internal roles (
brand-manager:dewalt-equivalent,campaign-ops,analyst-readonly). - Machine sessions (the nightly agent) use client_credentials against the same AD tenant, issued a service principal scoped to
catalog:readandcampaign:readonly — deliberately no write scopes, so a misbehaving automated job can't launch a campaign. - Every tool declares required scopes in its manifest; the server does its own scope check on top of what the token carries, so a compromised or overly broad token still gets rejected at the tool boundary, not just the gateway.
- All tool calls are logged with token subject + scope for audit, since campaign spend and warranty data both have compliance requirements.
Tool design
I split tools by write-risk rather than by resource type, because that's what actually needs different guardrails:
-
search_product_catalog(brand_id, query, filters, cursor)— read-only, paginated, cheap to expose broadly. -
get_dealer_locations(zip, radius_miles, brand_id)— read-only, backed by the dealer locator service. -
get_campaign_performance(campaign_id, date_range)— read-only, pulls from the analytics warehouse, not the live ad platform (avoids hammering rate-limited downstream APIs). -
create_campaign_segment(name, criteria, brand_id, dry_run)— write, requirescampaign-opsscope, requires an idempotency key parameter so retries from a flaky agent don't create duplicate segments, and defaultsdry_run=trueunless explicitly set false — an agent has to opt into a real write. -
submit_creative_for_approval(asset_id, campaign_id, reviewer_group)— write, kicks off a human approval workflow rather than doing anything irreversible itself. -
lookup_warranty_status(serial_number)— read-only but scoped tightly (warranty:read) since serial numbers can be tied to customer PII in the CRM; this tool intentionally does not return the linked customer record, only status/coverage dates, to keep the blast radius small if a token leaks.
Resources
Static-ish reference material is exposed as MCP resources rather than tools, since it doesn't need parameters: brand style guides, current campaign briefs, and product spec-sheet PDFs, each with a stable buttercup://brand/{brand_id}/style-guide style URI. This lets Claude pull brand voice guidelines into context without a round-trip tool call every time.
A couple of specific decisions I'd defend
-
No single mega-tool. I could've built one
query_martech_data(type, params)tool, but that pushes all the validation into the LLM's judgment about what params are valid for what type — separate tools give me real JSON-schema validation per action and let scopes map cleanly to individual tools. -
dry_rundefault true on the segment-creation tool specifically because campaign segments feed into paid ad spend downstream — an agent hallucinating a bad audience definition should cost nothing until a human (or an explicitdry_run=false) confirms it.