Skip to content

Latest commit

 

History

History
112 lines (73 loc) · 13 KB

CLAUDE.md

File metadata and controls

112 lines (73 loc) · 13 KB

mcp-bing-ads

Fork of bingads-mcp with Pathfinder OAuth PKCE layer and manager-account support.

Architecture

  • server.js — Express OAuth proxy + stdio child-per-session manager (mcp-trello pattern). Handles PKCE auth, bearer token validation, and session lifecycle.
  • src/index.ts → dist/index.js — The MCP server itself (TypeScript, compiled in Docker build stage). Spawned as a child process per session.
  • Dockerfile — Multi-stage build: builder stage installs devDeps and runs tsc; runtime stage uses --omit=dev and copies dist/.

Session handling

Follows the correct MCP stdio-per-session pattern (no session resurrection):

  • No mcp-session-id + initialize → spawn new child, return new session ID
  • Known session ID → reuse child, reset TTL
  • Unknown session ID → 404 (client reinitializes cleanly per MCP spec)
  • 30-min idle TTL per session

Configuration

No config.json is used in production. Config is loaded entirely from env vars:

Var Required Notes
MCP_AUTH_TOKEN yes Bearer token for claude.ai connector auth
OAUTH_CLIENT_ID yes claude-pathfinder
OAUTH_CLIENT_SECRET yes PKCE client secret
BING_ADS_DEVELOPER_TOKEN yes From Microsoft Advertising
BING_ADS_CLIENT_ID yes Azure app Application (Client) ID
BING_ADS_REFRESH_TOKEN yes OAuth refresh token (rotates on use — see below)
BING_ADS_CUSTOMER_ID yes Manager account customer ID (Pathfinder: 159333588)
BING_ADS_ACCOUNT_ID no Default account ID — omit for manager-account mode
BING_ADS_TOKEN_PATH yes Path to this env file for token rotation write-back

Token rotation

The refresh token rotates on every use. BING_ADS_TOKEN_PATH points to the live env file so the new token is written back automatically. On the droplet this is /opt/pmin-mcpinfrastructure/env/bing-ads.env, mounted via a volume.

If the container restarts with a stale (already-consumed) refresh token, it will fail on the first API call. Update BING_ADS_REFRESH_TOKEN in the env file and restart.

Manager account

BING_ADS_CUSTOMER_ID=159333588 is the Pathfinder manager account. BING_ADS_ACCOUNT_ID is intentionally omitted — resolveClient() dynamically creates a ClientConfig for any account_id parameter passed to tools, using the manager customer ID as the parent.

Account discovery (bing_ads_list_accounts)

Added to solve prospect-account lookup: every other tool already accepts an explicit account_id and works for any account under the manager (client or not) via resolveClient()'s dynamic fallback — the missing piece was discovery, since account IDs were previously only documented in client repos' context/accounts.md.

Calls Microsoft's GetAccountsInfo Customer Management operation (CUSTOMER_MGMT_BASE — a distinct base URL from CAMPAIGN_MGMT_BASE/REPORTING_BASE). OnlyParentAccounts: false is deliberate — it includes linked accounts (accounts granted to Pathfinder without ownership transfer, e.g. sales prospects mid-discovery), not just accounts the manager customer owns directly.

Do not route this call through apiCall()/getHeaders(). Those assume a ClientConfig (customer_id + account_id) exists, which isn't true at discovery time — GetAccountsInfo only needs Authorization + DeveloperToken headers, with CustomerId as a body field, not a header. Use customerApiCall() instead.

Response shape: the array key is AccountsInfo (plural), not AccountInfo. Microsoft's REST response is {"AccountsInfo": [...]} — AccountInfo (singular) is only the item type name in Microsoft's schema docs, never a JSON key in the response body. The original implementation, its unit tests, and the design spec all independently assumed the singular key; the tool passed every deploy check (container health, session-persistence smoke test, tools/list registration) but threw "Malformed GetAccountsInfo response: missing AccountInfo key" on the first live call. Fixed in commit c6c63b4 (2026-07-10) — see pmin-brain/context/tool-gotchas.md, "## Bing Ads MCP" section, for the full writeup. Verify any new Customer Management response shape against Microsoft's official REST reference (or a live call), not the SOAP type names.

