Competiscan MCP Docs Tool reference Get an API key

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.

Endpoint https://mcp.competiscan.com/mcp/

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.
Two documents, two questions.

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.

Add the server

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.

HeaderValueUse for
X-API-Keycsapi_<key>Agents, servers, jobs
AuthorizationBearer <cognito-jwt>End users, tenant apps
Keys are bearer credentials.

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.

Authenticating
401 — no credential

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.

Reading the dashboard

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.

Trailing slash costs you a round trip, not a call.

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.

Verify the connection

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:

HeaderValue
Content-Typeapplication/json
Acceptapplication/json, text/event-stream — both, or the server answers 406
Mcp-Session-Idechoed 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.

Every HTTP request on this endpoint is one metered call.

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.

A session, by hand

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.

tools/call — search_campaigns

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.

A 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.

Resolution, echoed back

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_fieldMeansUse for
search_date (default)When the panelist received the pieceMarket timing — when something was actually in a mailbox
approved_dateWhen Competiscan finished processing itReconciling against an internal review date
added_to_databaseWhen the record became queryableIncremental 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.

A date window is the cheapest thing you can add.

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.

Incremental sync

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.

Everything ends at 180 seconds.

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.

Cut short

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.

ArgumentTypeNotes
sectorlist17 values — Automotive, Banking, Credit Cards, Insurance, Investments, …
categorylistDepth 2, resolved within the chosen sector. Names repeat across branches
subcategorylistDepth 3, within the chosen category
subsubcategorylistDepth 4
media_channellist10 values — Direct Mail, Email, Online Display, Print, Social Media, …
audiencelist5 values — Consumer, Small Business Owner, Mortgage Broker, …
companylistCompanies on the piece. Max 50 per call
company_matchstringexact (default) or contains
date_from / date_tostringYYYY-MM-DD, inclusive, either may be omitted
date_fieldstringsearch_date (default), approved_date, added_to_database
countrystringOne country, not a list — United States or Canada
ocr_textlistWords to find in the scanned text. See below
ocr_text_matchstringall (default) or any
entry_idlistExact ids. Valid on its own — needs no other filter
panelist_idlistNumeric ids of the households that received the piece
panelist_typestringprimary or non_primary. Requires panelist_id
limitintDefault 50, max 10,000
include_totalboolRun the count. Default false
exact_countboolCount every match however long it takes
sortstringLeft unset, picks date when a date bound is present
advanced_filtersdictThe 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.sort echoes 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.

Worked examples

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.

ArgumentTypeNotes
entry_idslist (required)Max 25 per call — OCR text is long
max_charsintPer-piece cap, default 20,000
include_documentsboolPer-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:

statusMeansWhat to do
okText is presentRead it
not_ocredA real piece that has never been through OCR — text is nullUse pdf_url; the scan exists
not_foundNo approved, non-deleted piece carries that idA 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.

read_piece

discover_filters

The archive's own vocabulary, read live rather than remembered. Four modes, chosen by which argument you pass:

PassGet
nothingThe core filters and their values — the whole small vocabulary
parentThe children of a taxonomy node, e.g. "Credit Cards"
groupOne enhanced filter group, or "all" for every group
field + queryA 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.

Look it up rather than guessing it.

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 four modes

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.

A range starting at 0 is not "no filter".

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.

advanced_filters

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.

StatusCodeMeans
401missing_credentialsNo header at all
401invalid_api_keyUnknown or revoked key
401invalid_authorization_headerNot Bearer <token>
401token_expiredRefresh and retry
401invalid_token · invalid_audience · token_verification_failedThe token did not verify against the pool
403account_not_provisionedValid login, not linked to a client
429rate_limit_exceededBurst limit — carries retry_after_seconds
429quota_exceededMonthly cap — carries quota, used, period
503cognito_not_configuredBearer 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.

The three shapes

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 check

/health needs no credential, costs no quota and is not written to the usage log.