# MCP client compatibility

> What the Anectico MCP endpoint requires of a client, which protocol revisions it serves, and which hosts have been checked.

Canonical page: https://anectico.com/docs/reference/mcp-compatibility/


The remote endpoint is one URL:

```text
https://app.anectico.com/mcp
```

This page states what a client must do to talk to it, and — separately, and honestly — how far any
client has actually been checked against it.

## What the endpoint requires

Every item here is a property of the running service, not an aspiration.

| Requirement | Detail |
| --- | --- |
| Transport | **Streamable HTTP, stateless.** One endpoint, and it serves **`POST` only**. |
| Other methods | `GET` and `DELETE` are answered `405` with `Allow: POST`. There is no server-to-client stream to open and no session to delete. |
| `Accept` on `POST` | Must contain **both** `application/json` and `text/event-stream`. A request missing either is answered `400`. |
| Credential | A branded `an_` API key or an access token obtained through the advertised OAuth flow, in a **header**. Never in a query string or a request body. |
| Header form | OAuth uses `Authorization: Bearer <access-token>`. API keys use `Authorization: Bearer an_…`, `Authorization: ApiKey an_…`, `X-Anectico-API-Key: an_…`, or `X-API-Key: an_…`. |
| Session | None. Every request is independent and carries its own credential. |
| Scopes | `mcp:read` or `mcp:write` to reach the endpoint at all, plus the domain scope of every tool. |

The endpoint is deliberately a **tools-only MCP surface**. It does not publish MCP prompts or MCP
resources, so an empty `prompts/list` or `resources/list` response is expected rather than a sign
that setup failed. Product data and this documentation are exposed through tools; use `tools/list`
to verify the connection.

The older two-endpoint HTTP+SSE transport is **not served** — there is no separate `/sse` or
`/messages` path, and a client that requires it cannot connect.

**The credential is checked before anything else.** A malformed `Accept`, an unknown method and an
invalid `?profile=` are all answered only once the request has authenticated and cleared the
umbrella-scope check. So an unauthenticated probe of this endpoint learns `401` and nothing more,
whatever else is wrong with it — which is deliberate, and worth knowing when you are debugging a
`401` you expected to be a `400`.

### Authentication choices

The endpoint accepts either an `an_` API key or an audience-bound OAuth access token. A request with
no credential receives a `WWW-Authenticate` challenge pointing to the protected-resource metadata;
an OAuth-capable client follows that document to the advertised authorization server and uses PKCE.
API-key clients can continue sending their configured header without running discovery.

**There is nothing to obtain by hand for the OAuth path.** The advertised authorization server
accepts dynamic client registration, so a host registers itself on first connection and is issued a
**public** client — no client secret exists to store, and PKCE authenticates the exchange instead.
You are never asked for a client id, and an operator never has to pre-provision one.

The protected-resource metadata is served on **both** well-known locations, because the two are
constructed differently and clients in the wild ask for either:

```text
https://app.anectico.com/.well-known/oauth-protected-resource/mcp
https://app.anectico.com/.well-known/oauth-protected-resource
```

**The token must be bound to this endpoint's URL as its audience.** A conforming client sends the
resource indicator automatically. Both sides enforce it: the authorization server refuses to issue a
token for any other resource identifier — including near-misses such as a trailing slash or the bare
origin — and this endpoint refuses a token that arrives bound to something else, with a valid
signature and a valid issuer. That is deliberate and there is no setting that widens it. A `401`
carrying `invalid_token` after an authorization that appeared to succeed means the client requested
the wrong resource identifier; it should be exactly the URL at the top of this page.