Write gate is ON in production (turned on 1 Oct 2026 for tracking setup, same as Google Ads; one setting -- BING_ADS_MCP_WRITE -- switches it off). The switch is server-wide, so it also enables the three older write tools (bing_ads_pause_keywords, bing_ads_add_shared_negatives, bing_ads_update_campaign_budget) for every session, plus the three tracking writes below. The code default is still off (unset = read-only); production turns it on explicitly.

Write-tool reachability: mcp-bing-ads has six write-capable tools (bing_ads_pause_keywords, bing_ads_add_shared_negatives, bing_ads_update_campaign_budget, bing_ads_create_uet_tag, bing_ads_create_conversion_goal, bing_ads_update_conversion_goal), all gated behind BING_ADS_MCP_WRITE=true. That gate is independent of account discoverability — a newly-discoverable prospect/former-client account_id is no more writable than any other account, because the write gate blocks by tool, not by how the account_id was found.

No per-individual audit trail. Every Pathfinder staff session authenticates through the same static MCP_AUTH_TOKEN issued by the OAuth PKCE flow in server.js — there is no per-caller identity anywhere in this MCP, for this tool or any other. Structured request logging captures timestamp and operation name only — not tool arguments (filter/account_id or otherwise), and not caller identity. This was explicitly surfaced and signed off during design (see the design spec in pmin-apps/docs/nick/superpowers/specs/2026-07-09-bing-ads-prospect-account-lookup-design.hardened.md) rather than left implicit.

Conversion tracking (bing_ads_list_conversion_goals, bing_ads_conversion_performance)

Added because the three existing reporting tools only ever returned one aggregate conversion total per campaign/keyword/search-term, with no way to see which conversion goal (Purchase, Lead Form, Phone Call, etc.) drove it, and using Microsoft's deprecated Conversions column.

listConversionGoals() calls GetConversionGoalsByIds (Campaign Management, CAMPAIGN_MGMT_BASE — same base url as listCampaigns) through the existing apiCall()/getHeaders() path. getConversionPerformance() calls ConversionPerformanceReportRequest through the existing submitReport()/waitForReport() flow, identical in structure to getKeywordPerformance/getSearchTermReport — no new request-building path, no new polling logic for either tool.

GetConversionGoalsByIds's REST body diverges from its own SOAP docs — verified via live smoke test, not discoverable from Microsoft's documentation alone. Three corrections, none of which any unit test can catch since they're all wire-format issues, not response-mapping issues:

  1. ConversionGoalIds must be omitted from the body entirely, not sent as [] or null. The SOAP docs say null/empty means "all goals"; the REST binder treats [] as null and 400s with NullArrayArgument.
  2. ConversionGoalTypes must be a comma-delimited string ("AppDownload,AppInstall,..."), not the space-delimited string the SOAP docs literally show, not a JSON array, and not null. A space-delimited string or an array both 400 with a generic NullRequest/"the request message is null" that gives no hint the actual problem is the delimiter.
  3. InStoreTransaction is dropped from the default "all types" list — this manager customer isn't enrolled in Microsoft's In-Store Transaction pilot, so requesting it 400s with InStoreTransactionPilotNotEnabledForCustomer for every account under this manager. If Pathfinder ever onboards a customer that IS pilot-enrolled, this becomes a per-customer decision, not a global one.

If you touch this method, re-run scripts/smoke-test-conversion-tracking.js against a real account before considering it done — the failure modes above only surface against Microsoft's live API, never in npm test.

by_goal changes row grouping, not just content. Including Goal/GoalId/GoalType as columns makes Microsoft's Reporting API return one row per campaign per goal instead of one aggregate row per campaign. Do not default this to true — it would silently change the shape every caller of the base (non-goal) form already relies on.

No goal_ids filter parameter, deliberately. Microsoft's ConversionPerformanceReportRequest has no field to filter by goal ID anywhere in its Filter or Scope objects — it isn't achievable as a server-side filter. If per-goal isolation is needed, use by_goal: true and filter the response client-side.

