Skip to content
Stoqlab

Documentation

Stoqlab MCP server

Connect Claude and other AI assistants to Stoqlab data through the remote MCP server: setup for Claude, Cursor, VS Code and the SDKs, the read-only tools, limits and errors.

On this page

Stoqlab exposes its data to AI assistants through a remote Model Context Protocol server:

https://api.stoqlab.com/mcp

Ask Claude (or any MCP client) things like "which apps declare pubmatic.com as RESELLER?", "show me the supply tree of com.king.candycrushsaga for applovin.com", "what changed in sellers.json files this week?" and it calls the tools below. Every tool is read-only.

  • Transport: Streamable HTTP (one JSON-RPC message per POST, JSON response).
  • Protocol: dual-era. 2026-07-28 (per-request _meta, server/discover, no handshake) and the handshake-based 2025-11-25 / 2025-06-18 / 2025-03-26 (initialize). Clients pick automatically; the official SDKs (TypeScript 1.32, Python 2.3) were tested in both modes.
  • Auth: OAuth 2.1 (sign in with your Stoqlab account; Claude.ai, Claude Desktop, Claude Code and other OAuth-capable clients do this by themselves, see §1 and "OAuth" below), or an API key from the dashboard (API keys), sent as Authorization: Bearer <token>. For a key, use the type MCP only (ability mcp): it works only at /mcp (no REST, no exports, no list changes) and always expires (you pick 7, 30 or 90 days, or a date within 90 days). Ordinary read-only API keys (read) work too; keys with only lists:write are refused (403).
  • Read-only. Saved lists can only be changed through REST with a key that carries the opt-in lists:write ability, which an MCP key never has: a leaked MCP key can read data, nothing else.
  • Rate limits: the API v1 limits (docs/API.md §2), shared with REST: every MCP message (tools/list, each tools/call, the handshake) counts as one API request.
  • Daily data quota (docs/API.md §2): the rows of each tool result (its longest list, at least 1) count against the team's rows per day, shared with REST, exports and the dashboard. Responses carry X-RateLimit-Rows-Limit / -Remaining / -Reset.

1. Connect from Claude#

Claude.ai / Claude Desktop / Cowork / mobile (custom connector, OAuth)#

  1. In Claude: Customize → Connectors → Add custom connector (organization Owners add it for the organization; members then connect it themselves).
  2. URL: https://api.stoqlab.com/mcp. Leave the OAuth client ID / secret fields empty: Claude identifies itself with its published client metadata document.
  3. Select Connect. A Stoqlab window opens: sign in to the dashboard if needed, check the consent screen (app Claude, identity published by claude.ai, redirect to claude.ai, read-only access) and select Allow access.
  4. Enable the connector in a chat ("Search and tools" menu).

