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 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
+ [![npm version](https://img.shields.io/npm/v/asoradar-mcp.svg)](https://www.npmjs.com/package/asoradar-mcp)
4
+ [![npm downloads](https://img.shields.io/npm/dm/asoradar-mcp.svg)](https://www.npmjs.com/package/asoradar-mcp)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ });
@@ -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
+ }