@digestseo/mcp-gsc 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dev.vars.example +8 -0
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/SECURITY.md +36 -0
- package/SETUP.md +201 -0
- package/package.json +78 -0
- package/scripts/glama-start.mjs +36 -0
- package/src/crypto.ts +58 -0
- package/src/digest.ts +648 -0
- package/src/google.ts +716 -0
- package/src/index.ts +1202 -0
- package/src/storage.ts +98 -0
- package/wrangler.example.jsonc +35 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Local development secrets for `wrangler dev`.
|
|
2
|
+
# Copy this file to `.dev.vars` (gitignored) and fill in your own values.
|
|
3
|
+
# In production these are set with `wrangler secret put <NAME>` instead.
|
|
4
|
+
# See SETUP.md for how to obtain each value.
|
|
5
|
+
|
|
6
|
+
GOOGLE_CLIENT_ID=
|
|
7
|
+
GOOGLE_CLIENT_SECRET=
|
|
8
|
+
TOKEN_ENCRYPTION_KEY=
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.2.0] - 2026-06-10
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `get_capabilities` tool — returns the tool catalog and a non-destructive auth-status probe for better first-run discovery in lazy-loading clients.
|
|
12
|
+
- **Pagination** for `query_search_analytics`: new `start_row` parameter (Google's `startRow`), plus a `next_start_row` field in the response whenever a full page came back.
|
|
13
|
+
- **Parameter descriptions on every tool input** — including the `sc-domain:` vs URL-prefix property format trap, the YYYY-MM-DD date format, and the 2–3 day GSC data lag — so MCP clients make correct calls on the first try.
|
|
14
|
+
- **MCP tool annotations**: all five tools now declare `readOnlyHint: true` and human-readable titles.
|
|
15
|
+
- **Offline unit tests** (`npm run test:unit`, 15 tests): crypto roundtrip/tamper checks, OAuth URL construction, refresh-token error mapping, and Search Console request encoding — no deployment needed.
|
|
16
|
+
- **GitHub Actions CI**: typecheck + unit tests on every push and pull request.
|
|
17
|
+
- `SECURITY.md` with a private-reporting channel and a full data-handling inventory.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- `query_search_analytics` now returns compact JSON shaped as `{ row_count, start_row, rows, next_start_row? }` instead of a pretty-printed bare array — roughly 3× fewer tokens for the same data.
|
|
21
|
+
- Default `row_limit` lowered from 1000 to **100**. Most conversational questions need far fewer rows; bulk consumers can still request up to 25,000 and paginate.
|
|
22
|
+
- Input validation tightened: dates must be `YYYY-MM-DD`, `row_limit` must be an integer in 1–25000.
|
|
23
|
+
- Error messages no longer hardcode Claude.ai — they now say "your MCP client (e.g. Claude.ai → Settings → Connectors)".
|
|
24
|
+
- The version reported by the MCP server and `get_capabilities` is now read from `package.json` (single source of truth).
|
|
25
|
+
- A failure inside the OAuth provider's `completeAuthorization` now returns a clean 500 instead of an unhandled exception.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- Server name: the MCP server now identifies as `mcp-gsc` (in the `McpServer` name and the `GET /` response body) to match the repo, package, and worker name — it was previously `hosted-gsc-mcp`.
|
|
29
|
+
- A transient Google error no longer deletes a user's stored credentials. `refreshAccessToken` now treats only a definitive `invalid_grant` as a revocation; other non-OK responses (bare 400/401, 5xx, network) propagate as plain errors, so a temporary blip can no longer trigger `deleteUser`.
|
|
30
|
+
|
|
31
|
+
## [0.1.0] - 2026-06-02
|
|
32
|
+
|
|
33
|
+
Initial public release — a hosted MCP server for Google Search Console, self-hostable on Cloudflare Workers.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- One-click Google OAuth onboarding (`/authorize` → Google → `/google/callback`): connect with your Google account, no API keys to copy.
|
|
37
|
+
- Four read-only tools: `list_sites`, `query_search_analytics`, `inspect_url`, `list_sitemaps`.
|
|
38
|
+
- Bring-your-own Google OAuth credentials and Cloudflare KV — see [SETUP.md](SETUP.md).
|
|
39
|
+
- AES-GCM encryption of stored Google refresh tokens (`src/crypto.ts`).
|
|
40
|
+
- Structural smoke tests (`npm run test:smoke`).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DigestSEO
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# mcp-gsc
|
|
2
|
+
|
|
3
|
+
<!-- Logo asset for marketplace submissions: assets/logo-400.png (400x400). -->
|
|
4
|
+
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://modelcontextprotocol.io)
|
|
7
|
+
[](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.AKzar1el/mcp-gsc)
|
|
8
|
+
[](https://github.com/AKzar1el/mcp-gsc/actions/workflows/ci.yml)
|
|
9
|
+
[](https://www.npmjs.com/package/@digestseo/mcp-gsc)
|
|
10
|
+
[](https://smithery.ai/servers/digestseo/mcp-gsc)
|
|
11
|
+
|
|
12
|
+
Part of the **[DigestSEO](https://digestseo.com/)** suite of open-source SEO tools.
|
|
13
|
+
|
|
14
|
+
- Product: [digestseo.com/gsc-mcp](https://digestseo.com/gsc-mcp/)
|
|
15
|
+
- Support: [digestseo.com/support](https://digestseo.com/support/)
|
|
16
|
+
- Privacy: [digestseo.com/privacy](https://digestseo.com/privacy/)
|
|
17
|
+
|
|
18
|
+
The self-hosting launcher is available as [`@digestseo/mcp-gsc`](https://www.npmjs.com/package/@digestseo/mcp-gsc):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx -y @digestseo/mcp-gsc
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
For Google OAuth and Cloudflare deployment configuration, follow [SETUP.md](SETUP.md).
|
|
25
|
+
|
|
26
|
+
### Connect in 30 seconds
|
|
27
|
+
|
|
28
|
+
Every deployed instance exposes the same endpoint shape:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
https://<your-worker>.workers.dev/mcp
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The DigestSEO-hosted instance is available at:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
https://mcp-gsc.digestseo.com/mcp
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Claude Code**
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
claude mcp add --transport http gsc https://<your-worker>.workers.dev/mcp
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Cursor**
|
|
47
|
+
|
|
48
|
+
[](https://cursor.com/en/install-mcp?name=gsc&config=eyJ1cmwiOiJodHRwczovL21jcC1nc2MuZGlnZXN0c2VvLmNvbS9tY3AifQ%3D%3D)
|
|
49
|
+
|
|
50
|
+
**ChatGPT** — add it as a custom connector; see [Connect in your AI client](#connect-in-your-ai-client) below.
|
|
51
|
+
|
|
52
|
+
A self-hostable [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for **Google Search Console**. Connect it to Claude.ai, Cursor, ChatGPT, or any MCP-compatible client and ask your AI assistant about your site's organic search performance — impressions, clicks, top queries, index status, and sitemap health — straight from your own Google account.
|
|
53
|
+
|
|
54
|
+
It runs on [Cloudflare Workers](https://workers.cloudflare.com/) and ships with one-click Google OAuth onboarding: connect the server in your client, sign in with Google once, grant the requested Google Search Console permissions, and you're done. No API keys to copy around and no service-account JSON to manage.
|
|
55
|
+
|
|
56
|
+
> **Prefer zero setup?** The hosted version — with automatic weekly email digests delivered to your inbox — is at **[digestseo.com](https://digestseo.com)**. This repository is the open-source core you can run yourself.
|
|
57
|
+
|
|
58
|
+
## Tools
|
|
59
|
+
|
|
60
|
+
This server exposes 17 tools. Read-only analytics and reporting tools are marked with MCP's `readOnlyHint`; the write tools below can change Search Console properties, sitemaps, or indexing state.
|
|
61
|
+
|
|
62
|
+
| Tool | Access | What it does |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| **`server.capabilities`** | Read | List every tool this server exposes and report whether your Google connection is currently authenticated (`connected` / `not_connected`). Takes no arguments — a good first call for discovery. |
|
|
65
|
+
| **`sites.list`** | Read | List the Search Console properties the connected Google account can access (`siteUrl`, `permissionLevel`). |
|
|
66
|
+
| **`analytics.query`** | Read | Impressions, clicks, CTR, and average position over a date range, with dimensions, filters, pagination, and selectable search type. |
|
|
67
|
+
| **`urls.inspect`** | Read | Google's URL Inspection report for a single page. |
|
|
68
|
+
| **`sitemaps.list`** / **`sitemaps.get`** | Read | List submitted sitemaps or retrieve one sitemap's details. |
|
|
69
|
+
| **`insights.quick_wins`** / **`insights.cannibalization`** / **`insights.content_decay`** | Read | Surface optimization opportunities, competing pages, and declining content. |
|
|
70
|
+
| **`indexing.list_pages`** / **`analytics.compare`** | Read | Analyze pages receiving impressions and compare two periods. |
|
|
71
|
+
| **`reports.weekly_digest`** | Read | Generate a plain-language seven-day performance report with movers, top pages, and one recommended action. |
|
|
72
|
+
| **`sites.add`** / **`sites.delete`** | Write | Add or remove a Search Console property. |
|
|
73
|
+
| **`sitemaps.submit`** / **`sitemaps.delete`** | Write | Submit or remove a sitemap. |
|
|
74
|
+
| **`indexing.request`** | Write | Request indexing for an eligible URL through the Google Indexing API. |
|
|
75
|
+
|
|
76
|
+
The server requests Google Search Console and Indexing API scopes. Use a Google account with only the property access you intend to delegate, and review write-tool calls before approving them.
|
|
77
|
+
|
|
78
|
+
## What you can ask
|
|
79
|
+
|
|
80
|
+
Once connected, ask your assistant things like:
|
|
81
|
+
|
|
82
|
+
- *"What are my top 20 queries by clicks in the last 28 days?"*
|
|
83
|
+
- *"Compare impressions for example.com this month vs last month — which pages dropped?"*
|
|
84
|
+
- *"Is `https://example.com/pricing` indexed? When was it last crawled?"*
|
|
85
|
+
- *"Which queries does my blog rank position 5–15 for? Those are my quick wins."*
|
|
86
|
+
- *"Give me a weekly digest for `sc-domain:example.com` ending today."*
|
|
87
|
+
- *"Do any of my sitemaps have errors or warnings?"*
|
|
88
|
+
- *"Split my clicks into brand vs non-brand using a regex on the query."*
|
|
89
|
+
- *"How is my site doing in Google Discover vs regular web search?"*
|
|
90
|
+
|
|
91
|
+
## Connect in your AI client
|
|
92
|
+
|
|
93
|
+
Once you've deployed the server (see **[SETUP.md](SETUP.md)**), connect it by pasting your Worker's `/mcp` URL into your client:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
https://<your-worker>.workers.dev/mcp
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- **Claude.ai** — Settings → Connectors → **Add custom connector** → paste the `/mcp` URL. Leave Client ID and Client Secret blank. On first use, Claude opens a Google sign-in flow; grant read access and the connector turns green.
|
|
100
|
+
- **Cursor** — add it as a custom MCP server pointing at the same `/mcp` URL.
|
|
101
|
+
- **ChatGPT** (with connector/MCP support) — add a custom connector with the `/mcp` URL.
|
|
102
|
+
|
|
103
|
+
Any MCP-compatible client works — they all point at the same `/mcp` endpoint and share the same OAuth flow.
|
|
104
|
+
|
|
105
|
+
## Setup
|
|
106
|
+
|
|
107
|
+
Self-hosting means bringing your own Google OAuth credentials and Cloudflare account. The full, copy-pasteable walkthrough is in **[SETUP.md](SETUP.md)** — including an important note about Google's OAuth verification and the 7-day refresh-token limit while your app is unverified.
|
|
108
|
+
|
|
109
|
+
Quick shape:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
git clone https://github.com/<you>/mcp-gsc.git
|
|
113
|
+
cd mcp-gsc
|
|
114
|
+
npm install
|
|
115
|
+
cp wrangler.example.jsonc wrangler.jsonc # then paste in your KV ids
|
|
116
|
+
# set GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / TOKEN_ENCRYPTION_KEY as secrets
|
|
117
|
+
npm run deploy
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
See **[SETUP.md](SETUP.md)** for every step in detail.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
npm install
|
|
126
|
+
npm test # typecheck + offline unit tests (what CI runs)
|
|
127
|
+
npm run dev # wrangler dev (local)
|
|
128
|
+
npm run test:smoke # structural smoke tests against a deployment
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
[MIT](LICENSE) © 2026 DigestSEO.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
*Built and maintained by [Tomi Šeregi](https://tomiseregi.si), the builder behind [digestseo.com](https://digestseo.com) — weekly SEO digests for non-technical site owners.*
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Please **do not open a public issue** for security problems. Instead, use
|
|
6
|
+
[GitHub's private vulnerability reporting](https://github.com/AKzar1el/mcp-gsc/security/advisories/new)
|
|
7
|
+
on this repository. You'll get a response within a few days.
|
|
8
|
+
|
|
9
|
+
## What this server stores and how
|
|
10
|
+
|
|
11
|
+
A deployed instance of `mcp-gsc` handles Google OAuth credentials on behalf of
|
|
12
|
+
its users. The full data inventory:
|
|
13
|
+
|
|
14
|
+
| Data | Where | Protection |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| Google **refresh token** (per user) | Workers KV (`USER_KV`) | Encrypted at rest with **AES-256-GCM** (`src/crypto.ts`) using the `TOKEN_ENCRYPTION_KEY` secret; a fresh random IV per encryption. |
|
|
17
|
+
| Google account **id and email** (per user) | Workers KV (`USER_KV`) | Stored in plaintext alongside the encrypted token, used only to key and label the record. |
|
|
18
|
+
| Pending OAuth state | Workers KV (`OAUTH_KV`) | Single-use nonce, expires automatically after 10 minutes. |
|
|
19
|
+
| MCP client tokens | Workers KV (`OAUTH_KV`) | Managed by [`@cloudflare/workers-oauth-provider`](https://github.com/cloudflare/workers-oauth-provider). |
|
|
20
|
+
| Google **access tokens** | In-memory only (Durable Object) | Never written to storage; expire within an hour. |
|
|
21
|
+
|
|
22
|
+
Notes:
|
|
23
|
+
|
|
24
|
+
- The server requests only the **read-only** scope
|
|
25
|
+
`https://www.googleapis.com/auth/webmasters.readonly` (plus `openid email`
|
|
26
|
+
for identifying the account). It cannot modify Search Console data.
|
|
27
|
+
- Secrets (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY`)
|
|
28
|
+
are read from Worker secrets / `.dev.vars`, never from the repository.
|
|
29
|
+
- When Google reports a refresh token as revoked (`invalid_grant`), the stored
|
|
30
|
+
user record is deleted immediately.
|
|
31
|
+
- Search Console data is fetched on demand and returned to the MCP client;
|
|
32
|
+
it is never persisted by this server.
|
|
33
|
+
|
|
34
|
+
## Supported versions
|
|
35
|
+
|
|
36
|
+
Only the latest release on `main` is supported with security fixes.
|
package/SETUP.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Setup — self-hosting mcp-gsc
|
|
2
|
+
|
|
3
|
+
This guide walks you through deploying your own instance of `mcp-gsc` on Cloudflare Workers with your own Google OAuth credentials ("bring your own OAuth"). Budget about 30–40 minutes the first time.
|
|
4
|
+
|
|
5
|
+
By the end you'll have a Worker at `https://<your-worker>.workers.dev/mcp` that you can connect as a custom MCP connector in Claude.ai, Cursor, or ChatGPT.
|
|
6
|
+
|
|
7
|
+
> **Read [Step 7](#step-7--important-google-verification) before you start.** While your Google OAuth app is unverified, refresh tokens expire after **7 days** and you're capped at **100 users**. This is the single biggest reason self-hosting a Google Search Console MCP is heavier than a key-based MCP — it's how Google's OAuth works for the sensitive `webmasters` and `indexing` scopes, not a limitation of this project.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- A [Cloudflare account](https://dash.cloudflare.com/sign-up) (the free Workers plan is enough to start).
|
|
12
|
+
- A [Google account](https://accounts.google.com/) with access to the Search Console properties you want to query.
|
|
13
|
+
- Node.js 20+ and npm.
|
|
14
|
+
- The Wrangler CLI — installed for you by `npm install`; invoke it with `npx wrangler …`.
|
|
15
|
+
|
|
16
|
+
Clone and install:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
git clone https://github.com/<you>/mcp-gsc.git
|
|
20
|
+
cd mcp-gsc
|
|
21
|
+
npm install
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Authenticate Wrangler with Cloudflare (opens a browser):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx wrangler login
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Step 1 — Create a Google Cloud project and enable the Search Console API
|
|
33
|
+
|
|
34
|
+
1. Go to the [Google Cloud Console](https://console.cloud.google.com/).
|
|
35
|
+
2. Create a new project (top bar → project dropdown → **New Project**). Name it anything, e.g. `mcp-gsc`.
|
|
36
|
+
3. With that project selected, enable the **Google Search Console API**:
|
|
37
|
+
- Go to **APIs & Services → Library**.
|
|
38
|
+
- Search for **"Google Search Console API"**.
|
|
39
|
+
- Open it and click **Enable**.
|
|
40
|
+
|
|
41
|
+
Direct link: <https://console.cloud.google.com/apis/library/searchconsole.googleapis.com>
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Step 2 — Configure the OAuth consent screen
|
|
46
|
+
|
|
47
|
+
1. Go to **APIs & Services → OAuth consent screen**.
|
|
48
|
+
2. Choose **User type: External**, then **Create**.
|
|
49
|
+
3. Fill in the required app info (app name, user support email, developer contact email). The app name is what users see on the Google sign-in screen.
|
|
50
|
+
4. On the **Scopes** step, click **Add or remove scopes** and add these scopes:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
https://www.googleapis.com/auth/webmasters
|
|
54
|
+
https://www.googleapis.com/auth/indexing
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
These are **sensitive** scopes. They grant read-write access to Search Console data and the Google Indexing API (required to manage sites, sitemaps, and request URL crawling).
|
|
58
|
+
5. On the **Test users** step, click **Add users** and add your own Google email address (and any teammates who need access while the app is in Testing).
|
|
59
|
+
6. Save. Leave the **Publishing status** as **Testing** for now — see [Step 7](#step-7--important-google-verification).
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Step 3 — Create an OAuth client (Web application)
|
|
64
|
+
|
|
65
|
+
1. Go to **APIs & Services → Credentials → Create credentials → OAuth client ID**.
|
|
66
|
+
2. **Application type: Web application.**
|
|
67
|
+
3. Under **Authorized redirect URIs**, add both of these:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
https://<your-worker>.workers.dev/google/callback
|
|
71
|
+
http://localhost:8787/google/callback
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- Replace `<your-worker>` with your Worker's name + subdomain. If you don't know it yet, deploy once (Step 6) to see the assigned `*.workers.dev` URL, then come back and add it here. The default Worker name is `mcp-gsc` (set in `wrangler.jsonc`).
|
|
75
|
+
- The `http://localhost:8787/google/callback` entry is for local development with `npm run dev`.
|
|
76
|
+
- The path must be exactly `/google/callback` — that's the route this server handles.
|
|
77
|
+
- The DigestSEO-hosted instance also requires this exact callback URI in the OAuth client configured on its Worker: `https://mcp-gsc.digestseo.com/google/callback`.
|
|
78
|
+
4. Click **Create**. Copy the **Client ID** and **Client secret** — you'll set them as secrets in the next step.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Step 4 — Set the Worker secrets
|
|
83
|
+
|
|
84
|
+
This server reads three secrets. **Never commit these to git** — set them with `wrangler secret put`, which stores them encrypted in Cloudflare:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npx wrangler secret put GOOGLE_CLIENT_ID
|
|
88
|
+
# paste the Client ID from Step 3
|
|
89
|
+
|
|
90
|
+
npx wrangler secret put GOOGLE_CLIENT_SECRET
|
|
91
|
+
# paste the Client secret from Step 3
|
|
92
|
+
|
|
93
|
+
npx wrangler secret put TOKEN_ENCRYPTION_KEY
|
|
94
|
+
# paste the key generated below
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`TOKEN_ENCRYPTION_KEY` is the **AES-256 key** used by `src/crypto.ts` to encrypt each user's Google refresh token before it's stored in KV. Generate a fresh 32-byte key, base64-encoded:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
openssl rand -base64 32
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Paste that value when prompted. Keep it safe — if you lose it, the refresh tokens already in KV become undecryptable and every user has to reconnect.
|
|
104
|
+
|
|
105
|
+
For **local development**, put the same three values in `.dev.vars` instead — copy `.dev.vars.example` to `.dev.vars` (it's gitignored):
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
GOOGLE_CLIENT_ID=...
|
|
109
|
+
GOOGLE_CLIENT_SECRET=...
|
|
110
|
+
TOKEN_ENCRYPTION_KEY=...
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Step 5 — Create the KV namespaces
|
|
116
|
+
|
|
117
|
+
The server uses two Workers KV namespaces: `OAUTH_KV` (pending OAuth state and issued tokens) and `USER_KV` (encrypted refresh tokens, keyed per user).
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npx wrangler kv namespace create OAUTH_KV
|
|
121
|
+
npx wrangler kv namespace create USER_KV
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Each command prints an `id`. Copy the template to your real config and paste the two ids in:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
cp wrangler.example.jsonc wrangler.jsonc
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Then edit `wrangler.jsonc` and replace the placeholders:
|
|
131
|
+
|
|
132
|
+
```jsonc
|
|
133
|
+
"kv_namespaces": [
|
|
134
|
+
{ "binding": "OAUTH_KV", "id": "YOUR_OAUTH_KV_ID" }, // ← paste OAUTH_KV id
|
|
135
|
+
{ "binding": "USER_KV", "id": "YOUR_USER_KV_ID" } // ← paste USER_KV id
|
|
136
|
+
]
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`wrangler.jsonc` is gitignored because it contains your account's namespace ids. The `wrangler.example.jsonc` template stays in git.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Step 6 — Apply migrations, deploy, and verify
|
|
144
|
+
|
|
145
|
+
The Durable Object migration declared in `wrangler.jsonc` (`migrations` → tag `v1`, `new_sqlite_classes: ["GscMcpAgent"]`) is applied automatically the first time you deploy. Deploy:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npm run deploy
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Wrangler prints your Worker URL, e.g. `https://mcp-gsc.<your-subdomain>.workers.dev`. If you didn't know that host in Step 3, add `https://<that-host>/google/callback` to the Authorized redirect URIs now, then deploy again.
|
|
152
|
+
|
|
153
|
+
Smoke-check the deployment:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
curl https://<your-worker>.workers.dev/healthz
|
|
157
|
+
# → ok
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Opening the root URL in a browser also prints the `/mcp` connect URL.
|
|
161
|
+
|
|
162
|
+
Now connect it in **Claude.ai**:
|
|
163
|
+
|
|
164
|
+
1. Settings → **Connectors** → **Add custom connector**.
|
|
165
|
+
2. Paste `https://<your-worker>.workers.dev/mcp`.
|
|
166
|
+
3. Leave **Client ID** and **Client Secret** blank.
|
|
167
|
+
4. Click through; Claude opens a Google sign-in flow. Sign in with a Google account you added as a **test user** in Step 2, and grant read access to your Search Console properties.
|
|
168
|
+
5. The connector turns green. Ask: *"What sites do I have in Search Console?"*
|
|
169
|
+
|
|
170
|
+
The same `/mcp` URL works in Cursor and ChatGPT.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Step 7 — Important: Google verification
|
|
175
|
+
|
|
176
|
+
**Read this carefully — it determines whether your self-hosted instance keeps working past a week.**
|
|
177
|
+
|
|
178
|
+
While your OAuth app's **Publishing status** is **Testing** (where it starts, and where Step 2 leaves it), three limits apply:
|
|
179
|
+
|
|
180
|
+
- **Unverified-app screen.** Users see Google's *"Google hasn't verified this app"* warning during sign-in and must click **Advanced → Go to \<app\> (unsafe)** to continue. That's expected for an unverified app — it isn't a problem with this server.
|
|
181
|
+
- **100-user cap.** At most 100 Google accounts can ever authorize the app.
|
|
182
|
+
- **Refresh tokens expire after 7 days.** This is the big one. In Testing mode, Google expires every refresh token **7 days** after it's issued. When that happens the tools start returning *"Google access revoked. Please reconnect this connector in Claude.ai"*, and the user has to reconnect to get a fresh token. **Any automation or scheduled job you build on top will break every 7 days** until you fix this.
|
|
183
|
+
|
|
184
|
+
To remove all three limits you must move the app to **Publishing status: In production**:
|
|
185
|
+
|
|
186
|
+
- In **APIs & Services → OAuth consent screen**, click **Publish app**.
|
|
187
|
+
- Because `webmasters` and `indexing` are **sensitive scopes**, Google requires **OAuth verification**: you submit the app for review, justify the scopes, and (for sensitive/restricted scopes) may need to verify domain ownership and complete a security assessment. **This review can take days to several weeks.**
|
|
188
|
+
- Once the app is **In production and verified**, the unverified-app screen goes away, the 100-user cap is lifted, and refresh tokens stop expiring on the 7-day clock.
|
|
189
|
+
|
|
190
|
+
**Bottom line:** for personal use with one or two Google accounts, Testing mode is fine as long as you don't mind reconnecting roughly every 7 days. For anything shared or automated, you'll want to complete Google's verification — and that, not the code, is the heaviest part of self-hosting a Google Search Console MCP.
|
|
191
|
+
|
|
192
|
+
> Don't want to deal with verification at all? The hosted version at **[digestseo.com](https://digestseo.com)** has already been through it (and adds automatic weekly email digests on top).
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Troubleshooting
|
|
197
|
+
|
|
198
|
+
- **`redirect_uri_mismatch`** — the redirect URI in Step 3 must match your Worker host exactly, including `https://` and the `/google/callback` path. Check for typos and trailing slashes.
|
|
199
|
+
- **`Google did not return a refresh_token`** — you need a fresh consent. This server requests `access_type=offline` with `prompt=consent`, but if you've authorized before, remove the app from your [Google Account permissions](https://myaccount.google.com/permissions) and reconnect to force a new refresh token.
|
|
200
|
+
- **"Google access revoked" after about a week** — that's the 7-day Testing-mode expiry from Step 7. Reconnect, or move the app to production.
|
|
201
|
+
- **401 on `/mcp`** — expected when unauthenticated. Connect through your MCP client's OAuth flow rather than calling `/mcp` directly in a browser.
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@digestseo/mcp-gsc",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"main": "scripts/glama-start.mjs",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mcp-gsc": "scripts/glama-start.mjs"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"description": "Self-hostable Google Search Console MCP server for analytics, URL inspection, sitemaps, indexing, and SEO reporting on Cloudflare Workers.",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/AKzar1el/mcp-gsc.git"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://digestseo.com/gsc-mcp/",
|
|
17
|
+
"author": {
|
|
18
|
+
"name": "DigestSEO",
|
|
19
|
+
"url": "https://digestseo.com/"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/AKzar1el/mcp-gsc/issues"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"scripts/glama-start.mjs",
|
|
27
|
+
"wrangler.example.jsonc",
|
|
28
|
+
".dev.vars.example",
|
|
29
|
+
"README.md",
|
|
30
|
+
"SETUP.md",
|
|
31
|
+
"SECURITY.md",
|
|
32
|
+
"CHANGELOG.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"keywords": [
|
|
36
|
+
"mcp",
|
|
37
|
+
"model-context-protocol",
|
|
38
|
+
"google-search-console",
|
|
39
|
+
"google-search-console-mcp",
|
|
40
|
+
"search-console",
|
|
41
|
+
"search-analytics",
|
|
42
|
+
"indexing-api",
|
|
43
|
+
"seo",
|
|
44
|
+
"cloudflare-workers",
|
|
45
|
+
"claude"
|
|
46
|
+
],
|
|
47
|
+
"scripts": {
|
|
48
|
+
"dev": "wrangler dev",
|
|
49
|
+
"deploy": "wrangler deploy",
|
|
50
|
+
"build": "npm run typecheck",
|
|
51
|
+
"start": "node scripts/glama-start.mjs",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"test": "npm run typecheck && npm run test:unit",
|
|
54
|
+
"test:unit": "tsx --test tests/unit.test.ts",
|
|
55
|
+
"test:smoke": "node --test tests/smoke.test.mjs",
|
|
56
|
+
"prepublishOnly": "npm test"
|
|
57
|
+
},
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public",
|
|
60
|
+
"registry": "https://registry.npmjs.org/"
|
|
61
|
+
},
|
|
62
|
+
"mcpName": "io.github.AKzar1el/mcp-gsc",
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"@cloudflare/workers-oauth-provider": "^0.8.1",
|
|
65
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
66
|
+
"agents": "^0.13.1",
|
|
67
|
+
"wrangler": "^4.93.0",
|
|
68
|
+
"zod": "^4.4.3"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@cloudflare/workers-types": "^4.20260520.1",
|
|
72
|
+
"tsx": "^4.20.0",
|
|
73
|
+
"typescript": "^6.0.3"
|
|
74
|
+
},
|
|
75
|
+
"engines": {
|
|
76
|
+
"node": ">=20.0.0"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
|
|
4
|
+
const port = process.env.PORT || '8080';
|
|
5
|
+
const forwardedVariables = ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'TOKEN_ENCRYPTION_KEY'];
|
|
6
|
+
const args = [
|
|
7
|
+
'dev',
|
|
8
|
+
'--config',
|
|
9
|
+
'wrangler.example.jsonc',
|
|
10
|
+
'--local',
|
|
11
|
+
'--ip',
|
|
12
|
+
'0.0.0.0',
|
|
13
|
+
'--port',
|
|
14
|
+
port,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
for (const name of forwardedVariables) {
|
|
18
|
+
if (process.env[name]) {
|
|
19
|
+
args.push('--var', `${name}:${process.env[name]}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const wranglerCli = createRequire(import.meta.url).resolve('wrangler/bin/wrangler.js');
|
|
24
|
+
const child = spawn(process.execPath, [wranglerCli, ...args], { stdio: 'inherit' });
|
|
25
|
+
|
|
26
|
+
const forwardSignal = (signal) => child.kill(signal);
|
|
27
|
+
process.on('SIGINT', () => forwardSignal('SIGINT'));
|
|
28
|
+
process.on('SIGTERM', () => forwardSignal('SIGTERM'));
|
|
29
|
+
|
|
30
|
+
child.on('exit', (code, signal) => {
|
|
31
|
+
if (signal) {
|
|
32
|
+
process.kill(process.pid, signal);
|
|
33
|
+
} else {
|
|
34
|
+
process.exit(code ?? 1);
|
|
35
|
+
}
|
|
36
|
+
});
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
function base64Decode(b64: string): Uint8Array<ArrayBuffer> {
|
|
2
|
+
const binary = atob(b64);
|
|
3
|
+
const buf = new ArrayBuffer(binary.length);
|
|
4
|
+
const bytes = new Uint8Array(buf);
|
|
5
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
6
|
+
return bytes;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function base64Encode(bytes: Uint8Array): string {
|
|
10
|
+
let binary = '';
|
|
11
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
12
|
+
return btoa(binary);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function importKey(base64Key: string): Promise<CryptoKey> {
|
|
16
|
+
const keyBytes = base64Decode(base64Key);
|
|
17
|
+
return crypto.subtle.importKey(
|
|
18
|
+
'raw',
|
|
19
|
+
keyBytes,
|
|
20
|
+
{ name: 'AES-GCM' },
|
|
21
|
+
false,
|
|
22
|
+
['encrypt', 'decrypt'],
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function encryptToken(
|
|
27
|
+
plaintext: string,
|
|
28
|
+
base64Key: string,
|
|
29
|
+
): Promise<{ ciphertext: string; iv: string }> {
|
|
30
|
+
const key = await importKey(base64Key);
|
|
31
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
32
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
33
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
34
|
+
{ name: 'AES-GCM', iv },
|
|
35
|
+
key,
|
|
36
|
+
encoded,
|
|
37
|
+
);
|
|
38
|
+
return {
|
|
39
|
+
ciphertext: base64Encode(new Uint8Array(ciphertext)),
|
|
40
|
+
iv: base64Encode(iv),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function decryptToken(
|
|
45
|
+
ciphertext: string,
|
|
46
|
+
iv: string,
|
|
47
|
+
base64Key: string,
|
|
48
|
+
): Promise<string> {
|
|
49
|
+
const key = await importKey(base64Key);
|
|
50
|
+
const ctBytes = base64Decode(ciphertext);
|
|
51
|
+
const ivBytes = base64Decode(iv);
|
|
52
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
53
|
+
{ name: 'AES-GCM', iv: ivBytes },
|
|
54
|
+
key,
|
|
55
|
+
ctBytes,
|
|
56
|
+
);
|
|
57
|
+
return new TextDecoder().decode(decrypted);
|
|
58
|
+
}
|