Each person signs in with their own Stoqlab account: the plan, plan features and rate limit of that account apply (per plan: 30, 120, 600 or 3,000 requests per minute, shared with that account's REST and API-key traffic). Tokens are refreshed automatically; API keys → Connected apps lists every app connected this way, with Disconnect.

Fixed key instead of OAuth (organizations with the beta request-header option): create a key of type MCP only (AI assistants) and add the header Authorization: Bearer 12|stoq_… in the connector's Request headers. Every member of the Claude organization then uses that one Stoqlab account and its rate limit, and the key cannot be revoked per person: prefer OAuth.

Claude Code#

claude mcp add --transport http stoqlab https://api.stoqlab.com/mcp

then run /mcp in a Claude Code session, pick stoqlab → Authenticate, and allow access in the browser window that opens (Claude Code receives the authorization on http://localhost:<port>/callback; the consent screen warns about that, as it should). With an API key instead:

claude mcp add --transport http stoqlab https://api.stoqlab.com/mcp \
  --header "Authorization: Bearer $STOQLAB_TOKEN"

Claude Desktop through a local bridge (API key; only if OAuth is not an option)#

claude_desktop_config.json:

{
  "mcpServers": {
    "stoqlab": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://api.stoqlab.com/mcp", "--header", "Authorization:${STOQLAB_AUTH}"],
      "env": { "STOQLAB_AUTH": "Bearer 12|stoq_..." }
    }
  }
}

(No space after Authorization: — it avoids an argument-escaping bug on Windows.)

Claude API (Messages API MCP connector)#

"mcp_servers": [{
  "type": "url",
  "url": "https://api.stoqlab.com/mcp",
  "name": "stoqlab",
  "authorization_token": "12|stoq_..."
}]

2. Connect from other MCP clients#

Clients that implement the MCP authorization spec (2025-06-18 or later: VS Code, ChatGPT connectors, MCP Inspector, the official SDKs' OAuth helpers, mcp-remote) connect with only the URL and run the OAuth flow below. Their redirect URI must be a loopback address (http://127.0.0.1:<port>/…, http://localhost:<port>/…) or an allowed https callback (see "OAuth"). Any client that speaks Streamable HTTP and can send a fixed header works with an API key:

Cursor (.cursor/mcp.json):

{ "mcpServers": { "stoqlab": {
  "url": "https://api.stoqlab.com/mcp",
  "headers": { "Authorization": "Bearer ${env:STOQLAB_TOKEN}" }
} } }

VS Code (.vscode/mcp.json):

{
  "servers": { "stoqlab": { "type": "http", "url": "https://api.stoqlab.com/mcp",
    "headers": { "Authorization": "Bearer ${input:stoqlab_token}" } } },
  "inputs": [{ "type": "promptString", "id": "stoqlab_token", "description": "Stoqlab API token", "password": true }]
}

Python SDK (mcp ≥ 2):

import httpx2
from mcp import Client
from mcp.client.streamable_http import streamable_http_client

http = httpx2.AsyncClient(headers={"Authorization": f"Bearer {token}"})
async with Client(streamable_http_client("https://api.stoqlab.com/mcp", http_client=http)) as c:
    result = await c.call_tool("search_apps", {"query": "block puzzle"})

TypeScript SDK:

const transport = new StreamableHTTPClientTransport(new URL("https://api.stoqlab.com/mcp"),
  { requestInit: { headers: { Authorization: `Bearer ${token}` } } });
await new Client({ name: "my-app", version: "1.0.0" }).connect(transport);

OpenAI Agents SDK: MCPServerStreamableHttp(params={"url": "https://api.stoqlab.com/mcp", "headers": {"Authorization": f"Bearer {token}"}}).

curl (legacy era, no handshake state is kept, so any request can come first):

curl -s https://api.stoqlab.com/mcp \
  -H "Authorization: Bearer $STOQLAB_TOKEN" -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" -H "MCP-Protocol-Version: 2025-11-25" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_app","arguments":{"store":"android","store_id":"com.king.candycrushsaga"}}}'

Browser-based clients are not supported: the endpoint sends no CORS headers and a CORS preflight (OPTIONS /mcp) answers 405. Call it from a server or a desktop client. Non-browser clients that still send an Origin header (some desktop / Electron apps and proxies) must use an allowed origin; a request with an Origin that is not allowed gets 403.

2b. OAuth#

Stoqlab runs a small OAuth 2.1 authorization server for the MCP endpoint (web/app/OAuth). It follows the MCP authorization spec (2025-06-18 / 2025-11-25) and Claude's connector requirements.

Protected resource https://api.stoqlab.com/mcp (RFC 9728 metadata at /.well-known/oauth-protected-resource/mcp and /.well-known/oauth-protected-resource)
Issuer https://api.stoqlab.com (RFC 8414 metadata at /.well-known/oauth-authorization-server)
Authorization endpoint https://app.stoqlab.com/oauth/authorize (dashboard sign-in + consent screen)
Token / revocation / registration https://api.stoqlab.com/oauth/token, /oauth/revoke (RFC 7009), /oauth/register (RFC 7591)
Grant types authorization_code (PKCE S256 required; plain and no-PKCE refused), refresh_token
Clients public only (token_endpoint_auth_method: none): Client ID Metadata Documents (client_id = an https URL; client_id_metadata_document_supported: true) or dynamic registration
Scope mcp:read (read-only MCP tools). offline_access is accepted; refresh tokens are always issued
Tokens opaque (stoq_at_… access, stoq_rt_… refresh), stored as SHA-256 hashes; access 1 hour, refresh single-use and rotated on every refresh, a grant idle 30 days must sign in again

