Field note 32/ Engineering

The API Wrapper Compiled. The Live API Disagreed.

What one MCP server across Google marketing APIs taught us about live contract tests, OAuth account routing, target selection, and write safety.

Fig. 01Engineering · Note 32

The MCP server compiled and started cleanly. Then its tools met the live APIs, which rejected retired fields and endpoints. Other query shapes still looked reasonable to the installed client library and failed remotely.

One helper call also cleared shared authentication state. A Business Profile request failed, and the same side effect could break the Local Services request that came after it. The bug was not in either tool's input. It was in the process-wide auth context both tools inherited, which changed our definition of done for a marketing API integration.

Types describe the client code you installed. They do not prove the remote API still behaves that way. Only a live contract test can do that.

One tool surface did not mean one contract

We were removing repeated dashboard work. The server put Google Analytics, Tag Manager, Search Console, Google Ads, Business Profile, Local Services Ads, PageSpeed, and Indexing behind one MCP tool surface.

An operator could discover an account, run a report, inspect a property, update a campaign, or change a listing without rebuilding auth and transport for every task. The model received structured tool definitions instead of instructions to click through several unrelated interfaces.

A unified interface hid several incompatible contracts. Some modules use generated Google clients. Some call REST endpoints directly. Ads and Local Services use a separate client with its own query language, resource names, enum behavior, and manager-account rules. Business Profile itself is split across account management, business information, performance, Q&A, and older v4 surfaces. The MCP server gave operators one entry point while each remote contract kept its own rules.

Compilation stops on our side of the boundary. The compiler can check a Zod schema, a TypeScript function, and whatever declarations shipped with an SDK. It cannot check whether a remote field is still selectable, whether Google added a policy-required property, or whether an enabled OAuth scope is enough to perform a particular write.

OAuth multi-account architecture became a routing problem

A single refresh token worked until the available resources stopped belonging to one Google identity.

The obvious workaround was manual switching: authenticate as one person for advertising calls, sign out, then authenticate as another person for analytics and business-profile calls. That is tolerable in a demo. In an operating tool, it makes every call depend on hidden human memory.

We replaced the flat token file with a versioned store keyed by identity. Each account keeps its own credentials. A default account handles unmatched calls, while a routing configuration maps tool modules to the identity that should own them. Before a handler runs, the server activates the routed OAuth client, updates the shared Google client context, and exposes the corresponding refresh token to the Ads path.

That architecture forced us to separate the caller identity from the target resource:

  1. Which Google identity is making the request? OAuth and module routing answer this.
  2. Which customer, property, container, or location is the request targeting? The tool input and resource-discovery flow answer this.

The correct identity can still target the wrong child account. The correct customer ID can still fail if the active identity cannot access it. Account discovery therefore belongs in every live verification pass rather than being buried in setup.

We added tools to list signed-in identities, choose a fallback, remove an identity, and discover Ads accounts available directly to the current OAuth user. Module routing reduced manual switching. It did not remove the need to pass and verify the actual target.

The current implementation reflects that difference, though not perfectly. High-impact Ads and Local Services writes generally require a customer ID. Business Profile writes require a location or full resource name. Several Ads reads still allow an environment default, and a small number of mutations retain that fallback. We treat those fallbacks as unfinished safety work.

The Google Ads API rejected locally valid assumptions

The first live failures looked like ordinary query bugs. Four reporting requests used fields or resource combinations the server would not accept.

One conversion query mixed metrics into a shape that required a date segment. An asset query selected a field that was not selectable on that resource. An audience report joined through the wrong criterion type. A change-history filter supplied a date where the API expected a full timestamp.

All four were valid strings. TypeScript had nothing useful to say about them. The Google Ads API did.

Campaign creation exposed a second class of disagreement. The remote service had begun requiring a political-advertising declaration on campaign creation. A previously accepted bidding option had been retired. The field name used by the client for a "maximize clicks" strategy still carried an older proto name, while the intuitive key was silently dropped and left the bidding strategy unset.

