ADS Mediapilote, the agency’s paid media platform, syncs our clients’ Google Ads, Meta and TikTok campaigns into a shared MySQL database. It already has a built-in assistant with ten tools that answers with charts. In July 2026 I added an MCP (Model Context Protocol) server so that the assistants the team already uses, Claude Code, Claude Desktop or claude.ai, can read and edit those campaigns straight from a conversation. This is how it is put together, where I put the guardrails, and what broke once people started using it.
A thin adapter over existing clients
The server lives in a single Next.js route, exposed over Streamable HTTP with no session: every request carries its own token, is authenticated on its own, and nothing is kept in memory between calls. I use mcp-handler on top of the official SDK, with a 300-second ceiling per call, because creating a Google campaign chains several mutations.
The route holds no business logic. It declares the tools’ zod schemas, reads the token’s scope, then calls functions in src/lib/mcp/tools/, split into five modules: reads, metrics, writes, live reads and audit. The split started because the first file grew too large, but its real benefit is practical: everything can be tested without the MCP SDK, as plain function calls.
Most of the work already existed. The Google Ads, Meta and TikTok clients implement a single interface, AdPlatformClient, which the sync and the web interface had been using for months. The MCP server simply plugged a new entry point into those clients and into the mutation helpers shared with the REST routes. That is why a server the assistants could genuinely use shipped in a day.
27 tools in four families
The server exposes 27 tools. I grouped them around two questions: does the tool read the database or call the ad network, and can it change anything?
| Family | Examples | Source | Scope |
|---|---|---|---|
| Read | list_campaigns, get_metrics_daily, get_geo_metrics | synced database | read |
| Live read | get_campaign_live, get_metrics_live, google_ads_query | ad network API | read |
| Write | set_campaign_status, update_campaign_budget, create_ad | database, then network | write |
| Raw access | platform_api_request | ad network API | write |
The database versus live split matters more than it looks. Left to itself, an assistant happily reaches for the freshest tool on every question, and ad network quotas are finite. Database reads are fast and cost no API call. Live tools are described as reserved for cases where freshness wins: right after a write, or to find a campaign that has not been synced yet. Those descriptions are the only lever I have on the assistant’s choice, so I wrote them like API documentation, naming the right alternative each time (“for a day-by-day breakdown, use get_metrics_daily instead”).
google_ads_query runs a free-form GAQL query, for the reporting the structured tools do not cover: search terms, quality score, keywords. platform_api_request goes further. It is an escape hatch to the network’s raw API, for audiences, extensions or rules that no structured tool handles.
Reads, writes and where the guardrails go
The first barrier is scope. A token carries read, or write, which includes reading. Every write tool starts with the same check, and every write call, successful or not, is logged:
export async function setCampaignStatusTool(scope, params, ctx) {
assertWriteScope(scope); // throws "forbidden" for a read-only token
return withAudit("set_campaign_status", ctx, params, async () => {
const result = await setCampaignStatus(params.campaignId, params.status);
if (!result.ok) throw new McpToolError("Campaign not found", "not_found");
return {
campaignId: params.campaignId,
status: params.status,
platformUpdateSucceeded: result.platform.success,
platformError: result.platform.error ?? null,
};
});
}withAudit records the caller, the tool, its arguments and the outcome. The log write is awaited before responding, but its failure never fails the tool. Reads are not logged at all: a deliberate decision, noted in a comment, to keep the table from filling up with thousands of lookups.
The second barrier is the order of operations, which depends on the kind of write. An update (status, budget, name, targeting) writes to the database first, then pushes to the network on a best-effort basis. If the network refuses, the tool does not throw: it returns platformUpdateSucceeded: false with the original message, and it is up to the assistant to say so. A creation does the opposite: network first, because the ID it returns is needed, and a complete failure if that call fails. No local row can exist without a valid network ID.
The third barrier is input. platform_api_request and google_ads_query only accept a relative path, always resolved against the client’s hard-coded base URL: an absolute URL, a //host or a .. is rejected before any network call. Without that, a poorly steered assistant could send a request elsewhere carrying the network’s OAuth credentials. google_ads_query also rejects any query that does not start with SELECT. The GAQL search endpoint cannot mutate anyway, but I would rather validate than trust. Finally, upload_asset only takes the ID of a file already in the media library, never a public URL.
That leaves confirmation. I did not build a server-side confirmation step: no dry-run mode, no approval token to send back. Confirmation relies on the MCP client, which asks the user before each tool call, and on scopes, which let read-only access go to people who have no business changing anything. It is a conscious choice for an in-house team, and it is also the first thing I would revisit, as explained below.
Authentication came in two stages. First, static API keys created by administrators, with only their hash stored. Ten days later, OAuth 2.1 with metadata discovery, dynamic client registration and PKCE, built on the existing Google sign-in: the MCP client now only needs the server URL, and consent happens in the browser. An agency client account cannot authorise an application, whichever mode is used.
Three APIs, one vocabulary
For an assistant, three networks speaking three dialects means three times as many ways to get it wrong. All normalisation therefore lives in the clients, not in the tools: a status is enabled, paused, removed or draft, a budget is daily or lifetime, and an amount is in currency units.
Underneath, each network has its own conventions. Google Ads expresses amounts in micros (one euro is 1,000,000), Meta in cents, TikTok in units. Google writes ENABLED, Meta ACTIVE, TikTok ENABLE. TikTok has no daily budget as such, only BUDGET_MODE_DAY and BUDGET_MODE_TOTAL. Meta objectives (OUTCOME_SALES, OUTCOME_LEADS…) map onto a shared vocabulary (conversions, leads). Each client holds its mapping tables in both directions:
const OUR_STATUS_TO_META: Record<CampaignStatus, string> = {
enabled: "ACTIVE",
paused: "PAUSED",
removed: "DELETED",
draft: "PAUSED", // Meta has no draft state: pause instead
};
function centsToMajor(cents: string | number | undefined): number {
if (cents == null) return 0;
return Number(cents) / 100;
}Not everything normalises. Network-specific detail stays available in a rawSettings field, and the creation tools accept a settings object passed through untouched. For Google, it is merged into the campaign resource and overrides the defaults. That valve saves adding a schema parameter for every campaign type.
What broke in use
The body that was never sent. The day after launch, platform_api_request worked for reads and never for writes. Its body parameter was declared as z.unknown(), which produces an empty JSON Schema, {}. The MCP client therefore never advertised it as a parameter to fill in, and the assistant never sent a request body. Not one :mutate call or Graph API write went through. The fix was one line: a typed z.record(), like the query parameter that already worked.
body: z
.union([z.record(z.string(), z.unknown()), z.string()])
.optional()
.describe("Request body as a JSON object. A JSON string is also accepted."),A second fix the same day added the union with z.string(). Some clients had cached the old schema and serialised the body as a JSON string; the tool now parses it back into an object. The lesson goes beyond this case: in MCP the schema is not just validation, it is the interface the model sees. An overly loose type does not make a parameter more flexible, it makes it invisible.
Demand Gen. In August, creating a Google Demand Gen campaign failed. The first version created the budget and then the campaign in two calls: when the second failed, an orphaned budget was left behind and the next attempt hit DUPLICATE_NAME. Budget and campaign now go out in a single googleAds:mutate, linked by a temporary ID (-1). The budget is created as non-shared, since Demand Gen rejects a shared one. A maximizeConversions bidding strategy applies by default, because the API requires one at creation, and the EU political advertising field, also mandatory, gets a default that settings can override. Geographic targeting and audiences for this campaign type are set at ad group level, so the server documentation points to platform_api_request for those.
What I’d keep / what I’d change
I would keep the thin-adapter design. The MCP server was quick to write because it only wired up existing clients, and the tests cover ordinary functions. I would also keep the split between database reads and live reads, and the platform_api_request escape hatch: without it, every uncovered need would have meant a new tool and a redeploy.
I would change three things. First, confirmation should not rest on the MCP client alone. The protocol’s tool annotations (read-only, destructive) are not filled in, and a dry-run parameter on budget and status mutations would let the assistant show the effect before applying it. Second, every status mapping table should be typed over the full union of possible values, so the compiler flags a missing case instead of falling back silently. Third, platform_api_request requires the write scope even for a GET, because I cannot guarantee that a raw path has no side effects. That is cautious, but it means granting write access to someone who only wants to read an audience. A list of known read-only paths would settle it.
The rest of the platform, from sync to client reports and production tracking, is covered on the ADS Mediapilote project page.

