# Connecting MCP clients to Action Signal AI

The MCP server is at:
- **Production**: `https://www.actionsignal.ai/api/mcp`
- **Local dev**:  `http://localhost:3000/api/mcp`

There are two ways to authenticate, both supported indefinitely:

1. **OAuth 2.1 (recommended for end users)** — the in-app "Add Custom
   Connector" flow on Claude Desktop, Claude Code, ChatGPT, and Codex
   discovers our authorization server, opens a browser for consent, and
   "just works." Tokens rotate automatically. **No JSON editing.**
2. **Personal Access Tokens (PATs, for CI / headless / power users)** —
   issue a long-lived bearer token at `/settings/security/tokens`, paste
   it into your client's config file. Same scope model as OAuth; just no
   browser flow and no expiry rotation.

> PATs are **not deprecated** and won't be. They're the right tool for
> headless agents, CI scripts, and engineers who prefer config files.
> The recommendation order below is "what most users want first."

## OAuth 2.1 — recommended

### Claude Desktop

1. Open **Settings → Connectors → Add Custom Connector**.
2. Paste: `https://www.actionsignal.ai/api/mcp`
3. Click **Add**. A browser tab opens.
4. Sign in to Action Signal AI (if you weren't already).
5. The consent screen shows which scopes the client wants. Review and
   click **Connect to Claude Desktop**.
6. The browser closes and tools appear in Claude Desktop within a few
   seconds.

To disconnect, either remove the connector from Claude Desktop or revoke
the grant at **Action Signal AI → Settings → Security → Tokens → Active
connections**.

### Claude Code

`claude mcp add` will offer to register via OAuth if it sees a
`/.well-known/oauth-authorization-server` response (we serve one). Walk
the prompts; the consent flow is identical to Claude Desktop.

### ChatGPT

Custom Connectors → Add → MCP Server. Paste the URL; ChatGPT discovers
the authorization endpoint, opens the consent flow in a popup.

### Codex CLI

```bash
codex mcp add action-signal-ai --url https://www.actionsignal.ai/api/mcp
```

Codex detects OAuth support from the URL and opens the consent browser flow
automatically — no `--transport` or `--oauth` flag needed (older Codex
versions required both; current versions infer them). If the server is
already added but not signed in, re-authenticate with:

```bash
codex mcp login action-signal-ai
```

Verify the connection:

```bash
codex mcp get action-signal-ai
codex mcp list
```

If Action Signal's tools don't show up immediately, start a new Codex task
or restart Codex.

### What you'll see on the consent screen

- **Action Signal AI** branding at the top (so it's unambiguous which
  resource you're authorizing).
- The MCP client's display name (e.g. *"Claude Desktop wants access to
  your data"*).
- Your tenant — *"Granting from: <Your Organization>"*.
- A plain-English list of scopes. Examples:
  - *"View your tracked products and their data health"*
  - *"Refresh product data from Amazon (capped at 50 refreshes/day per
    connection)"*
  - *"Trigger new optimization analyses — counts against your AI credit
    budget"*
- **Connect** and **Cancel** buttons.

If something looks off (wrong client name, wrong tenant, scopes you
don't recognize), cancel and reach out to support.

## Personal Access Tokens — headless / CI / power users

Get a PAT from `/settings/security/tokens`. The plaintext is shown
**once** in a modal — save it before closing.

Do not paste PATs directly into shared docs, screenshots, shell history, or
source control. Prefer the env-var patterns below, where your client reads
the token from an environment variable by name rather than storing the
literal secret in a config file or command line.

### Claude Code (config file)

Edit `~/.config/claude-code/mcp.json` (macOS/Linux) or
`%APPDATA%\claude-code\mcp.json` (Windows):

```json
{
  "mcpServers": {
    "action-signal-ai": {
      "transport": "http",
      "url": "https://www.actionsignal.ai/api/mcp",
      "headers": {
        "Authorization": "Bearer asai_pat_REPLACE_ME"
      }
    }
  }
}
```

Restart Claude Code.

### Claude Desktop (config file)

Settings → Developer → Edit Config. Same JSON shape as above. For
non-engineers, use the OAuth flow instead — or click **Quick connect**
in `/settings/security/tokens` and we'll generate a `mcp-remote`-wrapped
PAT snippet you can paste in 30 seconds.

### Codex CLI (PAT)

```bash
export ACTION_SIGNAL_PAT="asai_pat_REPLACE_ME"

codex mcp add action-signal-ai \
  --url https://www.actionsignal.ai/api/mcp \
  --bearer-token-env-var ACTION_SIGNAL_PAT
```

`--bearer-token-env-var` tells Codex which environment variable holds the
token — the token itself is never written to Codex's config file.

### ChatGPT (PAT)

Custom Connectors → Add → MCP Server. Paste the URL; choose Bearer
token auth; paste the PAT.

### Custom agents (TypeScript)

```ts
import { McpClient } from "@modelcontextprotocol/sdk/client";
import { StreamableHttpTransport } from "@modelcontextprotocol/sdk/client/streamableHttp";

const transport = new StreamableHttpTransport(
  new URL("https://www.actionsignal.ai/api/mcp"),
  {
    requestInit: {
      headers: { Authorization: `Bearer ${process.env.ACTION_SIGNAL_PAT}` },
    },
  }
);

const client = new McpClient({ name: "my-agent", version: "1.0.0" });
await client.connect(transport);
const tools = await client.listTools();
```

### OpenAI Agents SDK

Action Signal is a standard Streamable HTTP MCP server — point the SDK's
hosted MCP / Streamable HTTP tool at the URL above and pass
`Authorization: Bearer <PAT>` the same way as the TypeScript example. See
OpenAI's [MCP integration docs](https://openai.github.io/openai-agents-python/mcp/)
for the SDK-specific hosted and Streamable HTTP patterns.

## What you can do (47 tools)

| Tool | Scope | Description |
|---|---|---|
| `products_list` | `read:products` | List your owned products |
| `products_get` | `read:products` | Single product details + data_health |
| `products_create` | `write:products` | Onboard a new ASIN |
| `products_create_from_url` | `write:products` | Onboard an owned DTC or Shopify product from its storefront product URL (no full report today; a DTC product shows `tracked` once its storefront reviews are collected) |
| `products_archive` | `write:products` | Soft-delete |
| `products_unarchive` | `write:products` | Inverse |
| `products_demote_to_competitor` | `write:products` | Demote an owned product back to competitor-only |
| `listing_title_set_regenerate` | `write:products` | Regenerate the draft listing title and highlights for an owned product. Calls an LLM and replaces the current draft |
| `shopify_apply` | `write:products` | Write listing fields back to a product on your connected Shopify store, as a draft unless the store is set to apply directly |
| `dashboard_overview` | `read:products` | Cross-product snapshot: products, reports, jobs, credits |
| `competitors_list` | `read:products` | List competitors tracked for a product |
| `competitors_add` | `write:products` | Add a competitor by ASIN or URL |
| `competitors_remove` | `write:products` | Stop tracking a competitor |
| `product_readiness` | `read:products` | Composite readiness check before triggering analysis |
| `reports_list` | `read:reports` | Recent optimization reports |
| `reports_get_section` | `read:reports` | One section of an 8-section report |
| `voc_get_customers_say` | `read:voc` | Aspect-level sentiment + quotes |
| `audience_panels_list` | `read:audience` | List synthetic audience panels in the workspace, with `round_count`, `thread_count` and `last_asked_at` showing which were questioned and when |
| `audience_personas_get` | `read:audience` | The buyer personas in an audience panel |
| `audience_threads_list` | `read:audience` | Past conversations with your panels, most recent question first, across the workspace or for one panel. Free |
| `audience_thread_get` | `read:audience` | Read back one past conversation: every question with each persona's answer, the round synthesis, and reactions listed separately. Free |
| `audience_create` | `write:audience` | Generate a new audience panel. Free: bounded by your plan's panel slots, not by credits |
| `audience_panel_rename` | `write:audience` | Rename a panel. Free; touches only the name. Product-scoped panels keep names unique per product |
| `audience_ask` | `write:audience` | Ask an audience panel a question, optionally with up to 4 images (`attachments`). One audience credit per call for a panel of up to 8 personas, two for a larger panel (`audience_panels_list` reports `question_credit_cost`), whatever the image count |
| `personas_get` | `read:audience` | DEPRECATED — use `audience_personas_get`. Buyer personas for a product |
| `ingestion_dispatch` | `write:ingestion` | Fetch / refresh data for a product |
| `ingestion_retry` | `write:ingestion` | Retry one failed ingestion path for a product (an Amazon step, Walmart, Customers Say or DTC). `ingestion_retry_step` is the older name for the same tool |
| `review_source_attach` | `write:ingestion` | Attach a direct-to-consumer storefront review source to a product so its reviews are collected on the next ingestion |
| `analysis_trigger` | `write:analysis` | Kick a full optimization run for one product |
| `analysis_trigger_bulk` | `write:analysis` | Kick optimization runs across many products in one call |
| `jobs_get_status` | `read:jobs` | Poll status of any background job |
| `credits_get_balance` | `read:billing` | Credit position + per-token caps |

A few tools that support the Action Signal team's own workflows are omitted
from the table above. Your connection does not need them.

## Typical workflows

### Catch up on a brand's portfolio

```
Agent: "Show me the latest reports for my top 3 products."
Tools: products_list (limit=3) → reports_list (per product) → reports_get_section
```

### Onboard a new ASIN and analyze it

```
products_create(asin) → jobs_get_status (poll enrichment) →
ingestion_dispatch (after enrichment) → analysis_trigger →
jobs_get_status (poll run) → reports_get_section
```

### Compare VOC across products

```
products_list → for each: voc_get_customers_say(product_id)
```

### Test a creative on a panel

```
Agent: "Show my buyers these two ad variants and ask which one they'd click."
Tools: audience_panels_list → audience_ask(panel_id, question,
attachments=[{image_url}, {image_url}])
```

`attachments` takes up to 4 images, each an object with exactly one of
`image_url` or `image_base64`. Prefer `image_url`: a public `https` URL the
server fetches itself (a signed URL works). The fetch refuses plain `http`,
credentials in the URL, and any URL that resolves, directly or through a
redirect, to a private, loopback, link-local or cloud-metadata address. The
download is capped at 12 MB and the type is read from the file's bytes, not
its headers: JPEG, PNG and WebP are accepted. `image_base64` is for a caller
already holding the bytes; the whole request is limited to about 4 MB by the
hosting platform, so inline images top out near 3 MB in total.

Every image is re-encoded to at most 1024px on the long edge with its
metadata removed (PNG if it has transparency, JPEG otherwise), so send the
original. After re-encoding the same limits as the dashboard apply: 4.5 MiB
per image and 8 MiB per question. A refused image is a `validation_error`
whose message names the attachment by position (`Attachment 2: ...`) and is
refused before any credit is spent; a URL that times out or returns an HTTP
error is `downstream_unavailable`. The response carries `attachment_count`.

Images go with that one question. A follow-up `audience_ask` does not re-send
them, so attach them again when the follow-up is about the image itself. In
`audience_thread_get` the question shows `has_attachments: true`; the image
bytes are never returned.

### Read back what an audience said

```
Agent: "Pull up the latest panel interview and show me the result."
Tools: audience_threads_list (no panel_id: newest question first, whole
workspace) → audience_thread_get(session_id)
```

Both calls read stored conversations and spend no credit; only
`audience_ask` runs a new, paid round. `audience_threads_list` pages with
`limit` and `offset`. `audience_thread_get` returns rounds numbered from 1 in
the order asked (the same `question_number` `audience_feedback_record`
takes), each with its own synthesis. One response carries about 40,000
characters of text. Without `from_round` it returns the most recent rounds
that fit; pass `from_round` and follow `next_from_round` to page through a
long thread. Any text cut to fit carries a sibling `<field>_full_length`, and
`persona_id` narrows the read to one persona's answers so they come back in
full. Reaction-round replies are listed under `reactions`, never mixed into
`responses`. Attachments are flagged with `has_attachments`, never returned.
The whole-thread `summary` is cached when generated, so
`summary_predates_latest_question: true` marks one written before later
questions.

Threads include `simulation` rounds, `direct` 1:1 chats and stored `live_call`
transcripts. A live call's rounds carry `asked_at: null`, because the
transcript does not record when each turn was spoken. `audience_ask` with a
`session_id` continues `simulation` threads only; it refuses 1:1 chats, live
calls and prospects' Signal Brief threads.

## Limits

| Limit | Default |
|---|---|
| Per-token requests / min | 60 |
| Per-token requests / day | 5,000 |
| Per-token `write:ingestion` calls / day | 3 × your plan's product slots (5–50) |
| Per-token report credits / 24h | 25% of your plan's monthly report allotment |
| Per-token audience credits / 24h | 25% of your plan's monthly audience allotment |
| PAT expiry | 90 days (max 365) |
| OAuth access token TTL | 1 hour (refresh rotates seamlessly) |
| OAuth refresh token TTL | 30 days |
| Verify cache TTL | 30 s (max revocation latency) |
| Request body size | about 4 MB (larger requests get HTTP 413 from the hosting platform, with a plain-text body) |

A request with a missing or malformed `Authorization` header gets HTTP 401
before its body is read, with the OAuth discovery `WWW-Authenticate` header and
a JSON-RPC error whose `id` is `null`.

The request, spend and ingestion limits are per-token. The dashboard and other tokens spend
independently. OAuth and PAT tokens share the same caps; they don't pool.

The three spend limits are **derived from your plan**, so they move when you
change tier or buy product-slot add-ons — Growth (10 slots) gets 30 ingestion
calls, 10 report credits and 125 audience credits per token per day; Pro (30
slots) gets 50, 30 and 500. Requests/min and requests/day are flat across plans;
they exist to bound abuse, not to meter entitlement.

The report and audience caps are **separate ceilings** — `analysis_trigger`
spends the first, `audience_ask` the second, and exhausting one leaves the other
untouched. Both are also separate from your workspace credit balance: a call can
be refused because the token hit its 24h burst cap even when the workspace has
plenty of credits left. `credits_get_balance` reports both (`per_token` and
`per_token_audience`) alongside the pool balances.

When issuing a PAT you can set **lower** limits than your plan allows — useful
for a shared or long-lived token. You can't set higher ones: the mint form
rejects an over-plan value with `exceeds_tier_limit`, and enforcement clamps to
the plan ceiling on every request, so a token issued before a downgrade doesn't
keep the old allowance.

## Revoking access

- **OAuth**: `/settings/security/tokens` → Active connections → Disconnect.
  Or from your MCP client's connector UI.
- **PAT**: `/settings/security/tokens` → Active tokens → Revoke.
- **Either way**: revocation propagates within ≤ 30 s (the verify cache
  TTL). Disconnecting an OAuth grant cascades — all access tokens minted
  by that grant are revoked at the same time.

## Errors

The JSON-RPC `error.code` and `data.error_code` fields are stable:

| `error_code` | Meaning | Action |
|---|---|---|
| `unauthorized` | Missing / invalid / expired / revoked token | Re-authenticate (OAuth: reconnect from your client; PAT: re-issue) |
| `scope_denied` | Token is missing the required scope | OAuth: reconnect with extra scopes; PAT: issue a new token |
| `rate_limited` | Per-token RPM or RPD cap | Back off; `data.reset_at` tells you when |
| `ingestion_capped` | Per-token `write:ingestion` daily cap | Wait or use a different token |
| `credit_capped` | Per-token 24h credit cap, or the workspace pool is empty | `data.pool` says which pool (`report` / `audience`). `data.reason='workspace_balance'` → buy credits; otherwise it's the token cap and `data.reset_at` says when it frees up |
| `not_found` | Resource doesn't exist in this tenant | — (we deliberately don't leak if it exists elsewhere) |
| `conflict` | Idempotency / state collision | Read the `data` payload |
| `downstream_unavailable` | A service behind the tool (data collection or analysis) failed | Retry later |
| `validation_error` | Bad arguments | Check the tool's input schema |

## Reporting issues

`support@actionsignal.ai` — include the `error_code` and a timestamp.