The wrapper compiled because the local code and installed package allowed the objects we constructed. The request failed because policy requirements, server validation, and the live proto contract had moved.

The code change was small and the contract change was not: add the required declaration, remove the retired setting, and use the field the live API actually accepts. A live creation test then proved that specific path. A successful compile never could.

Business Profile and Local Services failed for different reasons

The Google Business Profile API failure was an obsolete endpoint assumption. Performance reporting had moved away from the older insights endpoint to a dedicated Business Profile Performance API. The replacement uses a different base URL, method, metric model, and response shape.

The Local Services failures were field-level contract drift inside Google Ads queries. Contact details could be selected as one message object, but not as individually selected subfields. A charged-lead field had a different name. An enum filter included a value that did not exist. A conversation field had moved under a different message object. A harmless-looking WHERE 1=1 was not harmless to that resource. An insurance value was flat where the earlier assumption expected a nested money object.

These were not one bug. They were several remote rules producing the same local symptom: the tool returned an API error.

The E2E pass mattered because it exercised the real endpoint with real authorization and a valid target. Mocking the SDK would have repeated our own assumptions back to us. A generated type could tell us what the installed package knew. Neither could tell us which query fields the service would accept that day.

Authentication and discovery had side effects too

OAuth was not a setup task we could complete once and forget.

We added authentication tools inside the MCP server, including a local callback and a manual code path for blocked redirects. Startup was changed so missing tokens did not prevent the authentication tool itself from loading. New logins were added to the identity store instead of replacing the previous user.

Then runtime behavior introduced another failure. The HTTP stack selected an older fetch implementation under a newer Node runtime. Gzipped OAuth responses could close prematurely, which broke token exchange and other compressed API responses. The eventual patch forced the Google HTTP client onto Node's native fetch implementation before auth or API modules initialized.

One Business Profile helper reset global Google options while preparing a call. That erased the active auth client. Business Profile requests failed, and later Local Services calls could fail because they read the same global state.

That is the causal chain we now test: authenticate, discover, route, call, then call a different module. A single successful request does not prove auth isolation. It may only prove that the previous request left the process in a favorable state.

Read tools and write tools need different safety rules

A read sent to the wrong account wastes time and may expose data to the wrong workflow. A write sent to the wrong account can change a budget, publish a tag container, edit a business listing, answer a review, or message a lead.

For reads, the operator needs discoverability, explicit scoping, pagination, and a clear error when the identity lacks access. Defaults can be useful when the result is inspectable and reversible.

For writes, the target should be explicit at the point of mutation. New campaigns should begin paused unless activation is deliberate. The tool should show what it changed and return the remote result. Service enablement also has to be separated from write approval: a read endpoint can work while the corresponding write remains gated by an additional provider allowlist.

Some of that is built. Campaign creation defaults to paused. Many mutation tools require resource IDs. Read tools exist to discover those resources first. What is not built uniformly is a preview mode, confirmation token, idempotency key, or post-write readback for every mutation. We are not calling that solved.

What became non-negotiable

There is no checked-in live contract suite today, so our sequence for a new marketing API integration is still an operating procedure:

  1. Type-check the local implementation, but label that result correctly: the client code compiles.
  2. Authenticate through the same path the operator will use, including refresh behavior.
  3. Discover resources with the active identity instead of copying an identifier from an old document.
  4. Run a representative live read for each API surface, not one read for the whole server.
  5. Exercise a bounded write against a designated test resource, then read the result back.
  6. Run a second module afterward to catch leaked auth or process-wide state.
  7. Turn each remote rejection into a contract case with the endpoint, API version, identity class, target class, request shape, and expected response recorded.

Step five is where the contract becomes real. A 200 response proves that a request was accepted. We count the integration as verified only after the live call uses the intended identity and target and a readback confirms the resulting state.

Read next

Related by topic