asoradar-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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +198 -0
- package/dist/http.js +68 -0
- package/dist/index.js +172 -0
- package/dist/openapi.js +260 -0
- package/package.json +56 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `asoradar-mcp` are documented here.
|
|
4
|
+
|
|
5
|
+
## 1.0.0 — 2026-06-24
|
|
6
|
+
|
|
7
|
+
First public release — an MCP server that turns the [ASORadar](https://asoradar.com)
|
|
8
|
+
App Store / Google Play reviews + ASO API into tools for AI agents.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Auto-generated tools** — one MCP tool per non-deprecated endpoint, built from
|
|
12
|
+
the live ASORadar OpenAPI spec at startup. Covers App Store (`/a1/*`) and Google
|
|
13
|
+
Play (`/g1/*`): reviews, app metadata, version history, ratings histograms,
|
|
14
|
+
search, charts, similar apps, developer portfolios, privacy / Data Safety, the
|
|
15
|
+
AI-agent composites (clone-brief, niche-scan, metadata/version/privacy/data-safety
|
|
16
|
+
diffs, developer portfolio stats, review sentiment, reviews-by-version), and
|
|
17
|
+
webhook subscriptions.
|
|
18
|
+
- **JSON request-body tools** — endpoints with a JSON object body (e.g.
|
|
19
|
+
`post_webhooks` to create a subscription) map their body fields onto tool
|
|
20
|
+
arguments; a body field that collides with a path/query param defers to the
|
|
21
|
+
addressing param.
|
|
22
|
+
- **`get_capabilities`** — a built-in meta-tool returning the live tool catalog and
|
|
23
|
+
roadmap (no API request spent). Call it first to discover what's available.
|
|
24
|
+
- **Auth + safety** — `ASORADAR_KEY` sent as the `x-access-key` header; trusted-host
|
|
25
|
+
check before the key is ever sent; upstream tool descriptions wrapped as untrusted
|
|
26
|
+
documentation to blunt prompt injection; required arguments validated locally so a
|
|
27
|
+
malformed call never spends a request; response size + timeout limits.
|
|
28
|
+
- **Client install docs** for Claude Code, Claude Desktop, Cursor, OpenAI Codex,
|
|
29
|
+
Zed, and Windsurf. `stdio` transport.
|
|
30
|
+
|
|
31
|
+
### Notes
|
|
32
|
+
- Each tool call is one or more metered ASORadar API requests; composite tools fan
|
|
33
|
+
out to several. New accounts get [100 free requests](https://asoradar.com/registration).
|
|
34
|
+
- Roadmap: synthesized composite tools with provenance + workflow prompts (0.2.x).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ASORadar
|
|
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,198 @@
|
|
|
1
|
+
# asoradar-mcp
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/asoradar-mcp)
|
|
4
|
+
[](https://www.npmjs.com/package/asoradar-mcp)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
MCP server for [ASORadar](https://asoradar.com) — App Reviews API for Apple App Store and Google Play. Available on npm: [`asoradar-mcp`](https://www.npmjs.com/package/asoradar-mcp).
|
|
8
|
+
|
|
9
|
+
Auto-generates MCP tools from the ASORadar OpenAPI spec at startup, so every non-deprecated endpoint is exposed without hand-written wrappers (`GET /a1/reviews` → `get_a1_reviews`, `GET /a1/clone-brief/{app_id}` → `get_a1_clone-brief_app_id`, `POST /webhooks` → `post_webhooks`). The tool catalog tracks the live API — App Store (`/a1/*`) and Google Play (`/g1/*`) reviews, metadata, version history, ratings histograms, search/charts, AI-agent composites (clone-brief, niche-scan, diffs, sentiment, portfolio stats), and webhook subscriptions.
|
|
10
|
+
|
|
11
|
+
## Get 100 Free API Requests
|
|
12
|
+
|
|
13
|
+
[Sign up](https://asoradar.com/registration) and get **100 free ASORadar requests** — no credit card required. Enough to wire up the MCP server, try a few prompts in Claude/Cursor/Codex, and evaluate the data quality before committing.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
ASORADAR_KEY=your-api-key npx -y asoradar-mcp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`npx` fetches and runs the latest version every time — no install step.
|
|
22
|
+
|
|
23
|
+
## Install in Claude Code
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
claude mcp add asoradar \
|
|
27
|
+
-e ASORADAR_KEY=your-api-key \
|
|
28
|
+
-- npx -y asoradar-mcp
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The config is persisted in `~/.claude.json`.
|
|
32
|
+
|
|
33
|
+
## Install in Claude Desktop
|
|
34
|
+
|
|
35
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"mcpServers": {
|
|
40
|
+
"asoradar": {
|
|
41
|
+
"command": "npx",
|
|
42
|
+
"args": ["-y", "asoradar-mcp"],
|
|
43
|
+
"env": {
|
|
44
|
+
"ASORADAR_KEY": "your-api-key"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Install in Cursor
|
|
52
|
+
|
|
53
|
+
Add to `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` globally:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"asoradar": {
|
|
59
|
+
"command": "npx",
|
|
60
|
+
"args": ["-y", "asoradar-mcp"],
|
|
61
|
+
"env": {
|
|
62
|
+
"ASORADAR_KEY": "your-api-key"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Install in OpenAI Codex
|
|
70
|
+
|
|
71
|
+
Append to `~/.codex/config.toml`:
|
|
72
|
+
|
|
73
|
+
```toml
|
|
74
|
+
[mcp_servers.asoradar]
|
|
75
|
+
command = "npx"
|
|
76
|
+
args = ["-y", "asoradar-mcp"]
|
|
77
|
+
|
|
78
|
+
[mcp_servers.asoradar.env]
|
|
79
|
+
ASORADAR_KEY = "your-api-key"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Install in Zed
|
|
83
|
+
|
|
84
|
+
Add to `~/.config/zed/settings.json`:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"context_servers": {
|
|
89
|
+
"asoradar": {
|
|
90
|
+
"command": "npx",
|
|
91
|
+
"args": ["-y", "asoradar-mcp"],
|
|
92
|
+
"env": {
|
|
93
|
+
"ASORADAR_KEY": "your-api-key"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Install in Windsurf
|
|
101
|
+
|
|
102
|
+
Add to `~/.codeium/windsurf/mcp_config.json`:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"mcpServers": {
|
|
107
|
+
"asoradar": {
|
|
108
|
+
"command": "npx",
|
|
109
|
+
"args": ["-y", "asoradar-mcp"],
|
|
110
|
+
"env": {
|
|
111
|
+
"ASORADAR_KEY": "your-api-key"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## What you can ask
|
|
119
|
+
|
|
120
|
+
Plain-English prompts work — the AI picks the right tool and parameters.
|
|
121
|
+
|
|
122
|
+
| Prompt | Tool used |
|
|
123
|
+
|---|---|
|
|
124
|
+
| Pull recent App Store reviews for Instagram (id 389801252). | `get_a1_reviews` (`app_id`, `country`) |
|
|
125
|
+
| Find 5-star reviews of `com.whatsapp` on Google Play mentioning "crash". | `get_g1_reviews` (`app_id`, `rating=5`, `search=crash`) |
|
|
126
|
+
| What changed in Telegram's last few App Store releases? | `get_a1_version-history_app_id` |
|
|
127
|
+
| Give me a clone brief for this app — metadata, ratings, top complaints. | `get_a1_clone-brief_app_id` |
|
|
128
|
+
| Scout the top finance apps in the US and their rating distributions. | `get_a1_niche-scan` (`category`, `country`) |
|
|
129
|
+
| Diff TikTok's data-safety declaration vs last snapshot. | `get_g1_datasafety_app_id_diff` |
|
|
130
|
+
| Subscribe a webhook to notify me when this app ships a new version. | `post_webhooks` (`kind=app_changes`, `target_url`, `criteria`) |
|
|
131
|
+
| What tools do you have, and what do they cost? | `get_capabilities` |
|
|
132
|
+
|
|
133
|
+
Call **`get_capabilities`** first — it returns the live tool catalog (≈44 tools, auto-generated from the API) and the roadmap, and spends no API request.
|
|
134
|
+
|
|
135
|
+
## Configuration
|
|
136
|
+
|
|
137
|
+
| Env variable | Description | Required |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| `ASORADAR_KEY` | Your ASORadar access key (sent as `x-access-key` header). | yes |
|
|
140
|
+
| `ASORADAR_URL` | Base URL. Default: `https://api.asoradar.com`. | no |
|
|
141
|
+
| `ASORADAR_SPEC_URL` | OpenAPI spec URL. Default: `<ASORADAR_URL>/openapi.json`. | no |
|
|
142
|
+
| `ASORADAR_TAGS` | Whitelist tags (comma-separated) to limit which tools are exposed. | no |
|
|
143
|
+
| `ASORADAR_EXCLUDE_TAGS` | Additional tags to exclude (`System` is excluded by default). | no |
|
|
144
|
+
| `ASORADAR_TIMEOUT_MS` | Per-request timeout in ms. Default: `30000`. | no |
|
|
145
|
+
| `ASORADAR_SPEC_TIMEOUT_MS` | OpenAPI spec fetch timeout in ms. Default: `60000`. | no |
|
|
146
|
+
| `ASORADAR_MAX_RESPONSE_BYTES` | Max response size in bytes. Default: `10485760` (10 MB). | no |
|
|
147
|
+
| `ASORADAR_MAX_SPEC_BYTES` | Max OpenAPI spec size in bytes. Default: `8388608` (8 MB). | no |
|
|
148
|
+
|
|
149
|
+
## How it works
|
|
150
|
+
|
|
151
|
+
1. **Fetch spec.** On startup, `asoradar-mcp` fetches `/openapi.json` from `api.asoradar.com` and parses every endpoint.
|
|
152
|
+
2. **Generate tools.** Each non-deprecated endpoint becomes one MCP tool with a typed input schema. Query and path params plus JSON request-body fields (e.g. `POST /webhooks`) all map onto the tool's arguments. Operations that require header/cookie params, or a non-JSON request body, are skipped since we can't safely forward them.
|
|
153
|
+
3. **Forward calls.** When the AI calls a tool, the server validates required arguments locally (so a malformed call never spends a request), then forwards it with your `x-access-key` header — query/path in the URL, body fields as JSON — and returns the response.
|
|
154
|
+
|
|
155
|
+
> **Metering.** Every tool call is one or more ASORadar API requests billed against your plan. Composite tools (`clone-brief`, `niche-scan`, `*-diff`, `portfolio-stats`, `reviews/sentiment`) fan out to several underlying calls each. Start with the [100 free requests](#get-100-free-api-requests); `get_capabilities` and local validation errors are free.
|
|
156
|
+
|
|
157
|
+
## Filtering tools
|
|
158
|
+
|
|
159
|
+
To keep the surface small and focused, set `ASORADAR_TAGS` to a comma-separated whitelist of OpenAPI tags. The live tags are: `App Store / Reviews`, `App Store / Metadata`, `App Store / Discovery`, `App Store / AI Agents`, the four `Google Play / …` equivalents, and `Webhooks`.
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
# Only the App Store AI-agent composites + reviews:
|
|
163
|
+
ASORADAR_KEY=... ASORADAR_TAGS="App Store / AI Agents,App Store / Reviews" npx -y asoradar-mcp
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Or remove specific groups with `ASORADAR_EXCLUDE_TAGS`:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
ASORADAR_KEY=... ASORADAR_EXCLUDE_TAGS="Webhooks" npx -y asoradar-mcp
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Roadmap
|
|
173
|
+
|
|
174
|
+
- **Now** — atomic tools auto-generated 1:1 from the API (reviews, metadata, discovery, AI-agent composites, webhook subscriptions) + `get_capabilities`.
|
|
175
|
+
- **Next (0.2.x)** — synthesized composite tools that fan out across endpoints and return evidence with provenance (cited review/snapshot/version IDs), plus workflow prompts (research-competitor, scout-category, post-mortem-release).
|
|
176
|
+
|
|
177
|
+
The MCP returns structured facts only — it never calls an LLM, never invents endpoints the API doesn't expose, and never emits recommendations. Synthesis is always the agent's job. `get_capabilities` reports the live `available` and `planned` lists.
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
git clone https://github.com/asoradar/asoradar-mcp.git
|
|
183
|
+
cd asoradar-mcp
|
|
184
|
+
npm install
|
|
185
|
+
npm run build # compile TypeScript
|
|
186
|
+
npm test # mock-server smoke + unit tests
|
|
187
|
+
npm run test:e2e # real API e2e (needs ASORADAR_KEY env var)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Run from source without building:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
ASORADAR_KEY=... npm run dev
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
MIT — see [LICENSE](LICENSE).
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export class FetchError extends Error {
|
|
2
|
+
kind;
|
|
3
|
+
constructor(message, kind) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.kind = kind;
|
|
6
|
+
this.name = "FetchError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export async function fetchWithLimit(url, init, maxBytes, timeoutMs) {
|
|
10
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
11
|
+
// Disable connection reuse: some upstream HTTP servers close keep-alive
|
|
12
|
+
// sockets aggressively and undici's pool reuses a dead socket on the next
|
|
13
|
+
// request, hanging until the AbortSignal fires.
|
|
14
|
+
const headers = new Headers(init.headers);
|
|
15
|
+
headers.set("connection", "close");
|
|
16
|
+
let resp;
|
|
17
|
+
try {
|
|
18
|
+
resp = await fetch(url, { ...init, signal, headers });
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (isAbortError(err)) {
|
|
22
|
+
throw new FetchError(`Request timed out after ${timeoutMs}ms`, "timeout");
|
|
23
|
+
}
|
|
24
|
+
throw new FetchError(`Network error: ${err instanceof Error ? err.message : String(err)}`, "network");
|
|
25
|
+
}
|
|
26
|
+
const body = resp.body;
|
|
27
|
+
if (!body) {
|
|
28
|
+
return { ok: resp.ok, status: resp.status, statusText: resp.statusText, text: "", truncated: false };
|
|
29
|
+
}
|
|
30
|
+
const reader = body.getReader();
|
|
31
|
+
const chunks = [];
|
|
32
|
+
let size = 0;
|
|
33
|
+
let truncated = false;
|
|
34
|
+
try {
|
|
35
|
+
while (true) {
|
|
36
|
+
const { done, value } = await reader.read();
|
|
37
|
+
if (done)
|
|
38
|
+
break;
|
|
39
|
+
size += value.byteLength;
|
|
40
|
+
if (size > maxBytes) {
|
|
41
|
+
truncated = true;
|
|
42
|
+
await reader.cancel();
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
chunks.push(value);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (isAbortError(err)) {
|
|
50
|
+
throw new FetchError(`Response read timed out after ${timeoutMs}ms`, "timeout");
|
|
51
|
+
}
|
|
52
|
+
throw new FetchError(`Network error during read: ${err instanceof Error ? err.message : String(err)}`, "network");
|
|
53
|
+
}
|
|
54
|
+
if (truncated) {
|
|
55
|
+
throw new FetchError(`Response exceeded size limit (${maxBytes} bytes)`, "too-large");
|
|
56
|
+
}
|
|
57
|
+
const merged = new Uint8Array(size);
|
|
58
|
+
let offset = 0;
|
|
59
|
+
for (const c of chunks) {
|
|
60
|
+
merged.set(c, offset);
|
|
61
|
+
offset += c.byteLength;
|
|
62
|
+
}
|
|
63
|
+
const text = new TextDecoder("utf-8").decode(merged);
|
|
64
|
+
return { ok: resp.ok, status: resp.status, statusText: resp.statusText, text, truncated: false };
|
|
65
|
+
}
|
|
66
|
+
function isAbortError(err) {
|
|
67
|
+
return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
|
|
68
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { buildTools, buildUrl, buildBody, isTrustedUrl, } from "./openapi.js";
|
|
7
|
+
import { fetchWithLimit, FetchError } from "./http.js";
|
|
8
|
+
// Single source of truth for the version: package.json (published alongside
|
|
9
|
+
// dist/). Resolves to ../package.json from both dist/index.js and src/index.ts.
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const VERSION = require("../package.json").version;
|
|
12
|
+
const USER_AGENT = `asoradar-mcp/${VERSION}`;
|
|
13
|
+
const CAPABILITIES_TOOL_NAME = "get_capabilities";
|
|
14
|
+
const API_KEY = process.env.ASORADAR_KEY;
|
|
15
|
+
const BASE_URL = (process.env.ASORADAR_URL ?? "https://api.asoradar.com").replace(/\/$/, "");
|
|
16
|
+
const SPEC_URL = process.env.ASORADAR_SPEC_URL ?? `${BASE_URL}/openapi.json`;
|
|
17
|
+
const SPEC_TIMEOUT_MS = numEnv("ASORADAR_SPEC_TIMEOUT_MS", 60_000);
|
|
18
|
+
const API_TIMEOUT_MS = numEnv("ASORADAR_TIMEOUT_MS", 30_000);
|
|
19
|
+
const MAX_SPEC_BYTES = numEnv("ASORADAR_MAX_SPEC_BYTES", 8 * 1024 * 1024);
|
|
20
|
+
const MAX_RESPONSE_BYTES = numEnv("ASORADAR_MAX_RESPONSE_BYTES", 10 * 1024 * 1024);
|
|
21
|
+
// System endpoints (health checks, internal probes) are noise for an AI client.
|
|
22
|
+
// Operators can extend the list with ASORADAR_EXCLUDE_TAGS.
|
|
23
|
+
const DEFAULT_EXCLUDED_TAGS = ["System"];
|
|
24
|
+
const includeTags = parseTagList(process.env.ASORADAR_TAGS);
|
|
25
|
+
const excludeTags = new Set([
|
|
26
|
+
...DEFAULT_EXCLUDED_TAGS,
|
|
27
|
+
...parseTagList(process.env.ASORADAR_EXCLUDE_TAGS),
|
|
28
|
+
]);
|
|
29
|
+
if (!API_KEY) {
|
|
30
|
+
process.stderr.write("Error: ASORADAR_KEY environment variable is required.\n" +
|
|
31
|
+
"Get your API key at https://asoradar.com/tokens\n");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
if (!isTrustedUrl(BASE_URL)) {
|
|
35
|
+
process.stderr.write(`Warning: ASORADAR_URL points to a non-standard host (${BASE_URL}). ` +
|
|
36
|
+
`Your x-access-key will be sent there. ` +
|
|
37
|
+
`Only use this for self-hosted or proxied ASORadar over HTTPS or localhost.\n`);
|
|
38
|
+
}
|
|
39
|
+
// SPEC_URL controls the tool surface — a malicious spec can author tool
|
|
40
|
+
// descriptions that prompt-inject the model. Default points back to BASE_URL,
|
|
41
|
+
// but explicit override deserves a warning if it goes off-domain.
|
|
42
|
+
if (!isTrustedUrl(SPEC_URL)) {
|
|
43
|
+
process.stderr.write(`Warning: ASORADAR_SPEC_URL points to a non-standard host (${SPEC_URL}). ` +
|
|
44
|
+
`Tool definitions will be loaded from there. ` +
|
|
45
|
+
`Only override this if you trust that origin.\n`);
|
|
46
|
+
}
|
|
47
|
+
function parseTagList(value) {
|
|
48
|
+
if (!value)
|
|
49
|
+
return [];
|
|
50
|
+
return value
|
|
51
|
+
.split(",")
|
|
52
|
+
.map((t) => t.trim())
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
function numEnv(name, fallback) {
|
|
56
|
+
const raw = process.env[name];
|
|
57
|
+
if (!raw)
|
|
58
|
+
return fallback;
|
|
59
|
+
const n = Number(raw);
|
|
60
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
61
|
+
}
|
|
62
|
+
async function loadSpec() {
|
|
63
|
+
process.stderr.write(`Fetching OpenAPI spec from ${SPEC_URL}...\n`);
|
|
64
|
+
const result = await fetchWithLimit(SPEC_URL, { method: "GET", headers: { accept: "application/json", "user-agent": USER_AGENT } }, MAX_SPEC_BYTES, SPEC_TIMEOUT_MS);
|
|
65
|
+
if (!result.ok) {
|
|
66
|
+
throw new Error(`Failed to fetch OpenAPI spec: ${result.status} ${result.statusText}`);
|
|
67
|
+
}
|
|
68
|
+
return JSON.parse(result.text);
|
|
69
|
+
}
|
|
70
|
+
async function main() {
|
|
71
|
+
const spec = await loadSpec();
|
|
72
|
+
const entries = buildTools(spec, {
|
|
73
|
+
includeTags,
|
|
74
|
+
excludeTags,
|
|
75
|
+
reservedNames: [CAPABILITIES_TOOL_NAME],
|
|
76
|
+
});
|
|
77
|
+
const byName = new Map(entries.map((e) => [e.tool.name, e]));
|
|
78
|
+
process.stderr.write(`Loaded ${entries.length} ASORadar tools` +
|
|
79
|
+
(includeTags.length ? ` (tags: ${includeTags.join(", ")})` : "") +
|
|
80
|
+
`\n`);
|
|
81
|
+
// A built-in introspection tool: lets an agent discover the available tool
|
|
82
|
+
// catalog and the roadmap deliberately, instead of shipping non-functional
|
|
83
|
+
// "coming soon" stubs that agents treat as broken tools.
|
|
84
|
+
const CAPABILITIES_TOOL = {
|
|
85
|
+
name: CAPABILITIES_TOOL_NAME,
|
|
86
|
+
description: "[meta] List the ASORadar tools this MCP server exposes (auto-generated from the live API) plus the planned roadmap. Takes no arguments — call it first to discover what you can do. Every underlying API call is metered against your ASORadar plan (100 free requests on signup); composite tools (clone-brief, niche-scan, *-diff, portfolio-stats, reviews/sentiment) each fan out to several API calls.",
|
|
87
|
+
inputSchema: { type: "object", properties: {} },
|
|
88
|
+
};
|
|
89
|
+
const capabilitiesPayload = () => ({
|
|
90
|
+
server: "asoradar-mcp",
|
|
91
|
+
version: VERSION,
|
|
92
|
+
docs: "https://github.com/asoradar/asoradar-mcp#readme",
|
|
93
|
+
available: entries.map((e) => ({ name: e.tool.name, description: e.tool.description })),
|
|
94
|
+
planned: [
|
|
95
|
+
{ capability: "synthesized composite tools (provenance-cited evidence)", version: "0.2.x" },
|
|
96
|
+
{ capability: "workflow prompts (research-competitor, scout-category, post-mortem-release)", version: "0.2.x" },
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
const server = new Server({ name: "asoradar-mcp", version: VERSION }, { capabilities: { tools: {} } });
|
|
100
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
101
|
+
tools: [CAPABILITIES_TOOL, ...entries.map((e) => e.tool)],
|
|
102
|
+
}));
|
|
103
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
104
|
+
if (request.params.name === CAPABILITIES_TOOL.name) {
|
|
105
|
+
return {
|
|
106
|
+
content: [{ type: "text", text: JSON.stringify(capabilitiesPayload(), null, 2) }],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const entry = byName.get(request.params.name);
|
|
110
|
+
if (!entry) {
|
|
111
|
+
return {
|
|
112
|
+
isError: true,
|
|
113
|
+
content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const args = (request.params.arguments ?? {});
|
|
117
|
+
// Validate required args before spending a request. A missing required
|
|
118
|
+
// path param leaves a literal `{name}` in the URL and a missing required
|
|
119
|
+
// body field would 422 — both would still bill the call.
|
|
120
|
+
const nullish = (v) => v === undefined || v === null;
|
|
121
|
+
const missing = [
|
|
122
|
+
...entry.parameters.filter((p) => p.required && nullish(args[p.name])).map((p) => p.name),
|
|
123
|
+
...entry.requiredBodyProperties.filter((n) => nullish(args[n])),
|
|
124
|
+
];
|
|
125
|
+
if (missing.length > 0) {
|
|
126
|
+
return {
|
|
127
|
+
isError: true,
|
|
128
|
+
content: [
|
|
129
|
+
{ type: "text", text: `Missing required argument(s): ${missing.join(", ")}` },
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const url = buildUrl(BASE_URL, entry.path, args, entry.parameters);
|
|
134
|
+
const body = buildBody(args, entry.bodyProperties);
|
|
135
|
+
const headers = {
|
|
136
|
+
"x-access-key": API_KEY,
|
|
137
|
+
accept: "application/json",
|
|
138
|
+
"user-agent": USER_AGENT,
|
|
139
|
+
};
|
|
140
|
+
const init = { method: entry.method.toUpperCase(), headers };
|
|
141
|
+
if (body !== undefined) {
|
|
142
|
+
headers["content-type"] = "application/json";
|
|
143
|
+
init.body = JSON.stringify(body);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const resp = await fetchWithLimit(url, init, MAX_RESPONSE_BYTES, API_TIMEOUT_MS);
|
|
147
|
+
if (!resp.ok) {
|
|
148
|
+
return {
|
|
149
|
+
isError: true,
|
|
150
|
+
content: [
|
|
151
|
+
{ type: "text", text: `HTTP ${resp.status} ${resp.statusText}\n${resp.text}` },
|
|
152
|
+
],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return { content: [{ type: "text", text: resp.text }] };
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
const message = err instanceof FetchError ? `${err.kind}: ${err.message}` :
|
|
159
|
+
err instanceof Error ? err.message : String(err);
|
|
160
|
+
return {
|
|
161
|
+
isError: true,
|
|
162
|
+
content: [{ type: "text", text: `Request failed: ${message}` }],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
await server.connect(new StdioServerTransport());
|
|
167
|
+
}
|
|
168
|
+
main().catch((err) => {
|
|
169
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
170
|
+
process.stderr.write(`Failed to start asoradar-mcp: ${message}\n`);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
});
|
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "options", "head"]);
|
|
2
|
+
export function sanitizeName(method, path) {
|
|
3
|
+
const raw = `${method}_${path}`
|
|
4
|
+
.replace(/^\/+/, "")
|
|
5
|
+
.replace(/\{([^}]+)\}/g, "$1")
|
|
6
|
+
.replace(/[^a-zA-Z0-9_-]+/g, "_")
|
|
7
|
+
.replace(/_+/g, "_")
|
|
8
|
+
.replace(/^_+|_+$/g, "");
|
|
9
|
+
return raw.slice(0, 128) || "tool";
|
|
10
|
+
}
|
|
11
|
+
export function resolveRef(ref, spec) {
|
|
12
|
+
if (!ref.startsWith("#/"))
|
|
13
|
+
return {};
|
|
14
|
+
const parts = ref.slice(2).split("/");
|
|
15
|
+
let node = spec;
|
|
16
|
+
for (const part of parts) {
|
|
17
|
+
if (node && typeof node === "object" && part in node) {
|
|
18
|
+
node = node[part];
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return node ?? {};
|
|
25
|
+
}
|
|
26
|
+
export function expandSchema(schema, spec, seen = new Set()) {
|
|
27
|
+
if (!schema || typeof schema !== "object")
|
|
28
|
+
return schema;
|
|
29
|
+
if (Array.isArray(schema)) {
|
|
30
|
+
return schema.map((v) => expandSchema(v, spec, seen));
|
|
31
|
+
}
|
|
32
|
+
const obj = schema;
|
|
33
|
+
if (typeof obj.$ref === "string") {
|
|
34
|
+
const ref = obj.$ref;
|
|
35
|
+
if (seen.has(ref))
|
|
36
|
+
return {};
|
|
37
|
+
const next = new Set(seen);
|
|
38
|
+
next.add(ref);
|
|
39
|
+
return expandSchema(resolveRef(ref, spec), spec, next);
|
|
40
|
+
}
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
43
|
+
out[key] = expandSchema(value, spec, seen);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
export function resolveParameter(param, spec) {
|
|
48
|
+
if ("$ref" in param && typeof param.$ref === "string") {
|
|
49
|
+
const resolved = resolveRef(param.$ref, spec);
|
|
50
|
+
if (resolved && typeof resolved === "object" && "name" in resolved && "in" in resolved) {
|
|
51
|
+
return resolved;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return param;
|
|
56
|
+
}
|
|
57
|
+
export function mergeParameters(pathParams, opParams, spec) {
|
|
58
|
+
const byKey = new Map();
|
|
59
|
+
for (const raw of pathParams ?? []) {
|
|
60
|
+
const p = resolveParameter(raw, spec);
|
|
61
|
+
if (p)
|
|
62
|
+
byKey.set(`${p.in}:${p.name}`, p);
|
|
63
|
+
}
|
|
64
|
+
for (const raw of opParams ?? []) {
|
|
65
|
+
const p = resolveParameter(raw, spec);
|
|
66
|
+
if (p)
|
|
67
|
+
byKey.set(`${p.in}:${p.name}`, p);
|
|
68
|
+
}
|
|
69
|
+
return [...byKey.values()];
|
|
70
|
+
}
|
|
71
|
+
// Resolve an operation's JSON request body into a flat object schema we can map
|
|
72
|
+
// onto MCP tool args. Only application/json object bodies are supported; any
|
|
73
|
+
// other media type or non-object schema returns null so the caller can skip the
|
|
74
|
+
// op rather than call it with an unserializable body. The body's required props
|
|
75
|
+
// are honored only when the body itself is required.
|
|
76
|
+
export function extractJsonBody(requestBody, spec) {
|
|
77
|
+
const content = requestBody?.content;
|
|
78
|
+
if (!content)
|
|
79
|
+
return null;
|
|
80
|
+
const media = content["application/json"] ?? content["application/*+json"];
|
|
81
|
+
if (!media?.schema)
|
|
82
|
+
return null;
|
|
83
|
+
const expanded = expandSchema(media.schema, spec);
|
|
84
|
+
const props = expanded?.properties;
|
|
85
|
+
if (!expanded || expanded.type !== "object" || !props || typeof props !== "object")
|
|
86
|
+
return null;
|
|
87
|
+
const propNames = Object.keys(props);
|
|
88
|
+
const required = requestBody.required !== false && Array.isArray(expanded.required)
|
|
89
|
+
? expanded.required.filter((r) => propNames.includes(r))
|
|
90
|
+
: [];
|
|
91
|
+
return { schema: expanded, props: propNames, required };
|
|
92
|
+
}
|
|
93
|
+
export function buildInputSchema(parameters, spec, body) {
|
|
94
|
+
const properties = {};
|
|
95
|
+
const required = [];
|
|
96
|
+
for (const param of parameters) {
|
|
97
|
+
const schema = expandSchema(param.schema ?? { type: "string" }, spec);
|
|
98
|
+
if (param.description) {
|
|
99
|
+
schema.description = param.description;
|
|
100
|
+
}
|
|
101
|
+
properties[param.name] = schema;
|
|
102
|
+
if (param.required) {
|
|
103
|
+
required.push(param.name);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (body) {
|
|
107
|
+
const bodyProps = (body.schema.properties ?? {});
|
|
108
|
+
for (const [name, sch] of Object.entries(bodyProps)) {
|
|
109
|
+
// A path/query param of the same name wins (it is the addressing arg).
|
|
110
|
+
if (!(name in properties))
|
|
111
|
+
properties[name] = sch;
|
|
112
|
+
}
|
|
113
|
+
for (const r of body.required) {
|
|
114
|
+
if (!required.includes(r))
|
|
115
|
+
required.push(r);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const result = { type: "object", properties };
|
|
119
|
+
if (required.length > 0) {
|
|
120
|
+
result.required = required;
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
// Pick the JSON request-body object from tool args (only the declared body
|
|
125
|
+
// props). Returns undefined when the op has no body or no body args were given.
|
|
126
|
+
export function buildBody(args, bodyProperties) {
|
|
127
|
+
if (bodyProperties.length === 0)
|
|
128
|
+
return undefined;
|
|
129
|
+
const body = {};
|
|
130
|
+
for (const name of bodyProperties) {
|
|
131
|
+
if (args[name] !== undefined && args[name] !== null)
|
|
132
|
+
body[name] = args[name];
|
|
133
|
+
}
|
|
134
|
+
return Object.keys(body).length > 0 ? body : undefined;
|
|
135
|
+
}
|
|
136
|
+
export function shouldIncludeOperation(op, includeTags, excludeTags) {
|
|
137
|
+
if (op.deprecated)
|
|
138
|
+
return false;
|
|
139
|
+
const tags = op.tags ?? [];
|
|
140
|
+
if (tags.some((t) => excludeTags.has(t)))
|
|
141
|
+
return false;
|
|
142
|
+
if (includeTags.length > 0) {
|
|
143
|
+
return tags.some((t) => includeTags.includes(t));
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
export function buildTools(spec, opts = {}) {
|
|
148
|
+
const includeTags = opts.includeTags ?? [];
|
|
149
|
+
const excludeTags = opts.excludeTags ?? new Set();
|
|
150
|
+
const entries = [];
|
|
151
|
+
// Seed reserved (built-in) names so a same-named spec endpoint is suffixed
|
|
152
|
+
// and stays reachable instead of being silently shadowed by the built-in.
|
|
153
|
+
const usedNames = new Set(opts.reservedNames ?? []);
|
|
154
|
+
for (const [path, pathItem] of Object.entries(spec.paths ?? {})) {
|
|
155
|
+
if (!pathItem || typeof pathItem !== "object")
|
|
156
|
+
continue;
|
|
157
|
+
const pathParams = pathItem.parameters;
|
|
158
|
+
for (const [method, raw] of Object.entries(pathItem)) {
|
|
159
|
+
if (!HTTP_METHODS.has(method))
|
|
160
|
+
continue;
|
|
161
|
+
if (!raw || typeof raw !== "object")
|
|
162
|
+
continue;
|
|
163
|
+
const op = raw;
|
|
164
|
+
if (!shouldIncludeOperation(op, includeTags, excludeTags))
|
|
165
|
+
continue;
|
|
166
|
+
// A JSON object request body is mapped onto tool args (e.g. POST
|
|
167
|
+
// /webhooks). A requestBody we can't serialize (non-JSON or non-object)
|
|
168
|
+
// means the op promises semantics we don't honor — skip it.
|
|
169
|
+
const body = extractJsonBody(op.requestBody, spec);
|
|
170
|
+
if (op.requestBody && !body)
|
|
171
|
+
continue;
|
|
172
|
+
// Skip operations whose parameters include header/cookie/etc. — we only
|
|
173
|
+
// forward query and path params. Anything else means the spec promises
|
|
174
|
+
// semantics we don't honor.
|
|
175
|
+
const allParams = mergeParameters(pathParams, op.parameters, spec);
|
|
176
|
+
if (allParams.some((p) => p.in !== "query" && p.in !== "path"))
|
|
177
|
+
continue;
|
|
178
|
+
// Body props that don't collide with an addressing (path/query) param are
|
|
179
|
+
// routed into the JSON body at call time.
|
|
180
|
+
const paramNames = new Set(allParams.map((p) => p.name));
|
|
181
|
+
const bodyProperties = body ? body.props.filter((n) => !paramNames.has(n)) : [];
|
|
182
|
+
const requiredBodyProperties = body
|
|
183
|
+
? body.required.filter((n) => !paramNames.has(n))
|
|
184
|
+
: [];
|
|
185
|
+
let name = sanitizeName(method, path);
|
|
186
|
+
if (usedNames.has(name)) {
|
|
187
|
+
let suffix = 2;
|
|
188
|
+
while (usedNames.has(`${name}_${suffix}`))
|
|
189
|
+
suffix++;
|
|
190
|
+
name = `${name}_${suffix}`;
|
|
191
|
+
}
|
|
192
|
+
usedNames.add(name);
|
|
193
|
+
// Tool descriptions originate in remote OpenAPI text. Wrap them so the
|
|
194
|
+
// model treats the contents as documentation, not instructions, and
|
|
195
|
+
// keep the cap tight to limit prompt-injection surface.
|
|
196
|
+
const upstreamDoc = [op.summary, op.description]
|
|
197
|
+
.filter(Boolean)
|
|
198
|
+
.join(" ")
|
|
199
|
+
.slice(0, 512);
|
|
200
|
+
const description = upstreamDoc
|
|
201
|
+
? `[${method.toUpperCase()} ${path}] Upstream API doc (untrusted): ${upstreamDoc}`
|
|
202
|
+
: `[${method.toUpperCase()} ${path}]`;
|
|
203
|
+
entries.push({
|
|
204
|
+
tool: {
|
|
205
|
+
name,
|
|
206
|
+
description,
|
|
207
|
+
inputSchema: buildInputSchema(allParams, spec, body),
|
|
208
|
+
},
|
|
209
|
+
method,
|
|
210
|
+
path,
|
|
211
|
+
parameters: allParams,
|
|
212
|
+
bodyProperties,
|
|
213
|
+
requiredBodyProperties,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return entries;
|
|
218
|
+
}
|
|
219
|
+
export function buildUrl(baseUrl, pathTemplate, args, parameters) {
|
|
220
|
+
let path = pathTemplate;
|
|
221
|
+
const query = new URLSearchParams();
|
|
222
|
+
for (const param of parameters) {
|
|
223
|
+
const value = args[param.name];
|
|
224
|
+
if (value === undefined || value === null)
|
|
225
|
+
continue;
|
|
226
|
+
if (param.in === "path") {
|
|
227
|
+
path = path.replace(`{${param.name}}`, encodeURIComponent(String(value)));
|
|
228
|
+
}
|
|
229
|
+
else if (param.in === "query") {
|
|
230
|
+
if (Array.isArray(value)) {
|
|
231
|
+
for (const v of value)
|
|
232
|
+
query.append(param.name, String(v));
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
query.append(param.name, String(value));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const qs = query.toString();
|
|
240
|
+
return `${baseUrl}${path}${qs ? `?${qs}` : ""}`;
|
|
241
|
+
}
|
|
242
|
+
export const TRUSTED_HOSTS = new Set(["asoradar.com", "api.asoradar.com"]);
|
|
243
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
244
|
+
// A trusted URL is HTTPS to a known asoradar host, or HTTP/HTTPS to localhost
|
|
245
|
+
// (for self-hosted dev). Plaintext to any other host risks leaking the access
|
|
246
|
+
// key, so callers should warn or refuse.
|
|
247
|
+
export function isTrustedUrl(url) {
|
|
248
|
+
try {
|
|
249
|
+
const parsed = new URL(url);
|
|
250
|
+
const host = parsed.hostname.toLowerCase();
|
|
251
|
+
if (LOCAL_HOSTS.has(host))
|
|
252
|
+
return true;
|
|
253
|
+
if (parsed.protocol !== "https:")
|
|
254
|
+
return false;
|
|
255
|
+
return TRUSTED_HOSTS.has(host);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "asoradar-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for ASORadar — App Reviews API for App Store and Google Play. Auto-generates tools from the ASORadar OpenAPI spec.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"asoradar-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"CHANGELOG.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc && node -e \"try{require('fs').chmodSync('dist/index.js',0o755)}catch(e){}\"",
|
|
16
|
+
"start": "node dist/index.js",
|
|
17
|
+
"dev": "tsx src/index.ts",
|
|
18
|
+
"test": "node --import tsx --test test/openapi.test.ts test/smoke.test.ts",
|
|
19
|
+
"test:e2e": "node --import tsx --test test/e2e.test.ts",
|
|
20
|
+
"prepublishOnly": "npm run build && npm test"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"mcp-server",
|
|
25
|
+
"asoradar",
|
|
26
|
+
"app-store",
|
|
27
|
+
"google-play",
|
|
28
|
+
"app-reviews",
|
|
29
|
+
"aso",
|
|
30
|
+
"api",
|
|
31
|
+
"claude",
|
|
32
|
+
"ai",
|
|
33
|
+
"model-context-protocol"
|
|
34
|
+
],
|
|
35
|
+
"author": "ASORadar <support@asoradar.com>",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"homepage": "https://asoradar.com",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/asoradar/asoradar-mcp.git"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/asoradar/asoradar-mcp/issues"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^25.6.0",
|
|
50
|
+
"tsx": "^4.21.0",
|
|
51
|
+
"typescript": "^6.0.3"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=20"
|
|
55
|
+
}
|
|
56
|
+
}
|