An OAuth connection is deliberately **less capable than the person who authorized it** — it is
capped at a fixed delegation ceiling regardless of that person's role. See
[How agent access is enforced](/docs/agents/security-model#an-oauth-connection-is-capped-below-the-member-it-belongs-to)
for exactly what is inside and outside it.

OAuth authenticates an existing Anectico membership; it does not create one. Before a person's
first OAuth MCP connection, a workspace owner must invite or provision them, and the person must
complete normal Anectico sign-in (or `anectico login`) once. A resolution failure is one generic
`403` with no account, email, or workspace detail, so a valid token cannot be used to enumerate
Anectico identities. See [Connect an AI agent with MCP](/docs/agents/connect-mcp) for the remediation.

## Protocol revisions

Five revisions are served, and the endpoint negotiates rather than requiring one:

| Revision | How a client reaches it |
| --- | --- |
| `2026-07-28` | `server/discover`, with the `Mcp-Protocol-Version` and `Mcp-Method` headers and the matching per-request `_meta`. There is no `initialize` at this revision. |
| `2025-11-25` | The `initialize` handshake. |
| `2025-06-18` | The `initialize` handshake. |
| `2025-03-26` | The `initialize` handshake. |
| `2024-11-05` | The `initialize` handshake. |

One edge worth knowing: an `initialize` request that names `2026-07-28` in its body but sends none
of that revision's per-request headers or `_meta` is negotiated **down** to `2025-11-25`, because
`initialize` is precisely the handshake that revision removes. A transitional client that sets the
modern headers as well is not downgraded — it gets an error, because `initialize` no longer exists
there.

## What we generate for each host, and how far each has been checked

Native OAuth is a three-legged handshake — the host, our endpoint, and the authorization server
behind it — and "verified" means different things depending on how much of that handshake actually
ran. A second question sits behind that one and is easy to skip: once a host is connected, does it
get the RIGHT ANSWERS? Both are recorded below, per host, as of 2026-08-28.

| Host | Verified to | How |
| --- | --- | --- |
| **Codex** | A full native-OAuth round trip, a real tool call, **and** answer-for-answer agreement with our API | Our own conformance runs drive Codex's own OAuth login unattended against a disposable deployment of our authorization server: discovery, dynamic client registration, the PKCE authorization round trip, token exchange, and one tool call executed with the resulting access token — no static key at any point. Separately, the read workflows below are run through the real Codex CLI against a deployed environment, and the data it receives is compared field by field with the same objects read over the API. |
| **Claude Code** | The same three things, to the same standard | The unattended round trip runs for Claude Code exactly as for Codex, and completes with no human input; a person has also completed the browser sign-in against our production authorization server, and that session resolved to a real external identity rather than to an API key. The same read workflows are run through the real Claude Code CLI and compared with the API in the same way. Until 2026-08-27 this host was held to a weaker bar than Codex — it only had to appear in the host's server list, which a host also prints for a static key — and that gap is closed. |
| **A generic client built on the official MCP SDK** | Spec conformance, not vendor-specific compatibility | Our own conformance runs exercise this shape on every run: it proves the endpoint works for *any* spec-conformant client, not just the named hosts. |
| **Cursor** | Not run | Accepted as compatible on the strength of the two hosts above, since it uses the same protocol machinery — a decision, not a measurement. |
| **Gemini** | Not attempted | No run has been made against it yet. |

Separately from any host, our own conformance runs exercise the whole OAuth handshake up to the
browser step on every run, with no host involved: reading both protected-resource metadata
locations and checking they are identical, following the authorization server's own discovery
document, registering a client dynamically exactly as a cold host would, and confirming that the
authorization server binds tokens to this endpoint's URL and refuses every near-miss (wrong
audience, wrong verifier, a replayed code, an unregistered redirect). That covers every part a host
cannot work around when it is wrong, independent of which host is in front of it.

### What "the right answers" means here

Three read workflows are run end to end through each host and checked against the API:

- **Follow one agent run by id.** Every field the host receives is compared with the same field from
  the API. They must be equal, allowing only for the differences the two surfaces are documented to
  have — counts that travel as text in one and as numbers in the other, and the markers that flag
  content your own systems supplied.
