Changelog
[Unreleased]
Fixed
- A
__proto__key in an answer from Google is dropped, at any depth. It used to reach the text block but notstructuredContent, 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 randomget_indexing_statusbody.
[0.3.0] - 2026-09-07
Added
- A demo GIF in the README and on the documentation home page, recorded from
docs/demo.tapewith no credentials: the tool list, the same list narrowed by theessentialpreset, and the startup abort a mistyped tool name produces.
Changed
- The tool reference marks the
essentialpreset 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,websiteUrlandiconsnow travel withnameandversion, so a client that shows a server to a person has something to show. All four were already inserver.jsonfor the registry and reached no client at all; a test compares the two so they cannot drift. - Server
instructions. Results carry anuntrustedmarker, 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_analyticsis budgeted. The text block was held to the 100 kB result budget by halving the table;structuredContent.rowscarried 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 atruncatedblock naming the nextstart_row;rowCountis 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 erroror a crash:rows: {}(rows.slice is not a function), a key that was a number (value.replace is not a function),site: nullon a verification resource (Cannot read properties of nullout oflist_verified_sitesandsetup_site), an empty200(Buffer.byteLengthonundefined, fromget_site,get_sitemap,get_indexing_status,request_indexingandupdate_site_owners), andinspectionResult: 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_statushas 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 throwProtocolError: … 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 createnow carries--verify-tag. - The status is decided before the body is read. A
429or a5xxwhose 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, sosc-domain:foo bar, a line break followed by instructions, oruser@host:portnormalised cleanly and reached the sentence of a confirmation dialog — the one line a person acts on, and one thatunverify_sitebuilds from the identifier of a verification resource Google returned. The part aftersc-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
structuredContentraw (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 runstoWellFormed(). - Text quoted from an error body is labelled, stripped and cut, as is a
content-typeheader 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 thenormalizeSiteUrlmessages reached fromGSC_SITE_URL,GSC_ALLOWED_SITESand everysite_urlargument, 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-kilobytesite_urlcomes 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_sitemapsat 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
-----BEGINmarkers quadratic, and a key cut off before itsENDline 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.mdargues from the code that runs. It named atupleResourceKeythat 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 andserveStdionegotiates both eras.- mcp-approval 0.8.2. A sealed dialog answer is single-use since 0.8.1: the same
requestStatepresented 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.0already 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_sitemapbinds its token to[property, feedpath]andupdate_site_ownersto[property, ...owners]. This server never sorted those tuples — a localtupleResourceKeyfingerprinted 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 behindsetResourceKeywould have allowed since both are URLs from the same string space — but the decision lived in a hand-written function. The keys now come fromorderedResourceKeyin 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
outputSchemaand answers withstructuredContentbeside 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 doesquery_search_analytics' rendered table: the rows themselves are now in the structured half.Every tool that reports Google's data carries
untrusted: trueandsource: "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_tokenremains 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.ELICITATIONswitches the dialog off —falsesends 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=falsereaches 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 neithertruenorfalsestops 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.mdpage.
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": truerather 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 asanyOfbranches 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_tokenprompt is an error result. What was asked for did not happen, which is whatisErrorsays. 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 the2026-07-28era is served it; until now itsserver/discoverprobe was answered with "Method not found" and only2025-11-25was 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-wiredStdioServerTransportserved it.GSC_READ_ONLYnow also accepts1andyes, in any casing or padding — the spellings a compose file or a systemd unit is most likely to use. Under the previous exact match ontruethey left every write tool registered while the operator believed the server could not write. Deliberately more tolerant thanELICITATION, 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
typescriptbelow 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-approvalandsvg-asset-setrather 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_FILEno 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 withGSC_KEY_FILEunset 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 saidservice-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
createTokenSourcerefuses 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.budgetedJsonno 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_analyticsis held to the result budget it is the reason for.MAX_RESULT_BYTESis 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.pageandquerydimension 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 thestart_rowthat 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.
escapeCellescaped the pipe but not the backslash, soshoes \| nikecame out asshoes \\| 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_sitereports 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, andadd_siteon an unverified domain succeeds while producing a property that answers 403 for every piece of data.submit_sitemapsandinspect_urlsdo 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_URLdefaults the property for every tool that takes one, andGSC_ALLOWED_SITESrestricts which properties the server may touch at all.GSC_ALLOW_TOOLS/GSC_DENY_TOOLSnarrow the tool list by name orlist_*prefix;essentialselects the five read tools that cover looking at a property.GSC_READ_ONLY=trueregisters 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_ONLYadditionally swapswebmastersforwebmasters.readonly, so writes are impossible below the tool layer. - The four irreversible operations —
delete_site,delete_sitemap,unverify_siteandupdate_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_ownersreads 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_SITEShas no exemptions. Tools that name a property are checked inresolveSite; 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_sitesandlist_verified_sitesfilter to the allowlist and report how many entries they withheld — the ids the second one returns are whatunverify_siteacts 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 fromgoogle-auth-libraryrather 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.
encodeURIComponentescapes 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 viewreported the unscoped name as free, but npm's similarity check runs only at publish time and refused it as too close to the existinggoogle-searchconsole-mcp. The MCP registry name is unaffected — that isio.github.ni-c/google-search-console-mcpeither way. - There is no
hosts.tsSSRF 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.mobileFriendlyTestis 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.