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.
- mcp_search_console_multi-0.1.0/.dockerignore +9 -0
- mcp_search_console_multi-0.1.0/.github/workflows/ci.yml +23 -0
- mcp_search_console_multi-0.1.0/.gitignore +27 -0
- mcp_search_console_multi-0.1.0/Dockerfile +16 -0
- mcp_search_console_multi-0.1.0/PKG-INFO +210 -0
- mcp_search_console_multi-0.1.0/README.md +192 -0
- mcp_search_console_multi-0.1.0/accounts.example.json +19 -0
- mcp_search_console_multi-0.1.0/gsc/__init__.py +3 -0
- mcp_search_console_multi-0.1.0/gsc/accounts.py +114 -0
- mcp_search_console_multi-0.1.0/gsc/auth/__init__.py +0 -0
- mcp_search_console_multi-0.1.0/gsc/auth/oauth.py +30 -0
- mcp_search_console_multi-0.1.0/gsc/auth/service_account.py +11 -0
- mcp_search_console_multi-0.1.0/gsc/retry.py +69 -0
- mcp_search_console_multi-0.1.0/gsc/server.py +429 -0
- mcp_search_console_multi-0.1.0/gsc/tools/__init__.py +0 -0
- mcp_search_console_multi-0.1.0/pyproject.toml +35 -0
|
@@ -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,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"
|