# MCP

The Signals MCP server. One HTTP POST speaks the MCP streamable-HTTP protocol and answers with a server-sent event stream. GET, PUT, PATCH and DELETE answer **405** with `Allow: POST`, even when the key is missing and even while the API is closed. POST without a key, or with an invalid, revoked or `ejk_` key, is **401**. A valid `gtk_` key while the Signals API is closed answers **403** `api_not_enabled`.

The assistant server at `/api/mcp` (key prefix `ejk_`) is a different server. Connect it from [Connect your assistant](/docs/getting-started/quickstart). An `ejk_` key cannot call this URL.

## Authentication

Send `Authorization: Bearer gtk_…`. Create the key under **Connect assistant → API keys**. A missing, invalid or revoked key is **401**. A valid key while this API is not enabled for your organization answers **403** `api_not_enabled`, with what the key can do instead. Every response includes `mySDR-Api-Version`, `mySDR-Request-Id` and `Cache-Control: no-store`, and a response to an accepted key also `X-RateLimit-*` (so not a 401, a 405, an OPTIONS or a 404 for an unknown path). Send `Accept: application/json, text/event-stream` and a JSON-RPC body; the response is an SSE stream, not a single JSON body (read the JSON on its `data:` line), and keeps `Cache-Control: no-cache, no-transform`. A `tools/call` refused by the rate limit answers plain JSON instead (a tool error with `isError` that says when to retry), so read the `Content-Type`. The server is stateless: `tools/list` and `tools/call` work without `initialize`, which an MCP client library sends anyway.

### Body parameters

- **`jsonrpc`** `string` · **required** — Always `2.0`. One of: `2.0`.
- **`id`** `integer | string` — Your id for the request; the response carries it back. Leave it out on a notification such as `notifications/initialized`.
- **`method`** `string` · **required** — `initialize`, `tools/list`, `tools/call`, or a notification.
- **`params`** `object` — For `tools/call`: `name` (the tool) and `arguments` (its input). For `initialize`: `protocolVersion`, `capabilities` and `clientInfo`.

## Response

- **`event stream`** `text/event-stream` · **required** — MCP JSON-RPC messages. A `tools/call` result carries the REST body plus `dashboard_url` where the tool has one.

## Credits

The HTTP call is free. A tool spends the credits of the REST endpoint it calls. `preview_cost`, `lookup_company`, `get_search_results`, `list_mentions` and `manage_monitor` spend nothing.

## Errors

- **400** `-32700` — The body is not JSON, or not a JSON-RPC message.
- **405** `-32000` — GET, PUT, PATCH and DELETE answer a JSON-RPC error and `Allow: POST`, before the key is checked and while the API is closed. POST without a key is 401.
- **406** `-32000` — The request does not accept both `application/json` and `text/event-stream` (the `Accept` header).
- **413** `-32000` — The body is larger than 512 KB.
- **415** `-32000` — The `Content-Type` is not `application/json`.
- **401** `authentication_error` — The Authorization header is missing, or the key is unknown or revoked.
- **403** `permission_error` — API access is not part of your plan, or your account is suspended.
- **403** `api_not_enabled` — The key is valid, but the Signals API or the Company API is not enabled for your organization yet. The error says what this key can do.
- **429** `rate_limit_error` — More than 60 requests per minute, or more than 3 at the same time, on one key. Wait for the Retry-After header.
- **500** `internal_error` — Something went wrong on our side.

## Example

### cURL

```bash
curl -X POST "https://mysdr.app/api/v1/mcp" \
  -H "Authorization: Bearer $MYSDR_API_KEY" \
  -H "Accept: application/json, text/event-stream" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/list"
  }'
```

### JavaScript

```javascript
const response = await fetch('https://mysdr.app/api/v1/mcp', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.MYSDR_API_KEY}`,
    'Content-Type': 'application/json',
    "Accept": "application/json, text/event-stream"
  },
  body: JSON.stringify({
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/list"
  })
});
const text = await response.text();
const body = response.headers.get('content-type')?.includes('text/event-stream')
  ? JSON.parse(text.split('\n').find((line) => line.startsWith('data: ')).slice(6))
  : JSON.parse(text);
```

### Python

```python
import json
import os
import urllib.request

payload = {
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list"
}

request = urllib.request.Request(
    "https://mysdr.app/api/v1/mcp",
    headers={
    "Authorization": f"Bearer {os.environ['MYSDR_API_KEY']}",
    "Content-Type": "application/json",
    "Accept": "application/json, text/event-stream"
    },
    data=json.dumps(payload).encode(),
    method="POST",
)
with urllib.request.urlopen(request) as response:
    text = response.read().decode()
    if "text/event-stream" in response.headers.get("Content-Type", ""):
        body = json.loads(next(line[6:] for line in text.splitlines() if line.startswith("data: ")))
    else:
        body = json.loads(text)
```

### Example response

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      {
        "name": "lookup_company"
      },
      {
        "name": "enrich"
      },
      {
        "name": "preview_cost"
      },
      {
        "name": "search_signals"
      },
      {
        "name": "get_search_results"
      },
      {
        "name": "company_leads"
      },
      {
        "name": "create_monitor"
      },
      {
        "name": "list_mentions"
      },
      {
        "name": "manage_monitor"
      }
    ]
  }
}
```
## preview_cost and max_credits