Breaking change, not versioned/fallback-gated. The three existing reporting tools (bing_ads_get_campaign_performance, bing_ads_keyword_performance, bing_ads_search_term_report) requested Microsoft's deprecated Conversions column; they now request ConversionsQualified instead. The JSON key itself changes, not just the values — parseCsv() uses the requested column name verbatim as the response's JSON key (confirmed via live smoke test against production, 2026-07-13), so callers see a ConversionsQualified field where they previously saw Conversions, and the values are not directly comparable to pre-change values either. This ships as a single breaking change with no parallel use_qualified_conversions-style parameter — if a real caller needs a migration path later, that's a separate decision made when the need is real, not built speculatively.

Tracking setup (bing_ads_list_uet_tags, bing_ads_create_uet_tag, bing_ads_create_conversion_goal, bing_ads_update_conversion_goal)

Added 2026-10-02 (Trello "Dev | MCP Updates", tracking-only scope approved by Nick). Pure builders/mappers live in src/uetTags.ts and src/conversionGoals.ts; the API calls are in BingAdsManager (createUetTag, createConversionGoal, updateConversionGoal). Every write returns the record read back from Microsoft, not an echo of the request.

Wire format, all verified live against Pathfinder AI (139187173), not just the docs:

  • Write paths are POST /UetTags, POST /ConversionGoals (add) and PUT /ConversionGoals (update) -- not /Add. A wrong path 404s with an empty body. Reads are .../UetTags/QueryByIds and .../ConversionGoals/QueryByIds.
  • Goal Type in JSON is Url, Event, Duration, PagesViewedPerVisit -- NOT UrlGoal/EventGoal (that 400s NullRequest "Unsupported Type value"). Url fields are flat (UrlExpression, UrlOperator), no nested object.
  • GoalCategory is required on add and is only returned by the list call if you ask: ReturnAdditionalFields: "GoalCategory". Without it category is silently null.
  • Event ValueOperator only accepts Equals, GreaterThan, LessThan (not ...EqualTo); a bad enum 400s with NullRequest and the bad field in Details.
  • Status: "Deleted" is refused (InvalidConversionGoalStatus), despite the docs listing it. Pausing is the only way to retire a goal; paused goals stay in the list and keep their name (names are unique per customer).
  • An Event-goal update must re-send the match rule: Microsoft deletes any expression/operator pair an update leaves out. buildUpdateGoalBody merges the goal's current rule from a fresh list call. Revenue is only replaced when the caller passes revenue_* (CurrencyCode: "AUD" works).
  • Scope defaults to Account here (Microsoft's own default is Customer, shared across every account) and cannot be changed after creation. Goal creation or editing can also switch on MSCLKID auto-tagging for the whole account (all its existing campaigns; every account under the customer for a Customer-scope goal) -- the create and update tools' descriptions say so; flag it before using the tool on a client account.
  • UET tags can never be deleted. Create one only when a site has none; bing_ads_create_uet_tag and bing_ads_create_conversion_goal run once with no automatic retry (a timed-out create may have succeeded, and a retry would duplicate).
  • Microsoft's wrong-ID errors are friendly (InvalidUetTagId); createConversionGoal also checks tag_id against bing_ads_list_uet_tags first.

Azure app

  • App name: Bing Ads MCP (Azure Portal → App registrations)
  • Application (Client) ID: 4e6682ed-5c7a-4ad4-9d41-999e4bd469bd
  • Tenant: common (multi-tenant + personal accounts)
  • Platform: Mobile and desktop applications (required for device code flow)
  • "Allow public client flows": enabled
  • Credentials stored in 1Password: Claude_Remote_MCP - Bing Ads (Claude Code vault)

Healthcheck

Uses wget (not curl) — node:22-alpine doesn't include curl.

Deployment

Deployed to mcp-server DO droplet at https://bing-ads.mcp.pathfindermarketing.com.au/mcp. See PM-Labs/pmin-mcpinfrastructure for docker-compose.yml, Caddyfile, and env-templates/bing-ads.env.example.