seo-netmedia — MCP server

seo-netmedia exposes a Google SEO engine (Search Console + GA4) as MCP tools that any MCP-aware AI — Claude, Notion AI, and others — can discover and call. The AI asks for numbers; the tools return the data (with month-over-month deltas and ready-to-paste Markdown tables) and the AI writes the narrative report.

AI client  (Claude / Notion AI / ...)
   │   tool call   (Authorization: Bearer <MCP_API_KEY>)
   ▼
[ ngrok / Cloudflare tunnel ]      ← only because we host locally
   ▼
mcp_server  →  core/  →  Google Search Console + GA4

Connect an AI client

Replace <your-host> with your tunnel host and <MCP_API_KEY> with the MCP_API_KEY from your .env. The tool endpoint is always your host + /mcp.

Claude Desktop

Add the server as a custom connector.

  1. Open Settings → Connectors → Add custom connector.
  2. Name: seo-netmedia
  3. URL: https://<your-host>/mcp
  4. Under Advanced, add an HTTP header Authorization: Bearer <MCP_API_KEY>.
  5. Save, then start a chat — the SEO tools appear in the tools menu.

Claude Code

Register the HTTP server from the CLI.

  1. Run the command below (swap in your host + token).
  2. Verify with claude mcp list; the tools are then available in your sessions.
claude mcp add --transport http seo-netmedia \
  https://&lt;your-host&gt;/mcp \
  --header "Authorization: Bearer &lt;MCP_API_KEY&gt;"

Notion AI

Needs a Business/Enterprise workspace + admin.

  1. Admin: Settings → Notion AI → AI connectors → Enable Custom MCP servers.
  2. Open a Custom Agent → Tools & Access → Add connection → Custom MCP server.
  3. URL: https://<your-host>/mcp
  4. Auth: API key / bearer token → value = your <MCP_API_KEY>.
  5. Save, then in chat ask it to generate the SEO report.

Generic MCP client

Anything that speaks MCP over HTTP.

  1. Endpoint: https://<your-host>/mcp
  2. Transport: streamable-http
  3. Auth header: Authorization: Bearer <MCP_API_KEY>
{
  "mcpServers": {
    "seo-netmedia": {
      "url": "https://&lt;your-host&gt;/mcp",
      "headers": { "Authorization": "Bearer &lt;MCP_API_KEY&gt;" }
    }
  }
}

Tool reference

Generated from the live server registry — always in sync with the deployed tools.

Report & data tools

gsc_clicks(clients: list, start_date: str | None = None, end_date: str | None = None)

Total Search Console clicks per client for a date range.

`clients` (required) is a list of GSC property strings, e.g. `["sc-domain:example.com", "https://foo.com/"]`. Each is queried independently; a property that errors is reported with `clicks: null` and an `error` message rather than failing the whole call. Dates are ISO `YYYY-MM-DD`; omit them to default to the last 28 days. Returns JSON {range, clients, total_clicks}.

ParameterTypeDescription
clientslist
start_datestr | None
end_datestr | None

gsc_index_status(url: str, site: str | None = None)

Is a URL already indexed by Google? Returns Google's URL Inspection result.

Inspects one page within a verified Search Console property and returns Google's response **unmodified** (the raw `inspectionResult` envelope). Read `inspectionResult.indexStatusResult.verdict` (`PASS` = on Google) and `.coverageState` for the human detail (e.g. "Submitted and indexed" vs "URL is unknown to Google"). The service account must be a verified user of the property (else 403); a bad URL/site or auth error surfaces as an error, not a captured field.

ParameterTypeDescription
urlstrabsolute page URL to inspect, e.g. "https://example.com/".
sitestr | Nonethe GSC property the URL belongs to, e.g. "sc-domain:example.com" or "https://example.com/". Omit to default to the Domain property of the URL's host (`sc-domain:<host>`); pass it explicitly for a URL-prefix or subfolder property.

gsc_indexing(urls: list | str)

Notify Google to (re)crawl one or more URLs via the Indexing API.

`urls` (required) is a single absolute URL or a list of them; bare hosts like `example.com` are accepted and normalized to `https://example.com`. Each usable URL is submitted independently — one that errors gets `ok: false` plus an error message instead of failing the whole call. A blank or malformed entry never reaches the API and is reported under `skipped`. The service account must be an Owner of the property in Search Console (else a 403). Returns JSON {type, submitted, succeeded, retries, results, skipped} where `results` is one {url, ok, ...} per submitted URL and `skipped` is one {url, reason} per unusable input.

