yandex-webmaster-mcp 1.0.0
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/LICENSE +21 -0
- package/README.md +254 -0
- package/package.json +49 -0
- package/src/auth.mjs +112 -0
- package/src/index.mjs +847 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Alternex
|
|
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,254 @@
|
|
|
1
|
+
# yandex-webmaster-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [Yandex Webmaster API](https://yandex.com/dev/webmaster/doc/en/) — site analytics, indexing status, search queries, and SEO diagnostics.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/yandex-webmaster-mcp)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
[English](#english) | [Русский](#русский)
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## English
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
| Tool | Description |
|
|
17
|
+
|------|-------------|
|
|
18
|
+
| `get-user` | Get authenticated user ID |
|
|
19
|
+
| `list-hosts` | List all sites in Webmaster |
|
|
20
|
+
| `get-host` | Get site information and status |
|
|
21
|
+
| `get-summary` | Site statistics (SQI, indexed pages, problems) |
|
|
22
|
+
| `get-sqi-history` | Site Quality Index history |
|
|
23
|
+
| `get-diagnostics` | Site health diagnostics |
|
|
24
|
+
| `get-popular-queries` | Popular search queries (shows, clicks, positions) |
|
|
25
|
+
| `get-query-history` | Aggregated query statistics over time |
|
|
26
|
+
| `get-indexing-history` | Pages downloaded by robot (by HTTP status) |
|
|
27
|
+
| `get-indexing-samples` | Examples of downloaded pages |
|
|
28
|
+
| `get-insearch-history` | Pages in search results over time |
|
|
29
|
+
| `get-insearch-samples` | Examples of pages in search |
|
|
30
|
+
| `get-search-events-history` | Pages added/removed from search |
|
|
31
|
+
| `get-search-events-samples` | Examples of search events |
|
|
32
|
+
| `get-external-links` | Backlinks pointing to the site |
|
|
33
|
+
| `get-external-links-history` | Backlinks count over time |
|
|
34
|
+
| `get-broken-internal-links` | Broken internal links |
|
|
35
|
+
| `get-broken-internal-links-history` | Broken links count over time |
|
|
36
|
+
| `get-sitemaps` | Auto-detected sitemap files |
|
|
37
|
+
| `get-sitemap` | Specific sitemap details |
|
|
38
|
+
| `get-user-sitemaps` | User-added sitemaps |
|
|
39
|
+
| `get-important-urls` | Monitored important pages |
|
|
40
|
+
| `get-important-url-history` | Important page change history |
|
|
41
|
+
| `get-recrawl-quota` | Reindexing quota status |
|
|
42
|
+
|
|
43
|
+
### Installation
|
|
44
|
+
|
|
45
|
+
#### Claude Desktop
|
|
46
|
+
|
|
47
|
+
Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"mcpServers": {
|
|
52
|
+
"yandex-webmaster": {
|
|
53
|
+
"command": "npx",
|
|
54
|
+
"args": ["-y", "yandex-webmaster-mcp"],
|
|
55
|
+
"env": {
|
|
56
|
+
"YANDEX_WEBMASTER_TOKEN": "your_oauth_token"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Getting an OAuth Token
|
|
64
|
+
|
|
65
|
+
Follow the [official Yandex Webmaster OAuth guide](https://yandex.com/dev/webmaster/doc/en/tasks/how-to-get-oauth) or these steps:
|
|
66
|
+
|
|
67
|
+
#### Step 1: Create an OAuth Application
|
|
68
|
+
|
|
69
|
+
1. Go to [Yandex OAuth app registration](https://oauth.yandex.ru/client/new)
|
|
70
|
+
2. Create an application:
|
|
71
|
+
- Select platform: **Web services** (Веб-сервисы)
|
|
72
|
+
- Set Redirect URI to: `https://oauth.yandex.ru/verification_code`
|
|
73
|
+
- Under "Data access", enable these scopes:
|
|
74
|
+
- `webmaster:hostinfo` — access to site statistics
|
|
75
|
+
- `webmaster:verify` — access to site verification
|
|
76
|
+
3. Save the application and copy your **ClientID** (and **Client secret** for auth command)
|
|
77
|
+
|
|
78
|
+
#### Step 2: Get a Token
|
|
79
|
+
|
|
80
|
+
**Option A: Using the auth command**
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
export YANDEX_CLIENT_ID=your_client_id
|
|
84
|
+
export YANDEX_CLIENT_SECRET=your_client_secret
|
|
85
|
+
|
|
86
|
+
npx yandex-webmaster-mcp auth
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Option B: Manual token retrieval**
|
|
90
|
+
|
|
91
|
+
Open this URL in your browser (replace `<ClientID>` with your actual ID):
|
|
92
|
+
```
|
|
93
|
+
https://oauth.yandex.ru/authorize?response_type=token&client_id=<ClientID>
|
|
94
|
+
```
|
|
95
|
+
Authorize and copy the token from the resulting page.
|
|
96
|
+
|
|
97
|
+
#### Step 3: Configure
|
|
98
|
+
|
|
99
|
+
Set the token as `YANDEX_WEBMASTER_TOKEN` environment variable.
|
|
100
|
+
|
|
101
|
+
> **Note:** Tokens are valid for 6 months. Repeat Step 2 to get a new token when it expires.
|
|
102
|
+
|
|
103
|
+
### Usage Examples
|
|
104
|
+
|
|
105
|
+
**List your sites:**
|
|
106
|
+
```
|
|
107
|
+
"Show me all my sites in Yandex Webmaster"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Get site statistics:**
|
|
111
|
+
```
|
|
112
|
+
"What's the SQI and indexing status for example.com?"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Analyze search queries:**
|
|
116
|
+
```
|
|
117
|
+
"What are the top search queries bringing traffic to my site?"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Check backlinks:**
|
|
121
|
+
```
|
|
122
|
+
"Show me external links pointing to my site"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Site diagnostics:**
|
|
126
|
+
```
|
|
127
|
+
"Are there any SEO problems detected for my site?"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Русский
|
|
133
|
+
|
|
134
|
+
### Возможности
|
|
135
|
+
|
|
136
|
+
| Инструмент | Описание |
|
|
137
|
+
|------------|----------|
|
|
138
|
+
| `get-user` | Получить ID пользователя |
|
|
139
|
+
| `list-hosts` | Список всех сайтов в Вебмастере |
|
|
140
|
+
| `get-host` | Информация о сайте |
|
|
141
|
+
| `get-summary` | Статистика сайта (ИКС, индексация, проблемы) |
|
|
142
|
+
| `get-sqi-history` | История изменения ИКС |
|
|
143
|
+
| `get-diagnostics` | Диагностика проблем сайта |
|
|
144
|
+
| `get-popular-queries` | Популярные поисковые запросы |
|
|
145
|
+
| `get-query-history` | История статистики запросов |
|
|
146
|
+
| `get-indexing-history` | История загрузки страниц роботом |
|
|
147
|
+
| `get-indexing-samples` | Примеры загруженных страниц |
|
|
148
|
+
| `get-insearch-history` | История страниц в поиске |
|
|
149
|
+
| `get-insearch-samples` | Примеры страниц в поиске |
|
|
150
|
+
| `get-search-events-history` | История добавления/удаления из поиска |
|
|
151
|
+
| `get-search-events-samples` | Примеры изменений в поиске |
|
|
152
|
+
| `get-external-links` | Внешние ссылки на сайт |
|
|
153
|
+
| `get-external-links-history` | История количества внешних ссылок |
|
|
154
|
+
| `get-broken-internal-links` | Битые внутренние ссылки |
|
|
155
|
+
| `get-broken-internal-links-history` | История битых ссылок |
|
|
156
|
+
| `get-sitemaps` | Обнаруженные файлы Sitemap |
|
|
157
|
+
| `get-sitemap` | Детали конкретного Sitemap |
|
|
158
|
+
| `get-user-sitemaps` | Добавленные пользователем Sitemap |
|
|
159
|
+
| `get-important-urls` | Мониторинг важных страниц |
|
|
160
|
+
| `get-important-url-history` | История изменений важных страниц |
|
|
161
|
+
| `get-recrawl-quota` | Квота на переобход |
|
|
162
|
+
|
|
163
|
+
### Установка
|
|
164
|
+
|
|
165
|
+
#### Claude Desktop
|
|
166
|
+
|
|
167
|
+
Добавьте в конфигурацию Claude Desktop:
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"mcpServers": {
|
|
172
|
+
"yandex-webmaster": {
|
|
173
|
+
"command": "npx",
|
|
174
|
+
"args": ["-y", "yandex-webmaster-mcp"],
|
|
175
|
+
"env": {
|
|
176
|
+
"YANDEX_WEBMASTER_TOKEN": "ваш_oauth_токен"
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Получение OAuth токена
|
|
184
|
+
|
|
185
|
+
Следуйте [официальной инструкции Яндекса](https://yandex.com/dev/webmaster/doc/ru/tasks/how-to-get-oauth) или этим шагам:
|
|
186
|
+
|
|
187
|
+
#### Шаг 1: Создание OAuth-приложения
|
|
188
|
+
|
|
189
|
+
1. Перейдите на [страницу регистрации приложения](https://oauth.yandex.ru/client/new)
|
|
190
|
+
2. Создайте приложение:
|
|
191
|
+
- Выберите платформу: **Веб-сервисы**
|
|
192
|
+
- Укажите Redirect URI: `https://oauth.yandex.ru/verification_code`
|
|
193
|
+
- В разделе "Доступ к данным" включите:
|
|
194
|
+
- `webmaster:hostinfo` — доступ к статистике сайтов
|
|
195
|
+
- `webmaster:verify` — доступ к верификации сайтов
|
|
196
|
+
3. Сохраните приложение и скопируйте **ClientID** (и **Client secret** для команды auth)
|
|
197
|
+
|
|
198
|
+
#### Шаг 2: Получение токена
|
|
199
|
+
|
|
200
|
+
**Вариант А: Через команду auth**
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
export YANDEX_CLIENT_ID=your_client_id
|
|
204
|
+
export YANDEX_CLIENT_SECRET=your_client_secret
|
|
205
|
+
|
|
206
|
+
npx yandex-webmaster-mcp auth
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Вариант Б: Вручную**
|
|
210
|
+
|
|
211
|
+
Откройте в браузере (замените `<ClientID>` на ваш ID):
|
|
212
|
+
```
|
|
213
|
+
https://oauth.yandex.ru/authorize?response_type=token&client_id=<ClientID>
|
|
214
|
+
```
|
|
215
|
+
Авторизуйтесь и скопируйте токен со страницы результата.
|
|
216
|
+
|
|
217
|
+
#### Шаг 3: Настройка
|
|
218
|
+
|
|
219
|
+
Установите токен в переменную окружения `YANDEX_WEBMASTER_TOKEN`.
|
|
220
|
+
|
|
221
|
+
> **Примечание:** Токен действителен 6 месяцев. Для получения нового повторите шаг 2.
|
|
222
|
+
|
|
223
|
+
### Примеры использования
|
|
224
|
+
|
|
225
|
+
**Список сайтов:**
|
|
226
|
+
```
|
|
227
|
+
"Покажи все мои сайты в Яндекс.Вебмастере"
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**Статистика сайта:**
|
|
231
|
+
```
|
|
232
|
+
"Какой ИКС и статус индексации у example.com?"
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**Анализ запросов:**
|
|
236
|
+
```
|
|
237
|
+
"Какие поисковые запросы приводят трафик на мой сайт?"
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**Проверка ссылок:**
|
|
241
|
+
```
|
|
242
|
+
"Покажи внешние ссылки на мой сайт"
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
**Диагностика:**
|
|
246
|
+
```
|
|
247
|
+
"Есть ли какие-то SEO проблемы на моем сайте?"
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yandex-webmaster-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for Yandex Webmaster API - site analytics, indexing status, search queries, and SEO diagnostics",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.mjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"yandex-webmaster-mcp": "src/index.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/*.mjs",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node src/index.mjs"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"mcp",
|
|
20
|
+
"model-context-protocol",
|
|
21
|
+
"yandex",
|
|
22
|
+
"webmaster",
|
|
23
|
+
"seo",
|
|
24
|
+
"indexing",
|
|
25
|
+
"search-queries",
|
|
26
|
+
"analytics",
|
|
27
|
+
"russian",
|
|
28
|
+
"claude",
|
|
29
|
+
"ai"
|
|
30
|
+
],
|
|
31
|
+
"author": "Alternex",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/altrr2/yandex-tools-mcp.git",
|
|
36
|
+
"directory": "packages/yandex-webmaster-mcp"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/altrr2/yandex-tools-mcp/issues"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/altrr2/yandex-tools-mcp/tree/main/packages/yandex-webmaster-mcp#readme",
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18.0.0"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
47
|
+
"zod": "^4.3.4"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/auth.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
|
|
3
|
+
const OAUTH_URL = 'https://oauth.yandex.ru/authorize';
|
|
4
|
+
const TOKEN_URL = 'https://oauth.yandex.ru/token';
|
|
5
|
+
|
|
6
|
+
function openBrowser(url) {
|
|
7
|
+
const start = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
8
|
+
|
|
9
|
+
import('node:child_process').then(({ exec }) => {
|
|
10
|
+
exec(`${start} "${url}"`);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function prompt(question) {
|
|
15
|
+
const rl = readline.createInterface({
|
|
16
|
+
input: process.stdin,
|
|
17
|
+
output: process.stdout,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
rl.question(question, (answer) => {
|
|
22
|
+
rl.close();
|
|
23
|
+
resolve(answer.trim());
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function exchangeCodeForToken(code, clientId, clientSecret) {
|
|
29
|
+
const response = await fetch(TOKEN_URL, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
33
|
+
},
|
|
34
|
+
body: new URLSearchParams({
|
|
35
|
+
grant_type: 'authorization_code',
|
|
36
|
+
code,
|
|
37
|
+
client_id: clientId,
|
|
38
|
+
client_secret: clientSecret,
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
const error = await response.text();
|
|
44
|
+
throw new Error(`Token exchange failed: ${error}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const data = await response.json();
|
|
48
|
+
return data.access_token;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runAuth() {
|
|
52
|
+
const clientId = process.env.YANDEX_CLIENT_ID;
|
|
53
|
+
const clientSecret = process.env.YANDEX_CLIENT_SECRET;
|
|
54
|
+
|
|
55
|
+
if (!clientId || !clientSecret) {
|
|
56
|
+
console.error('Missing credentials. Set environment variables:');
|
|
57
|
+
console.error(' export YANDEX_CLIENT_ID=your_client_id');
|
|
58
|
+
console.error(' export YANDEX_CLIENT_SECRET=your_client_secret');
|
|
59
|
+
console.error('');
|
|
60
|
+
console.error('Get these from https://oauth.yandex.ru/client/new by creating an app with Webmaster API access.');
|
|
61
|
+
console.error('');
|
|
62
|
+
console.error('Then run: npx yandex-webmaster-mcp auth');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log('Starting OAuth flow for Yandex Webmaster...\n');
|
|
67
|
+
|
|
68
|
+
const authUrl = `${OAUTH_URL}?response_type=code&client_id=${clientId}`;
|
|
69
|
+
|
|
70
|
+
console.log('Opening browser for authorization...');
|
|
71
|
+
console.log("If browser doesn't open, visit this URL manually:\n");
|
|
72
|
+
console.log(authUrl);
|
|
73
|
+
console.log('');
|
|
74
|
+
|
|
75
|
+
openBrowser(authUrl);
|
|
76
|
+
|
|
77
|
+
const code = await prompt('Paste the authorization code from Yandex: ');
|
|
78
|
+
|
|
79
|
+
if (!code) {
|
|
80
|
+
console.error('No code provided. Aborting.');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.log('\nExchanging code for token...');
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const token = await exchangeCodeForToken(code, clientId, clientSecret);
|
|
88
|
+
|
|
89
|
+
console.log('\n✓ Authorization successful!\n');
|
|
90
|
+
console.log('Your access token:');
|
|
91
|
+
console.log('─'.repeat(50));
|
|
92
|
+
console.log(token);
|
|
93
|
+
console.log('─'.repeat(50));
|
|
94
|
+
console.log('\nAdd it to your MCP config:');
|
|
95
|
+
console.log(`
|
|
96
|
+
{
|
|
97
|
+
"mcpServers": {
|
|
98
|
+
"yandex-webmaster": {
|
|
99
|
+
"command": "npx",
|
|
100
|
+
"args": ["-y", "yandex-webmaster-mcp"],
|
|
101
|
+
"env": {
|
|
102
|
+
"YANDEX_WEBMASTER_TOKEN": "${token}"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
`);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error('Failed to get token:', err);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
// Handle CLI commands
|
|
8
|
+
const command = process.argv[2];
|
|
9
|
+
if (command === 'auth') {
|
|
10
|
+
const { runAuth } = await import('./auth.mjs');
|
|
11
|
+
await runAuth();
|
|
12
|
+
} else {
|
|
13
|
+
await runServer();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function runServer() {
|
|
17
|
+
const BASE_URL = 'https://api.webmaster.yandex.net/v4';
|
|
18
|
+
|
|
19
|
+
// Cache user_id for the session
|
|
20
|
+
let cachedUserId = null;
|
|
21
|
+
|
|
22
|
+
function getToken() {
|
|
23
|
+
const token = process.env.YANDEX_WEBMASTER_TOKEN;
|
|
24
|
+
if (!token) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'YANDEX_WEBMASTER_TOKEN environment variable is required. Run "npx yandex-webmaster-mcp auth" to get a token.',
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return token;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function apiRequest(endpoint, options = {}) {
|
|
33
|
+
const url = `${BASE_URL}${endpoint}`;
|
|
34
|
+
const response = await fetch(url, {
|
|
35
|
+
method: options.method || 'GET',
|
|
36
|
+
headers: {
|
|
37
|
+
Authorization: `OAuth ${getToken()}`,
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
...options.headers,
|
|
40
|
+
},
|
|
41
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
const errorText = await response.text();
|
|
46
|
+
throw new Error(`Webmaster API error (${response.status}): ${errorText}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return response.json();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Get user ID (cached)
|
|
53
|
+
async function getUserId() {
|
|
54
|
+
if (!cachedUserId) {
|
|
55
|
+
const data = await apiRequest('/user');
|
|
56
|
+
cachedUserId = data.user_id;
|
|
57
|
+
}
|
|
58
|
+
return cachedUserId;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Helper to build host endpoint
|
|
62
|
+
async function hostEndpoint(hostId, path = '') {
|
|
63
|
+
const userId = await getUserId();
|
|
64
|
+
return `/user/${userId}/hosts/${encodeURIComponent(hostId)}${path}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Format date for API
|
|
68
|
+
function formatDate(date) {
|
|
69
|
+
if (!date) return undefined;
|
|
70
|
+
return new Date(date).toISOString();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const server = new McpServer({name:'yandex-webmaster',version:'1.0.0'});
|
|
74
|
+
|
|
75
|
+
// ============ Core Tools ============
|
|
76
|
+
|
|
77
|
+
server.registerTool(
|
|
78
|
+
'get-user',
|
|
79
|
+
{
|
|
80
|
+
title: 'Get User ID',
|
|
81
|
+
description: 'Returns the authenticated user ID. This ID is required for all other API calls.',
|
|
82
|
+
inputSchema: {},
|
|
83
|
+
},
|
|
84
|
+
async () => {
|
|
85
|
+
const userId = await getUserId();
|
|
86
|
+
return {
|
|
87
|
+
content: [{ type: 'text', text: `User ID: ${userId}` }],
|
|
88
|
+
structuredContent: { user_id: userId },
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
server.registerTool(
|
|
94
|
+
'list-hosts',
|
|
95
|
+
{
|
|
96
|
+
title: 'List Sites',
|
|
97
|
+
description: 'Returns the list of sites added by the user to Yandex Webmaster.',
|
|
98
|
+
inputSchema: {},
|
|
99
|
+
},
|
|
100
|
+
async () => {
|
|
101
|
+
const userId = await getUserId();
|
|
102
|
+
const data = await apiRequest(`/user/${userId}/hosts`);
|
|
103
|
+
|
|
104
|
+
const summary = data.hosts
|
|
105
|
+
.map((h) => `- ${h.unicode_host_url || h.ascii_host_url} (verified: ${h.verified}, id: ${h.host_id})`)
|
|
106
|
+
.join('\n');
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: 'text', text: `Found ${data.hosts.length} sites:\n\n${summary}` }],
|
|
110
|
+
structuredContent: data,
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
server.registerTool(
|
|
116
|
+
'get-host',
|
|
117
|
+
{
|
|
118
|
+
title: 'Get Site Info',
|
|
119
|
+
description: 'Returns detailed information about a specific site.',
|
|
120
|
+
inputSchema: {
|
|
121
|
+
host_id: z.string().describe('Site identifier (e.g., "https:example.com:443")'),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
async ({ host_id }) => {
|
|
125
|
+
const endpoint = await hostEndpoint(host_id);
|
|
126
|
+
const data = await apiRequest(endpoint);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
content: [
|
|
130
|
+
{
|
|
131
|
+
type: 'text',
|
|
132
|
+
text: `Site: ${data.unicode_host_url || data.ascii_host_url}\nStatus: ${data.host_data_status}\nVerified: ${data.verified}`,
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
structuredContent: data,
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// ============ Statistics & Summary ============
|
|
141
|
+
|
|
142
|
+
server.registerTool(
|
|
143
|
+
'get-summary',
|
|
144
|
+
{
|
|
145
|
+
title: 'Get Site Summary',
|
|
146
|
+
description:
|
|
147
|
+
'Returns general site statistics including SQI, indexed pages count, excluded pages, and problem counts.',
|
|
148
|
+
inputSchema: {
|
|
149
|
+
host_id: z.string().describe('Site identifier'),
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
async ({ host_id }) => {
|
|
153
|
+
const endpoint = await hostEndpoint(host_id, '/summary');
|
|
154
|
+
const data = await apiRequest(endpoint);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
content: [
|
|
158
|
+
{
|
|
159
|
+
type: 'text',
|
|
160
|
+
text: `Site Summary:\n- SQI: ${data.sqi}\n- Searchable pages: ${data.searchable_pages_count}\n- Excluded pages: ${data.excluded_pages_count}\n- Problems: Fatal=${data.site_problems?.FATAL || 0}, Critical=${data.site_problems?.CRITICAL || 0}, Possible=${data.site_problems?.POSSIBLE_PROBLEM || 0}, Recommendations=${data.site_problems?.RECOMMENDATION || 0}`,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
structuredContent: data,
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
server.registerTool(
|
|
169
|
+
'get-sqi-history',
|
|
170
|
+
{
|
|
171
|
+
title: 'Get SQI History',
|
|
172
|
+
description: 'Returns the history of Site Quality Index (SQI) changes over time.',
|
|
173
|
+
inputSchema: {
|
|
174
|
+
host_id: z.string().describe('Site identifier'),
|
|
175
|
+
date_from: z.string().optional().describe('Start date (YYYY-MM-DD)'),
|
|
176
|
+
date_to: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
async ({ host_id, date_from, date_to }) => {
|
|
180
|
+
let endpoint = await hostEndpoint(host_id, '/sqi-history');
|
|
181
|
+
const params = new URLSearchParams();
|
|
182
|
+
if (date_from) params.set('date_from', formatDate(date_from));
|
|
183
|
+
if (date_to) params.set('date_to', formatDate(date_to));
|
|
184
|
+
if (params.toString()) endpoint += `?${params}`;
|
|
185
|
+
|
|
186
|
+
const data = await apiRequest(endpoint);
|
|
187
|
+
|
|
188
|
+
const points = data.points || [];
|
|
189
|
+
const text = points.length
|
|
190
|
+
? points.map((p) => `${p.date.split('T')[0]}: SQI ${p.value}`).join('\n')
|
|
191
|
+
: 'No SQI history available';
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
content: [{ type: 'text', text: `SQI History (${points.length} points):\n\n${text}` }],
|
|
195
|
+
structuredContent: data,
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// ============ Diagnostics ============
|
|
201
|
+
|
|
202
|
+
server.registerTool(
|
|
203
|
+
'get-diagnostics',
|
|
204
|
+
{
|
|
205
|
+
title: 'Get Site Diagnostics',
|
|
206
|
+
description:
|
|
207
|
+
'Returns site diagnostics with detected problems categorized by severity (fatal, critical, possible, recommendation).',
|
|
208
|
+
inputSchema: {
|
|
209
|
+
host_id: z.string().describe('Site identifier'),
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
async ({ host_id }) => {
|
|
213
|
+
const endpoint = await hostEndpoint(host_id, '/diagnostics');
|
|
214
|
+
const data = await apiRequest(endpoint);
|
|
215
|
+
|
|
216
|
+
const problems = data.problems || {};
|
|
217
|
+
const lines = Object.entries(problems).map(
|
|
218
|
+
([key, val]) =>
|
|
219
|
+
`- ${key}: ${val.severity} (${val.state}) - last updated: ${val.last_state_update?.split('T')[0] || 'N/A'}`,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
content: [
|
|
224
|
+
{
|
|
225
|
+
type: 'text',
|
|
226
|
+
text: lines.length ? `Diagnostics:\n\n${lines.join('\n')}` : 'No diagnostic issues detected.',
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
structuredContent: data,
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// ============ Search Queries ============
|
|
235
|
+
|
|
236
|
+
server.registerTool(
|
|
237
|
+
'get-popular-queries',
|
|
238
|
+
{
|
|
239
|
+
title: 'Get Popular Queries',
|
|
240
|
+
description: 'Returns popular search queries that brought users to the site, sorted by shows or clicks.',
|
|
241
|
+
inputSchema: {
|
|
242
|
+
host_id: z.string().describe('Site identifier'),
|
|
243
|
+
order_by: z.enum(['TOTAL_SHOWS', 'TOTAL_CLICKS']).describe('Sort by shows or clicks'),
|
|
244
|
+
device_type: z
|
|
245
|
+
.enum(['ALL', 'DESKTOP', 'MOBILE', 'TABLET', 'MOBILE_AND_TABLET'])
|
|
246
|
+
.optional()
|
|
247
|
+
.describe('Device filter (default: ALL)'),
|
|
248
|
+
date_from: z.string().optional().describe('Start date (YYYY-MM-DD)'),
|
|
249
|
+
date_to: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
|
250
|
+
limit: z.number().min(1).max(500).optional().describe('Number of results (default: 100, max: 500)'),
|
|
251
|
+
offset: z.number().min(0).optional().describe('Offset for pagination'),
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
async ({ host_id, order_by, device_type, date_from, date_to, limit = 100, offset }) => {
|
|
255
|
+
let endpoint = await hostEndpoint(host_id, '/search-queries/popular');
|
|
256
|
+
const params = new URLSearchParams();
|
|
257
|
+
params.set('order_by', order_by);
|
|
258
|
+
params.set('query_indicator', 'TOTAL_SHOWS');
|
|
259
|
+
params.append('query_indicator', 'TOTAL_CLICKS');
|
|
260
|
+
params.append('query_indicator', 'AVG_SHOW_POSITION');
|
|
261
|
+
params.append('query_indicator', 'AVG_CLICK_POSITION');
|
|
262
|
+
if (device_type) params.set('device_type_indicator', device_type);
|
|
263
|
+
if (date_from) params.set('date_from', formatDate(date_from));
|
|
264
|
+
if (date_to) params.set('date_to', formatDate(date_to));
|
|
265
|
+
if (limit) params.set('limit', limit.toString());
|
|
266
|
+
if (offset) params.set('offset', offset.toString());
|
|
267
|
+
endpoint += `?${params}`;
|
|
268
|
+
|
|
269
|
+
const data = await apiRequest(endpoint);
|
|
270
|
+
|
|
271
|
+
const queries = data.queries || [];
|
|
272
|
+
const summary = queries
|
|
273
|
+
.slice(0, 20)
|
|
274
|
+
.map(
|
|
275
|
+
(q, i) =>
|
|
276
|
+
`${i + 1}. "${q.query_text}" - shows: ${q.indicators?.TOTAL_SHOWS || 0}, clicks: ${q.indicators?.TOTAL_CLICKS || 0}, avg pos: ${q.indicators?.AVG_SHOW_POSITION?.toFixed(1) || 'N/A'}`,
|
|
277
|
+
)
|
|
278
|
+
.join('\n');
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
content: [
|
|
282
|
+
{
|
|
283
|
+
type: 'text',
|
|
284
|
+
text: `Popular queries (${data.count} total, showing top ${Math.min(20, queries.length)}):\n\n${summary}`,
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
structuredContent: data,
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
server.registerTool(
|
|
293
|
+
'get-query-history',
|
|
294
|
+
{
|
|
295
|
+
title: 'Get All Queries History',
|
|
296
|
+
description: 'Returns aggregated search query statistics over time (shows, clicks, positions).',
|
|
297
|
+
inputSchema: {
|
|
298
|
+
host_id: z.string().describe('Site identifier'),
|
|
299
|
+
device_type: z
|
|
300
|
+
.enum(['ALL', 'DESKTOP', 'MOBILE', 'TABLET', 'MOBILE_AND_TABLET'])
|
|
301
|
+
.optional()
|
|
302
|
+
.describe('Device filter (default: ALL)'),
|
|
303
|
+
date_from: z.string().optional().describe('Start date (YYYY-MM-DD)'),
|
|
304
|
+
date_to: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
async ({ host_id, device_type, date_from, date_to }) => {
|
|
308
|
+
let endpoint = await hostEndpoint(host_id, '/search-queries/all/history');
|
|
309
|
+
const params = new URLSearchParams();
|
|
310
|
+
params.set('query_indicator', 'TOTAL_SHOWS');
|
|
311
|
+
params.append('query_indicator', 'TOTAL_CLICKS');
|
|
312
|
+
params.append('query_indicator', 'AVG_SHOW_POSITION');
|
|
313
|
+
if (device_type) params.set('device_type_indicator', device_type);
|
|
314
|
+
if (date_from) params.set('date_from', formatDate(date_from));
|
|
315
|
+
if (date_to) params.set('date_to', formatDate(date_to));
|
|
316
|
+
endpoint += `?${params}`;
|
|
317
|
+
|
|
318
|
+
const data = await apiRequest(endpoint);
|
|
319
|
+
|
|
320
|
+
const shows = data.indicators?.TOTAL_SHOWS || [];
|
|
321
|
+
const text = shows
|
|
322
|
+
.slice(-14)
|
|
323
|
+
.map((p) => `${p.date.split('T')[0]}: ${p.value.toLocaleString()} shows`)
|
|
324
|
+
.join('\n');
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
content: [{ type: 'text', text: `Query history (last 14 days shown):\n\n${text}` }],
|
|
328
|
+
structuredContent: data,
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
// ============ Indexing ============
|
|
334
|
+
|
|
335
|
+
server.registerTool(
|
|
336
|
+
'get-indexing-history',
|
|
337
|
+
{
|
|
338
|
+
title: 'Get Indexing History',
|
|
339
|
+
description:
|
|
340
|
+
'Returns the history of pages downloaded by the robot, grouped by HTTP status codes (2xx, 3xx, 4xx, 5xx).',
|
|
341
|
+
inputSchema: {
|
|
342
|
+
host_id: z.string().describe('Site identifier'),
|
|
343
|
+
date_from: z.string().optional().describe('Start date (YYYY-MM-DD)'),
|
|
344
|
+
date_to: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
async ({ host_id, date_from, date_to }) => {
|
|
348
|
+
let endpoint = await hostEndpoint(host_id, '/indexing/history');
|
|
349
|
+
const params = new URLSearchParams();
|
|
350
|
+
if (date_from) params.set('date_from', formatDate(date_from));
|
|
351
|
+
if (date_to) params.set('date_to', formatDate(date_to));
|
|
352
|
+
if (params.toString()) endpoint += `?${params}`;
|
|
353
|
+
|
|
354
|
+
const data = await apiRequest(endpoint);
|
|
355
|
+
|
|
356
|
+
const indicators = data.indicators || {};
|
|
357
|
+
const summary = Object.entries(indicators)
|
|
358
|
+
.map(([code, points]) => {
|
|
359
|
+
const latest = points[points.length - 1];
|
|
360
|
+
return `${code}: ${latest?.value || 0} pages (as of ${latest?.date?.split('T')[0] || 'N/A'})`;
|
|
361
|
+
})
|
|
362
|
+
.join('\n');
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
content: [{ type: 'text', text: `Indexing history by HTTP status:\n\n${summary}` }],
|
|
366
|
+
structuredContent: data,
|
|
367
|
+
};
|
|
368
|
+
},
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
server.registerTool(
|
|
372
|
+
'get-indexing-samples',
|
|
373
|
+
{
|
|
374
|
+
title: 'Get Downloaded Pages Samples',
|
|
375
|
+
description: 'Returns examples of pages downloaded by the robot.',
|
|
376
|
+
inputSchema: {
|
|
377
|
+
host_id: z.string().describe('Site identifier'),
|
|
378
|
+
limit: z.number().min(1).max(100).optional().describe('Number of samples (default: 10)'),
|
|
379
|
+
offset: z.number().min(0).optional().describe('Offset for pagination'),
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
async ({ host_id, limit = 10, offset }) => {
|
|
383
|
+
let endpoint = await hostEndpoint(host_id, '/indexing/samples');
|
|
384
|
+
const params = new URLSearchParams();
|
|
385
|
+
params.set('limit', limit.toString());
|
|
386
|
+
if (offset) params.set('offset', offset.toString());
|
|
387
|
+
endpoint += `?${params}`;
|
|
388
|
+
|
|
389
|
+
const data = await apiRequest(endpoint);
|
|
390
|
+
|
|
391
|
+
const samples = data.samples || [];
|
|
392
|
+
const text = samples
|
|
393
|
+
.map((s) => `- ${s.url} (${s.http_code}) - ${s.access_date?.split('T')[0] || 'N/A'}`)
|
|
394
|
+
.join('\n');
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
content: [{ type: 'text', text: `Downloaded pages (${data.count} total):\n\n${text || 'No samples'}` }],
|
|
398
|
+
structuredContent: data,
|
|
399
|
+
};
|
|
400
|
+
},
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
server.registerTool(
|
|
404
|
+
'get-insearch-history',
|
|
405
|
+
{
|
|
406
|
+
title: 'Get Pages In Search History',
|
|
407
|
+
description: 'Returns the history of pages appearing in search results over time.',
|
|
408
|
+
inputSchema: {
|
|
409
|
+
host_id: z.string().describe('Site identifier'),
|
|
410
|
+
date_from: z.string().optional().describe('Start date (YYYY-MM-DD)'),
|
|
411
|
+
date_to: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
async ({ host_id, date_from, date_to }) => {
|
|
415
|
+
let endpoint = await hostEndpoint(host_id, '/indexing/insearch/history');
|
|
416
|
+
const params = new URLSearchParams();
|
|
417
|
+
if (date_from) params.set('date_from', formatDate(date_from));
|
|
418
|
+
if (date_to) params.set('date_to', formatDate(date_to));
|
|
419
|
+
if (params.toString()) endpoint += `?${params}`;
|
|
420
|
+
|
|
421
|
+
const data = await apiRequest(endpoint);
|
|
422
|
+
|
|
423
|
+
const points = data.history || [];
|
|
424
|
+
const text = points
|
|
425
|
+
.slice(-14)
|
|
426
|
+
.map((p) => `${p.date.split('T')[0]}: ${p.value.toLocaleString()} pages in search`)
|
|
427
|
+
.join('\n');
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
content: [{ type: 'text', text: `Pages in search (last 14 days):\n\n${text || 'No data'}` }],
|
|
431
|
+
structuredContent: data,
|
|
432
|
+
};
|
|
433
|
+
},
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
server.registerTool(
|
|
437
|
+
'get-insearch-samples',
|
|
438
|
+
{
|
|
439
|
+
title: 'Get Pages In Search Samples',
|
|
440
|
+
description: 'Returns examples of pages currently appearing in search results.',
|
|
441
|
+
inputSchema: {
|
|
442
|
+
host_id: z.string().describe('Site identifier'),
|
|
443
|
+
limit: z.number().min(1).max(100).optional().describe('Number of samples (default: 10)'),
|
|
444
|
+
offset: z.number().min(0).optional().describe('Offset for pagination'),
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
async ({ host_id, limit = 10, offset }) => {
|
|
448
|
+
let endpoint = await hostEndpoint(host_id, '/indexing/insearch/samples');
|
|
449
|
+
const params = new URLSearchParams();
|
|
450
|
+
params.set('limit', limit.toString());
|
|
451
|
+
if (offset) params.set('offset', offset.toString());
|
|
452
|
+
endpoint += `?${params}`;
|
|
453
|
+
|
|
454
|
+
const data = await apiRequest(endpoint);
|
|
455
|
+
|
|
456
|
+
const samples = data.samples || [];
|
|
457
|
+
const text = samples.map((s) => `- ${s.url}`).join('\n');
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
content: [{ type: 'text', text: `Pages in search (${data.count} total):\n\n${text || 'No samples'}` }],
|
|
461
|
+
structuredContent: data,
|
|
462
|
+
};
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
// ============ Search Events ============
|
|
467
|
+
|
|
468
|
+
server.registerTool(
|
|
469
|
+
'get-search-events-history',
|
|
470
|
+
{
|
|
471
|
+
title: 'Get Search Events History',
|
|
472
|
+
description: 'Returns the history of pages added to or removed from search results.',
|
|
473
|
+
inputSchema: {
|
|
474
|
+
host_id: z.string().describe('Site identifier'),
|
|
475
|
+
date_from: z.string().optional().describe('Start date (YYYY-MM-DD)'),
|
|
476
|
+
date_to: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
async ({ host_id, date_from, date_to }) => {
|
|
480
|
+
let endpoint = await hostEndpoint(host_id, '/search-urls/events/history');
|
|
481
|
+
const params = new URLSearchParams();
|
|
482
|
+
if (date_from) params.set('date_from', formatDate(date_from));
|
|
483
|
+
if (date_to) params.set('date_to', formatDate(date_to));
|
|
484
|
+
if (params.toString()) endpoint += `?${params}`;
|
|
485
|
+
|
|
486
|
+
const data = await apiRequest(endpoint);
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
content: [{ type: 'text', text: `Search events history retrieved.` }],
|
|
490
|
+
structuredContent: data,
|
|
491
|
+
};
|
|
492
|
+
},
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
server.registerTool(
|
|
496
|
+
'get-search-events-samples',
|
|
497
|
+
{
|
|
498
|
+
title: 'Get Search Events Samples',
|
|
499
|
+
description: 'Returns examples of pages that recently appeared in or were removed from search.',
|
|
500
|
+
inputSchema: {
|
|
501
|
+
host_id: z.string().describe('Site identifier'),
|
|
502
|
+
event_type: z.enum(['APPEARED', 'REMOVED']).describe('Event type to filter'),
|
|
503
|
+
limit: z.number().min(1).max(100).optional().describe('Number of samples (default: 10)'),
|
|
504
|
+
offset: z.number().min(0).optional().describe('Offset for pagination'),
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
async ({ host_id, event_type, limit = 10, offset }) => {
|
|
508
|
+
let endpoint = await hostEndpoint(host_id, '/search-urls/events/samples');
|
|
509
|
+
const params = new URLSearchParams();
|
|
510
|
+
params.set('event_type', event_type);
|
|
511
|
+
params.set('limit', limit.toString());
|
|
512
|
+
if (offset) params.set('offset', offset.toString());
|
|
513
|
+
endpoint += `?${params}`;
|
|
514
|
+
|
|
515
|
+
const data = await apiRequest(endpoint);
|
|
516
|
+
|
|
517
|
+
const samples = data.samples || [];
|
|
518
|
+
const text = samples.map((s) => `- ${s.url} (${s.event_date?.split('T')[0] || 'N/A'})`).join('\n');
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
content: [
|
|
522
|
+
{
|
|
523
|
+
type: 'text',
|
|
524
|
+
text: `Pages ${event_type.toLowerCase()} (${data.count} total):\n\n${text || 'No samples'}`,
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
structuredContent: data,
|
|
528
|
+
};
|
|
529
|
+
},
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
// ============ Links ============
|
|
533
|
+
|
|
534
|
+
server.registerTool(
|
|
535
|
+
'get-external-links',
|
|
536
|
+
{
|
|
537
|
+
title: 'Get External Links (Backlinks)',
|
|
538
|
+
description: 'Returns examples of external links pointing to the site (backlinks).',
|
|
539
|
+
inputSchema: {
|
|
540
|
+
host_id: z.string().describe('Site identifier'),
|
|
541
|
+
limit: z.number().min(1).max(100).optional().describe('Number of samples (default: 10)'),
|
|
542
|
+
offset: z.number().min(0).optional().describe('Offset for pagination'),
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
async ({ host_id, limit = 10, offset }) => {
|
|
546
|
+
let endpoint = await hostEndpoint(host_id, '/links/external/samples');
|
|
547
|
+
const params = new URLSearchParams();
|
|
548
|
+
params.set('limit', limit.toString());
|
|
549
|
+
if (offset) params.set('offset', offset.toString());
|
|
550
|
+
endpoint += `?${params}`;
|
|
551
|
+
|
|
552
|
+
const data = await apiRequest(endpoint);
|
|
553
|
+
|
|
554
|
+
const links = data.links || [];
|
|
555
|
+
const text = links
|
|
556
|
+
.map((l) => `- ${l.source_url} -> ${l.destination_url} (found: ${l.discovery_date || 'N/A'})`)
|
|
557
|
+
.join('\n');
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
content: [{ type: 'text', text: `External links (${data.count} total):\n\n${text || 'No backlinks found'}` }],
|
|
561
|
+
structuredContent: data,
|
|
562
|
+
};
|
|
563
|
+
},
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
server.registerTool(
|
|
567
|
+
'get-external-links-history',
|
|
568
|
+
{
|
|
569
|
+
title: 'Get External Links History',
|
|
570
|
+
description: 'Returns the history of external links count over time.',
|
|
571
|
+
inputSchema: {
|
|
572
|
+
host_id: z.string().describe('Site identifier'),
|
|
573
|
+
date_from: z.string().optional().describe('Start date (YYYY-MM-DD)'),
|
|
574
|
+
date_to: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
|
575
|
+
},
|
|
576
|
+
},
|
|
577
|
+
async ({ host_id, date_from, date_to }) => {
|
|
578
|
+
let endpoint = await hostEndpoint(host_id, '/links/external/history');
|
|
579
|
+
const params = new URLSearchParams();
|
|
580
|
+
if (date_from) params.set('date_from', formatDate(date_from));
|
|
581
|
+
if (date_to) params.set('date_to', formatDate(date_to));
|
|
582
|
+
if (params.toString()) endpoint += `?${params}`;
|
|
583
|
+
|
|
584
|
+
const data = await apiRequest(endpoint);
|
|
585
|
+
|
|
586
|
+
const points = data.history || [];
|
|
587
|
+
const text = points
|
|
588
|
+
.slice(-14)
|
|
589
|
+
.map((p) => `${p.date.split('T')[0]}: ${p.value.toLocaleString()} backlinks`)
|
|
590
|
+
.join('\n');
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
content: [{ type: 'text', text: `External links history:\n\n${text || 'No data'}` }],
|
|
594
|
+
structuredContent: data,
|
|
595
|
+
};
|
|
596
|
+
},
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
server.registerTool(
|
|
600
|
+
'get-broken-internal-links',
|
|
601
|
+
{
|
|
602
|
+
title: 'Get Broken Internal Links',
|
|
603
|
+
description: 'Returns examples of broken internal links on the site.',
|
|
604
|
+
inputSchema: {
|
|
605
|
+
host_id: z.string().describe('Site identifier'),
|
|
606
|
+
limit: z.number().min(1).max(100).optional().describe('Number of samples (default: 10)'),
|
|
607
|
+
offset: z.number().min(0).optional().describe('Offset for pagination'),
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
async ({ host_id, limit = 10, offset }) => {
|
|
611
|
+
let endpoint = await hostEndpoint(host_id, '/links/internal/samples');
|
|
612
|
+
const params = new URLSearchParams();
|
|
613
|
+
params.set('limit', limit.toString());
|
|
614
|
+
if (offset) params.set('offset', offset.toString());
|
|
615
|
+
endpoint += `?${params}`;
|
|
616
|
+
|
|
617
|
+
const data = await apiRequest(endpoint);
|
|
618
|
+
|
|
619
|
+
const links = data.links || [];
|
|
620
|
+
const text = links.map((l) => `- ${l.source_url} -> ${l.destination_url} (${l.status || 'broken'})`).join('\n');
|
|
621
|
+
|
|
622
|
+
return {
|
|
623
|
+
content: [
|
|
624
|
+
{ type: 'text', text: `Broken internal links (${data.count} total):\n\n${text || 'No broken links'}` },
|
|
625
|
+
],
|
|
626
|
+
structuredContent: data,
|
|
627
|
+
};
|
|
628
|
+
},
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
server.registerTool(
|
|
632
|
+
'get-broken-internal-links-history',
|
|
633
|
+
{
|
|
634
|
+
title: 'Get Broken Internal Links History',
|
|
635
|
+
description: 'Returns the history of broken internal links count over time.',
|
|
636
|
+
inputSchema: {
|
|
637
|
+
host_id: z.string().describe('Site identifier'),
|
|
638
|
+
date_from: z.string().optional().describe('Start date (YYYY-MM-DD)'),
|
|
639
|
+
date_to: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
async ({ host_id, date_from, date_to }) => {
|
|
643
|
+
let endpoint = await hostEndpoint(host_id, '/links/internal/history');
|
|
644
|
+
const params = new URLSearchParams();
|
|
645
|
+
if (date_from) params.set('date_from', formatDate(date_from));
|
|
646
|
+
if (date_to) params.set('date_to', formatDate(date_to));
|
|
647
|
+
if (params.toString()) endpoint += `?${params}`;
|
|
648
|
+
|
|
649
|
+
const data = await apiRequest(endpoint);
|
|
650
|
+
|
|
651
|
+
const points = data.history || [];
|
|
652
|
+
const text = points
|
|
653
|
+
.slice(-14)
|
|
654
|
+
.map((p) => `${p.date.split('T')[0]}: ${p.value} broken links`)
|
|
655
|
+
.join('\n');
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
content: [{ type: 'text', text: `Broken internal links history:\n\n${text || 'No data'}` }],
|
|
659
|
+
structuredContent: data,
|
|
660
|
+
};
|
|
661
|
+
},
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
// ============ Sitemaps ============
|
|
665
|
+
|
|
666
|
+
server.registerTool(
|
|
667
|
+
'get-sitemaps',
|
|
668
|
+
{
|
|
669
|
+
title: 'Get Sitemaps',
|
|
670
|
+
description: 'Returns the list of sitemap files detected for the site.',
|
|
671
|
+
inputSchema: {
|
|
672
|
+
host_id: z.string().describe('Site identifier'),
|
|
673
|
+
limit: z.number().min(1).max(100).optional().describe('Number of results (default: 10)'),
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
async ({ host_id, limit = 10 }) => {
|
|
677
|
+
let endpoint = await hostEndpoint(host_id, '/sitemaps');
|
|
678
|
+
const params = new URLSearchParams();
|
|
679
|
+
params.set('limit', limit.toString());
|
|
680
|
+
endpoint += `?${params}`;
|
|
681
|
+
|
|
682
|
+
const data = await apiRequest(endpoint);
|
|
683
|
+
|
|
684
|
+
const sitemaps = data.sitemaps || [];
|
|
685
|
+
const text = sitemaps
|
|
686
|
+
.map(
|
|
687
|
+
(s) =>
|
|
688
|
+
`- ${s.sitemap_url}\n Type: ${s.sitemap_type}, URLs: ${s.urls_count}, Errors: ${s.errors_count}, Sources: ${(s.sources || []).join(', ')}`,
|
|
689
|
+
)
|
|
690
|
+
.join('\n');
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
content: [{ type: 'text', text: `Sitemaps:\n\n${text || 'No sitemaps found'}` }],
|
|
694
|
+
structuredContent: data,
|
|
695
|
+
};
|
|
696
|
+
},
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
server.registerTool(
|
|
700
|
+
'get-sitemap',
|
|
701
|
+
{
|
|
702
|
+
title: 'Get Sitemap Info',
|
|
703
|
+
description: 'Returns detailed information about a specific sitemap file.',
|
|
704
|
+
inputSchema: {
|
|
705
|
+
host_id: z.string().describe('Site identifier'),
|
|
706
|
+
sitemap_id: z.string().describe('Sitemap identifier'),
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
async ({ host_id, sitemap_id }) => {
|
|
710
|
+
const endpoint = await hostEndpoint(host_id, `/sitemaps/${encodeURIComponent(sitemap_id)}`);
|
|
711
|
+
const data = await apiRequest(endpoint);
|
|
712
|
+
|
|
713
|
+
return {
|
|
714
|
+
content: [
|
|
715
|
+
{
|
|
716
|
+
type: 'text',
|
|
717
|
+
text: `Sitemap: ${data.sitemap_url}\nType: ${data.sitemap_type}\nURLs: ${data.urls_count}\nErrors: ${data.errors_count}\nLast accessed: ${data.last_access_date?.split('T')[0] || 'N/A'}`,
|
|
718
|
+
},
|
|
719
|
+
],
|
|
720
|
+
structuredContent: data,
|
|
721
|
+
};
|
|
722
|
+
},
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
server.registerTool(
|
|
726
|
+
'get-user-sitemaps',
|
|
727
|
+
{
|
|
728
|
+
title: 'Get User-Added Sitemaps',
|
|
729
|
+
description: 'Returns the list of sitemap files manually added by the user.',
|
|
730
|
+
inputSchema: {
|
|
731
|
+
host_id: z.string().describe('Site identifier'),
|
|
732
|
+
limit: z.number().min(1).max(100).optional().describe('Number of results (default: 10)'),
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
async ({ host_id, limit = 10 }) => {
|
|
736
|
+
let endpoint = await hostEndpoint(host_id, '/user-added-sitemaps');
|
|
737
|
+
const params = new URLSearchParams();
|
|
738
|
+
params.set('limit', limit.toString());
|
|
739
|
+
endpoint += `?${params}`;
|
|
740
|
+
|
|
741
|
+
const data = await apiRequest(endpoint);
|
|
742
|
+
|
|
743
|
+
const sitemaps = data.sitemaps || [];
|
|
744
|
+
const text = sitemaps
|
|
745
|
+
.map((s) => `- ${s.sitemap_url} (URLs: ${s.urls_count}, Errors: ${s.errors_count})`)
|
|
746
|
+
.join('\n');
|
|
747
|
+
|
|
748
|
+
return {
|
|
749
|
+
content: [{ type: 'text', text: `User-added sitemaps:\n\n${text || 'No user-added sitemaps'}` }],
|
|
750
|
+
structuredContent: data,
|
|
751
|
+
};
|
|
752
|
+
},
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
// ============ Important URLs ============
|
|
756
|
+
|
|
757
|
+
server.registerTool(
|
|
758
|
+
'get-important-urls',
|
|
759
|
+
{
|
|
760
|
+
title: 'Get Important URLs',
|
|
761
|
+
description: 'Returns the list of important pages being monitored.',
|
|
762
|
+
inputSchema: {
|
|
763
|
+
host_id: z.string().describe('Site identifier'),
|
|
764
|
+
limit: z.number().min(1).max(100).optional().describe('Number of results (default: 10)'),
|
|
765
|
+
offset: z.number().min(0).optional().describe('Offset for pagination'),
|
|
766
|
+
},
|
|
767
|
+
},
|
|
768
|
+
async ({ host_id, limit = 10, offset }) => {
|
|
769
|
+
let endpoint = await hostEndpoint(host_id, '/important-urls');
|
|
770
|
+
const params = new URLSearchParams();
|
|
771
|
+
params.set('limit', limit.toString());
|
|
772
|
+
if (offset) params.set('offset', offset.toString());
|
|
773
|
+
endpoint += `?${params}`;
|
|
774
|
+
|
|
775
|
+
const data = await apiRequest(endpoint);
|
|
776
|
+
|
|
777
|
+
const urls = data.urls || [];
|
|
778
|
+
const text = urls.map((u) => `- ${u.url} (status: ${u.indexing_status || 'N/A'})`).join('\n');
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
content: [{ type: 'text', text: `Important URLs (${data.count} total):\n\n${text || 'No important URLs'}` }],
|
|
782
|
+
structuredContent: data,
|
|
783
|
+
};
|
|
784
|
+
},
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
server.registerTool(
|
|
788
|
+
'get-important-url-history',
|
|
789
|
+
{
|
|
790
|
+
title: 'Get Important URL History',
|
|
791
|
+
description: 'Returns the history of changes for a specific important URL.',
|
|
792
|
+
inputSchema: {
|
|
793
|
+
host_id: z.string().describe('Site identifier'),
|
|
794
|
+
url: z.string().describe('The important URL to get history for'),
|
|
795
|
+
date_from: z.string().optional().describe('Start date (YYYY-MM-DD)'),
|
|
796
|
+
date_to: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
async ({ host_id, url, date_from, date_to }) => {
|
|
800
|
+
let endpoint = await hostEndpoint(host_id, '/important-urls/history');
|
|
801
|
+
const params = new URLSearchParams();
|
|
802
|
+
params.set('url', url);
|
|
803
|
+
if (date_from) params.set('date_from', formatDate(date_from));
|
|
804
|
+
if (date_to) params.set('date_to', formatDate(date_to));
|
|
805
|
+
endpoint += `?${params}`;
|
|
806
|
+
|
|
807
|
+
const data = await apiRequest(endpoint);
|
|
808
|
+
|
|
809
|
+
return {
|
|
810
|
+
content: [{ type: 'text', text: `Important URL history for ${url}` }],
|
|
811
|
+
structuredContent: data,
|
|
812
|
+
};
|
|
813
|
+
},
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
// ============ Recrawl Quota ============
|
|
817
|
+
|
|
818
|
+
server.registerTool(
|
|
819
|
+
'get-recrawl-quota',
|
|
820
|
+
{
|
|
821
|
+
title: 'Get Recrawl Quota',
|
|
822
|
+
description: 'Returns the current reindexing quota status.',
|
|
823
|
+
inputSchema: {
|
|
824
|
+
host_id: z.string().describe('Site identifier'),
|
|
825
|
+
},
|
|
826
|
+
},
|
|
827
|
+
async ({ host_id }) => {
|
|
828
|
+
const endpoint = await hostEndpoint(host_id, '/recrawl/quota');
|
|
829
|
+
const data = await apiRequest(endpoint);
|
|
830
|
+
|
|
831
|
+
return {
|
|
832
|
+
content: [
|
|
833
|
+
{
|
|
834
|
+
type: 'text',
|
|
835
|
+
text: `Recrawl quota:\n- Daily limit: ${data.daily_quota || 'N/A'}\n- Remaining: ${data.quota_remainder || 'N/A'}`,
|
|
836
|
+
},
|
|
837
|
+
],
|
|
838
|
+
structuredContent: data,
|
|
839
|
+
};
|
|
840
|
+
},
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
// Start server
|
|
844
|
+
const transport = new StdioServerTransport();
|
|
845
|
+
await server.connect(transport);
|
|
846
|
+
console.error('Yandex Webmaster MCP server running on stdio');
|
|
847
|
+
}
|