`preview_cost` is free. It does not call a source. Send `endpoint` (`searches`, `leads`, `person.enrich` or `monitors`) and `body`, the JSON the real call would use. The result is a `cost_preview`: `credits_per_row`, `max_rows`, `max_credits` and `period` (`call` or `day`). The REST equivalents are `POST /searches/preview`, `POST /leads/preview`, `POST /person/enrich/preview` and `POST /monitors/preview`.

`max_credits` is an integer cap on the real call. The call stops before the next row would pass it. That row is left out (`truncated_by` `max_credits`) and is not charged. Below the cost of one row the call is `402` `max_credits_exceeded` and nothing runs. It works with every key. A monitor uses `max_credits_per_day` instead.

## Tools

The server name is `mysdr`. `tools/list` returns these nine tools. Each one calls the REST endpoint below with your `gtk_` key. A result that has `dashboard_url` opens the same search, monitor or people in mySDR (`/en/gtm/leads` or `/en/gtm/signals`).

### lookup_company

Free. Input: `name` (the company name, or a LinkedIn company page URL). Output: `object` `list` with the pages whose name matches in `data`, or an empty `data` and a message. REST: `GET /companies/lookup`, same body.

### enrich

A profile is **1 credit** when one is found. `include_email: true` adds **1** when an address is found. A company is **1 credit** when one is found. A miss is free. There is no phone field: a phone number is Find Phone (POST /person/phone). Input: exactly one of `profile_urls`, `person_ids` (the `per_` rows of `search_signals` and `company_leads`), or company `domains` / `linkedin_urls` / `names`. Optional: `include_email` (a person), `fields` and `exact_match` (a company), `max_credits`. Output: the enrich body for that person or company. REST: `POST /person/enrich` or `POST /company/enrich`.

### preview_cost

Free. Input: `endpoint` and `body` (the JSON of the real call; `max_credits` goes inside `body`). Output: `cost_preview`. REST: the `/preview` route for that endpoint.

### search_signals

The rows of one signal: what the source shows, with no AI judging them. A person from engagement, listening, an event or a new role is **1 credit**, a funding company **1 credit**, a technology company **3**. A search that finds nobody costs nothing, and the same arguments on the same day return the same search without charging again. Input: `signal` (every type), optional `limit`, `cursor` (the `result.next_cursor` of an earlier answer, with the same signal), `exclude_companies` and `max_credits`. It waits up to 25 seconds and reports progress. Output: `result` (`rows`, `has_more`, `next_cursor`), the first 25 rows in `results`, `next_page_cursor` for the rest, `notes`, `funnel` and `dashboard_url`; still running: `search_id` and a poll hint. A LinkedIn row is a person with their headline and up to 3 things they did, each with the post: the assistant decides which rows fit, then enriches those with `person_ids`. An event is never looked up: send its exact name with the year, its hashtags or its organizer page. To follow a signal over time, call it again later, for example every week with a short `lookback_days`. REST: `POST /searches`, then `GET /searches/{search_id}/results`.

### get_search_results

Free. Input: `search_id` (`srch_` from `search_signals`), optional `page_cursor` (the `next_page_cursor` of the previous page). Output: the search's `status` (`queued` or `running`: no rows yet and a poll hint), then 25 rows per page in `data`, `next_page_cursor` (`null` = no more), and on the first page `result`, `funnel` and `notes`, plus `dashboard_url`. Also reads a search that was still running when `search_signals` answered. REST: `GET /searches/{search_id}` and `GET /searches/{search_id}/results`.

### company_leads

**1 credit** per person. A company that does not match costs nothing. Input: `companies` (each with one of `id`, `domain` or `linkedin_url`), `filters` (`titles`, `seniority`, `departments`, `countries`; `roles` is the earlier name of `titles`), optional `per_company`, `limit`, `cursor` and `max_credits`. Output: `per_` rows and `dashboard_url`. REST: `POST /leads`.

### create_monitor

Creating it is free. Each new mention on a run is **1 credit**. Input: `name`, `keywords`, optional `schedule` (`daily`; `hourly` stays off unless that deployment has turned it on), `min_relevance`, `tags`, `max_credits_per_day`. A keyword can carry the settings of [Create a monitor](/docs/api-reference/create-monitor). They are checked on the text of each post the search found, so they can only drop posts. Output: the monitor and `dashboard_url`. REST: `POST /monitors`.

### list_mentions

Free. Input: optional `monitor_id` (`mon_`), `min_relevance` and `cursor` (the `next_cursor` of the previous page). Output: the mentions already delivered in `data` (an empty list when there are none), each with the `keywords` it counts for and its author (`author_name`, `author_linkedin_url`, `author_headline`), plus `has_more` and `next_cursor`. REST: `GET /mentions`.

### manage_monitor

Free. Input: `action` (`list`, `get`, `update`, `pause`, `resume` or `delete`) and, for every action except `list`, `monitor_id`. `update` takes `name`, `keywords` (the whole new list), `max_credits_per_day` (or `remove_cap: true` for no cap; null means "not given", as in every tool), `min_relevance`, `tags` and `schedule`; what you leave out stays. `list` takes `cursor`. Output: the monitor (or the list) and `dashboard_url`. `delete` stops the monitor for good; its mentions stay readable for 30 days. REST: `GET /monitors`, `GET`, `PATCH` and `DELETE /monitors/{monitor_id}`.

