Skip to content

Changelog ​

[Unreleased] ​

Fixed ​

  • A __proto__ key in an answer from Google is dropped, at any depth. It used to reach the text block but not structuredContent, because a client parses that against the output schema and zod assigns fields, which on that name sets a prototype instead. The two channels then disagreed about the same answer. The weekly property run found it with a random get_indexing_status body.

[0.3.0] - 2026-09-07 ​

Added ​

  • A demo GIF in the README and on the documentation home page, recorded from docs/demo.tape with no credentials: the tool list, the same list narrowed by the essential preset, and the startup abort a mistyped tool name produces.

Changed ​

  • The tool reference marks the essential preset and the tools that ask a person before they act, per tool rather than only in the introduction. A test keeps both sets in step with the code.

Added ​

  • The server introduces itself in full. title, description, websiteUrl and icons now travel with name and version, so a client that shows a server to a person has something to show. All four were already in server.json for the registry and reached no client at all; a test compares the two so they cannot drift.
  • Server instructions. Results carry an untrusted marker, but that is read after the fact — this is the channel a model sees before it calls anything.
  • An OpenSSF Scorecard run, weekly and on every push to main, reporting into the Security tab next to CodeQL and Trivy. The badge is the second in the row.

Security ​

  • The structured half of query_search_analytics is budgeted. The text block was held to the 100 kB result budget by halving the table; structuredContent.rows carried every row Google returned beside it — 25 000 rows with a page and a query dimension came to 47 MB in one result. The structured half now carries exactly the rows the table shows, measured as sent, with a truncated block naming the next start_row; rowCount is the number Google returned.
  • A boundary between Google's JSON and the promises made about it. Every tool declares an output schema and the SDK holds the answer to it, so a value of the wrong shape was a whole call answered with Output validation error or a crash: rows: {} (rows.slice is not a function), a key that was a number (value.replace is not a function), site: null on a verification resource (Cannot read properties of null out of list_verified_sites and setup_site), an empty 200 (Buffer.byteLength on undefined, from get_site, get_sitemap, get_indexing_status, request_indexing and update_site_owners), and inspectionResult: 0. Rows, keys, metrics, site blocks and empty answers are now shaped at the boundary — an entry of the wrong shape is dropped or written out as text, never the reason the listing fails — and a property test feeds every read tool arbitrary JSON and shaped envelopes with arbitrary leaves through the whole server.
  • get_indexing_status has an open output schema. It declared a closed object and answered with Google's record verbatim, so a field Google adds made a client that had listed the tools throw ProtocolError: … must NOT have additional properties. Open now, like every other pass-through record, with the three named fields held to their types at the boundary. The test harness lists the tools on every connection, which is what would have caught this.
  • The publish job installs with --ignore-scripts. It holds the OIDC token for npm Trusted Publishing, and every dependency's install hook ran while it did; nothing in the tree needs one. gh release create now carries --verify-tag.
  • The status is decided before the body is read. A 429 or a 5xx whose body was larger than the response ceiling was refused for its size — an error naming no status — so the retry keyed on the status never ran and the hint for it was never shown. An error body is now read under its own 64 kB ceiling and cut, never refused; success bodies are read under the ceiling for their endpoint (1 MB for a record, 8 MB for a URL inspection, 64 MB for a search analytics query), where every endpoint used to share the 64 MB one.
  • A domain property is a hostname. sc-domain: accepted anything without a slash, so sc-domain:foo bar, a line break followed by instructions, or user@host:port normalised cleanly and reached the sentence of a confirmation dialog — the one line a person acts on, and one that unverify_site builds from the identifier of a verification resource Google returned. The part after sc-domain: now has to be a hostname: no whitespace or control character, no credentials, port or path, labels of at most 63 characters.
  • Text is cleaned on the way out. Control characters reached structuredContent raw (the text block only escaped them because it is JSON) and lone surrogates went through as \ud800. Every upstream result now has C0/C1 controls and DEL stripped, lone surrogates repaired, and __proto__ keys kept as own properties, in both channels; table cells are cleaned before they are escaped, and every cut runs toWellFormed().
  • Text quoted from an error body is labelled, stripped and cut, as is a content-type header the answerer chose. The access token is checked before it becomes a header: the runtime's refusal of a header value quotes the whole value, and that refusal used to travel through the generic error path into the tool result.
  • Configuration diagnostics describe a rejected value instead of printing it. ELICITATION … got "<value>", and the normalizeSiteUrl messages reached from GSC_SITE_URL, GSC_ALLOWED_SITES and every site_url argument, echoed the value in full — a JWT, a JSON key or a client secret pasted into the line below the credential. A value is now quoted only when it is short and shaped like a setting or a property; a hundred-kilobyte site_url comes back as a sentence, not as itself. OAuth values and the key file path with a control character inside are refused at startup, naming the variable.
  • Caller strings have ceilings: 2 048 characters for a property, a URL and a resource id, 254 for an owner address and 100 owners, 35 for a language tag (letters, digits and hyphens), 4 096 for a filter expression and 50 filters, 64 for a confirmation token. The request path is cut in error messages.
  • A batch call has a wall-clock budget. submit_sitemaps at fifty entries, each retried up to three times with backoff, could hold one call for most of two hours during an outage. Both batch tools now stop after two minutes, checked before each request, and say how many entries were attempted.
  • The result shortener remembers where it cut, not what a value looks like. It skipped every string that ended in its own note, … (N more characters omitted), so a value the crawled site chose to end with those words was never shortened, the budget could not be met, and the tool answered an error for that one item; the dropped count of an array was read back out of its last element, which the backend could have written. Shortened slots are now remembered by identity for the duration of one pass. The pass also cuts several candidates per round, largest first, where one cut per round and a full serialisation after each made five megabytes of long strings cost eleven seconds on the thread that serves every request.
  • Credential redaction is linear and complete. The PEM pattern's lazy match made a message with repeated -----BEGIN markers quadratic, and a key cut off before its END line was not redacted at all. The pattern now accepts the end of the text as the end of a key, and reads at most 16 kB.
  • Dependency review on every pull request, pinned, failing on a high-severity advisory in the change.
  • The runtime image carries neither yarn, corepack nor the lockfile. Only npm had been removed.
  • SECURITY.md argues from the code that runs. It named a tupleResourceKey that no longer exists, said a sealed dialog answer presented twice was honoured twice, and reasoned from a protocol revision the server no longer speaks alone; mcp-approval has carried a nonce since 0.8.1 and serveStdio negotiates both eras.
  • mcp-approval 0.8.2. A sealed dialog answer is single-use since 0.8.1: the same requestState presented again within its lifetime used to be accepted again, and with a resource key that is the same every time — a whole stream, a fixed set of targets — every replay landed. npm users on ^0.8.0 already had the fix; the Docker image is built from the lockfile and carried 0.8.0 until this release.
  • Approval keys bound to positions. delete_sitemap binds its token to [property, feedpath] and update_site_owners to [property, ...owners]. This server never sorted those tuples — a local tupleResourceKey fingerprinted the targets in the order given, so a token issued for one property and feedpath did not confirm the pair with the roles swapped, which the sorted set key behind setResourceKey would have allowed since both are URLs from the same string space — but the decision lived in a hand-written function. The keys now come from orderedResourceKey in mcp-approval 0.8.2, which prefixes each part with its position before fingerprinting, and the local copy is gone. The owner list is still sorted by the caller, so the same owners in another order remain one confirmation.

