Competiscan MCP
Your Single Source for Market and Competitive Insights, as a tool your agent can call
Three tools over 20+ years of United States and Canadian direct mail, email and digital marketing campaigns, collected continuously through a live panelist network, and reachable by any Model Context Protocol client without a line of code.
This is the same archive the REST API serves, shaped for an agent instead
of a program. Filters take human-readable names rather than internal
identifiers, every response echoes back what your names resolved to, and
each tool returns a next_step line when a result was cut
short — so a model can correct its own query rather than reporting an
empty answer as a finding.
The three tools
search_campaigns— the only search tool. Core filters are typed arguments; the other 68 PowerSearch filters go in one dict.read_piece— the words printed on specific pieces, by id.discover_filters— the archive's own vocabulary, read live.
What you can ask it
- Which credit card offers a competitor mailed to consumers last quarter.
- How introductory APR pricing on balance-transfer offers moved over five years.
- What a specific piece actually said, word for word, with a link to the scan.
- Which companies mail into a category at all — before assuming who the field is.
This page is the manual — why a query is wrong, slow or empty. The tool reference is the catalogue: every argument, every accepted value, every response field, generated from the running server so it cannot go stale.
One endpoint, one credential. There is no SDK to install and nothing to keep in step with a schema — the tool definitions travel with the server.
Authentication
Every request to /mcp requires a credential. There are two,
and a request may present either — not both. Cognito is checked first
when present.
API key — service to service
A long-lived key for agents, backends and schedulers. Keys are stored as SHA-256 hashes and compared in constant time, so a lost key can be revoked but never recovered. You issue and replace yours yourself, from the account portal — no support ticket, and nobody at Competiscan ever sees it.
Bearer token — AWS Cognito
The path for user- and tenant-facing integrations. Exchange your Cognito credentials for an access token and send it as a bearer token. The token is verified on every request against the pool's JWKS: RS256 signature, issuer, expiry, token use, and app client.
| Header | Value | Use for |
|---|---|---|
X-API-Key | csapi_<key> | Agents, servers, jobs |
Authorization | Bearer <cognito-jwt> | End users, tenant apps |
Anyone holding one can spend your quota. Keep keys server-side, load them from the environment or a secret manager, and never ship one in browser or mobile code. An MCP client configuration file counts as server-side only if the machine it sits on is. If one leaks, replace it immediately at the portal — that revokes the old key the instant the new one appears.
A missing or unrecognised credential returns 401 with a code
that says which of the two failed — missing_credentials,
invalid_api_key or invalid_authorization_header.
The body is the same {"error": {…}} envelope every failure
uses, and it arrives before the protocol does: an unauthenticated client
never reaches the MCP handshake.
Your account portal
Sign in with your Competiscan account at
https://mcp.competiscan.com/portal/login
to see your tier, this month's usage against your quota, your recent
calls, and the last four characters of the key currently in force.
The portal is also where a key is born. Press Generate API key and it is created and shown once, on that screen, and never again — only a hash is kept, so neither support nor anyone else can read it back to you. Rotating works the same way, and the old key stops working the instant the new one appears; there is no overlap window, so rotate when you can update the caller in the same sitting.
The usage figure is the number your quota is enforced against, not a sample of the request log — the dashboard and the limiter read the same counter, so they cannot disagree.
Connect a client
The server speaks streamable HTTP, the transport every
current MCP client supports. Point the client at
https://mcp.competiscan.com/mcp/, give it the X-API-Key header, and
the three tools appear in its tool list.
Claude Desktop
Add the block in the rail to claude_desktop_config.json
(Settings → Developer → Edit Config) and restart the app. The tools show
up under the plug icon.
Claude Code
One command, shown in the rail. claude mcp list confirms the
connection; /mcp inside a session lists the tools.
Anything else
The MCP Inspector — npx @modelcontextprotocol/inspector — is
the quickest way to click through the tools by hand. For a script, any
MCP SDK works; the Python example in the rail uses
fastmcp, which is what this server is built on.
https://mcp.competiscan.com/mcp/ without the trailing slash answers
307 to https://mcp.competiscan.com/mcp//. The redirect happens
before authentication, so it is free — but configuring the client with
the slash saves the hop.
What the wire looks like
Only read this if you are writing the client yourself — an MCP SDK does all of it for you.
Every call is a JSON-RPC 2.0 message POSTed to
https://mcp.competiscan.com/mcp/. The response comes back as a
text/event-stream, which is why both content types have to
be acceptable to you:
| Header | Value |
|---|---|
Content-Type | application/json |
Accept | application/json, text/event-stream — both, or the server answers 406 |
Mcp-Session-Id | echoed back by initialize; send it on every later message |
A session is initialize, then a
notifications/initialized message, then any number of
tools/list and tools/call messages, and finally
a DELETE to release it. Clients also open one long-lived
GET for server-sent messages.
That includes the handshake. Opening a session and closing it costs
4 calls before any tool runs — the
initialize POST, the GET stream, the
notifications/initialized POST and the closing
DELETE — and each tools/call after that costs
one more. Hold one session open and make many calls in it; a client
that reconnects for every question spends 4× its
quota on protocol.
Your first call
Ask for ten Credit Cards direct mail pieces from the first quarter of
2026. Nothing here is an identifier: "Credit Cards" is a
sector name, "Direct Mail" a channel name, and the server
resolves both.
The response opens with showing, truncated and
took_ms, then a resolved block echoing what
every name matched, then results. Read resolved
the first time you run a query — it is how you confirm that the sector
you named is the node you meant before you trust a number that came out
of it.
Each row carries pdf_url and all three date dimensions, so
there is no second call to make for the scan or for a date the row
already knows.
Names, not IDs
Every filter takes the name a human would use. There is no id map to maintain, no lookup table to keep in step, and no numeric constant to get wrong — the server resolves names against the same vocabularies its validator uses, refreshed from the archive continuously.
An unrecognised value is an error, never a silent empty result. The message names the field and lists what would have been valid, so the fix is in the response rather than in a support thread.
Company matching
company matches the whole legal-entity name by default.
Switch company_match to "contains" for a
substring — useful when you know a brand but not the entity that mails
under it. A contains fragment must be at least
3 characters, and a single call accepts at most
50 company values.
contains match changes the count, not just the rows.
resolved.company_names lists every entity the fragment
expanded to. Read it before comparing two searches: "Capital One"
matching four entities and matching one are different questions with
different totals.
Three dates, and which one you mean
Every piece carries three dates, and they answer different questions. A
query filters on exactly one of them, chosen with
date_field.
date_field | Means | Use for |
|---|---|---|
search_date (default) | When the panelist received the piece | Market timing — when something was actually in a mailbox |
approved_date | When Competiscan finished processing it | Reconciling against an internal review date |
added_to_database | When the record became queryable | Incremental syncs — "what is new since I last looked" |
Both bounds are inclusive and either may be omitted for an open end. Every row returns all three dates whichever you filtered on, so a follow-up question about a different dimension does not need another call.
With a window the search sorts by date and stops as soon as it has
filled limit. Without one it has no early exit. If a query
is slow, the first thing to try is bounding it in time — not lowering
limit.
added_to_database is the only one of the three that moves
forward monotonically as records land, which is what makes it the right
cursor for a sync. search_date backfills.
Result size, totals and truncation
limit defaults to 50 and caps at
10,000. There is no pagination: truncated tells you
the set was cut short, and next_step says what to do about
it in a sentence a model can act on.
Do not round limit up
A limit the result set cannot fill costs real time. With a date window
the search stops the moment it has limit rows; ask for 5,000
when 60 exist and it walks the entire window to prove there is no
sixty-first. Ask for what you will read.
Counts
include_total is false by default, and that is
the fast path — a count is a second evaluation of your filters over the
whole matching set, not a by-product of the rows you got. Turn it on when
the number is the answer; leave it off when you only need the rows.
A total that would take too long comes back capped, with
total_is_capped: true — read it as "at least this many".
exact_count lifts the cap and pays for it in time.
The load balancer in front of this service closes a request the backend has not answered by then, and replies for it with a gateway error. An exact count over a very large set can hit that wall. Narrow the window, or accept the capped total.
search_campaigns
The only search tool. The filters below are typed arguments; the other 68
PowerSearch filters go in advanced_filters,
which routes the call to the enhanced endpoint without you choosing one.
| Argument | Type | Notes |
|---|---|---|
sector | list | 17 values — Automotive, Banking, Credit Cards, Insurance, Investments, … |
category | list | Depth 2, resolved within the chosen sector. Names repeat across branches |
subcategory | list | Depth 3, within the chosen category |
subsubcategory | list | Depth 4 |
media_channel | list | 10 values — Direct Mail, Email, Online Display, Print, Social Media, … |
audience | list | 5 values — Consumer, Small Business Owner, Mortgage Broker, … |
company | list | Companies on the piece. Max 50 per call |
company_match | string | exact (default) or contains |
date_from / date_to | string | YYYY-MM-DD, inclusive, either may be omitted |
date_field | string | search_date (default), approved_date, added_to_database |
country | string | One country, not a list — United States or Canada |
ocr_text | list | Words to find in the scanned text. See below |
ocr_text_match | string | all (default) or any |
entry_id | list | Exact ids. Valid on its own — needs no other filter |
panelist_id | list | Numeric ids of the households that received the piece |
panelist_type | string | primary or non_primary. Requires panelist_id |
limit | int | Default 50, max 10,000 |
include_total | bool | Run the count. Default false |
exact_count | bool | Count every match however long it takes |
sort | string | Left unset, picks date when a date bound is present |
advanced_filters | dict | The other 68 filters, flat |
Searching the printed text
ocr_text scans the words read off the scanned pages. It is a
substring scan, not an index lookup, so its cost is the rarity of the term
multiplied by the width of the window. Three rules follow, and the tool
enforces all three rather than letting you discover them as a timeout:
- Both date bounds are required. The scan walks the
window in slices from its newest end and stops when it has filled
limit; without bounds there is nothing to walk. - Ordering is forced to
date. That ordering is the early exit.resolved.sortechoes what was actually used. - At most 5 terms, each at least 3 characters.
query_walk.ocr_date_slices reports how many slices the scan
crossed before it stopped — a high number on a slow query means the term
is rarer than the window is wide.
entry_id is the one filter that stands alone — an identifier,
not a keyword. Everything else wants a narrowing companion.
read_piece
The words printed on specific pieces, by entry_id — the
inverse of the ocr_text filter. That filter finds pieces by
what is on them; this reads what is on a piece you have already found.
| Argument | Type | Notes |
|---|---|---|
entry_ids | list (required) | Max 25 per call — OCR text is long |
max_chars | int | Per-piece cap, default 20,000 |
include_documents | bool | Per-document breakdown. A piece almost always has exactly one |
Three outcomes, kept apart
Each piece comes back with a status, and the three are
distinct because they need different fixes:
status | Means | What to do |
|---|---|---|
ok | Text is present | Read it |
not_ocred | A real piece that has never been through OCR — text is null | Use pdf_url; the scan exists |
not_found | No approved, non-deleted piece carries that id | A typo, or it is not in the archive |
characters is the true stored length and
returned_characters what you got, so a clipped piece is
obvious without a second call. truncated means the server
cut it; clipped means your max_chars did.
discover_filters
The archive's own vocabulary, read live rather than remembered. Four modes, chosen by which argument you pass:
| Pass | Get |
|---|---|
| nothing | The core filters and their values — the whole small vocabulary |
parent | The children of a taxonomy node, e.g. "Credit Cards" |
group | One enhanced filter group, or "all" for every group |
field + query | A prefix search of one large vocabulary — company, dma, publication, affinity_name |
limit applies to field lookups only, defaults to
50 and caps at 500. A group response carries a
notes block flagging which filters in it are expensive and
which require a date range — worth reading before building a query around
one.
Company names, DMAs and publications are long vocabularies where a
value that is one character off is a rejected call. One
discover_filters lookup costs a fraction of a search and
removes the whole class of error.
The other 68 filters
Everything PowerSearch exposes beyond the core arguments — card networks,
rewards, mortgage terms, demographics, campaign flags, messaging and
geography — goes in advanced_filters as a flat
dict. Passing any of them routes the call to the enhanced
endpoint; passing none leaves it on the core one. You do not choose.
Ranges are two fields
A numeric range is <name>_min and
<name>_max as separate keys, never a pair or an object.
In a range, 0 and null both mean "not
stated" and never match. {"apr_min": 0} therefore
excludes every piece whose APR was never recorded — which is
most of them. Leave the field out entirely if you do not mean to filter
on it.
Two groups — dma and utility — require a date
range of their own; the tool refuses the call rather than letting it time
out. Call discover_filters(group="all") for the names and
their accepted values before guessing at one.
Errors
Failures arrive at one of three layers, and telling them apart is most of diagnosing one.
1 — Before the protocol: the credential
Authentication, rate limiting and quota run in front of the MCP handler,
so their failures are plain HTTP with the
{"error": {"code", "message"}} envelope and no JSON-RPC
wrapper at all. A client that reports "the server returned invalid JSON-RPC"
is almost always looking at one of these.
| Status | Code | Means |
|---|---|---|
| 401 | missing_credentials | No header at all |
| 401 | invalid_api_key | Unknown or revoked key |
| 401 | invalid_authorization_header | Not Bearer <token> |
| 401 | token_expired | Refresh and retry |
| 401 | invalid_token · invalid_audience · token_verification_failed | The token did not verify against the pool |
| 403 | account_not_provisioned | Valid login, not linked to a client |
| 429 | rate_limit_exceeded | Burst limit — carries retry_after_seconds |
| 429 | quota_exceeded | Monthly cap — carries quota, used, period |
| 503 | cognito_not_configured | Bearer auth is not available on this deployment |
2 — The protocol: JSON-RPC
A malformed message, a missing session id or an unacceptable
Accept header comes back as a JSON-RPC error object with a
negative code. 406 with
-32600 is the common one, and it means the
Accept header did not list both content types.
3 — The tool
A tool that ran and could not answer returns a normal result whose body
carries error and message — the call succeeded,
the query did not. These are the ones an agent can fix by itself:
unknown_filter_value lists what would have been valid,
bad_limit names the ceiling, no_api_key means
this server is missing its own upstream credential, and a gateway timeout
means the query needs narrowing.
Support
mcp@ai.competiscan.com reaches the team
that runs this service. Include the x-request-id from the
response headers — every request carries one, and it is the fastest way
for us to find your call in the log.
The tool reference is generated from the running server, so it is the authority on what an argument accepts. This page is the authority on why a query behaves the way it does.
Where the data comes from
Competiscan panelists across the United States and Canada receive pre-paid envelopes every two weeks and send back the marketing mail they received. Every record these tools return traces to a real piece a real household got.
That collection method is also the limitation worth knowing. Coverage reflects the panel, not a census — it is an excellent sample of what was mailed, not a complete register of every piece.
/health needs no credential, costs no quota and is not
written to the usage log.
MCP