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)

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"}].

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. Pass `params={"max_rows": N}` to cap the crawl; omit for the whole due table. 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` is a singleton: starting it while one is 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.

Client & admin tools

seo_client_add(domain: str, name: str | None = None, ga4_property_id: str | None = None)

Register a client by domain and start Search Console verification.

Stores the client (domain, name, GA4 property id), then requests a domain verification token. Because DNS verification can't complete until the record is live, this returns the exact DNS TXT record to add; once deployed, call seo_client_verify. Returns JSON {client, verification}.

ParameterTypeDescription
domainstrthe client's domain, e.g. "example.com" or "sc-domain:example.com".
namestr | Nonedisplay name for the client (optional).
ga4_property_idstr | Nonenumeric GA4 property id for this client (optional but needed for GA4 data).

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 registered client.

Call after the DNS TXT record from seo_client_add has propagated. On success the client is marked verified. Returns JSON {verified, detail}.

ParameterTypeDescription
domainstrthe client's domain (same value used with seo_client_add).

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.