ParameterTypeDescription
urlslist | str

gsc_verify_index(urls: list | str)

Check whether one or more URLs are indexed by Google (URL Inspection API).

`urls` (required) is a single absolute URL or a list of them. Each is inspected and its verdict returned: `INDEXED`, `NOT_INDEXED`, or `FORBIDDEN` (the service account isn't a verified user of the URL's Search Console property). URLs are grouped by the `sc-domain:` property derived from their host; a URL whose host can't be mapped to a property is reported under `skipped` as `CONFIG_ERROR`. The Inspection API allows ~2000 calls/day per property, so the run **stops as soon as Google returns a quota/rate-limit (429)** — `stopped` is `"quota"` in that case, else `null`. Returns JSON {inspected, indexed, succeeded, forbidden, config_errors, stopped, rows, skipped} where `rows` is one {url, status, indexed} per inspected URL.

ParameterTypeDescription
urlslist | str

notion_table_read(db_id: str | None = None, max_rows: int | None = None, filter: dict | None = None, sorts: list | None = None, find: dict | None = None)

Read the rows of a Notion database (the table you see in Notion).

Each row is flattened to plain values keyed by column name, with the Notion page id and link added as `_id` and `_url`. Pagination is followed to the end unless `max_rows` caps it.

ParameterTypeDescription
db_idstr | NoneNotion database id to read. Omit to use the configured SEO database (NOTION_SEO_DB_ID).
max_rowsint | Noneoptional cap on the number of rows returned.
filterdict | Nonea Notion filter object so the server returns only matching rows instead of the whole table, e.g. {"property": "Status", "select": {"equals": "Requested"}}.
sortslist | Nonea Notion sorts array to order the rows server-side, e.g. [{"property": "Created", "direction": "descending"}].
finddict | Nonea {"column": "value"} shorthand compiled to a server-side Notion filter (contains for text, equals for select/status/number/date), so you don't hand-build one, e.g. {"Website": "homeschooltutoring"}.

runner_job_status(job_id: str)

Full status of one job: state, progress, result/error, and logs (JSON).

ParameterTypeDescription
job_idstr

runner_jobs(random_string: str | None = None)

List all jobs this process knows about (JSON, no per-job logs).

ParameterTypeDescription
random_stringstr | Noneunused placeholder so clients that can't call a zero-argument tool (e.g. Notion AI) have a property to send.

runner_start(task: str = 'sample_api_query', count: int = 5, delay: float = 1.0, params: dict | None = None, webhook_url: str | None = None)

Start a background job and return its record (JSON, incl. `id`).

`task` picks what to run: `sample_api_query` (recursive — recurses one level per "page", sleeping `delay` seconds to replicate an API round-trip) or `sample_job` (a flat loop) — both driven by `count` (pages/steps) — or `verify_and_index`, the real per-row crawl: for each due page it inspects the URL and, only if it's not already indexed, submits it to the Indexing API, writing each verdict back to Notion along with Google's reasoning from the same inspection (`Coverage State`, `Google Canonical`, `User Canonical`, `Last Crawl Time`), which costs no extra quota. Pass `params={"max_rows": N}` to cap the crawl; omit for the whole due table. Or `sitemap_crawl`: for each client with a sitemap, `Enable Sitemap Crawl` = Enable and `GSC Auth Access` = Access, expand its `Sitemap XML URL` (recursing a sitemap index into its child sitemaps) and copy each unique page URL into the target table as a new row, skipping URLs already there and writing each client's `Sitemap Crawl Status`/`Sitemap Last Crawl` back — pass `params={"max_urls": N, "db_id": "<notion-db>"}` (omit `max_urls` to copy every URL, omit `db_id` for the configured `NOTION_MANUAL_INDEX_DB_ID`). Or `fetching_insights_analytics`: for each client with `GSC Auth Access` = Access, query the Search Analytics API for its GSC property and write the distinct `Queries` count, total `Clicks` and `Impressions` back to that client's row, stamping `FIA LastUpdate` — pass `params={"max_clients": N, "start_date": "YYYY-MM-DD", "end_date": "YYYY-MM-DD", "db_id": "<notion-db>"}` (omit `max_clients` to process every accessible client, omit the dates for the trailing 28 days, omit `db_id` for the configured `NOTION_WORKSPACES_DB_ID`). Or `fetching_page_insights`, the per-page counterpart: for each due row of the per-page table it resolves the row's URL to a Search Console property, fetches that property's page metrics once for all its rows, and writes each page's own `Clicks`, `Impressions`, `CTR` and `Average Position` back to its row, stamping `FPI LastUpdate`. A row whose URL earned no impressions in the window is recorded as zero clicks/impressions; a row already refreshed today is skipped. Clients whose row doesn't claim Search Console access are skipped without spending a call to be refused, and a property Google answers 403 for has that recorded on its client row as `GSC Auth Access` = `Access Error`. Pass `params={"max_rows": N, "start_date": "YYYY-MM-DD", "end_date": "YYYY-MM-DD", "db_id": "<notion-db>", "verified_only": true}` (omit `max_rows` for every due row, omit the dates for the trailing 28 days, omit `db_id` for the configured `NOTION_MANUAL_INDEX_DB_ID`, and set `verified_only` false to sweep every registered client, which is how access granted since the last run is picked up). If `webhook_url` is set, the runner POSTs the final job state there when it finishes. Poll `runner_job_status` for live `stats`. `verify_and_index`, `sitemap_crawl`, `fetching_insights_analytics` and `fetching_page_insights` are singletons: starting one while it's already running returns an `already_running` error with the running job's id.