[0.2.0] - 2026-09-03 ​

Added ​

  • Every tool declares an outputSchema and answers with structuredContent beside the text block. A client no longer has to parse prose to use a result — which six of them made unavoidable, since they answered with a sentence. The sentence stays, in the text block, and so does query_search_analytics' rendered table: the rows themselves are now in the structured half.

    Every tool that reports Google's data carries untrusted: true and source: "search-console" as fields, not only as a preamble in the text. A search query is a string a member of the public typed into Google and a page title comes from the crawled site, so a client that reads the structured half must not get either unframed. Six tools are without the marker: their answer is a property this server was given and a fact it established.

  • Tools that need a confirmation now ask the user, on clients that can show a prompt. The two-call confirm_token remains for clients that cannot, so nothing that works today stops working — but where a person can be asked, one is, instead of a token that only proves the same call was made twice.

  • ELICITATION switches the dialog off — false sends a client that could have been asked down the two-call-token path instead. For a scheduled job or a test harness, where a dialog is the wrong shape rather than an unwanted one.

    It does not remove the guard: there is no setting in which a guarded call goes unannounced. Two deliberate rough edges come with it. The variable is not prefixed, so one export ELICITATION=false reaches every MCP server in the environment — which is why a server started with it off prints a line saying so, and why the fallback text names the server instead of blaming a client that was working fine. And a value that is neither true nor falsestops the server: it is the only variable here that defaults to on, so failing open on a typo would leave the dialog running while the operator believed it was off. It is read after the credentials are wiped from the environment, so that exit cannot leave them behind.

  • A docs/guide/approval.md page.

