mcp-search-console-multi 0.1.0__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.token
5
+ *.json
6
+ !accounts.example.json
7
+ .git/
8
+ .env
9
+ credentials/
@@ -0,0 +1,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ lint:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v3
14
+ - run: uv run ruff check gsc/
15
+ - run: uv run ruff format --check gsc/
16
+
17
+ build:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: astral-sh/setup-uv@v3
22
+ - run: uv pip install --system -e .
23
+ - run: python -c "from gsc.server import mcp; print('Server imports OK')"
@@ -0,0 +1,27 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ dist/
7
+ build/
8
+ .venv/
9
+ venv/
10
+ .env
11
+
12
+ # Credentials — never commit these
13
+ *.json
14
+ !accounts.example.json
15
+ *.token
16
+ credentials/
17
+
18
+ # uv
19
+ uv.lock
20
+
21
+ # IDE
22
+ .vscode/
23
+ .idea/
24
+ *.swp
25
+
26
+ # OS
27
+ .DS_Store
@@ -0,0 +1,16 @@
1
+ FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY pyproject.toml .
6
+ COPY gsc/ gsc/
7
+
8
+ RUN uv pip install --system -e .
9
+
10
+ ENV MCP_TRANSPORT=sse
11
+ ENV MCP_HOST=0.0.0.0
12
+ ENV MCP_PORT=3001
13
+
14
+ EXPOSE 3001
15
+
16
+ CMD ["mcp-search-console"]
@@ -0,0 +1,210 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-search-console-multi
3
+ Version: 0.1.0
4
+ Summary: Multi-account Google Search Console MCP server
5
+ License: MIT
6
+ Keywords: ai,google-search-console,mcp,seo
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: fastmcp>=2.0.0
13
+ Requires-Dist: google-api-python-client>=2.120.0
14
+ Requires-Dist: google-auth-httplib2>=0.2.0
15
+ Requires-Dist: google-auth-oauthlib>=1.2.0
16
+ Requires-Dist: google-auth>=2.29.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # mcp-search-console
20
+
21
+ Multi-account Google Search Console MCP server. Connect any number of GSC accounts to Claude, Cursor, Codex, or any MCP-compatible AI assistant — and query them by name in the same session.
22
+
23
+ ```
24
+ # Install: uvx mcp-search-console-multi
25
+
26
+ # Ask your AI:
27
+ "Show me the top queries for my-site last month"
28
+ "Compare client-acme's performance between Q1 and Q2"
29
+ "Check indexing issues on client-beta's 5 product pages"
30
+ ```
31
+
32
+ ---
33
+
34
+ ## Why this one?
35
+
36
+ Most GSC MCP servers support one account per server process. This one lets you configure multiple accounts (your own sites + client sites) and switch between them per tool call — no restart needed.
37
+
38
+ | Feature | This server | Others |
39
+ |---|---|---|
40
+ | Multiple accounts | Yes — named, switchable | No — one per process |
41
+ | OAuth + service account | Both, mixed per account | Usually one type |
42
+ | Auto token refresh | Yes | Sometimes |
43
+ | Rate limit retry | Yes — exponential backoff | No |
44
+ | Destructive op guard | Yes — env flag required | Sometimes |
45
+ | SSE transport (remote) | Yes | Varies |
46
+
47
+ ---
48
+
49
+ ## Quickstart (uvx — no clone needed)
50
+
51
+ **1. Create your accounts config:**
52
+
53
+ ```bash
54
+ mkdir -p ~/.config/mcp-search-console
55
+ cp accounts.example.json ~/.config/mcp-search-console/accounts.json
56
+ # Edit it — add your accounts
57
+ ```
58
+
59
+ **2. Add to your MCP client config:**
60
+
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "search-console": {
65
+ "command": "uvx",
66
+ "args": ["mcp-search-console-multi"],
67
+ "env": {
68
+ "GSC_ACCOUNTS_CONFIG": "/Users/you/.config/mcp-search-console/accounts.json"
69
+ }
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ **3. Restart your AI client. Done.**
76
+
77
+ ---
78
+
79
+ ## Accounts config
80
+
81
+ Copy `accounts.example.json` and edit it:
82
+
83
+ ```json
84
+ {
85
+ "default": "my-site",
86
+ "accounts": {
87
+ "my-site": {
88
+ "type": "oauth",
89
+ "client_secrets_file": "~/.config/mcp-search-console/client_secrets.json",
90
+ "token_file": "~/.config/mcp-search-console/my-site.token"
91
+ },
92
+ "client-acme": {
93
+ "type": "service_account",
94
+ "credentials_file": "~/.config/mcp-search-console/acme.json"
95
+ }
96
+ }
97
+ }
98
+ ```
99
+
100
+ Set `GSC_ACCOUNTS_CONFIG` to its path, or put it at `~/.config/mcp-search-console/accounts.json` (default).
101
+
102
+ ### OAuth setup
103
+
104
+ 1. [Google Cloud Console](https://console.cloud.google.com/) → create project
105
+ 2. Enable the [Search Console API](https://console.cloud.google.com/apis/library/searchconsole.googleapis.com)
106
+ 3. Credentials → Create → OAuth client ID → Desktop app
107
+ 4. Download as `client_secrets.json`
108
+ 5. On first use, a browser window opens for you to authorise — token is saved automatically
109
+
110
+ ### Service account setup
111
+
112
+ 1. Google Cloud Console → Credentials → Create → Service Account
113
+ 2. Keys tab → Add Key → JSON → download
114
+ 3. In GSC, add the service account email as a user on each property
115
+
116
+ ---
117
+
118
+ ## Using multiple accounts
119
+
120
+ Every tool accepts an optional `account` parameter. Omit it to use your default.
121
+
122
+ ```
123
+ "Show top queries for my-site" # uses default
124
+ "Show top queries for client-acme" # uses named account
125
+ "Compare client-beta performance Jan vs Feb" # named account
126
+ ```
127
+
128
+ Or set the default mid-session:
129
+
130
+ ```
131
+ "Switch to client-acme as my default account"
132
+ ```
133
+
134
+ ---
135
+
136
+ ## Available tools
137
+
138
+ ### Account management
139
+ | Tool | What it does |
140
+ |---|---|
141
+ | `list_accounts` | Show all configured accounts and which is default |
142
+ | `set_default_account` | Change the default account |
143
+ | `reauthenticate` | Re-run OAuth flow or reload credentials for an account |
144
+
145
+ ### Properties
146
+ | Tool | What it does |
147
+ |---|---|
148
+ | `list_properties` | List all GSC properties |
149
+ | `get_site_details` | Verification + permission details for a property |
150
+
151
+ ### Search analytics
152
+ | Tool | What it does |
153
+ |---|---|
154
+ | `get_search_analytics` | Queries, pages, clicks, impressions, CTR, position |
155
+ | `get_performance_overview` | Site-level totals for a period |
156
+ | `compare_periods` | Side-by-side comparison of two date ranges |
157
+ | `get_advanced_search_analytics` | Analytics with dimension filters (country, device, etc.) |
158
+ | `get_search_by_page` | Queries driving traffic to a specific page |
159
+
160
+ ### URL inspection
161
+ | Tool | What it does |
162
+ |---|---|
163
+ | `inspect_url` | Indexing status, crawl date, mobile usability, rich results |
164
+ | `batch_inspect_urls` | Inspect up to 10 URLs at once |
165
+ | `check_indexing_issues` | Prioritised issue summary across multiple URLs |
166
+
167
+ ### Sitemaps
168
+ | Tool | What it does |
169
+ |---|---|
170
+ | `list_sitemaps` | All submitted sitemaps with status |
171
+ | `get_sitemap` | Details for a specific sitemap |
172
+ | `submit_sitemap` | Submit a new sitemap *(requires `GSC_ALLOW_DESTRUCTIVE=true`)* |
173
+ | `delete_sitemap` | Remove a sitemap *(requires `GSC_ALLOW_DESTRUCTIVE=true`)* |
174
+
175
+ ---
176
+
177
+ ## Environment variables
178
+
179
+ | Variable | Default | Description |
180
+ |---|---|---|
181
+ | `GSC_ACCOUNTS_CONFIG` | `~/.config/mcp-search-console/accounts.json` | Path to your accounts config |
182
+ | `GSC_ALLOW_DESTRUCTIVE` | unset | Set to `true` to enable sitemap submit/delete |
183
+ | `MCP_TRANSPORT` | `stdio` | Set to `sse` for remote/Docker deployment |
184
+ | `MCP_HOST` | `127.0.0.1` | SSE bind host (use `0.0.0.0` for all interfaces) |
185
+ | `MCP_PORT` | `3001` | SSE bind port |
186
+
187
+ ---
188
+
189
+ ## Remote deployment (Docker / VPS)
190
+
191
+ ```bash
192
+ docker build -t mcp-search-console .
193
+
194
+ docker run \
195
+ -e MCP_TRANSPORT=sse \
196
+ -e MCP_HOST=0.0.0.0 \
197
+ -e MCP_PORT=3001 \
198
+ -e GSC_ACCOUNTS_CONFIG=/config/accounts.json \
199
+ -v /path/to/config:/config \
200
+ -p 3001:3001 \
201
+ mcp-search-console
202
+ ```
203
+
204
+ Your MCP client connects to `http://your-server:3001/sse`.
205
+
206
+ ---
207
+
208
+ ## License
209
+
210
+ MIT
@@ -0,0 +1,192 @@
1
+ # mcp-search-console
2
+
3
+ Multi-account Google Search Console MCP server. Connect any number of GSC accounts to Claude, Cursor, Codex, or any MCP-compatible AI assistant — and query them by name in the same session.
4
+
5
+ ```
6
+ # Install: uvx mcp-search-console-multi
7
+
8
+ # Ask your AI:
9
+ "Show me the top queries for my-site last month"
10
+ "Compare client-acme's performance between Q1 and Q2"
11
+ "Check indexing issues on client-beta's 5 product pages"
12
+ ```
13
+
14
+ ---
15
+
16
+ ## Why this one?
17
+
18
+ Most GSC MCP servers support one account per server process. This one lets you configure multiple accounts (your own sites + client sites) and switch between them per tool call — no restart needed.
19
+
20
+ | Feature | This server | Others |
21
+ |---|---|---|
22
+ | Multiple accounts | Yes — named, switchable | No — one per process |
23
+ | OAuth + service account | Both, mixed per account | Usually one type |
24
+ | Auto token refresh | Yes | Sometimes |
25
+ | Rate limit retry | Yes — exponential backoff | No |
26
+ | Destructive op guard | Yes — env flag required | Sometimes |
27
+ | SSE transport (remote) | Yes | Varies |
28
+
29
+ ---
30
+
31
+ ## Quickstart (uvx — no clone needed)
32
+
33
+ **1. Create your accounts config:**
34
+
35
+ ```bash
36
+ mkdir -p ~/.config/mcp-search-console
37
+ cp accounts.example.json ~/.config/mcp-search-console/accounts.json
38
+ # Edit it — add your accounts
39
+ ```
40
+
41
+ **2. Add to your MCP client config:**
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "search-console": {
47
+ "command": "uvx",
48
+ "args": ["mcp-search-console-multi"],
49
+ "env": {
50
+ "GSC_ACCOUNTS_CONFIG": "/Users/you/.config/mcp-search-console/accounts.json"
51
+ }
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ **3. Restart your AI client. Done.**
58
+
59
+ ---
60
+
61
+ ## Accounts config
62
+
63
+ Copy `accounts.example.json` and edit it:
64
+
65
+ ```json
66
+ {
67
+ "default": "my-site",
68
+ "accounts": {
69
+ "my-site": {
70
+ "type": "oauth",
71
+ "client_secrets_file": "~/.config/mcp-search-console/client_secrets.json",
72
+ "token_file": "~/.config/mcp-search-console/my-site.token"
73
+ },
74
+ "client-acme": {
75
+ "type": "service_account",
76
+ "credentials_file": "~/.config/mcp-search-console/acme.json"
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ Set `GSC_ACCOUNTS_CONFIG` to its path, or put it at `~/.config/mcp-search-console/accounts.json` (default).
83
+
84
+ ### OAuth setup
85
+
86
+ 1. [Google Cloud Console](https://console.cloud.google.com/) → create project
87
+ 2. Enable the [Search Console API](https://console.cloud.google.com/apis/library/searchconsole.googleapis.com)
88
+ 3. Credentials → Create → OAuth client ID → Desktop app
89
+ 4. Download as `client_secrets.json`
90
+ 5. On first use, a browser window opens for you to authorise — token is saved automatically
91
+
92
+ ### Service account setup
93
+
94
+ 1. Google Cloud Console → Credentials → Create → Service Account
95
+ 2. Keys tab → Add Key → JSON → download
96
+ 3. In GSC, add the service account email as a user on each property
97
+
98
+ ---
99
+
100
+ ## Using multiple accounts
101
+
102
+ Every tool accepts an optional `account` parameter. Omit it to use your default.
103
+
104
+ ```
105
+ "Show top queries for my-site" # uses default
106
+ "Show top queries for client-acme" # uses named account
107
+ "Compare client-beta performance Jan vs Feb" # named account
108
+ ```
109
+
110
+ Or set the default mid-session:
111
+
112
+ ```
113
+ "Switch to client-acme as my default account"
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Available tools
119
+
120
+ ### Account management
121
+ | Tool | What it does |
122
+ |---|---|
123
+ | `list_accounts` | Show all configured accounts and which is default |
124
+ | `set_default_account` | Change the default account |
125
+ | `reauthenticate` | Re-run OAuth flow or reload credentials for an account |
126
+
127
+ ### Properties
128
+ | Tool | What it does |
129
+ |---|---|
130
+ | `list_properties` | List all GSC properties |
131
+ | `get_site_details` | Verification + permission details for a property |
132
+
133
+ ### Search analytics
134
+ | Tool | What it does |
135
+ |---|---|
136
+ | `get_search_analytics` | Queries, pages, clicks, impressions, CTR, position |
137
+ | `get_performance_overview` | Site-level totals for a period |
138
+ | `compare_periods` | Side-by-side comparison of two date ranges |
139
+ | `get_advanced_search_analytics` | Analytics with dimension filters (country, device, etc.) |
140
+ | `get_search_by_page` | Queries driving traffic to a specific page |
141
+
142
+ ### URL inspection
143
+ | Tool | What it does |
144
+ |---|---|
145
+ | `inspect_url` | Indexing status, crawl date, mobile usability, rich results |
146
+ | `batch_inspect_urls` | Inspect up to 10 URLs at once |
147
+ | `check_indexing_issues` | Prioritised issue summary across multiple URLs |
148
+
149
+ ### Sitemaps
150
+ | Tool | What it does |
151
+ |---|---|
152
+ | `list_sitemaps` | All submitted sitemaps with status |
153
+ | `get_sitemap` | Details for a specific sitemap |
154
+ | `submit_sitemap` | Submit a new sitemap *(requires `GSC_ALLOW_DESTRUCTIVE=true`)* |
155
+ | `delete_sitemap` | Remove a sitemap *(requires `GSC_ALLOW_DESTRUCTIVE=true`)* |
156
+
157
+ ---
158
+
159
+ ## Environment variables
160
+
161
+ | Variable | Default | Description |
162
+ |---|---|---|
163
+ | `GSC_ACCOUNTS_CONFIG` | `~/.config/mcp-search-console/accounts.json` | Path to your accounts config |
164
+ | `GSC_ALLOW_DESTRUCTIVE` | unset | Set to `true` to enable sitemap submit/delete |
165
+ | `MCP_TRANSPORT` | `stdio` | Set to `sse` for remote/Docker deployment |
166
+ | `MCP_HOST` | `127.0.0.1` | SSE bind host (use `0.0.0.0` for all interfaces) |
167
+ | `MCP_PORT` | `3001` | SSE bind port |
168
+
169
+ ---
170
+
171
+ ## Remote deployment (Docker / VPS)
172
+
173
+ ```bash
174
+ docker build -t mcp-search-console .
175
+
176
+ docker run \
177
+ -e MCP_TRANSPORT=sse \
178
+ -e MCP_HOST=0.0.0.0 \
179
+ -e MCP_PORT=3001 \
180
+ -e GSC_ACCOUNTS_CONFIG=/config/accounts.json \
181
+ -v /path/to/config:/config \
182
+ -p 3001:3001 \
183
+ mcp-search-console
184
+ ```
185
+
186
+ Your MCP client connects to `http://your-server:3001/sse`.
187
+
188
+ ---
189
+
190
+ ## License
191
+
192
+ MIT
@@ -0,0 +1,19 @@
1
+ {
2
+ "default": "my-site",
3
+ "accounts": {
4
+ "my-site": {
5
+ "type": "oauth",
6
+ "client_secrets_file": "/path/to/client_secrets.json",
7
+ "token_file": "/path/to/my-site.token"
8
+ },
9
+ "client-acme": {
10
+ "type": "service_account",
11
+ "credentials_file": "/path/to/acme-service-account.json"
12
+ },
13
+ "client-beta": {
14
+ "type": "oauth",
15
+ "client_secrets_file": "/path/to/client_secrets.json",
16
+ "token_file": "/path/to/beta.token"
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,3 @@
1
+ """mcp-search-console: Multi-account Google Search Console MCP server."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,114 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from googleapiclient.discovery import build
7
+
8
+ from gsc.auth.oauth import get_oauth_credentials
9
+ from gsc.auth.service_account import get_service_account_credentials
10
+
11
+ _DEFAULT_CONFIG_PATH = os.environ.get(
12
+ "GSC_ACCOUNTS_CONFIG", os.path.expanduser("~/.config/mcp-search-console/accounts.json")
13
+ )
14
+
15
+
16
+ class AccountError(Exception):
17
+ pass
18
+
19
+
20
+ class AccountManager:
21
+ def __init__(self, config_path: str = _DEFAULT_CONFIG_PATH):
22
+ self._config_path = config_path
23
+ self._config = self._load_config()
24
+ # Cache: account_name -> authenticated GSC service resource
25
+ self._clients: dict[str, object] = {}
26
+
27
+ def _load_config(self) -> dict:
28
+ path = Path(self._config_path)
29
+ if not path.exists():
30
+ raise AccountError(
31
+ f"Accounts config not found at {self._config_path}. "
32
+ "Copy accounts.example.json and set GSC_ACCOUNTS_CONFIG."
33
+ )
34
+ with path.open() as f:
35
+ return json.load(f)
36
+
37
+ def _resolve_account(self, account: Optional[str]) -> str:
38
+ name = account or self._config.get("default")
39
+ if not name:
40
+ raise AccountError("No account specified and no default set in config.")
41
+ if name not in self._config.get("accounts", {}):
42
+ raise AccountError(
43
+ f"Account '{name}' not found in config. "
44
+ f"Available: {', '.join(self._config['accounts'].keys())}"
45
+ )
46
+ return name
47
+
48
+ def get_client(self, account: Optional[str] = None):
49
+ name = self._resolve_account(account)
50
+
51
+ if name not in self._clients:
52
+ self._clients[name] = self._build_client(name)
53
+
54
+ # Re-validate credentials are still fresh on each access
55
+ client = self._clients[name]
56
+ creds = client._http.credentials
57
+ if hasattr(creds, "expired") and creds.expired:
58
+ # Force refresh and rebuild
59
+ del self._clients[name]
60
+ self._clients[name] = self._build_client(name)
61
+
62
+ return self._clients[name]
63
+
64
+ def _build_client(self, name: str):
65
+ cfg = self._config["accounts"][name]
66
+ auth_type = cfg.get("type", "oauth")
67
+
68
+ if auth_type == "oauth":
69
+ creds = get_oauth_credentials(
70
+ client_secrets_file=cfg["client_secrets_file"],
71
+ token_file=cfg["token_file"],
72
+ )
73
+ elif auth_type == "service_account":
74
+ creds = get_service_account_credentials(cfg["credentials_file"])
75
+ else:
76
+ raise AccountError(f"Unknown auth type '{auth_type}' for account '{name}'.")
77
+
78
+ return build("webmasters", "v3", credentials=creds, cache_discovery=False)
79
+
80
+ def list_accounts(self) -> list[dict]:
81
+ default = self._config.get("default")
82
+ result = []
83
+ for name, cfg in self._config.get("accounts", {}).items():
84
+ result.append(
85
+ {
86
+ "name": name,
87
+ "type": cfg.get("type", "oauth"),
88
+ "is_default": name == default,
89
+ "authenticated": name in self._clients,
90
+ }
91
+ )
92
+ return result
93
+
94
+ def set_default(self, account: str) -> None:
95
+ if account not in self._config.get("accounts", {}):
96
+ raise AccountError(
97
+ f"Account '{account}' not found. "
98
+ f"Available: {', '.join(self._config['accounts'].keys())}"
99
+ )
100
+ self._config["default"] = account
101
+ # Persist the change
102
+ with open(self._config_path, "w") as f:
103
+ json.dump(self._config, f, indent=2)
104
+
105
+ def invalidate(self, account: Optional[str] = None) -> None:
106
+ """Force re-authentication for an account (clears cached client)."""
107
+ name = self._resolve_account(account)
108
+ self._clients.pop(name, None)
109
+ # Also delete the token file so OAuth re-runs the flow
110
+ cfg = self._config["accounts"][name]
111
+ if cfg.get("type") == "oauth" and "token_file" in cfg:
112
+ token_path = Path(os.path.expanduser(cfg["token_file"]))
113
+ if token_path.exists():
114
+ token_path.unlink()
File without changes
@@ -0,0 +1,30 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from google.oauth2.credentials import Credentials
5
+ from google_auth_oauthlib.flow import InstalledAppFlow
6
+ from google.auth.transport.requests import Request
7
+
8
+ SCOPES = ["https://www.googleapis.com/auth/webmasters"]
9
+
10
+
11
+ def get_oauth_credentials(client_secrets_file: str, token_file: str) -> Credentials:
12
+ creds = None
13
+ token_path = Path(os.path.expanduser(token_file))
14
+
15
+ if token_path.exists():
16
+ creds = Credentials.from_authorized_user_file(str(token_path), SCOPES)
17
+
18
+ if not creds or not creds.valid:
19
+ if creds and creds.expired and creds.refresh_token:
20
+ creds.refresh(Request())
21
+ else:
22
+ flow = InstalledAppFlow.from_client_secrets_file(
23
+ os.path.expanduser(client_secrets_file), SCOPES
24
+ )
25
+ creds = flow.run_local_server(port=0)
26
+
27
+ token_path.parent.mkdir(parents=True, exist_ok=True)
28
+ token_path.write_text(creds.to_json())
29
+
30
+ return creds
@@ -0,0 +1,11 @@
1
+ import os
2
+
3
+ from google.oauth2 import service_account
4
+
5
+ SCOPES = ["https://www.googleapis.com/auth/webmasters"]
6
+
7
+
8
+ def get_service_account_credentials(credentials_file: str) -> service_account.Credentials:
9
+ return service_account.Credentials.from_service_account_file(
10
+ os.path.expanduser(credentials_file), scopes=SCOPES
11
+ )
@@ -0,0 +1,69 @@
1
+ import time
2
+ import functools
3
+ from typing import Callable, TypeVar
4
+
5
+ from googleapiclient.errors import HttpError
6
+
7
+ T = TypeVar("T")
8
+
9
+ _RETRYABLE_STATUS = {429, 500, 502, 503, 504}
10
+ _MAX_RETRIES = 5
11
+ _BASE_DELAY = 1.0 # seconds
12
+
13
+
14
+ def with_retry(fn: Callable[..., T], *args, **kwargs) -> T:
15
+ """
16
+ Call fn(*args, **kwargs) with exponential backoff on retryable HTTP errors.
17
+ Retryable: 429 (rate limit), 500/502/503/504 (transient server errors).
18
+ Non-retryable errors propagate immediately.
19
+ """
20
+ delay = _BASE_DELAY
21
+ for attempt in range(_MAX_RETRIES):
22
+ try:
23
+ return fn(*args, **kwargs)
24
+ except HttpError as e:
25
+ status = e.resp.status if hasattr(e, "resp") else 0
26
+ if status not in _RETRYABLE_STATUS or attempt == _MAX_RETRIES - 1:
27
+ # Non-retryable or exhausted — raise a clean message
28
+ raise _clean_http_error(e) from None
29
+ time.sleep(delay)
30
+ delay = min(delay * 2, 60) # cap at 60s
31
+ # unreachable, but satisfies type checkers
32
+ raise RuntimeError("retry loop exited unexpectedly")
33
+
34
+
35
+ def _clean_http_error(e: HttpError) -> Exception:
36
+ status = e.resp.status if hasattr(e, "resp") else "?"
37
+ reason = _extract_reason(e)
38
+ messages = {
39
+ 400: f"Bad request — {reason}",
40
+ 401: "Authentication failed. Run reauthenticate() for this account.",
41
+ 403: f"Access denied — {reason}. Check that this account has access to the GSC property.",
42
+ 404: (
43
+ "Property not found. Use list_properties() to see available properties. "
44
+ "Domain properties must be prefixed with 'sc-domain:' (e.g. sc-domain:example.com)."
45
+ ),
46
+ 429: "GSC API rate limit hit after retries. Wait a few minutes and try again.",
47
+ 500: "GSC API returned a server error. Try again shortly.",
48
+ 503: "GSC API is temporarily unavailable. Try again shortly.",
49
+ }
50
+ msg = messages.get(status, f"GSC API error {status}: {reason}")
51
+ return RuntimeError(msg)
52
+
53
+
54
+ def _extract_reason(e: HttpError) -> str:
55
+ try:
56
+ import json
57
+ content = json.loads(e.content)
58
+ error = content.get("error", {})
59
+ return error.get("message", str(e))
60
+ except Exception:
61
+ return str(e)
62
+
63
+
64
+ def retryable(fn):
65
+ """Decorator: wrap a function so all internal HttpErrors use with_retry semantics."""
66
+ @functools.wraps(fn)
67
+ def wrapper(*args, **kwargs):
68
+ return with_retry(fn, *args, **kwargs)
69
+ return wrapper
@@ -0,0 +1,429 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ from fastmcp import FastMCP
5
+
6
+ from gsc.accounts import AccountManager, AccountError
7
+ from gsc.retry import with_retry
8
+
9
+ mcp = FastMCP("mcp-search-console")
10
+ manager = AccountManager()
11
+
12
+
13
+ def _safe(fn):
14
+ """Return structured error dicts instead of raising — keeps MCP responses clean."""
15
+ def wrapper(*args, **kwargs):
16
+ try:
17
+ return fn(*args, **kwargs)
18
+ except AccountError as e:
19
+ return {"error": str(e)}
20
+ except RuntimeError as e:
21
+ return {"error": str(e)}
22
+ except Exception as e:
23
+ return {"error": f"Unexpected error: {type(e).__name__}: {str(e)}"}
24
+ wrapper.__name__ = fn.__name__
25
+ wrapper.__doc__ = fn.__doc__
26
+ return wrapper
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Account management tools
31
+ # ---------------------------------------------------------------------------
32
+
33
+ @mcp.tool()
34
+ @_safe
35
+ def list_accounts() -> list[dict]:
36
+ """List all configured GSC accounts and which is the current default."""
37
+ return manager.list_accounts()
38
+
39
+
40
+ @mcp.tool()
41
+ @_safe
42
+ def set_default_account(account: str) -> dict:
43
+ """Set the default account used when no account is specified in other tools."""
44
+ manager.set_default(account)
45
+ return {"success": True, "default": account}
46
+
47
+
48
+ @mcp.tool()
49
+ @_safe
50
+ def reauthenticate(account: Optional[str] = None) -> dict:
51
+ """
52
+ Force re-authentication for an account. Clears the cached token and re-runs
53
+ the OAuth flow (or reloads service account credentials). Useful when a token
54
+ has been revoked or you need to switch Google accounts.
55
+ """
56
+ manager.invalidate(account)
57
+ manager.get_client(account)
58
+ return {"success": True, "account": account or manager._config.get("default")}
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # GSC property tools
63
+ # ---------------------------------------------------------------------------
64
+
65
+ @mcp.tool()
66
+ @_safe
67
+ def list_properties(account: Optional[str] = None) -> list[dict]:
68
+ """List all Google Search Console properties for the specified account."""
69
+ service = manager.get_client(account)
70
+ response = with_retry(service.sites().list().execute)
71
+ sites = response.get("siteEntry", [])
72
+ return [{"url": s["siteUrl"], "permission_level": s.get("permissionLevel")} for s in sites]
73
+
74
+
75
+ @mcp.tool()
76
+ @_safe
77
+ def get_site_details(site_url: str, account: Optional[str] = None) -> dict:
78
+ """Get verification and permission details for a specific GSC property."""
79
+ service = manager.get_client(account)
80
+ return with_retry(service.sites().get(siteUrl=site_url).execute)
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Search analytics tools
85
+ # ---------------------------------------------------------------------------
86
+
87
+ @mcp.tool()
88
+ @_safe
89
+ def get_search_analytics(
90
+ site_url: str,
91
+ start_date: str,
92
+ end_date: str,
93
+ dimensions: Optional[list[str]] = None,
94
+ row_limit: int = 25,
95
+ account: Optional[str] = None,
96
+ ) -> dict:
97
+ """
98
+ Fetch search analytics data (clicks, impressions, CTR, position).
99
+
100
+ Args:
101
+ site_url: GSC property URL (e.g. 'https://example.com' or 'sc-domain:example.com')
102
+ start_date: Start date in YYYY-MM-DD format
103
+ end_date: End date in YYYY-MM-DD format
104
+ dimensions: List of dimensions — any of ['query', 'page', 'country', 'device', 'date']
105
+ row_limit: Number of rows to return (default 25, max 1000)
106
+ account: Account name from config (uses default if omitted)
107
+ """
108
+ service = manager.get_client(account)
109
+ body = {
110
+ "startDate": start_date,
111
+ "endDate": end_date,
112
+ "dimensions": dimensions or ["query"],
113
+ "rowLimit": min(max(1, row_limit), 1000),
114
+ "dataState": "all",
115
+ }
116
+ return with_retry(service.searchanalytics().query(siteUrl=site_url, body=body).execute)
117
+
118
+
119
+ @mcp.tool()
120
+ @_safe
121
+ def get_performance_overview(
122
+ site_url: str,
123
+ start_date: str,
124
+ end_date: str,
125
+ account: Optional[str] = None,
126
+ ) -> dict:
127
+ """
128
+ Get a high-level performance summary: total clicks, impressions, CTR, average position.
129
+ No dimension breakdown — use get_search_analytics for that.
130
+ """
131
+ service = manager.get_client(account)
132
+ body = {"startDate": start_date, "endDate": end_date, "dataState": "all"}
133
+ response = with_retry(service.searchanalytics().query(siteUrl=site_url, body=body).execute)
134
+ rows = response.get("rows", [])
135
+ if rows:
136
+ r = rows[0]
137
+ totals = {
138
+ "clicks": r.get("clicks", 0),
139
+ "impressions": r.get("impressions", 0),
140
+ "ctr": round(r.get("ctr", 0) * 100, 2),
141
+ "position": round(r.get("position", 0), 1),
142
+ }
143
+ else:
144
+ totals = {"clicks": 0, "impressions": 0, "ctr": 0.0, "position": 0.0}
145
+ return {"site_url": site_url, "period": f"{start_date} to {end_date}", **totals}
146
+
147
+
148
+ @mcp.tool()
149
+ @_safe
150
+ def compare_periods(
151
+ site_url: str,
152
+ period1_start: str,
153
+ period1_end: str,
154
+ period2_start: str,
155
+ period2_end: str,
156
+ dimensions: Optional[list[str]] = None,
157
+ row_limit: int = 25,
158
+ account: Optional[str] = None,
159
+ ) -> dict:
160
+ """
161
+ Compare search performance between two date ranges.
162
+ Returns rows for both periods side-by-side with delta calculations.
163
+ """
164
+ service = manager.get_client(account)
165
+ dims = dimensions or ["query"]
166
+
167
+ def fetch(start, end):
168
+ body = {
169
+ "startDate": start,
170
+ "endDate": end,
171
+ "dimensions": dims,
172
+ "rowLimit": row_limit,
173
+ "dataState": "all",
174
+ }
175
+ return with_retry(service.searchanalytics().query(siteUrl=site_url, body=body).execute)
176
+
177
+ p1 = fetch(period1_start, period1_end)
178
+ p2 = fetch(period2_start, period2_end)
179
+
180
+ def key(row):
181
+ return tuple(row.get("keys", []))
182
+
183
+ p1_index = {key(r): r for r in p1.get("rows", [])}
184
+ p2_index = {key(r): r for r in p2.get("rows", [])}
185
+
186
+ comparison = []
187
+ for k in set(p1_index) | set(p2_index):
188
+ r1 = p1_index.get(k, {})
189
+ r2 = p2_index.get(k, {})
190
+ comparison.append({
191
+ "keys": list(k),
192
+ "period1": {m: r1.get(m, 0) for m in ["clicks", "impressions", "ctr", "position"]},
193
+ "period2": {m: r2.get(m, 0) for m in ["clicks", "impressions", "ctr", "position"]},
194
+ "delta_clicks": r2.get("clicks", 0) - r1.get("clicks", 0),
195
+ "delta_impressions": r2.get("impressions", 0) - r1.get("impressions", 0),
196
+ })
197
+
198
+ comparison.sort(key=lambda x: abs(x["delta_clicks"]), reverse=True)
199
+ return {"dimensions": dims, "rows": comparison[:row_limit]}
200
+
201
+
202
+ @mcp.tool()
203
+ @_safe
204
+ def get_advanced_search_analytics(
205
+ site_url: str,
206
+ start_date: str,
207
+ end_date: str,
208
+ dimensions: Optional[list[str]] = None,
209
+ filters: Optional[list[dict]] = None,
210
+ row_limit: int = 25,
211
+ account: Optional[str] = None,
212
+ ) -> dict:
213
+ """
214
+ Advanced search analytics with dimension filters.
215
+
216
+ filters format: [{"dimension": "country", "operator": "equals", "expression": "usa"}]
217
+ operators: equals, notEquals, contains, notContains, includingRegex, excludingRegex
218
+ """
219
+ service = manager.get_client(account)
220
+ body = {
221
+ "startDate": start_date,
222
+ "endDate": end_date,
223
+ "dimensions": dimensions or ["query"],
224
+ "rowLimit": min(max(1, row_limit), 1000),
225
+ "dataState": "all",
226
+ }
227
+ if filters:
228
+ body["dimensionFilterGroups"] = [{"filters": filters}]
229
+ return with_retry(service.searchanalytics().query(siteUrl=site_url, body=body).execute)
230
+
231
+
232
+ @mcp.tool()
233
+ @_safe
234
+ def get_search_by_page(
235
+ site_url: str,
236
+ page_url: str,
237
+ start_date: str,
238
+ end_date: str,
239
+ row_limit: int = 25,
240
+ account: Optional[str] = None,
241
+ ) -> dict:
242
+ """Get search queries driving traffic to a specific page URL."""
243
+ service = manager.get_client(account)
244
+ body = {
245
+ "startDate": start_date,
246
+ "endDate": end_date,
247
+ "dimensions": ["query"],
248
+ "rowLimit": min(max(1, row_limit), 1000),
249
+ "dataState": "all",
250
+ "dimensionFilterGroups": [
251
+ {"filters": [{"dimension": "page", "operator": "equals", "expression": page_url}]}
252
+ ],
253
+ }
254
+ return with_retry(service.searchanalytics().query(siteUrl=site_url, body=body).execute)
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # URL inspection tools
259
+ # ---------------------------------------------------------------------------
260
+
261
+ @mcp.tool()
262
+ @_safe
263
+ def inspect_url(
264
+ site_url: str,
265
+ page_url: str,
266
+ account: Optional[str] = None,
267
+ ) -> dict:
268
+ """
269
+ Inspect a URL's indexing status, last crawl date, mobile usability,
270
+ and rich result eligibility.
271
+ """
272
+ service = manager.get_client(account)
273
+ body = {"inspectionUrl": page_url, "siteUrl": site_url}
274
+ return with_retry(service.urlInspection().index().inspect(body=body).execute)
275
+
276
+
277
+ @mcp.tool()
278
+ @_safe
279
+ def batch_inspect_urls(
280
+ site_url: str,
281
+ page_urls: list[str],
282
+ account: Optional[str] = None,
283
+ ) -> list[dict]:
284
+ """
285
+ Inspect multiple URLs at once. Returns one result per URL.
286
+ Maximum 10 URLs per call (GSC API limit).
287
+ """
288
+ if len(page_urls) > 10:
289
+ return [{"error": "Maximum 10 URLs per batch. Split into multiple calls."}]
290
+
291
+ service = manager.get_client(account)
292
+ results = []
293
+ for url in page_urls:
294
+ try:
295
+ body = {"inspectionUrl": url, "siteUrl": site_url}
296
+ result = with_retry(service.urlInspection().index().inspect(body=body).execute)
297
+ results.append({"url": url, "result": result})
298
+ except (RuntimeError, Exception) as e:
299
+ results.append({"url": url, "error": str(e)})
300
+ return results
301
+
302
+
303
+ @mcp.tool()
304
+ @_safe
305
+ def check_indexing_issues(
306
+ site_url: str,
307
+ page_urls: list[str],
308
+ account: Optional[str] = None,
309
+ ) -> list[dict]:
310
+ """
311
+ Check a list of URLs for indexing problems. Returns a prioritised summary
312
+ of issues — more actionable than raw inspect_url output.
313
+ """
314
+ if len(page_urls) > 10:
315
+ return [{"error": "Maximum 10 URLs per call."}]
316
+
317
+ service = manager.get_client(account)
318
+ results = []
319
+ for url in page_urls:
320
+ try:
321
+ body = {"inspectionUrl": url, "siteUrl": site_url}
322
+ raw = with_retry(service.urlInspection().index().inspect(body=body).execute)
323
+ ir = raw.get("inspectionResult", {})
324
+ index_status = ir.get("indexStatusResult", {})
325
+ verdict = index_status.get("verdict", "UNKNOWN")
326
+ results.append({
327
+ "url": url,
328
+ "verdict": verdict,
329
+ "coverage_state": index_status.get("coverageState", ""),
330
+ "last_crawled": index_status.get("lastCrawlTime", "never"),
331
+ "indexing_allowed": index_status.get("indexingAllowed"),
332
+ "robots_txt_state": index_status.get("robotsTxtState"),
333
+ "has_issues": verdict != "PASS",
334
+ })
335
+ except (RuntimeError, Exception) as e:
336
+ results.append({"url": url, "error": str(e)})
337
+
338
+ results.sort(key=lambda x: (0 if x.get("has_issues") else 1))
339
+ return results
340
+
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # Sitemap tools
344
+ # ---------------------------------------------------------------------------
345
+
346
+ @mcp.tool()
347
+ @_safe
348
+ def list_sitemaps(site_url: str, account: Optional[str] = None) -> list[dict]:
349
+ """List all sitemaps submitted to GSC for this property."""
350
+ service = manager.get_client(account)
351
+ response = with_retry(service.sitemaps().list(siteUrl=site_url).execute)
352
+ result = []
353
+ for s in response.get("sitemap", []):
354
+ errors = int(s.get("errors", 0))
355
+ warnings = int(s.get("warnings", 0))
356
+ result.append({
357
+ "path": s.get("path"),
358
+ "last_submitted": s.get("lastSubmitted"),
359
+ "last_downloaded": s.get("lastDownloaded"),
360
+ "is_pending": s.get("isPending"),
361
+ "is_sitemaps_index": s.get("isSitemapsIndex"),
362
+ "type": s.get("type"),
363
+ "warnings": warnings,
364
+ "errors": errors,
365
+ "status": "Error" if errors > 0 else ("Has warnings" if warnings > 0 else "OK"),
366
+ })
367
+ return result
368
+
369
+
370
+ @mcp.tool()
371
+ @_safe
372
+ def get_sitemap(site_url: str, sitemap_url: str, account: Optional[str] = None) -> dict:
373
+ """Get details and status of a specific sitemap."""
374
+ service = manager.get_client(account)
375
+ return with_retry(service.sitemaps().get(siteUrl=site_url, feedpath=sitemap_url).execute)
376
+
377
+
378
+ @mcp.tool()
379
+ @_safe
380
+ def submit_sitemap(
381
+ site_url: str,
382
+ sitemap_url: str,
383
+ account: Optional[str] = None,
384
+ ) -> dict:
385
+ """Submit a sitemap to Google Search Console. Requires GSC_ALLOW_DESTRUCTIVE=true."""
386
+ if not os.environ.get("GSC_ALLOW_DESTRUCTIVE"):
387
+ return {
388
+ "error": "Sitemap submission is disabled by default. "
389
+ "Set GSC_ALLOW_DESTRUCTIVE=true to enable."
390
+ }
391
+ service = manager.get_client(account)
392
+ with_retry(service.sitemaps().submit(siteUrl=site_url, feedpath=sitemap_url).execute)
393
+ return {"success": True, "submitted": sitemap_url}
394
+
395
+
396
+ @mcp.tool()
397
+ @_safe
398
+ def delete_sitemap(
399
+ site_url: str,
400
+ sitemap_url: str,
401
+ account: Optional[str] = None,
402
+ ) -> dict:
403
+ """Delete a sitemap from Google Search Console. Requires GSC_ALLOW_DESTRUCTIVE=true."""
404
+ if not os.environ.get("GSC_ALLOW_DESTRUCTIVE"):
405
+ return {
406
+ "error": "Sitemap deletion is disabled by default. "
407
+ "Set GSC_ALLOW_DESTRUCTIVE=true to enable."
408
+ }
409
+ service = manager.get_client(account)
410
+ with_retry(service.sitemaps().delete(siteUrl=site_url, feedpath=sitemap_url).execute)
411
+ return {"success": True, "deleted": sitemap_url}
412
+
413
+
414
+ # ---------------------------------------------------------------------------
415
+ # Entry point
416
+ # ---------------------------------------------------------------------------
417
+
418
+ def main():
419
+ transport = os.environ.get("MCP_TRANSPORT", "stdio")
420
+ if transport == "sse":
421
+ host = os.environ.get("MCP_HOST", "127.0.0.1")
422
+ port = int(os.environ.get("MCP_PORT", "3001"))
423
+ mcp.run(transport="sse", host=host, port=port)
424
+ else:
425
+ mcp.run(transport="stdio")
426
+
427
+
428
+ if __name__ == "__main__":
429
+ main()
File without changes
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mcp-search-console-multi"
7
+ version = "0.1.0"
8
+ description = "Multi-account Google Search Console MCP server"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ keywords = ["mcp", "google-search-console", "seo", "ai"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3.11",
18
+ ]
19
+ dependencies = [
20
+ "fastmcp>=2.0.0",
21
+ "google-api-python-client>=2.120.0",
22
+ "google-auth>=2.29.0",
23
+ "google-auth-oauthlib>=1.2.0",
24
+ "google-auth-httplib2>=0.2.0",
25
+ ]
26
+
27
+ [project.scripts]
28
+ mcp-search-console-multi = "gsc.server:main"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["gsc"]
32
+
33
+ [tool.ruff]
34
+ line-length = 100
35
+ target-version = "py311"