tryastro-mcp 0.1.2
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/README.md +69 -0
- package/bin/astro-mcp.js +2 -0
- package/docs/scenario-decomposition.md +61 -0
- package/package.json +37 -0
- package/server.js +893 -0
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# astro-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for Cursor with:
|
|
4
|
+
- dedicated tools for core TryAstro scenarios;
|
|
5
|
+
- one router tool for standard support/product queries.
|
|
6
|
+
|
|
7
|
+
## Included tools
|
|
8
|
+
|
|
9
|
+
1. `scenario_catalog`
|
|
10
|
+
2. `scenario_onboarding_first_value`
|
|
11
|
+
3. `scenario_keyword_research`
|
|
12
|
+
4. `scenario_competitor_analysis`
|
|
13
|
+
5. `scenario_localization_strategy`
|
|
14
|
+
6. `scenario_rankings_and_ratings`
|
|
15
|
+
7. `scenario_migration_and_transfer`
|
|
16
|
+
8. `standard_topics`
|
|
17
|
+
9. `standard_answer`
|
|
18
|
+
|
|
19
|
+
Scenario decomposition details are in `/docs/scenario-decomposition.md`.
|
|
20
|
+
|
|
21
|
+
## Local run
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
node /Users/vlad.korobkov/tryastro-mcp/server.js
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Smoke test:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm run smoke
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Package dry run:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm run pack:dry
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Cursor connection
|
|
40
|
+
|
|
41
|
+
Use the published npm package:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"astro-mcp": {
|
|
47
|
+
"command": "npx",
|
|
48
|
+
"args": ["-y", "-p", "tryastro-mcp", "astro-mcp"],
|
|
49
|
+
"env": {
|
|
50
|
+
"NPM_CONFIG_CACHE": "/tmp/astro-mcp-npm-cache",
|
|
51
|
+
"npm_config_cache": "/tmp/astro-mcp-npm-cache"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Publish checklist
|
|
59
|
+
|
|
60
|
+
1. Update `version` in `/package.json`.
|
|
61
|
+
2. Login: `npm login`.
|
|
62
|
+
3. Publish: `npm publish --access public`.
|
|
63
|
+
4. Verify install: `npx -y -p tryastro-mcp astro-mcp`.
|
|
64
|
+
|
|
65
|
+
## Data and source model
|
|
66
|
+
|
|
67
|
+
- Tools use a static, verified knowledge layer from TryAstro public pages and docs.
|
|
68
|
+
- Verification date is hardcoded in server output (`2026-02-22`).
|
|
69
|
+
- If source facts change (pricing/legal/features), update `/server.js` constants.
|
package/bin/astro-mcp.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# TryAstro: main scenario decomposition
|
|
2
|
+
|
|
3
|
+
Verification date: `2026-02-22`
|
|
4
|
+
|
|
5
|
+
## Scenario 1: Onboarding to first value
|
|
6
|
+
|
|
7
|
+
- Goal: create first-day baseline for ASO work.
|
|
8
|
+
- User outcome: app is connected, keywords are tracked, initial rankings and ratings baseline is captured.
|
|
9
|
+
- MCP tool: `scenario_onboarding_first_value`.
|
|
10
|
+
|
|
11
|
+
## Scenario 2: Keyword research loop
|
|
12
|
+
|
|
13
|
+
- Goal: build prioritized keyword pool for target stores/locales.
|
|
14
|
+
- User outcome: saved, segmented keyword list with ranking watchlist.
|
|
15
|
+
- MCP tool: `scenario_keyword_research`.
|
|
16
|
+
|
|
17
|
+
## Scenario 3: Competitor benchmarking
|
|
18
|
+
|
|
19
|
+
- Goal: find ranking and semantic gaps vs direct competitors.
|
|
20
|
+
- User outcome: actionable gap matrix and optimization backlog.
|
|
21
|
+
- MCP tool: `scenario_competitor_analysis`.
|
|
22
|
+
|
|
23
|
+
## Scenario 4: Localization expansion
|
|
24
|
+
|
|
25
|
+
- Goal: select countries/locales and roll out metadata localization by priority waves.
|
|
26
|
+
- User outcome: localized keyword sets and post-release monitoring plan.
|
|
27
|
+
- MCP tool: `scenario_localization_strategy`.
|
|
28
|
+
|
|
29
|
+
## Scenario 5: Rankings and ratings monitoring
|
|
30
|
+
|
|
31
|
+
- Goal: maintain regular monitoring and response loop for ranking/rating changes.
|
|
32
|
+
- User outcome: scheduled checks, alert logic, and weekly root-cause reviews.
|
|
33
|
+
- MCP tool: `scenario_rankings_and_ratings`.
|
|
34
|
+
|
|
35
|
+
## Scenario 6: Migration/import and transfer
|
|
36
|
+
|
|
37
|
+
- Goal: move data from other ASO tools or another Mac without losing history.
|
|
38
|
+
- User outcome: validated data parity and active license on target device.
|
|
39
|
+
- MCP tool: `scenario_migration_and_transfer`.
|
|
40
|
+
|
|
41
|
+
## Standard request layer
|
|
42
|
+
|
|
43
|
+
- Goal: answer typical product/support/legal/pricing questions quickly.
|
|
44
|
+
- MCP tools: `standard_topics` and `standard_answer`.
|
|
45
|
+
|
|
46
|
+
## Source pages
|
|
47
|
+
|
|
48
|
+
1. https://tryastro.app/
|
|
49
|
+
2. https://docs.tryastro.app/
|
|
50
|
+
3. https://docs.tryastro.app/first-access/
|
|
51
|
+
4. https://docs.tryastro.app/add-app/
|
|
52
|
+
5. https://docs.tryastro.app/add-keywords/
|
|
53
|
+
6. https://docs.tryastro.app/suggestions/
|
|
54
|
+
7. https://docs.tryastro.app/localizations/
|
|
55
|
+
8. https://docs.tryastro.app/rankings/
|
|
56
|
+
9. https://docs.tryastro.app/ratings/
|
|
57
|
+
10. https://docs.tryastro.app/import-export/
|
|
58
|
+
11. https://docs.tryastro.app/transfer/
|
|
59
|
+
12. https://tryastro.app/pricing/
|
|
60
|
+
13. https://tryastro.app/terms/
|
|
61
|
+
14. https://tryastro.app/privacy/
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tryastro-mcp",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "MCP server for TryAstro scenarios and standard support requests",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "server.js",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"mcp",
|
|
10
|
+
"cursor",
|
|
11
|
+
"tryastro",
|
|
12
|
+
"aso",
|
|
13
|
+
"app-store-optimization"
|
|
14
|
+
],
|
|
15
|
+
"bin": {
|
|
16
|
+
"astro-mcp": "./bin/astro-mcp.js"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./server.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"bin",
|
|
23
|
+
"docs",
|
|
24
|
+
"server.js",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"start": "node server.js",
|
|
29
|
+
"dev": "node --watch server.js",
|
|
30
|
+
"smoke": "node scripts/smoke-test.js",
|
|
31
|
+
"smoke:npx": "node scripts/smoke-test-npx.js",
|
|
32
|
+
"pack:dry": "npm pack --dry-run"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
const SERVER_INFO = {
|
|
5
|
+
name: "astro-mcp",
|
|
6
|
+
version: "0.1.2",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
10
|
+
const VERIFIED_ON = "2026-02-22";
|
|
11
|
+
|
|
12
|
+
const SOURCES = {
|
|
13
|
+
homepage: "https://tryastro.app/",
|
|
14
|
+
docs: "https://docs.tryastro.app/",
|
|
15
|
+
firstAccess: "https://docs.tryastro.app/first-access/",
|
|
16
|
+
addApp: "https://docs.tryastro.app/add-app/",
|
|
17
|
+
addKeywords: "https://docs.tryastro.app/add-keywords/",
|
|
18
|
+
suggestions: "https://docs.tryastro.app/suggestions/",
|
|
19
|
+
localizations: "https://docs.tryastro.app/localizations/",
|
|
20
|
+
rankings: "https://docs.tryastro.app/rankings/",
|
|
21
|
+
ratings: "https://docs.tryastro.app/ratings/",
|
|
22
|
+
importExport: "https://docs.tryastro.app/import-export/",
|
|
23
|
+
transfer: "https://docs.tryastro.app/transfer/",
|
|
24
|
+
pricing: "https://tryastro.app/pricing/",
|
|
25
|
+
terms: "https://tryastro.app/terms/",
|
|
26
|
+
privacy: "https://tryastro.app/privacy/",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const STANDARD_TOPICS = [
|
|
30
|
+
{
|
|
31
|
+
id: "capabilities",
|
|
32
|
+
title: "Product capabilities",
|
|
33
|
+
patterns: [
|
|
34
|
+
/what can .*astro|feature|capabilit|возможност|что умеет/i,
|
|
35
|
+
/keyword|competitor|localization|rankings|ratings|review/i,
|
|
36
|
+
],
|
|
37
|
+
answerRu:
|
|
38
|
+
"Astro фокусируется на ASO-операциях: поиск ключевых слов, анализ конкурентов, локализации, мониторинг позиций, мониторинг рейтинга и отзывов, а также импорт/экспорт ASO-данных.",
|
|
39
|
+
answerEn:
|
|
40
|
+
"Astro is focused on ASO operations: keyword research, competitor analysis, localization workflows, keyword ranking monitoring, ratings/reviews monitoring, and ASO data import/export.",
|
|
41
|
+
next: [
|
|
42
|
+
"Выберите сценарий через scenario_catalog и запустите соответствующий scenario tool.",
|
|
43
|
+
"Зафиксируйте список целевых стран/языков до старта keyword и localization сценариев.",
|
|
44
|
+
],
|
|
45
|
+
sources: [SOURCES.homepage, SOURCES.docs],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "pricing_billing",
|
|
49
|
+
title: "Pricing and billing",
|
|
50
|
+
patterns: [
|
|
51
|
+
/pricing|price|cost|billing|subscription|подписк|тариф|цена|стоимост/i,
|
|
52
|
+
/refund|возврат|renew|продлен/i,
|
|
53
|
+
],
|
|
54
|
+
answerRu:
|
|
55
|
+
"На сайте указан единый annual-план: $108 в год (приблизительно $9/месяц). Политика возврата: 14 дней с момента покупки через запрос на hello@tryastro.app. Биллинг обрабатывается через Lemon Squeezy.",
|
|
56
|
+
answerEn:
|
|
57
|
+
"The site shows a single annual plan: $108/year (about $9/month). Refund policy: 14 days from purchase via hello@tryastro.app. Billing is handled via Lemon Squeezy.",
|
|
58
|
+
next: [
|
|
59
|
+
"Для актуальной суммы и валюты проверяйте pricing page перед оплатой.",
|
|
60
|
+
"Для возврата отправьте email с данными покупки в течение 14 дней.",
|
|
61
|
+
],
|
|
62
|
+
sources: [SOURCES.pricing, SOURCES.terms],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "platform_coverage",
|
|
66
|
+
title: "Platform and geo coverage",
|
|
67
|
+
patterns: [
|
|
68
|
+
/platform|device|ios|ipados|macos|visionos|store|country|geo|регион|страны|поддержк/i,
|
|
69
|
+
/apple search ads|stores/i,
|
|
70
|
+
],
|
|
71
|
+
answerRu:
|
|
72
|
+
"Astro доступен на iOS, iPadOS, macOS и VisionOS, требует macOS 14+ для desktop. В коммуникации продукта фигурируют 60+ стран для Apple Search Ads и поддержка работы с 91 стором в keyword search.",
|
|
73
|
+
answerEn:
|
|
74
|
+
"Astro is available on iOS, iPadOS, macOS, and VisionOS, with macOS 14+ required for desktop. Product messaging mentions 60+ Apple Search Ads countries and support for 91 stores in keyword search.",
|
|
75
|
+
next: [
|
|
76
|
+
"Перед запуском сценариев зафиксируйте целевые сторы и локали в одном документе.",
|
|
77
|
+
"Если есть расхождения по охвату, сверяйте docs и приложение одновременно.",
|
|
78
|
+
],
|
|
79
|
+
sources: [SOURCES.homepage, SOURCES.firstAccess],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "privacy_legal",
|
|
83
|
+
title: "Privacy and legal",
|
|
84
|
+
patterns: [
|
|
85
|
+
/privacy|gdpr|legal|terms|dpa|subprocessor|данные|персональ|политик|услови/i,
|
|
86
|
+
/security|безопас/i,
|
|
87
|
+
],
|
|
88
|
+
answerRu:
|
|
89
|
+
"Согласно Privacy page, пользовательские данные хранятся локально на устройстве, а Astro по умолчанию не передает их на свои серверы. Юридические условия и ограничения использования описаны в Terms.",
|
|
90
|
+
answerEn:
|
|
91
|
+
"According to the Privacy page, user data is stored locally on the device and Astro does not send it to Astro servers by default. Legal terms and usage restrictions are defined in Terms.",
|
|
92
|
+
next: [
|
|
93
|
+
"Для комплаенса проверьте DPA/Sub-processors ссылки в футере перед закупкой.",
|
|
94
|
+
"Для юридических вопросов эскалируйте на hello@tryastro.app.",
|
|
95
|
+
],
|
|
96
|
+
sources: [SOURCES.privacy, SOURCES.terms],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "support_feedback",
|
|
100
|
+
title: "Support and feedback",
|
|
101
|
+
patterns: [
|
|
102
|
+
/support|help|ticket|contact|feedback|roadmap|баг|поддержк|тикет|контакт/i,
|
|
103
|
+
/hello@tryastro\.app/i,
|
|
104
|
+
],
|
|
105
|
+
answerRu:
|
|
106
|
+
"Базовые материалы доступны в docs.tryastro.app. Для запросов по подписке и возвратам используется hello@tryastro.app. На сайте также отмечены каналы feature requests и support ticket.",
|
|
107
|
+
answerEn:
|
|
108
|
+
"Core documentation is available at docs.tryastro.app. For subscription and refund requests, use hello@tryastro.app. The site also mentions feature requests and support ticket channels.",
|
|
109
|
+
next: [
|
|
110
|
+
"В тикете прикладывайте точный store/country/date диапазон и скриншоты.",
|
|
111
|
+
"Для продуктовых запросов сначала проверьте docs, потом отправляйте gap в feature request.",
|
|
112
|
+
],
|
|
113
|
+
sources: [SOURCES.docs, SOURCES.homepage, SOURCES.terms],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "migration_transfer",
|
|
117
|
+
title: "Migration and transfer",
|
|
118
|
+
patterns: [
|
|
119
|
+
/import|export|migration|transfer|switch|перенос|миграц|импорт|экспорт|лиценз/i,
|
|
120
|
+
/apptweak|sensor tower|mobile action|appfollow/i,
|
|
121
|
+
],
|
|
122
|
+
answerRu:
|
|
123
|
+
"Astro поддерживает импорт/экспорт ключевых слов через CSV и отдельный перенос всей истории через файл model.sqlite. Отдельно описан перенос лицензии на другой Mac через Lemon Squeezy.",
|
|
124
|
+
answerEn:
|
|
125
|
+
"Astro supports keyword import/export via CSV and full history migration through the model.sqlite file. License transfer to another Mac is described as a separate Lemon Squeezy flow.",
|
|
126
|
+
next: [
|
|
127
|
+
"Для полного переноса истории используйте сценарий с model.sqlite, не CSV.",
|
|
128
|
+
"Перед переносом лицензии выйдите из аккаунта на старом устройстве.",
|
|
129
|
+
],
|
|
130
|
+
sources: [SOURCES.importExport, SOURCES.transfer, SOURCES.terms],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "official_mcp_status",
|
|
134
|
+
title: "Official MCP availability",
|
|
135
|
+
patterns: [
|
|
136
|
+
/mcp|cursor|server|api|integration|интеграц|подключ/i,
|
|
137
|
+
/official|официальн/i,
|
|
138
|
+
],
|
|
139
|
+
answerRu:
|
|
140
|
+
"В Terms у Astro указан будущий функционал MCP как planned capability. Это означает, что на момент последней проверки официальный MCP может быть не выпущен публично, поэтому текущий сервер в этом репозитории реализован как независимая интеграция.",
|
|
141
|
+
answerEn:
|
|
142
|
+
"Astro Terms mention MCP as a planned capability. That implies the official MCP may not yet be publicly released, so the server in this repository is built as an independent integration.",
|
|
143
|
+
next: [
|
|
144
|
+
"Используйте этот MCP как workflow assistant для сценариев Astro.",
|
|
145
|
+
"Периодически проверяйте docs/terms на появление официального API или MCP endpoint.",
|
|
146
|
+
],
|
|
147
|
+
sources: [SOURCES.terms, SOURCES.docs],
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
const SCENARIO_SUMMARY = [
|
|
152
|
+
{
|
|
153
|
+
id: "scenario_onboarding_first_value",
|
|
154
|
+
title: "Onboarding to first value",
|
|
155
|
+
goal: "Поднять аккаунт, приложение и baseline-метрики в первый рабочий день.",
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: "scenario_keyword_research",
|
|
159
|
+
title: "Keyword research loop",
|
|
160
|
+
goal: "Собрать и приоритизировать семантическое ядро под целевые сторы.",
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: "scenario_competitor_analysis",
|
|
164
|
+
title: "Competitor benchmarking",
|
|
165
|
+
goal: "Собрать gap-анализ по конкурентам и найти быстрые точки роста.",
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: "scenario_localization_strategy",
|
|
169
|
+
title: "Localization expansion",
|
|
170
|
+
goal: "Спланировать и запустить локализацию по странам/языкам с приоритетами.",
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "scenario_rankings_and_ratings",
|
|
174
|
+
title: "Rankings and ratings monitoring",
|
|
175
|
+
goal: "Сделать цикл регулярного контроля позиций и качества пользовательского фидбека.",
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "scenario_migration_and_transfer",
|
|
179
|
+
title: "Migration/import and license transfer",
|
|
180
|
+
goal: "Без потери данных перейти с других ASO-сервисов или между устройствами.",
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
const TOOL_DEFINITIONS = [
|
|
185
|
+
{
|
|
186
|
+
name: "scenario_catalog",
|
|
187
|
+
description:
|
|
188
|
+
"Return decomposition of TryAstro core user scenarios and map each scenario to an MCP tool.",
|
|
189
|
+
inputSchema: {
|
|
190
|
+
type: "object",
|
|
191
|
+
properties: {},
|
|
192
|
+
additionalProperties: false,
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "scenario_onboarding_first_value",
|
|
197
|
+
description:
|
|
198
|
+
"Playbook for first-day onboarding: account setup, app setup, first keywords, and initial metrics baseline.",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: "object",
|
|
201
|
+
properties: {
|
|
202
|
+
app_name: { type: "string" },
|
|
203
|
+
primary_store: { type: "string" },
|
|
204
|
+
platform: {
|
|
205
|
+
type: "string",
|
|
206
|
+
enum: ["ios", "ipados", "macos", "visionos", "mixed"],
|
|
207
|
+
},
|
|
208
|
+
team_goal: { type: "string" },
|
|
209
|
+
},
|
|
210
|
+
additionalProperties: false,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: "scenario_keyword_research",
|
|
215
|
+
description:
|
|
216
|
+
"Run keyword research workflow using TryAstro search, suggestions, and save flow.",
|
|
217
|
+
inputSchema: {
|
|
218
|
+
type: "object",
|
|
219
|
+
properties: {
|
|
220
|
+
app_name: { type: "string" },
|
|
221
|
+
country: { type: "string" },
|
|
222
|
+
language: { type: "string" },
|
|
223
|
+
seed_keywords: {
|
|
224
|
+
type: "array",
|
|
225
|
+
items: { type: "string" },
|
|
226
|
+
},
|
|
227
|
+
target_keywords_count: { type: "integer", minimum: 1, maximum: 200 },
|
|
228
|
+
},
|
|
229
|
+
additionalProperties: false,
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "scenario_competitor_analysis",
|
|
234
|
+
description:
|
|
235
|
+
"Competitor analysis workflow: compare overlap, position deltas, and actionable keyword gaps.",
|
|
236
|
+
inputSchema: {
|
|
237
|
+
type: "object",
|
|
238
|
+
properties: {
|
|
239
|
+
app_name: { type: "string" },
|
|
240
|
+
country: { type: "string" },
|
|
241
|
+
competitors: {
|
|
242
|
+
type: "array",
|
|
243
|
+
items: { type: "string" },
|
|
244
|
+
},
|
|
245
|
+
focus_metric: { type: "string" },
|
|
246
|
+
},
|
|
247
|
+
additionalProperties: false,
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: "scenario_localization_strategy",
|
|
252
|
+
description:
|
|
253
|
+
"Localization workflow based on TryAstro country-localization and app-localization tools.",
|
|
254
|
+
inputSchema: {
|
|
255
|
+
type: "object",
|
|
256
|
+
properties: {
|
|
257
|
+
app_name: { type: "string" },
|
|
258
|
+
source_locale: { type: "string" },
|
|
259
|
+
target_countries: {
|
|
260
|
+
type: "array",
|
|
261
|
+
items: { type: "string" },
|
|
262
|
+
},
|
|
263
|
+
release_window: { type: "string" },
|
|
264
|
+
},
|
|
265
|
+
additionalProperties: false,
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: "scenario_rankings_and_ratings",
|
|
270
|
+
description:
|
|
271
|
+
"Monitoring workflow for keyword rankings and ratings/reviews over time.",
|
|
272
|
+
inputSchema: {
|
|
273
|
+
type: "object",
|
|
274
|
+
properties: {
|
|
275
|
+
app_name: { type: "string" },
|
|
276
|
+
countries: {
|
|
277
|
+
type: "array",
|
|
278
|
+
items: { type: "string" },
|
|
279
|
+
},
|
|
280
|
+
watch_keywords: {
|
|
281
|
+
type: "array",
|
|
282
|
+
items: { type: "string" },
|
|
283
|
+
},
|
|
284
|
+
check_cadence: {
|
|
285
|
+
type: "string",
|
|
286
|
+
enum: ["daily", "weekly"],
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
additionalProperties: false,
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: "scenario_migration_and_transfer",
|
|
294
|
+
description:
|
|
295
|
+
"Migration workflow for CSV keyword data, full history transfer, and license transfer between Macs.",
|
|
296
|
+
inputSchema: {
|
|
297
|
+
type: "object",
|
|
298
|
+
properties: {
|
|
299
|
+
source_tool: { type: "string" },
|
|
300
|
+
transfer_mode: {
|
|
301
|
+
type: "string",
|
|
302
|
+
enum: ["csv_keywords", "full_history_sqlite", "license_transfer"],
|
|
303
|
+
},
|
|
304
|
+
need_history: { type: "boolean" },
|
|
305
|
+
},
|
|
306
|
+
additionalProperties: false,
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
name: "standard_topics",
|
|
311
|
+
description:
|
|
312
|
+
"List standard support topics handled by this MCP (pricing, legal, privacy, support, migration, etc.).",
|
|
313
|
+
inputSchema: {
|
|
314
|
+
type: "object",
|
|
315
|
+
properties: {},
|
|
316
|
+
additionalProperties: false,
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: "standard_answer",
|
|
321
|
+
description:
|
|
322
|
+
"Answer standard TryAstro support questions by routing the query to known topic playbooks.",
|
|
323
|
+
inputSchema: {
|
|
324
|
+
type: "object",
|
|
325
|
+
properties: {
|
|
326
|
+
query: { type: "string" },
|
|
327
|
+
topic: {
|
|
328
|
+
type: "string",
|
|
329
|
+
enum: STANDARD_TOPICS.map((topic) => topic.id),
|
|
330
|
+
},
|
|
331
|
+
language: {
|
|
332
|
+
type: "string",
|
|
333
|
+
enum: ["ru", "en"],
|
|
334
|
+
default: "ru",
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
required: ["query"],
|
|
338
|
+
additionalProperties: false,
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
];
|
|
342
|
+
|
|
343
|
+
const TOOL_HANDLERS = {
|
|
344
|
+
scenario_catalog: () => {
|
|
345
|
+
const lines = [
|
|
346
|
+
"TryAstro main scenario decomposition",
|
|
347
|
+
"",
|
|
348
|
+
...SCENARIO_SUMMARY.flatMap((scenario, idx) => [
|
|
349
|
+
`${idx + 1}. ${scenario.title} (${scenario.id})`,
|
|
350
|
+
`Goal: ${scenario.goal}`,
|
|
351
|
+
]),
|
|
352
|
+
"",
|
|
353
|
+
"Standard request handling",
|
|
354
|
+
"Use standard_answer for typical support and product questions (pricing, legal, privacy, support, migration, platform coverage).",
|
|
355
|
+
"",
|
|
356
|
+
"Verified sources",
|
|
357
|
+
...Object.values(SOURCES).map((source, idx) => `${idx + 1}. ${source}`),
|
|
358
|
+
"",
|
|
359
|
+
`Verified on: ${VERIFIED_ON}`,
|
|
360
|
+
];
|
|
361
|
+
|
|
362
|
+
return asText(lines.join("\n"));
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
scenario_onboarding_first_value: (args = {}) => {
|
|
366
|
+
const appName = safeText(args.app_name, "your app");
|
|
367
|
+
const store = safeText(args.primary_store, "primary target store");
|
|
368
|
+
const platform = safeText(args.platform, "ios");
|
|
369
|
+
const goal = safeText(
|
|
370
|
+
args.team_goal,
|
|
371
|
+
"launch ASO baseline and first optimization backlog"
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const lines = [
|
|
375
|
+
"Scenario: Onboarding to first value",
|
|
376
|
+
`Context: app=${appName}, store=${store}, platform=${platform}, goal=${goal}`,
|
|
377
|
+
"",
|
|
378
|
+
"Steps",
|
|
379
|
+
"1. Verify access in the app and complete first run checks (docs: first-access).",
|
|
380
|
+
"2. Add the target app using URL or bundle ID flow (docs: add-app).",
|
|
381
|
+
"3. Add an initial keyword list and save it to persistent tracking (docs: add-keywords).",
|
|
382
|
+
"4. Run suggestions to expand the semantic pool and tag high-priority terms.",
|
|
383
|
+
"5. Open rankings and capture baseline positions for tracked keywords.",
|
|
384
|
+
"6. Open ratings view and capture baseline rating/review volume.",
|
|
385
|
+
"7. Export the initial snapshot so the team has a day-0 reference point.",
|
|
386
|
+
"",
|
|
387
|
+
"Definition of done",
|
|
388
|
+
"1. App is added and visible in working table.",
|
|
389
|
+
"2. Initial keyword set is saved and tracked.",
|
|
390
|
+
"3. Baseline ranking and rating snapshot is documented.",
|
|
391
|
+
"4. Team has a 7-day execution backlog based on gaps from baseline.",
|
|
392
|
+
"",
|
|
393
|
+
"Sources",
|
|
394
|
+
`1. ${SOURCES.firstAccess}`,
|
|
395
|
+
`2. ${SOURCES.addApp}`,
|
|
396
|
+
`3. ${SOURCES.addKeywords}`,
|
|
397
|
+
`4. ${SOURCES.rankings}`,
|
|
398
|
+
`5. ${SOURCES.ratings}`,
|
|
399
|
+
"",
|
|
400
|
+
`Verified on: ${VERIFIED_ON}`,
|
|
401
|
+
];
|
|
402
|
+
|
|
403
|
+
return asText(lines.join("\n"));
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
scenario_keyword_research: (args = {}) => {
|
|
407
|
+
const appName = safeText(args.app_name, "your app");
|
|
408
|
+
const country = safeText(args.country, "US");
|
|
409
|
+
const language = safeText(args.language, "English");
|
|
410
|
+
const targetCount = Number.isInteger(args.target_keywords_count)
|
|
411
|
+
? args.target_keywords_count
|
|
412
|
+
: 50;
|
|
413
|
+
const seeds = arrayToInline(args.seed_keywords, "none provided");
|
|
414
|
+
|
|
415
|
+
const lines = [
|
|
416
|
+
"Scenario: Keyword research loop",
|
|
417
|
+
`Context: app=${appName}, country=${country}, language=${language}, target_keywords=${targetCount}, seeds=${seeds}`,
|
|
418
|
+
"",
|
|
419
|
+
"Steps",
|
|
420
|
+
"1. Start from Add Keywords and enter seed terms relevant to the core use case.",
|
|
421
|
+
"2. Open Suggestions and switch to the target country/store scope.",
|
|
422
|
+
"3. Use Most Searched Keywords to discover high-volume opportunities.",
|
|
423
|
+
"4. Filter terms by relevance and intent, then save only commercially useful keywords.",
|
|
424
|
+
"5. Split selected terms into core/head, mid-tail, and long-tail batches.",
|
|
425
|
+
"6. Move selected keywords into rankings watchlist for daily/weekly monitoring.",
|
|
426
|
+
"7. Export shortlist for metadata and Apple Search Ads alignment.",
|
|
427
|
+
"",
|
|
428
|
+
"Definition of done",
|
|
429
|
+
`1. Prioritized keyword pool contains around ${targetCount} trackable candidates.`,
|
|
430
|
+
"2. Each selected keyword has owner and action (keep/test/remove).",
|
|
431
|
+
"3. Rankings watchlist is configured for all target locales.",
|
|
432
|
+
"",
|
|
433
|
+
"Sources",
|
|
434
|
+
`1. ${SOURCES.addKeywords}`,
|
|
435
|
+
`2. ${SOURCES.suggestions}`,
|
|
436
|
+
`3. ${SOURCES.rankings}`,
|
|
437
|
+
"",
|
|
438
|
+
`Verified on: ${VERIFIED_ON}`,
|
|
439
|
+
];
|
|
440
|
+
|
|
441
|
+
return asText(lines.join("\n"));
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
scenario_competitor_analysis: (args = {}) => {
|
|
445
|
+
const appName = safeText(args.app_name, "your app");
|
|
446
|
+
const country = safeText(args.country, "US");
|
|
447
|
+
const competitors = arrayToInline(args.competitors, "not specified");
|
|
448
|
+
const metric = safeText(args.focus_metric, "keyword overlap and ranking gap");
|
|
449
|
+
|
|
450
|
+
const lines = [
|
|
451
|
+
"Scenario: Competitor benchmarking",
|
|
452
|
+
`Context: app=${appName}, country=${country}, competitors=${competitors}, focus_metric=${metric}`,
|
|
453
|
+
"",
|
|
454
|
+
"Steps",
|
|
455
|
+
"1. In Suggestions, set your app and add competitor app URLs via settings.",
|
|
456
|
+
"2. Compare overlapping keywords and identify terms where competitors outrank you.",
|
|
457
|
+
"3. Open Rankings to validate position deltas on shared terms.",
|
|
458
|
+
"4. Segment gaps into quick wins (low effort), strategic bets, and defend positions.",
|
|
459
|
+
"5. Convert findings into backlog: metadata updates, localization tasks, ASA tests.",
|
|
460
|
+
"6. Re-check competitor deltas after each product metadata release.",
|
|
461
|
+
"",
|
|
462
|
+
"Definition of done",
|
|
463
|
+
"1. Competitor matrix is prepared with overlap, gap, and opportunity labels.",
|
|
464
|
+
"2. Top gap keywords are assigned to concrete optimization actions.",
|
|
465
|
+
"3. Team has a repeating competitor review cadence.",
|
|
466
|
+
"",
|
|
467
|
+
"Sources",
|
|
468
|
+
`1. ${SOURCES.suggestions}`,
|
|
469
|
+
`2. ${SOURCES.rankings}`,
|
|
470
|
+
"",
|
|
471
|
+
`Verified on: ${VERIFIED_ON}`,
|
|
472
|
+
];
|
|
473
|
+
|
|
474
|
+
return asText(lines.join("\n"));
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
scenario_localization_strategy: (args = {}) => {
|
|
478
|
+
const appName = safeText(args.app_name, "your app");
|
|
479
|
+
const sourceLocale = safeText(args.source_locale, "en-US");
|
|
480
|
+
const targets = arrayToInline(args.target_countries, "not specified");
|
|
481
|
+
const window = safeText(args.release_window, "next 30 days");
|
|
482
|
+
|
|
483
|
+
const lines = [
|
|
484
|
+
"Scenario: Localization expansion",
|
|
485
|
+
`Context: app=${appName}, source_locale=${sourceLocale}, target_countries=${targets}, release_window=${window}`,
|
|
486
|
+
"",
|
|
487
|
+
"Steps",
|
|
488
|
+
"1. Use Country Localizations to discover where your source locale is reused.",
|
|
489
|
+
"2. Prioritize countries by demand potential and current rank gap.",
|
|
490
|
+
"3. Build localized keyword sets for each target market.",
|
|
491
|
+
"4. Validate app-level localizations to avoid mismatch in metadata language.",
|
|
492
|
+
"5. Publish in waves (pilot countries first, then scale).",
|
|
493
|
+
"6. Track rankings and ratings after each localization release wave.",
|
|
494
|
+
"",
|
|
495
|
+
"Definition of done",
|
|
496
|
+
"1. Target country list is prioritized and approved.",
|
|
497
|
+
"2. Every market has localized keyword set and owner.",
|
|
498
|
+
"3. Post-release monitoring plan is active for each wave.",
|
|
499
|
+
"",
|
|
500
|
+
"Sources",
|
|
501
|
+
`1. ${SOURCES.localizations}`,
|
|
502
|
+
`2. ${SOURCES.rankings}`,
|
|
503
|
+
`3. ${SOURCES.ratings}`,
|
|
504
|
+
"",
|
|
505
|
+
`Verified on: ${VERIFIED_ON}`,
|
|
506
|
+
];
|
|
507
|
+
|
|
508
|
+
return asText(lines.join("\n"));
|
|
509
|
+
},
|
|
510
|
+
|
|
511
|
+
scenario_rankings_and_ratings: (args = {}) => {
|
|
512
|
+
const appName = safeText(args.app_name, "your app");
|
|
513
|
+
const countries = arrayToInline(args.countries, "US");
|
|
514
|
+
const watch = arrayToInline(args.watch_keywords, "core app keywords");
|
|
515
|
+
const cadence = safeText(args.check_cadence, "daily");
|
|
516
|
+
|
|
517
|
+
const lines = [
|
|
518
|
+
"Scenario: Rankings and ratings monitoring",
|
|
519
|
+
`Context: app=${appName}, countries=${countries}, watch_keywords=${watch}, cadence=${cadence}`,
|
|
520
|
+
"",
|
|
521
|
+
"Steps",
|
|
522
|
+
"1. Ensure keyword list is saved and included in rankings tracking.",
|
|
523
|
+
"2. Review position movement and trend chart on fixed cadence.",
|
|
524
|
+
"3. Switch to Ratings view for each store to monitor score and review count.",
|
|
525
|
+
"4. Correlate ranking drops/spikes with rating swings and release events.",
|
|
526
|
+
"5. Trigger fast actions: metadata tweak, keyword replacement, or review-response cycle.",
|
|
527
|
+
"6. Keep a change log so each optimization can be measured against outcome.",
|
|
528
|
+
"",
|
|
529
|
+
"Definition of done",
|
|
530
|
+
"1. Monitoring routine is stable and scheduled.",
|
|
531
|
+
"2. Alerts/actions exist for major ranking or rating changes.",
|
|
532
|
+
"3. Team can explain top movers weekly with evidence.",
|
|
533
|
+
"",
|
|
534
|
+
"Sources",
|
|
535
|
+
`1. ${SOURCES.rankings}`,
|
|
536
|
+
`2. ${SOURCES.ratings}`,
|
|
537
|
+
"",
|
|
538
|
+
`Verified on: ${VERIFIED_ON}`,
|
|
539
|
+
];
|
|
540
|
+
|
|
541
|
+
return asText(lines.join("\n"));
|
|
542
|
+
},
|
|
543
|
+
|
|
544
|
+
scenario_migration_and_transfer: (args = {}) => {
|
|
545
|
+
const sourceTool = safeText(args.source_tool, "AppTweak/Sensor Tower/Mobile Action/AppFollow");
|
|
546
|
+
const mode = safeText(args.transfer_mode, "csv_keywords");
|
|
547
|
+
const needHistory = typeof args.need_history === "boolean" ? args.need_history : true;
|
|
548
|
+
|
|
549
|
+
const lines = [
|
|
550
|
+
"Scenario: Migration/import and transfer",
|
|
551
|
+
`Context: source_tool=${sourceTool}, transfer_mode=${mode}, need_history=${needHistory}`,
|
|
552
|
+
"",
|
|
553
|
+
"Steps",
|
|
554
|
+
"1. Choose transfer mode: CSV keyword import, full history via model.sqlite, or license transfer.",
|
|
555
|
+
"2. For CSV mode, map columns to Astro schema and import in Add Keywords.",
|
|
556
|
+
"3. For full history migration, copy model.sqlite from old Mac and place it in Astro directory on the new Mac.",
|
|
557
|
+
"4. For license transfer, sign out on old Mac and use Lemon Squeezy link to transfer license.",
|
|
558
|
+
"5. Re-open app and validate keywords, rankings history, tags, and notes.",
|
|
559
|
+
"6. Keep rollback backup until parity checks are completed.",
|
|
560
|
+
"",
|
|
561
|
+
"Definition of done",
|
|
562
|
+
"1. Data parity is verified on key entities (keywords/rankings/history/tags).",
|
|
563
|
+
"2. License is active only on intended machine.",
|
|
564
|
+
"3. Team runbook includes rollback path.",
|
|
565
|
+
"",
|
|
566
|
+
"Sources",
|
|
567
|
+
`1. ${SOURCES.importExport}`,
|
|
568
|
+
`2. ${SOURCES.transfer}`,
|
|
569
|
+
`3. ${SOURCES.terms}`,
|
|
570
|
+
"",
|
|
571
|
+
`Verified on: ${VERIFIED_ON}`,
|
|
572
|
+
];
|
|
573
|
+
|
|
574
|
+
return asText(lines.join("\n"));
|
|
575
|
+
},
|
|
576
|
+
|
|
577
|
+
standard_topics: () => {
|
|
578
|
+
const lines = [
|
|
579
|
+
"Standard topics supported by standard_answer",
|
|
580
|
+
"",
|
|
581
|
+
...STANDARD_TOPICS.flatMap((topic, idx) => [
|
|
582
|
+
`${idx + 1}. ${topic.id} - ${topic.title}`,
|
|
583
|
+
]),
|
|
584
|
+
"",
|
|
585
|
+
"How to use",
|
|
586
|
+
'Call standard_answer with {"query":"..."} or force routing via {"query":"...","topic":"pricing_billing"}.',
|
|
587
|
+
"",
|
|
588
|
+
`Verified on: ${VERIFIED_ON}`,
|
|
589
|
+
];
|
|
590
|
+
return asText(lines.join("\n"));
|
|
591
|
+
},
|
|
592
|
+
|
|
593
|
+
standard_answer: (args = {}) => {
|
|
594
|
+
const query = safeText(args.query, "");
|
|
595
|
+
const language = safeText(args.language, "ru").toLowerCase() === "en" ? "en" : "ru";
|
|
596
|
+
|
|
597
|
+
if (!query) {
|
|
598
|
+
return asError("Input validation error: query is required.");
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const forcedTopicId = safeText(args.topic, "");
|
|
602
|
+
const topic =
|
|
603
|
+
STANDARD_TOPICS.find((item) => item.id === forcedTopicId) ||
|
|
604
|
+
detectStandardTopic(query);
|
|
605
|
+
|
|
606
|
+
if (!topic) {
|
|
607
|
+
const lines = [
|
|
608
|
+
"No direct topic match found for this query.",
|
|
609
|
+
`Query: ${query}`,
|
|
610
|
+
"",
|
|
611
|
+
"Try one of these topic ids in the topic field:",
|
|
612
|
+
...STANDARD_TOPICS.map((item) => `- ${item.id}`),
|
|
613
|
+
"",
|
|
614
|
+
"Or ask a more specific question (pricing, refund, legal, privacy, support, migration, platform coverage).",
|
|
615
|
+
"",
|
|
616
|
+
`Verified on: ${VERIFIED_ON}`,
|
|
617
|
+
];
|
|
618
|
+
return asText(lines.join("\n"));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const answer = language === "en" ? topic.answerEn : topic.answerRu;
|
|
622
|
+
const lines = [
|
|
623
|
+
`Detected topic: ${topic.id}`,
|
|
624
|
+
`Question: ${query}`,
|
|
625
|
+
"",
|
|
626
|
+
`Answer: ${answer}`,
|
|
627
|
+
"",
|
|
628
|
+
"Recommended next actions",
|
|
629
|
+
...topic.next.map((next, idx) => `${idx + 1}. ${next}`),
|
|
630
|
+
"",
|
|
631
|
+
"Sources",
|
|
632
|
+
...topic.sources.map((source, idx) => `${idx + 1}. ${source}`),
|
|
633
|
+
"",
|
|
634
|
+
`Verified on: ${VERIFIED_ON}`,
|
|
635
|
+
];
|
|
636
|
+
return asText(lines.join("\n"));
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
function detectStandardTopic(query) {
|
|
641
|
+
return STANDARD_TOPICS.find((topic) =>
|
|
642
|
+
topic.patterns.some((pattern) => pattern.test(query))
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function arrayToInline(value, fallback) {
|
|
647
|
+
if (!Array.isArray(value)) {
|
|
648
|
+
return fallback;
|
|
649
|
+
}
|
|
650
|
+
const clean = value
|
|
651
|
+
.filter((entry) => typeof entry === "string")
|
|
652
|
+
.map((entry) => entry.trim())
|
|
653
|
+
.filter(Boolean);
|
|
654
|
+
|
|
655
|
+
return clean.length > 0 ? clean.join(", ") : fallback;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function safeText(value, fallback) {
|
|
659
|
+
if (typeof value !== "string") {
|
|
660
|
+
return fallback;
|
|
661
|
+
}
|
|
662
|
+
const trimmed = value.trim();
|
|
663
|
+
return trimmed.length > 0 ? trimmed : fallback;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function asText(text) {
|
|
667
|
+
return {
|
|
668
|
+
content: [
|
|
669
|
+
{
|
|
670
|
+
type: "text",
|
|
671
|
+
text,
|
|
672
|
+
},
|
|
673
|
+
],
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function asError(text) {
|
|
678
|
+
return {
|
|
679
|
+
content: [
|
|
680
|
+
{
|
|
681
|
+
type: "text",
|
|
682
|
+
text,
|
|
683
|
+
},
|
|
684
|
+
],
|
|
685
|
+
isError: true,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function callTool(params) {
|
|
690
|
+
const name = safeText(params?.name, "");
|
|
691
|
+
const args = params?.arguments ?? {};
|
|
692
|
+
|
|
693
|
+
const handler = TOOL_HANDLERS[name];
|
|
694
|
+
if (!handler) {
|
|
695
|
+
return asError(`Unknown tool "${name}". Use tools/list to inspect supported tools.`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
try {
|
|
699
|
+
return handler(args);
|
|
700
|
+
} catch (error) {
|
|
701
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
702
|
+
return asError(`Tool execution error in "${name}": ${message}`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function handleRequest(message) {
|
|
707
|
+
const hasId = Object.prototype.hasOwnProperty.call(message, "id");
|
|
708
|
+
const id = message.id;
|
|
709
|
+
const method = message.method;
|
|
710
|
+
|
|
711
|
+
if (!method || typeof method !== "string") {
|
|
712
|
+
if (hasId) {
|
|
713
|
+
return makeError(id, -32600, "Invalid Request: method is required.");
|
|
714
|
+
}
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (method === "notifications/initialized") {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (method === "exit") {
|
|
723
|
+
process.exit(0);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (!hasId) {
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
switch (method) {
|
|
731
|
+
case "initialize":
|
|
732
|
+
return makeResult(id, {
|
|
733
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
734
|
+
capabilities: {
|
|
735
|
+
tools: {
|
|
736
|
+
listChanged: false,
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
serverInfo: SERVER_INFO,
|
|
740
|
+
});
|
|
741
|
+
case "ping":
|
|
742
|
+
return makeResult(id, {});
|
|
743
|
+
case "tools/list":
|
|
744
|
+
return makeResult(id, { tools: TOOL_DEFINITIONS });
|
|
745
|
+
case "tools/call":
|
|
746
|
+
return makeResult(id, callTool(message.params || {}));
|
|
747
|
+
case "shutdown":
|
|
748
|
+
return makeResult(id, null);
|
|
749
|
+
default:
|
|
750
|
+
return makeError(id, -32601, `Method not found: ${method}`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function makeResult(id, result) {
|
|
755
|
+
return {
|
|
756
|
+
jsonrpc: "2.0",
|
|
757
|
+
id,
|
|
758
|
+
result,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function makeError(id, code, message) {
|
|
763
|
+
return {
|
|
764
|
+
jsonrpc: "2.0",
|
|
765
|
+
id,
|
|
766
|
+
error: {
|
|
767
|
+
code,
|
|
768
|
+
message,
|
|
769
|
+
},
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function sendMessage(payload) {
|
|
774
|
+
const encoded = JSON.stringify(payload);
|
|
775
|
+
const bytes = Buffer.byteLength(encoded, "utf8");
|
|
776
|
+
const header = `Content-Length: ${bytes}\r\n\r\n`;
|
|
777
|
+
process.stdout.write(header + encoded);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function parseContentLength(headerText) {
|
|
781
|
+
const lines = headerText.split(/\r?\n/);
|
|
782
|
+
for (const line of lines) {
|
|
783
|
+
const match = /^Content-Length:\s*(\d+)\s*$/i.exec(line.trim());
|
|
784
|
+
if (match) {
|
|
785
|
+
return Number.parseInt(match[1], 10);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function findHeaderBoundary(buffer) {
|
|
792
|
+
const crlfBoundary = buffer.indexOf("\r\n\r\n");
|
|
793
|
+
if (crlfBoundary !== -1) {
|
|
794
|
+
return { index: crlfBoundary, separatorLength: 4 };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const lfBoundary = buffer.indexOf("\n\n");
|
|
798
|
+
if (lfBoundary !== -1) {
|
|
799
|
+
return { index: lfBoundary, separatorLength: 2 };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function isObjectMessage(value) {
|
|
806
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
let incoming = Buffer.alloc(0);
|
|
810
|
+
|
|
811
|
+
process.stdin.on("data", (chunk) => {
|
|
812
|
+
incoming = Buffer.concat([incoming, chunk]);
|
|
813
|
+
parseIncoming();
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
process.stdin.on("error", (error) => {
|
|
817
|
+
process.stderr.write(`stdin error: ${String(error)}\n`);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
process.on("uncaughtException", (error) => {
|
|
821
|
+
process.stderr.write(`uncaught exception: ${String(error)}\n`);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
function parseIncoming() {
|
|
825
|
+
while (incoming.length > 0) {
|
|
826
|
+
const boundary = findHeaderBoundary(incoming);
|
|
827
|
+
if (!boundary) {
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const header = incoming.slice(0, boundary.index).toString("utf8");
|
|
832
|
+
const contentLength = parseContentLength(header);
|
|
833
|
+
|
|
834
|
+
if (!Number.isFinite(contentLength)) {
|
|
835
|
+
incoming = incoming.slice(boundary.index + boundary.separatorLength);
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const bodyStart = boundary.index + boundary.separatorLength;
|
|
840
|
+
const bodyEnd = bodyStart + contentLength;
|
|
841
|
+
if (incoming.length < bodyEnd) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const body = incoming.slice(bodyStart, bodyEnd).toString("utf8");
|
|
846
|
+
incoming = incoming.slice(bodyEnd);
|
|
847
|
+
|
|
848
|
+
let message;
|
|
849
|
+
try {
|
|
850
|
+
message = JSON.parse(body);
|
|
851
|
+
} catch (error) {
|
|
852
|
+
process.stderr.write(`json parse error: ${String(error)}\n`);
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (Array.isArray(message)) {
|
|
857
|
+
if (message.length === 0) {
|
|
858
|
+
sendMessage(makeError(null, -32600, "Invalid Request: empty batch."));
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const responses = [];
|
|
863
|
+
for (const request of message) {
|
|
864
|
+
if (!isObjectMessage(request)) {
|
|
865
|
+
responses.push(
|
|
866
|
+
makeError(null, -32600, "Invalid Request: request must be an object."),
|
|
867
|
+
);
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const response = handleRequest(request);
|
|
872
|
+
if (response) {
|
|
873
|
+
responses.push(response);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (responses.length > 0) {
|
|
878
|
+
sendMessage(responses);
|
|
879
|
+
}
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (!isObjectMessage(message)) {
|
|
884
|
+
sendMessage(makeError(null, -32600, "Invalid Request: request must be an object."));
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const response = handleRequest(message);
|
|
889
|
+
if (response) {
|
|
890
|
+
sendMessage(response);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|