Changed ​

  • The advertised schemas avoid spellings that are legal JSON Schema and still get a tool refused, or its constraint silently dropped, by some MCP clients: an open object now writes "additionalProperties": true rather than the empty schema {} zod emits for it; a value that was left untyped is declared as what it really is; and a nullable field is written as anyOf branches rather than "type": ["string", "null"], which several clients read as a single type and then drop. What the tools accept and return is unchanged; only the way the schema says so is.

  • A result too large to shorten is now an error rather than an envelope saying so. The envelope was a different shape from what the tool declares it returns, which the SDK refuses.

  • The two-call confirm_token prompt is an error result. What was asked for did not happen, which is what isError says. The text is unchanged and still carries the token.

  • stdio is served through serveStdio, so the connection's era is negotiated on the opening exchange rather than assumed. A client that pins the 2026-07-28 era is served it; until now its server/discover probe was answered with "Method not found" and only 2025-11-25 was on offer. A client that speaks the older era sees no change — it is still pinned to one instance for the life of the connection, exactly as a hand-wired StdioServerTransport served it.

  • GSC_READ_ONLY now also accepts 1 and yes, in any casing or padding — the spellings a compose file or a systemd unit is most likely to use. Under the previous exact match on true they left every write tool registered while the operator believed the server could not write. Deliberately more tolerant than ELICITATION, which is fatal on anything it does not recognise: this one is a protection, that one is a permission.

  • Runs on MCP SDK 2.0. Existing clients see the same protocol revision they always did; the change is the package layout behind it, and it is what lets the dialog above work on both protocol eras from one code path — including behind a stateless gateway, where the older mechanism silently fell back to the weaker token for every client.

  • The linter is oxlint instead of eslint plus typescript-eslint, which lifts the TypeScript ceiling: typescript-eslint pins typescript below 6.1, so this repository was held on TypeScript 6 by its linter rather than by its code.

  • The tool filter, the confirmation store and the documentation-asset generator now come from mcp-tool-allowlist, mcp-approval and svg-asset-set rather than from copies kept here — 791 fewer lines, and one place to fix each. None of them has a runtime dependency of its own.

Fixed ​

  • An empty GSC_SERVICE_ACCOUNT_KEY_FILE no longer falls back to application default credentials. A variable that is set but empty is now a startup error, like every other malformed credential here.

    It is not a spelling anybody has to type: GSC_SERVICE_ACCOUNT_KEY_FILE=${GSC_KEY_FILE} in a compose file with GSC_KEY_FILE unset substitutes the empty string rather than dropping the variable. An empty path reached google-auth-library as { keyFile: '' }, which is falsy there and therefore ignored — so the library searched for application default credentials and the server ran as whatever the machine is logged into, while its startup line said service-account. On a developer workstation that is usually an account that can see every property in the organisation. A complete OAuth2 credential set at the same time was discarded in favour of it.

    Two further changes come with it. A service account key and OAuth2 variables set together are now a startup error instead of the OAuth ones being ignored in silence — two named identities is the same ambiguity as two service account keys, and the answer is the same. And createTokenSource refuses a service-account credential with no key and no key file, so the fallback cannot be reached even if a future caller builds that shape another way.

  • budgetedJson no longer spins on strings it cannot shorten. The replacement for an over-long string is its first 200 characters plus a ~30-character marker, so a string of exactly 230 characters was replaced by a string of 230 characters and offered again on the next pass, forever. On a single-threaded runtime that stops the whole server, not just the call. Already-shortened strings are now excluded from later passes, and both shrinking loops have a round ceiling that ends in the same honest give-up as running out of things to cut.

  • query_search_analytics is held to the result budget it is the reason for. MAX_RESULT_BYTES is documented as existing because of this tool, and it was the one tool that never measured itself against it: the row cap bounds how many rows a table has, and nothing bounded how wide one is. page and query dimension values are arbitrary length, so a page-by-query breakdown on a property with facet navigation returned several times the budget in one result. Rows are now dropped whole until the table fits, naming what was dropped and the start_row that fetches it, and a single cell is capped at 200 characters.

[0.1.1] - 2026-08-30 ​

Fixed ​

  • A search query containing a backslash directly before a pipe no longer breaks the analytics table. escapeCell escaped the pipe but not the backslash, so shoes \| nike came out as shoes \\| nike — an escaped backslash followed by a live column separator, which is exactly the row split the escaping prevents.

[0.1.0] - 2026-08-29 ​

First release.

