There is a particular kind of work that never lives in one place. You run ads in one screen. You collect form responses in another. To know whether a cheap click actually became a registration, you copy a number out of the ad dashboard, open the form admin, and reconcile it by hand. The two systems do not know about each other, so a human becomes the join layer.
For eight days I tested whether that join could move out of my head and into a single conversation. The setup: two MCP servers connected to the same AI client. One is an ad-side connector that returns the running campaign's metrics. The other is FORMLOVA's connector, which returns form outcomes. MCP — the Model Context Protocol — is the shared doorway through which an AI client connects safely to an outside service. With both doorways open in one chat, the LLM can pull from one side, pull from the other, and fuse the results in the same turn.
This article is about the design pattern that makes that fusion clean, and about one honest finding that fell out of it.
The shift: who performs the join
When you wire two SaaS products together the traditional way, one of them usually has to know about the other. You build a connector inside product A that calls product B's API, maps the fields, and stores the joined result. That connector is a maintenance liability, and it bakes one vendor's data model into another's.
MCP lets you skip that. If both products expose MCP servers, an AI client can connect to both at once. The client becomes the orchestrator. It reads from the ad-side server, reads from the form-side server, and performs the join itself, in the conversation, at the moment the user asks.
That reframes what each server should return. A server no longer has to reach across the boundary and fetch the other service's data to be useful. It only has to return its own data, accurately, in a shape the client can line up against another source. The join is not the server's job. The join is the client's job.
FORMLOVA's documentation states this responsibility boundary explicitly. FORMLOVA's job is to provide accurate data and to make its tools' input/output shapes clear. The LLM's job is to orchestrate across multiple MCP servers and to confirm sensitive steps with the user. The connected service's job is to accept and store. No connector code lives inside FORMLOVA for each external service it might be paired with. The tool definition is the contract; if it is good, the LLM handles the rest.
A tool that returns one source, on purpose
The concrete expression of that boundary is the ad-attribution tool on the FORMLOVA side. Its job is narrow and deliberate: break form responses down by ad ID, UTM source, and campaign, and return views, response counts, and conversion rate per ad. That is all. It does not call the ad platform. It does not know the ad platform's spend, impressions, or click counts. It returns FORMLOVA's own data, keyed in a way that another source can be lined up against.
Conceptually the tool returns rows like this:
// FORMLOVA's ad-attribution tool: single-source, keyed by ad id.
// It returns the form-side truth and NOTHING from the ad platform.
type AdAttributionRow = {
adId: string; // join key against the ad-side MCP
utmSource: string;
campaign: string;
views: number; // form-side view count
responses: number; // form-side response count
cvr: number; // responses / views, form-side only
};
async function getFormAdAttribution(userId: string, formId: string): Promise<AdAttributionRow[]> {
// Ownership check first; the tool only ever sees this user's data.
const access = await checkFormAccess(formId, userId, { requiredRole: "viewer" });
if (!access.ok) return [];
// Aggregate FORMLOVA's own response data by ad id.
// There is no fetch() to the ad platform anywhere in here.
return aggregateResponsesByAdId(formId, userId);
}
The thing worth noticing is the absence. There is no HTTP call to the ad platform inside this handler. The tool is intentionally incomplete on its own. It returns half of a picture and trusts the client to supply the other half from a different MCP server.
That feels wrong if your instinct is "a tool should answer the user's whole question." But the whole question here — did this ad produce outcomes, and at what cost? — spans two services. If FORMLOVA tried to answer it alone, FORMLOVA would have to embed an ad-platform connector, keep credentials for it, track its API changes, and re-implement that for every ad platform a user might run. Instead it returns the one source it owns with certainty, and lets the join happen where both sources are already in scope: the chat.
What the join looked like over eight days
Here is how the two halves came together in practice.
The first half came from the ad-side MCP. A single plain request in chat returned the standard metrics. Over eight days the campaign spent ¥6,597, with 5,578 impressions, a reach of 3,065, a frequency of 1.82, 704 clicks, a CTR of 12.62%, a CPM of ¥1,183, and a CPC the admin rounds to ¥9 — divide spend by clicks and the real figure is about ¥9.4. Spend was delivered steadily, roughly in the ¥570 to ¥1,080 range per day.
Taken alone, those numbers look good. A 12.62% CTR is high for a sign-up ad. A ~¥9.4 CPC is cheap. But the ad-side numbers cannot tell me what those clicks turned into. That is the whole point of the boundary: the ad MCP ends at "clicks came in."
The second half came from FORMLOVA's MCP, in the same chat. I switched connectors — same conversation, different doorway — and pulled the form's response breakdown by ad ID. Now both sources were on the table.
This is where the client-side join earns its keep. Divide the ad-side spend by the form-side conversion count, and acquisition cost falls out right there in chat. Neither server computed that number; the client did, because only the client had both sides. Looking at one MCP alone never reaches this calculation. The ad side ends at "clicks came in"; the form side ends at "responses arrived." Only fusing the two in one conversation connects them.
I could chart it too, without leaving the chat. Spend, impressions, clicks, CTR — switching between metrics, I plotted the eight-day daily trend on the spot. No dashboard, no re-applied filters, no second screen.
The mirage: where the cheap clicks actually went
The daily trend had a smell to it. Impressions ran high in the first half and roughly halved later in the window, yet CTR climbed in the second half. A rising price per impression with a rising click rate is a little unnatural — more competitive placements are not usually easier to click. So I split the delivery out by placement.
| Placement | Impressions | Clicks | Spend | CTR |
|---|---|---|---|---|
| Audience Network — rewarded video | 3,477 | 476 | ¥2,832 | 13.69% |
| Audience Network — classic | 1,870 | 223 | ¥2,641 | 11.93% |
| Facebook feed | 24 | 2 | ¥216 | — |
| Instagram feed | 139 | 1 | ¥589 | — |
The skew was stark. Those two Audience Network slots alone accounted for about 96% of impressions and about 83% of spend. The Facebook and Instagram feeds I had actually meant to reach received a rounding error of delivery — 24 and 139 impressions respectively.
That reframes the flattering top-line. The cheap, high-CTR delivery had, for the most part, flowed into Audience Network rewarded-video slots — the ones inside apps where you watch a video for a reward, where near-mistap clicks are common and CTR tends to read higher than reality. The efficiency numbers looked great because the destination was not what I had assumed. A click count alone never tells you whether the click meant anything.
I want to be precise about how I know this. The placement breakdown came from the ad-side MCP. The judgment about whether those clicks led anywhere came from lining the ad side up against the form side — the join the client performed. Either source alone would have left me guessing. Together they let me say, with data rather than a hunch, that the delivery was mostly a mirage.
Rebuilding the ad in the same chat
Because the problem was visible, I fixed it in the same flow, through the ad-side MCP, without opening the ad manager.
I rebuilt the ad set: placement limited to the Facebook and Instagram feeds only, Audience Network excluded, automatic audience expansion off, target Japan age 25 and up, reusing the same video creative I had been running. From rebuild to activation, every step was a conversational instruction.
Two properties of this matter for an MCP audience. First, it is not just reading — the same conversation that surfaced the problem also acted on it, which is the difference between an analytics surface and an operations surface. Second, each operation is reversible. Re-enabling and pausing ad sets restores the prior state, so the cost of acting on an in-chat diagnosis is low.
I will not pretend this is the final word on the campaign. Work remains: firming up the measurement parameters that tie ads to forms, verifying that the conversion event is received on the ad side, and the larger question of whether this channel suits this product at all. But the loop — read both sides, fuse them, find the problem, act, all in one chat — closed.
Why this is a pattern, not a one-off
Strip away the specifics and a reusable design pattern is left.
When you expose your product over MCP, resist the urge to make every tool answer cross-service questions by itself. A tool that fetches another service's data to "complete the answer" is a connector in disguise: it carries credentials it should not, couples you to an API you do not control, and multiplies as the number of pairable services grows. It also makes the LLM's job harder, because now two services' data models collide inside one opaque response.
The alternative is to return your own source, cleanly keyed, and let the client join. Design the output so another source can be lined up against it — stable join keys (here, the ad ID), explicit field names, no hidden aggregation across services. Then the LLM, which is already connected to both MCP servers, becomes the join layer for free. It computes the cross-service metric — acquisition cost, in this case, as spend divided by conversion count — in the conversation, at the moment it is asked.
The responsibility split is the whole design: your server returns accurate single-source data; the client orchestrates and joins; the paired service stores. Each part is simple because none of them is trying to be all three. And the payoff is concrete — over eight days I reconciled ad metrics against form outcomes, charted the trend, diagnosed a placement mirage, and rebuilt the ad, without opening a single dashboard. The join lived in the chat because each server was content to return only what it owned.
Links
- FORMLOVA field report (internal canonical): https://formlova.com/en/blog/meta-ads-mcp-formlova-verification-en
- Parent hub — the FORMLOVA MCP form service guide: https://formlova.com/en/blog/mcp-form-service-guide-en
FORMLOVA is a chat-first form service whose entry point is an MCP server, available from MCP-capable AI clients such as Claude, ChatGPT, Gemini, Cursor, and Windsurf.
Top comments (0)