跳到正文
DEV Community· Jan-Willem Bobbink·· 2 小時前AI 評分53

構建 Google Merchant Center MCP 伺服器:Merchant API 未提及的實作細節

Building a Google Merchant Center MCP server: what the Merchant API doesn't tell you

AI 導讀

Jan-Willem Bobbink 為 GSC Wizard 接入 Google Merchant Center,新增十項購物工具,並分享建構 MCP 伺服器的實作經驗。

正文

I run GSC Wizard, an SEO tool with an MCP server that already exposed Search Console and GA4 to Claude and ChatGPT. This month I added Google Merchant Center: ten shopping tools that cover free listing performance, the products Google won't show, competitor visibility, price benchmarks, best sellers, title hygiene and a feed audit that crawls the shop's product pages.

This post is about the parts that took longer than they should have. Some of it is the Merchant API, some of it is designing tools for a language model rather than for a developer, and some of it is plain queueing. If you're building an MCP server on top of a Google API, most of it will transfer.

If you'd rather see it working first, here is an 8-prompt demo on an example shop with invented data: YouTube

The shape of the system

There are two deploy units that matter here. The web app (Next.js, Supabase, a ClickHouse warehouse) connects to Merchant Center, imports the reports into the warehouse and draws the dashboards. The MCP server is a separate Node service at https://mcp.gscwizard.com/mcp, with OAuth 2.1 for Claude and ChatGPT connectors and API keys for Claude Code and other clients.

Both need the same Merchant API client, and for a while they each had one. They drifted the way two copies always drift: different conversion-metric selection, a different ORDER BY, different rate-limit detection, and one copy missing a fallback the other had. The MCP server can't import from the app (its tsconfig has rootDir: ./src and its Docker build only copies its own folder), so the fix was to make the app's client the single source of truth and vendor it:

// MCP/scripts/sync-gmc.mjs (abridged)
// Owns an EXPLICIT file list. context.ts and token.ts are MCP-specific
// (error mapping, token fan-out) and stay hand-written next to the copies.
//   node scripts/sync-gmc.mjs          # write the vendored copies
//   node scripts/sync-gmc.mjs --check  # exit 1 if they are stale

The vendored files are committed, and a unit test runs the --check mode, so a change to the app's client that isn't synced fails CI instead of quietly shipping two behaviours. The client has to stay pure for this to work: it imports only its own siblings, never the app's utilities.

Trap 1: there is no read-only scope

The Merchant API has one OAuth scope, https://www.googleapis.com/auth/content, and Google's consent screen describes it as "Manage your product listings and accounts for Google Shopping". There is no read-only variant. So anyone connecting a reporting tool grants write access, and the honest thing to do is to make the tools not use it.

Nine of the ten shopping tools only read. The tenth, run_feed_audit, queues a crawl inside my own app and never writes to Merchant Center. Clients and directory reviews care about that, and they read it from the tool annotations, so the server derives the hints from each tool's declared scope and emits every one of them explicitly:

export function resolveAnnotations(tool: ToolDefinition): ToolAnnotations {
  const a = tool.annotations ?? {}
  return {
    title: a.title ?? tool.title,
    readOnlyHint: a.readOnlyHint ?? tool.scope === 'read',
    destructiveHint: a.destructiveHint ?? false,
    openWorldHint: a.openWorldHint ?? false,
    ...(a.idempotentHint !== undefined ? { idempotentHint: a.idempotentHint } : {}),
  }
}

Two details in there came from reviews, not from the spec. OpenAI's Apps review wants every hint present rather than left to protocol defaults, and Anthropic's connector directory reads the tool's name from annotations.title and flags tools that only carry the top-level title. Publishing both costs nothing.

Trap 2: every merchant has to register your Cloud project

The old Content API for Shopping answered any authorised call. The Merchant API doesn't: it only talks to Google Cloud projects that the Merchant Center account has registered as a developer. Until that happens, even listing accounts fails with an error telling you to register the project and "try calling the API again in 5 minutes".

The catch is that the registration is per Merchant Center account. Every merchant who connects has to register your project, and only an admin of their account can do it. Google's own quickstart is clear that it's mandatory ("To use Merchant API, you must register your developer contact information"), but none of the integration write-ups I read before building this mentioned it, so the first real shop I connected hit it.

The fix was to stop surfacing the developer error and do the registration from the app. When the account listing fails with that message, the API returns registrationRequired, the UI shows a card, and the card calls:

POST https://merchantapi.googleapis.com/accounts/v1/accounts/{MERCHANT_ID}/developerRegistration:registerGcp
{ "developerEmail": "..." }

with a token that has the content scope and belongs to an admin of that account. The app tries each Merchant-scoped login the user has connected, because only an admin may register, and the card has a "Check again" button for Google's few-minute delay. On the MCP side, list_gmc_accounts reports registrationRequired with a pointer to the app, so the model can explain the situation instead of retrying.