Added ​

  • MCP server for Google Search Console, covering three Google APIs in full: Search Console v1 (properties, sitemaps, search analytics, URL inspection), Site Verification v1 (proving and managing ownership) and the Indexing API v3.
  • 21 tools. Eighteen map one-to-one onto an API method; the other three exist because the APIs do not have them and the work needs them:
    • setup_site reports which of the four steps a property is missing and hands over the DNS record or meta tag to place. Nothing in either API enforces that order, and add_site on an unverified domain succeeds while producing a property that answers 403 for every piece of data.
    • submit_sitemaps and inspect_urls do one property's worth of work in a single call, reporting per-entry results.
  • Three ways to authenticate, tried in a fixed order: a service account key (raw JSON or base64), an OAuth2 refresh token, or application default credentials. A partial OAuth2 triple is refused rather than silently falling through to whichever account the machine is logged into.
  • Relative date ranges — period: "last28days" — resolved against today in Pacific Time, which is the time zone Search Console counts days in. A range computed in UTC asks for a day Google has no data for.
  • Search analytics come back as a compact table with totals, at roughly a third the tokens of the API's JSON. CTR is computed from the totals and average position is weighted by impressions, and the result says plainly that the rows do not sum to the property total when the query dimension is used.
  • GSC_SITE_URL defaults the property for every tool that takes one, and GSC_ALLOWED_SITES restricts which properties the server may touch at all.
  • GSC_ALLOW_TOOLS / GSC_DENY_TOOLS narrow the tool list by name or list_* prefix; essential selects the five read tools that cover looking at a property.
  • GSC_READ_ONLY=true registers only the twelve read tools; the nine write tools never reach the client's tool list.

Security ​

  • The requested OAuth scopes follow the registered tools. A server narrowed to the Search Console tools never asks for the Site Verification or Indexing scope — which is the difference between working and not for a service account under domain-wide delegation, where an undelegated scope fails the whole token request. GSC_READ_ONLY additionally swaps webmasters for webmasters.readonly, so writes are impossible below the tool layer.
  • The four irreversible operations — delete_site, delete_sitemap, unverify_site and update_site_owners — are two-step: the first call returns a short-lived confirmation token bound to those exact arguments, so a confirmation for one property cannot be replayed against another, and one for a two-name owner list cannot execute a three-name one. update_site_owners reads like an update and is a replacement: the list passed becomes the complete owner list, so a single well-formed call removes everyone else.
  • GSC_ALLOWED_SITES has no exemptions. Tools that name a property are checked in resolveSite; the Indexing API tools, which name a page instead, match the URL against the list the way Search Console scopes a property; and the verification tools, which take an opaque resource id, resolve it to a property before acting. list_sites and list_verified_sites filter to the allowlist and report how many entries they withheld — the ids the second one returns are what unverify_site acts on.
  • Search queries, page titles and crawl diagnostics are marked as untrusted content, on every result carrying an upstream payload. Search queries in particular are strings arbitrary members of the public typed into Google, and page titles come from whoever runs the crawled site.
  • A confirmation prompt's two sentences are built only from values the server derived. Where an operation cannot be described without naming its subject — a sitemap URL, an owner list — the subject is quoted below them as data, flattened to a single line so it cannot open one of its own.
  • Credentials are deleted from the environment after start-up (including the path in GSC_SERVICE_ACCOUNT_KEY_FILE, which points at one), never sent to a redirect target, and never echoed into an error message — including when the rejected value is a key pasted into the wrong variable, and including the one error path whose text comes from google-auth-library rather than from here.
  • Results are budgeted: list results drop whole entries rather than overflowing the model's context and name the call that fetches the rest, and a single oversized object has its largest field shortened — at any depth, arrays as well as strings — rather than being truncated into unparseable JSON.
  • Verification resource ids are rejected when they are a path of dots. encodeURIComponent escapes a slash but not a dot, so .. would otherwise resolve /webResource/.. back to the collection endpoint.

Notes ​

  • The npm package is @ni-c/google-search-console-mcp, scoped, while the repository, the image and the docs domain are unscoped. npm view reported the unscoped name as free, but npm's similarity check runs only at publish time and refused it as too close to the existing google-searchconsole-mcp. The MCP registry name is unaffected — that is io.github.ni-c/google-search-console-mcp either way.
  • There is no hosts.ts SSRF guard here, unlike its sibling servers, and that is a decision rather than an omission: there is no configurable target host. Every request goes to one of three hard-coded Google endpoints.
  • urlTestingTools.mobileFriendlyTest is deliberately not exposed. It is still in Google's discovery document and the service behind it was switched off in December 2023, so a tool for it could only ever return an error.

Released under the MIT License.