- **Ask about something in another project.** The host must be told it was not found, in wording
  that reveals nothing about whether it exists, and in the same wording any other client gets.
- **Read a record whose own text tries to give the reader instructions.** It must come back marked
  as content rather than as instruction, unchanged, and the host must not act on it.

One further check is what makes the rest meaningful: mid-run, the key the host is using is revoked
and the identical request is repeated. The host must stop being able to read the data. Without that
step, a host answering correctly from its own memory would be indistinguishable from a host actually
talking to us.

That is a statement about our testing, not a prediction about yours. Any client that speaks
stateless Streamable HTTP and can send a fixed header **should be compatible**, because that is the
whole of what the endpoint requires. What this table says is which files `anectico agent bootstrap`
writes for you — a configuration generated, not an integration verified.

| Host | What the bootstrap does for it |
| --- | --- |
| Anectico CLI | Nothing to configure — `anectico mcp verify` is a client. It speaks protocol 2026-07-28 and uses `server/discover`; a server that does not offer that version is reported as a failure rather than quietly downgraded. |
| Claude Code | Installs the CLI skill and writes `.mcp.json` in the working directory: `type: "http"`, the URL, and an `Authorization` header. That is the same file `claude mcp add --scope project` writes. |
| Cursor | Installs the CLI skill and writes `.cursor/mcp.json` with the URL and an `Authorization` header. Cursor's remote-server form is documented as URL plus headers, so no transport discriminator is written. |
| Codex | Installs the CLI skill and **prints** the server entry for you to paste. Codex keeps MCP servers in `~/.codex/config.toml` beside unrelated settings, which the bootstrap will not rewrite. |
| Gemini | Installs the CLI skill and prints the entry, as for Codex. Gemini keeps servers in `.gemini/settings.json`. |
| Anything else | Nothing. Configure a remote Streamable HTTP server with the URL and header above. |

`anectico agent bootstrap` finishes by connecting **with Anectico's own client**, using the key it
just minted, and reporting how many tools that key is served. That verifies the credential, the
endpoint and the scopes — the three things most likely to be wrong. It does not launch your host and
cannot tell you your host works. Ask the host itself: list its tools once it has connected.

### Environment-variable expansion is the usual failure

The configuration Anectico writes references `${ANECTICO_API_KEY}` instead of embedding the secret,
so the file stays safe to commit. Claude Code documents that expansion for `.mcp.json`. Cursor does
not document it either way — if it does not expand, the header arrives as the literal text
`Bearer ${ANECTICO_API_KEY}` and the server rejects it. Re-run the bootstrap with `--embed-secret`
to write the literal key into a file created with owner-only permissions, and do not commit it.

## What is not supported

- **Credentials outside a header.** No query-string or body form is accepted, on any path.
- **Shared caching of the tool list.** Tool-list responses carry `ttlMs: 0` and
  `cacheScope: "private"`, because what a key is offered depends on that key's scopes. Re-list when
  you need it — do not carry a list across a change of credential or of permissions.
- **Cancelling a call in progress, before the `2026-07-28` protocol.** Cancellation is propagated to
  the running handler only for clients on that revision; on the earlier ones, disconnecting ends
  your side of the exchange and leaves the call to finish on its own.

## Tool-list size, and when it bites

MCP hosts do not report having too many tools. The overflow simply stops being offered to the model
inside the client. Anectico therefore keeps its list small on purpose, reaching the long tail through
gateway tools, and designs against two self-imposed ceilings: **40**, past which we assume a host may
silently drop tools, and **50**, past which we assume tool-selection accuracy degrades. Those two
numbers are our conservative design targets, not measurements of any particular client — no host
publishes its real limit, which is exactly why we budget against a pessimistic one.

What a key is actually served, derived from the current capability catalog and registration rules on
2026-09-04. Server tests pin the published bundle and served-tool counts, but catalog or profile
changes can alter them; treat them as a sizing guide and ask your own key:

| Key | Tools offered |
| --- | --- |
| `investigate` bundle (20 scopes, read-only) | 36 |
| `agent-observability` bundle (10 scopes) | 14, or 9 with `?profile=agent-observability` |
| `incident` bundle (38 scopes) | 49 |
| Every scope the surface uses | 49 |

An `incident` key is therefore above the 40 line. Two things fix it, and only one of them is a
credential change:

- Connect with `?profile=investigate` for a read-only session — that key is then served **35** tools
  and no write door at all, and cannot invoke a mutation for the life of the connection. It also
  loses the incident, on-call and delivery reads, which are not investigate-native. Its scopes are
  unchanged; reconnecting without the parameter restores the full surface.
- Mint a narrower key for the job. [Scope recipes for agent jobs](/docs/agents/scope-recipes) sizes
  each one.

Adding `?profile=incident` to an `incident` key subtracts `list_agent_fleet`, which belongs to the
agent-observability profile, so the key is served **48** tools. A profile only ever subtracts — never
the reverse.

## Failure symptoms, in the order you will meet them

| Symptom | Cause |
| --- | --- |
| `401`, **empty body**, `WWW-Authenticate: Bearer realm="anectico", resource_metadata="…"` | No credential header reached the server at all. There is no error code and no body, on purpose: nothing was attempted, so there is nothing to report a failure about. The `resource_metadata` parameter is the recovery path — follow it. |
| `401` — `authentication required` | **Malformed, unknown, revoked and expired keys all answer identically**, on purpose: the endpoint will not confirm that a key it rejects ever existed. Check first that the variable expanded, then that the key is live and unexpired. |
| `401` — `invalid_token`, `the access token signature is invalid` | A bearer that is not an `an_` key was validated against the advertised authorization server and failed. Most often this is a signed-in user's own Anectico session token, which is not a credential here; it is also what a token from some other issuer gets. |
| `401` — `invalid_token`, after an authorization that succeeded | The token is bound to the wrong audience. The client requested a resource identifier that is not this endpoint's URL. |
| `403` OAuth challenge with `insufficient_scope` and no named scope | The token validated but no existing Anectico membership could be resolved. Follow the explicit first-access steps above or contact support with the attempt time; the response is intentionally generic. |
| `403` — `the mcp:read or mcp:write scope is required for the MCP endpoint` | The key authenticates but was never granted an umbrella scope. |
| `405`, `Allow: POST` | The client opened a `GET` stream or sent a `DELETE`. This endpoint is `POST`-only and stateless. |
| `400` — `Accept must contain both 'application/json' and 'text/event-stream'` | The client is not speaking Streamable HTTP, or something is stripping the header. Note this arrives **after** authentication, so a bad `Accept` on an unauthenticated request still answers `401`. |
| `400` — `invalid_profile` | See the four shapes below. |
| Connects, **zero tools** | The key holds an umbrella scope and no domain scope. Every tool needs both. |

Because it is refused rather than ignored, `?profile=` has four distinct rejections and each names
what to fix:

| What you sent | Message |
| --- | --- |
| `?profile=incidnet` | `unknown profile "incidnet" — use one of: …` |
| `?profile=` | `profile must not be empty — use one of: …` |
| `?profile=%20` | `profile must not be empty — use one of: …` |
| `?profile=a&profile=b` | `profile must be supplied exactly once — use one of: …` |

Every one is a `400` carrying `"error": "invalid_profile"` and the list of valid names. The last two
matter more than they look: a URL builder that appends an empty parameter, and one that appends the
same parameter twice, are both common, and either **silently serving the whole surface** would look
exactly like the filter working. Names are matched exactly and are case-sensitive — `?profile=Incident`
is an unknown profile, not `incident`.

- [Connect an AI agent with MCP](/docs/agents/connect-mcp)
- [MCP tool reference](/docs/reference/mcp-tools)
- [Scope recipes for agent jobs](/docs/agents/scope-recipes)