Trap 3: v1 renamed fields that v1beta code still reads

The Merchant API's v1 renamed Product.attributes to Product.productAttributes, and the single gtin to a repeated gtins. Both are listed as breaking changes in Google's v1beta-to-v1 migration guide, and both are easy to miss if your mapper has a forgiving fallback, because nothing errors: the fields just come back empty. Mine read the old path, and the product link is the only place the API exposes a product's landing page URL, which is the join key for everything that compares a product with its page.

The mapper now reads both, newest first:

function mapProductLinkRow(product: RawProduct): GmcProductLinkRow {
  // v1 `productAttributes` first, v1beta `attributes` second, flat fields last.
  const v1 = product.productAttributes ?? {}
  const legacy = product.attributes ?? {}
  const pick = <K extends keyof RawProductAttributes>(key: K): unknown =>
    v1[key] ?? legacy[key] ?? product[key]

  // v1 `gtins` (repeated) wins; a v1beta single `gtin` becomes a one-entry list.
  const gtins = toStrArray(pick('gtins'))
  // ...
}

The general lesson: when a mapper falls back silently, test it against a real v1 payload, not against the shape you remember.

Trap 4: some accounts and reports simply aren't available

Two kinds of "no" come back from the Merchant API that look like bugs if you don't expect them.

Advanced (multi-client) accounts are parents. You can list them, but you can't run reports against them; you have to pick one of their sub-accounts. The account picker renders advanced accounts as non-selectable headings with their sub-accounts underneath, because letting someone choose a parent would only surface as an API error minutes later.

Then there's eligibility. Google only publishes competitive visibility rows for accounts and categories above traffic thresholds it doesn't document. Price benchmarks need products with valid GTINs, and price suggestions need purchase conversions reported. An account that misses a threshold gets an empty result or a refusal, not an explanation. The tools turn that into a sentence the model can repeat ("Google does not make this report available for this account; it depends on eligibility; other reports are unaffected") instead of returning an empty table that the model will happily summarise as "you have no competitors".

Designing tools for a model, not for a developer

The biggest design decision was not to expose the API. Google now has its own Merchant API MCP server (more on that below), and its report tool takes Merchant Center Query Language: the model writes a query against the reporting tables and gets rows back. That's the right design for a developer's coding agent. For a shop owner asking "which products should I fix first?" it means the model has to know the schema, write a correct query, page through raw rows and do the arithmetic, and any of those steps can go wrong silently.

So each tool does one job and returns an answer-shaped payload. Four patterns did most of the work.

Summary first, rows later. A shop can have thousands of problem products. get_product_listing_status pages with offset / nextOffset at up to 200 rows per call, but the first call already carries the counts for the whole catalog:

{
  "problemCount": 1284,
  "merchantActionCount": 1027,
  "issueCounts": [{ "code": "missing_gtin", "count": 318 }, "..."],
  "rows": ["... up to 200, highest click potential first ..."],
  "hasMore": true,
  "nextOffset": 200
}

"How many products need fixing, and what's the most common issue?" is answered from the first call. The model only pages when the user wants the actual list. The rows come ranked by Google's own click potential, and "needs your action" is split from "waiting on Google", because a pending review isn't something the merchant can fix.

Say "not set up", don't throw. If a site has no linked Merchant Center account, the tools return a structured notConfigured result with a reason and a hint instead of an error. Errors make models retry or apologise. A reason makes them tell the user what to do next.

Ship the caveat with the data. The most common wrong answer in this domain is adding two numbers that should never be added. Search Console's merchant listings and Merchant Center's free listings both report product clicks, but they count different surfaces. So the caveat travels in every response, worded for the model:

const SURFACE_NOTE =
  'Merchant Center free-listing numbers cover every free surface (Shopping tab, Search, Images, ' +
  'Lens, YouTube, Maps) and count a click only when it lands on the product page, while Search ' +
  'Console merchant listings counts enriched product results in web Search only. The two measure ' +
  'different surfaces and never reconcile to one figure; compare their trends, not their totals.'

A note in the tool description helps, but a note in the response is what the model reads at the moment it's about to do the sum.

Roll up before you return. Competitive visibility comes back from Google as daily rows per domain. The tool adds a per-domain roll-up (best rank, average relative visibility, page overlap, higher-position rate) next to the rows, so "who outshines me?" doesn't depend on the model averaging a few hundred rows correctly.

The trade-off is real: one tool per job means a question outside those jobs has no tool to go to. For this audience, I'll take it.

The feed audit: crawling other people's HTML safely

The feed audit runs 21 checks on every product, and some of them fetch the product page to compare it with the feed: is the feed's price on the page, does the page have complete Product structured data, does the page title match the feed title, are there photos on the page the feed never got.