ParameterTypeDescription
taskstr
countint
delayfloat
paramsdict | None
webhook_urlstr | None

runner_status(random_string: str | None = None)

Is the runner busy? Returns JSON {state: IDLE|RUNNING, jobs, counts}.

ParameterTypeDescription
random_stringstr | Noneunused placeholder so clients that can't call a zero-argument tool (e.g. Notion AI) have a property to send.

runner_stop(job_id: str)

Request a stop (cooperative); the job moves to STOPPED at its next check.

ParameterTypeDescription
job_idstr

runner_stop_all(random_string: str | None = None)

Request a stop for every running/pending job (cooperative); each moves to STOPPED at its next check. Returns JSON {stopped, ids}.

ParameterTypeDescription
random_stringstr | Noneunused placeholder so clients that can't call a zero-argument tool (e.g. Notion AI) have a property to send.

sitemap_client_urls(client: str, max_urls: int | None = None, check_existing: bool = False, db_id: str | None = None)

Preview the page URLs in one client's sitemap — the dry run of the crawl.

Read-only: resolves the client's row in the Workspaces DB, expands its "Sitemap XML URL" (following a sitemap index into its child sitemaps) and returns the page URLs. Nothing is inspected in Google and no Notion row is created — use this to check a client's URL list before running the sitemap_crawl job, which processes every enabled client and writes rows.

ParameterTypeDescription
clientstrthe client/workspace name, matched as a case-insensitive substring of the clients table's title column, so a short handle is enough.
max_urlsint | Nonecap on the URLs returned; `truncated` reports whether the cap cut the list short.
check_existingboolalso read the target table and split the URLs into already-present vs new (slower — it reads the whole table).
db_idstr | Nonetarget table to check against when check_existing is true. Omit for the configured Manual Indexing table.

Client & admin tools

seo_client_list(random_string: str | None = None)

List all registered clients and their verification status (JSON).

ParameterTypeDescription
random_stringstr | Noneunused placeholder. Some MCP clients (e.g. Notion AI) can't invoke a tool with an empty input schema; this optional argument gives them a property to send. Leave it empty.

seo_client_verify(domain: str)

Finalize Search Console verification for a client.

Clients are authored by hand in the Notion Workspaces DB; this tool only proves ownership of one that already exists there. On success the client is marked verified. Returns JSON {verified, detail}.

ParameterTypeDescription
domainstrthe client's domain, e.g. "example.com" or "sc-domain:example.com".

seo_service_account_email(random_string: str | None = None)

Show the Google service-account email that needs access to client data.

Grant THIS email Search Console **Owner** and GA4 **Viewer** on each client's property, otherwise the data tools return nothing. Returns JSON {client_email, note}.

ParameterTypeDescription
random_stringstr | Noneunused placeholder so clients that can't call a zero-argument tool (e.g. Notion AI) have a property to send.