401 challenge. Every unauthenticated or invalid-token request to /mcp answers 401 with WWW-Authenticate: Bearer resource_metadata="https://api.stoqlab.com/.well-known/oauth-protected-resource/mcp", scope="mcp:read" (plus error="invalid_token" when a token was sent). The JSON-RPC error body is unchanged.

Flow. Client gets the 401 → reads the protected resource metadata → reads the authorization server metadata → sends the user to /oauth/authorize with client_id, redirect_uri, code_challenge (S256), state, scope, resource=https://api.stoqlab.com/mcp → the user signs in to the dashboard (if needed) and approves → redirect with code, state and iss → client exchanges the code at /oauth/token (form-encoded, with code_verifier, redirect_uri, client_id, resource) → {"access_token","token_type":"Bearer","expires_in":3600,"refresh_token","scope"}.

Security properties.

  • PKCE S256 on every code; codes are single-use (2 minutes) and reusing one revokes everything issued from it.
  • Refresh-token rotation with replay detection: presenting an already-used refresh token revokes the whole grant (every access and refresh token of that authorization) and answers invalid_grant, like any bad, expired or revoked refresh token. A client that loses a refresh response must sign in again.
  • Audience binding (RFC 8707): resource must name this MCP server (scheme/host case, a default port and a trailing slash are ignored); the grant stores it and /mcp refuses tokens whose grant names another resource. OAuth tokens are refused by the REST API (/v1, 401): they carry only the mcp ability.
  • Redirect URIs: exact match against the client's registered list; loopback (127.0.0.1, [::1], localhost) matches on any port. Allowed targets: loopback over http, https origins in MCP_OAUTH_REDIRECT_ALLOW (default claude.ai, claude.com, chatgpt.com, vscode.dev, insiders.vscode.dev), and for a metadata-document client its own client_id origin. A missing or mismatched redirect URI is shown as an error page, never redirected to.
  • Consent is required for every authorization (no silent re-approval). The screen names the client, the host that published its metadata document (or "self-registered" for dynamic registration), the redirect host, and warns when the redirect is a loopback address. The pending request is kept in the session behind a one-time nonce bound to the signed-in user, so the form cannot alter it.
  • Client metadata documents are fetched over https on port 443 only, from host names whose every address is public (pinned for the connection), without redirects or proxies, at most 16 KB in 3 seconds; cached per Cache-Control (5 minutes to 1 day), served stale for up to 7 days when a refresh fails, failures cached for a minute. Copies of Claude's and Claude Code's documents are built in as a last resort (claude.ai's bot protection has blocked server-side fetches from some cloud ranges). Logos are inlined as data: URIs only when they are PNG/JPEG/GIF/WebP ≤ 64 KB.
  • Plan, plan features and rate limits: an OAuth token acts as its user exactly like an MCP-only API key (plan.feature gates in tools, the per-account api limiter shared with REST and keys). With teams, the grant records the team picked on the consent screen.
  • Rate limits: discovery / token / revoke / consent 120 per minute per IP, registration 20 per hour per IP. Anthropic's egress range (160.79.104.0/21, MCP_CLIENT_EGRESS_CIDRS) carries every claude.ai user: there the API per-IP limit counts per bearer token and the per-IP caps on anonymous / failed requests are multiplied by MCP_EGRESS_MULTIPLIER (20).

Revoking. Users: API keys → Connected apps → Disconnect (immediate). Clients: POST /oauth/revoke with token and client_id (a refresh token revokes the whole grant, an access token only itself). Admins disabling an account revoke its grants with its API keys.

Not supported (by design): implicit and password grants, client_credentials, confidential clients and client secrets, JWT access tokens / introspection, OAuth on the REST API (use API keys there), browser-based (CORS) MCP clients, OpenID Connect discovery (/.well-known/openid-configuration answers 404; clients use the RFC 8414 document first).


3. Tools#

All tools return one text block holding compact JSON. List tools return one page plus next_cursor: pass it back as cursor with the same arguments to continue (null = done). recent_changes takes it as since (cursor is accepted as an alias). Cursors reach at most 10,000 rows deep; narrow the query beyond that. Whitespace-only arguments count as not given (search_apps with query: " " still needs another filter).

Third-party text. App, developer and seller names, URLs and app-ads.txt / sellers.json lines come from stores and files anyone can publish. The server instructions and the descriptions of get_app, get_supply_tree, recent_changes and audit_domain tell the model to treat them as data, never as instructions, and every name / title field is capped at 200 characters.

Tool What it answers Main arguments
search_apps Find apps by name / store id / bundle id / store link / developer (domain or exact name), filters store, category (Play id or store name), contains_ads, min_installs, status; ads (store flag or app-ads.txt with authorised sellers), country (ISO codes, any of them) with availability (likely default / confirmed) and min_audience_share (0–1, iOS), each row then with country_availability; fields (list of keys / dot paths) trims the rows. Most ratings first. query, developer, filters, limit ≤ 50, cursor
get_app One app: listing facts, developer + company legal entity, 1/7/30-day growth (the changes need plan feature history; without it they are null and growth.plan_required = "history"), ratings by country (top N + totals; each row's availability source and last_checked_at — on Google Play, availability only: inferred = US page served, offer not checked), privacy labels / Data safety, similar and same-developer apps, recent store events, app-ads.txt status with cross-check counts and supply-tree summary; estimated audience by country and inferred ad formats. store (optional), store_id (or bundle id), countries_limit ≤ 60, related_limit ≤ 12
get_supply_tree Plan feature tree (Professional and up). The declared supply tree: hop 1 = app-ads.txt lines with seller and check_status, deeper hops through intermediaries. Without ad_system: an index of every ad system with line counts plus the first lines. view: "forward" reads it as buyers do: developer → direct_sellers (DIRECT lines) → resellers branching under the seller they buy from (match.by sellers_chain / seller_domain, match.via), then unmatched_resellers with unmatched.reason (API §3.3a); max_depth then counts levels and limit / cursor page over branches. Add group: "ad_system" for one node per ad system and relationship under each parent (account_count, accounts — each line with its own checks, at most max_children, accounts_omitted for the rest —, status_summary, resellers, children grouped the same way; API §3.3a "Grouped by ad system"). view: "direct" lists the direct SSPs: each ad system with a DIRECT line, its accounts and sellers_json_status, the resellers under it and their share (API §3.3b); ad_system keeps one, limit / cursor page. store (optional), store_id, view ("lines" by default, "forward" or "direct"), group ("ad_system", forward view), ad_system, verification (one §4b value), max_depth 1–4 (default 2), max_children ≤ 50, limit ≤ 100 roots, cursor
apps_by_network Reverse lookup: apps whose app-ads.txt declares an ad system, optionally relationship and one seller_id (stale ids too), with how each app declares it. ad_system, relationship, seller_id, store, status, country, sort, limit ≤ 50, cursor
get_seller One sellers.json entry (also removed ones), its history, DIRECT/RESELLER line counts. ad_system, seller_id, events_limit ≤ 50
network_share Plan feature trends (Business and up). Ad systems ranked by share of app-ads.txt files / apps / Android installs declaring them (latest day, 7-day change), or one system's weekly trend. sort, limit ≤ 50, or ad_system + weeks ≤ 52
growth_leaderboard Plan feature trends (Business and up). Fastest growing Google Play apps by exact installs over 1/7/30 days. window, sort (abs/pct), category, contains_ads, min_installs, limit ≤ 50, cursor
audit_domain The stored app-ads.txt audit of one developer domain (dashboard Check, API GET /v1/domains/{domain}/audit): file status and reason ("could not verify" = our side), parse errors, cross-check counts and failing lines, ad systems with DIRECT/RESELLER counts, apps using the file, recent history. Never starts a crawl: an unchecked host is a tool error. domain (domain or URL), problem_lines_limit ≤ 100, ad_systems_limit ≤ 200, apps_limit ≤ 20
recent_changes Plan feature history (Professional and up). Change feed: app-ads.txt edits seen by our crawler (with added/removed lines; history backfilled from the Internet Archive is never an event), sellers.json changes, new/delisted/relisted apps, availability. Newest first without since/from; oldest first after a cursor or timestamp. types, from, since (or cursor), limit ≤ 50, max_diff_lines ≤ 50
recent_alerts Plan feature alerts (Professional and up). The caller's own alerts: changes on the apps, developer domains, ad systems and lists their alert rules watch, newest first. Read-only (never marks alerts read). types, rule_id, unread_only, limit ≤ 50
find_publishers Plan feature prospecting (Business and up). SSP prospecting: publishers whose app-ads.txt declares one or more competitor ad systems but not yours, grouped by publisher domain (or OWNERDOMAIN / MANAGERDOMAIN), most reach first, with developer name, live apps, Google Play installs, ratings, the competitors declared (DIRECT / RESELLER), top app and categories. ad_system, competitors (1-10), relationship, store, category, country, contains_ads, min_installs, min_ratings, updated_within_days, owner_domain, manager_domain, group_by, sort, limit ≤ 50, cursor
verify_seats Plan feature verification_api (Business and up). Seat / reseller verification: for each (app or app-ads.txt host, ad system, seller ID, optional relationship) whether the app-ads.txt authorises the seat (line, since when), the seller's sellers.json entry (PUBLISHER names that may be a person's are withheld), the supply verification of the line, data freshness and a verdict authorised / unauthorised / unverifiable with reasons (API.md §3.18). items (≤ 50, and the plan's batch size): store, store_id or host, ad_system, seller_id, relationship
validate_schain Plan feature schain (Professional and up). Validates an OpenRTB SupplyChain object node by node: syntax, sellers.json of each asi, the sid listed with a fitting seller_type, node 0 DIRECT / later nodes RESELLER in the app's app-ads.txt (or a site's ads.txt), and the complete flag; verdict per node and for the chain (API.md §3.19). schain (object, JSON string or a bid request holding it), store / store_id, host or site

Plans (since 2026-10-05). Tools follow the same plan features as the dashboard and API v1 (docs/API.md §2b; admins have every feature). The plan is that of the team the key acts in (the team that was current when the key was created, docs/API.md §2c); recent_alerts returns the team's alerts. A tool the key's plan does not include answers a normal tools/call result with isError: true whose text starts with plan_required: and names the feature, the plan that includes it and the upgrade link; the plan is checked before the arguments. tools/list is the same for every caller (each gated tool's description ends with the plan it needs). Before this date every tool was open to every plan: free (Explorer) keys lose get_supply_tree, recent_changes, network_share and growth_leaderboard, and the growth changes in get_app.

Supply path score. get_app adds appads.supply_path (API.md §4c): score 0–100 and band for every plan; chain_depth, parts and counts need plan feature tree (without it they are null and plan_required: "tree" is set). App rows of search_apps and apps_by_network add appads.path_score.

app-ads.txt applicability. get_app and the app rows of search_apps / apps_by_network add appads_applicable (API.md §3.1): false when the listing says the app contains no ads and no valid app-ads.txt was found, i.e. "not applicable" rather than a missing or invalid file.

Audience and ad formats. get_app adds audience_by_country and ad_formats, the same objects as the API app detail (API.md §3.2). audience_by_country: an iOS app's estimated split by country (each storefront's share of the ratings summed over the storefronts we check, top 10 + other, for every plan); Android gets method: null and a note (Google Play publishes nothing per country). ad_formats: formats sold by the networks the app-ads.txt declares (banner, interstitial, rewarded, native, app_open, playable, offerwall, ctv), inferred, never confirmed in the SDK; the networks behind each format (via) need plan feature tree (without it via is null and ad_formats.plan_required = "tree"). Each format has sizes[] ({size, name, device, networks, networks_unconfirmed, via}: sizes the networks sell per their own docs, ranked by the app's devices) and ad_formats.form_factor (iPhone / iPad support from the App Store; nulls on Android), as in API.md §3.2; never the sizes the app actually serves.

Country and ads. search_apps takes the storefront and ads filters of GET /v1/apps (API.md §3.1): country ("DE" or "DE,AT,CH" = any of them) with availability — likely (default: not known to be unavailable; store apps are worldwide unless restricted) or confirmed (a storefront check, or ratings counted there; every app is checked in its home storefront, apps with an app-ads.txt and the most popular ones in the 30 largest ad markets every 30 days) — and min_audience_share (0–1, iOS per-storefront rating share); ads: true = the store's "contains ads" flag or an app-ads.txt with authorised sellers (the main iOS signal: Apple's flag is known for ~1 % of iOS apps). With country each row carries country_availability {country, status, source, audience_share, checked_at}. fields (e.g. ["store_id", "bundle_id", "name"]) keeps only those keys, so a page of 50 bundles stays small; whole lists are better fetched over REST (GET /v1/apps?…&format=csv). Example: "List Android bundles that show ads and are available in Germany" → search_apps with {"store": "android", "country": "DE", "ads": true, "fields": ["bundle_id", "name"]}.

Supply verification. get_app adds appads.verification (both directions of the sellers.json check: score, counts per value, cert_mismatch, publisher_domains; API.md §4b); get_supply_tree adds verification, cert_authority_id and cert_check to every hop-1 line and takes verification (one §4b value, e.g. domain_mismatch) to list only those lines; audit_domain returns the domain checker's verification block (API.md §3.17).

Arguments are validated against each tool's inputSchema (unknown arguments are rejected; numbers and booleans sent as strings and enum values in any case are accepted).

Never returned: developer e-mail, phone or postal address (the store's trader contact data), the app-ads.txt CONTACT value, internal ids, operational data. Legal entities are shown only for companies (name and country), exactly like API v1.

Output size#

Every tool result is capped at 40,000 bytes (≈ 10k tokens):

  • list tools shrink the page until it fits and return next_cursor for the rest;
  • get_supply_tree also prunes by max_depth / max_children (children_omitted counts what was cut);
  • recent_changes lists at most max_diff_lines lines per side of an app-ads.txt change (counts stay complete);
  • a single object that still does not fit (e.g. get_app with huge limits) is a tool error asking for less.

4. Errors#

Situation What the client gets
Bad argument, unknown app / ad system / seller, cursor from another query, result too large Normal tools/call result with isError: true and a sentence the model can act on
Tool not included in the key's plan isError: true, text starting with plan_required: (feature, plan that includes it, upgrade link)
Daily data quota used up isError: true, text starting with row_quota_exceeded: or key_row_quota_exceeded: (resets at 00:00 UTC)
Unexpected server failure inside a tool isError: true, generic message (details are only logged)
Unknown tool JSON-RPC -32602 "Unknown tool: …"
Unknown method JSON-RPC -32601 (HTTP 404 for 2026-07-28 requests, 200 for handshake-era requests)
Malformed JSON / not a JSON-RPC object / batch -32700 / -32600, HTTP 400
2026-07-28: missing or mismatching MCP-Protocol-Version, Mcp-Method, Mcp-Name -32020 HeaderMismatch, HTTP 400
2026-07-28: missing _meta protocolVersion / clientCapabilities -32602, HTTP 400
Unsupported protocol version -32022 with data.supported (all versions above), HTTP 400
Missing / invalid / revoked / expired token HTTP 401 + WWW-Authenticate: Bearer, JSON-RPC -31401; an expired key adds data.reason = token_expired and data.expired_at
Key without the mcp or read ability HTTP 403, -31403, data.error = forbidden
Rate limited HTTP 429 + Retry-After, JSON-RPC -31429, data.retry_after
Origin not allowed HTTP 403, -31403 (with the request id)
Body over 64 KB HTTP 413, -31413 (before auth and the rate limits)
GET / DELETE / OPTIONS on /mcp HTTP 405, Allow: POST (no standalone SSE stream, no sessions, no browser clients)

HTTP-layer failures use application codes outside the JSON-RPC reserved range: -31000 − HTTP status. Their data carries http_status and the API v1 error code (unauthenticated, rate_limited …).

Want an API key?

Every plan starts with a short conversation and sales-assisted onboarding.