The page HTML comes from the merchant's own site, so I treat it as hostile input. The scanners are linear on purpose: indexOf loops, no [^>]*>-style regexes, and no translating robots.txt * wildcards into .* patterns. One quadratic regex on a pathological page can stall the whole process, and a crawler that processes thousands of pages per run will eventually meet that page.

A few other choices that held up:

  • robots.txt before every request and every redirect hop, and the property's crawl-rate setting is honoured. The crawler identifies itself and has a public bot page.
  • A blocked crawl is skipped, not failed. If the first 5 pages are all refused, or more than 90% of the last 50 are (with at least 20 seen), the crawl stops and the run is scored on the feed checks alone. Firewalls aren't the merchant's feed quality problem.
  • Images are probed, not downloaded. To check that every image is at least 1024×1024, the probe does a ranged GET of 64 KB with a 10-second timeout and reads the dimensions from the header bytes, with a cap per run.
  • Price matching is tolerant where reality is messy. A difference under max(0.01, 0.5%) passes. A miss is critical when the page shows one price, but only a warning when it shows several or a range. And when the feed's sale price isn't on the page but the regular price is, and the feed gives no sale dates, it's a warning: the sale may simply not have started.
  • It doesn't render JavaScript. That's a known gap: a price that a script injects after load won't be seen, so the check only trusts what's in the HTML.

The score is deliberately boring: penalties of 10, 3 and 1 for critical, warning and info failures over the applicable (offer, check) pairs, a feed plane and a page plane blended 60/40, and the page plane only counted once at least half of the product pages (and at least 100) were fetched. Boring formulas are easy to explain to a model, and to a merchant.

The import queue served the wrong shops first

The app imports 180 days of Merchant Center history when a shop connects, about 390 work units per shop, into a Postgres-backed queue that workers claim with FOR UPDATE SKIP LOCKED. When I measured it in production, Google was never the bottleneck: the Merchant API allows 100,000 reports.search calls per merchant per day, and the busiest merchant was using under a hundred. The limits were all mine, and two of them were ordering bugs.

First, accounts were listed biggest-backlog-first, so the few shops with the largest backfills took the slots on every tick, while a newly connected shop that needed one day waited behind them. Second, a backfill enqueues all of a shop's units in one transaction, so they share one enqueued_at, and ORDER BY enqueued_at left their order to the query planner. In practice that meant oldest day first, so the last 28 days, which every report shows by default, landed last.

The fix I'm rolling out lists accounts least recently served first, and claims within an account like this:

ORDER BY
  date_trunc('minute', q.enqueued_at),  -- batches in enqueue order
  CASE q.kind                           -- the catalog first, then dated reports
    WHEN 'product_status' THEN 0
    WHEN 'product_links'  THEN 0
    WHEN 'price_snapshot' THEN 0
    WHEN 'performance'    THEN 1
    WHEN 'non_product'    THEN 1
    ELSE 2
  END,
  q.date DESC NULLS FIRST,              -- newest day first
  q.enqueued_at,
  q.id
FOR UPDATE OF q SKIP LOCKED
LIMIT 1

Truncating to the minute keeps batches in order while still letting a deferred unit (which gets a fresh enqueued_at) fall behind the batch it was deferred out of. If you have a backfill queue, check what your ORDER BY does when every row in a batch has the same timestamp. Mine did nothing useful.

What about Google's official MCP?

Since May 2026 Google offers the Merchant API MCP Access Service at merchantapi.googleapis.com/mcp. It's an experimental alpha with 13 tools, including writes that create and fetch data sources, and a report tool that takes raw Merchant Center Query Language. You set it up with a Google Cloud project, developer registration, an OAuth access token or service account, and an X-Goog-User-Project header, and Google's documented clients are Antigravity, the Claude CLI and cURL. Its calls share your Merchant API quota.

If you're a developer working against the Merchant API from a coding agent, that's probably what you want: it's official, it has no subscription, and raw queries can answer things no fixed tool anticipated. What I built is for the other audience, people who want answers about their shop in Claude or ChatGPT without a Cloud project, plus the parts the Merchant API doesn't have (the page-crawling feed audit, Search Console's merchant listings). You can also run both side by side; MCP clients load several servers happily.

Try it, or steal the ideas

The endpoint is https://mcp.gscwizard.com/mcp. It's a paid product with a 7-day free trial, so I want to be upfront about that, but most of this post applies to any MCP server on top of a Google API: vendor one client instead of keeping two, derive your annotations instead of hand-writing them, return reasons instead of errors, put the caveat in the response, and check your queue's ORDER BY.

I'm happy to answer questions about any of it in the comments, especially the Merchant API side.

來源:DEV Community · dev.to