Connect can act as an **MCP client** against an endpoint you host. A platform
operator registers the URL, Connect asks it what tools it has, and those tools
are offered to members in Connect's own assistant.

```
                       registration (once, by an operator)
Connect  ──POST──►  https://your-app.example.com/api/mcp
                    Connect-Signature: t=…,v1=…
                    tools/list            ← signed, no bearer
                    ◄── the tools, pinned as a snapshot

                       a member asks the assistant something
Connect  ──POST──►  https://your-app.example.com/api/mcp
                    Connect-Signature: t=…,v1=…
                    Authorization: Bearer <connect session token>
                    tools/call            ← runs as that member
```

Two credentials answering two questions. **The signature says the request is
Connect's**; **the bearer says which member it is for.** Discovery carries only
the first, because a tool list is a question about your server's shape rather
than about anyone's data.

<Callout>
The token Connect forwards is an ordinary Connect session token for the member
who asked. Resolve it the way you resolve any other one — the answer you give
should be exactly what that person would see in your own UI. Connect holds no
model of your permissions and does not try to.
</Callout>

<Callout>
**There is no new secret to configure, and no key to choose.** The signing key
is derived from an API key you already hold. Connect stores `sha256` of that key
and never the plaintext — so it cannot sign with the key itself, but that hash
is a value *both sides can compute*, and an HMAC needs nothing more.
</Callout>

## The server, in a few lines

If your product keeps its data in the Connect store, you already have a tool
list: `@cosmoops/connect-sdk/mcp` derives it from the same `storeSchema` you
seed, so there are no tool definitions to write or keep in step.

```ts
// app/api/mcp/route.ts
import { createStoreToolset } from "@cosmoops/connect-sdk/mcp";

const toolset = createStoreToolset({
  collections: STORE_SCHEMA,
  data: connectClient().data,
  workspaceId,
  asUser: requireSession(sessionToken), // resolved per call - see below
  include: [COLLECTIONS.deals, COLLECTIONS.contacts],
  writes: "none",
});
```

See [the MCP reference](/docs/reference/mcp) for the full options, including
the transport wiring the route handler needs.

A server that is *not* store-backed works exactly as well. Connect speaks the
protocol, not the SDK — anything that answers `tools/list` and `tools/call`
over Streamable HTTP is a valid endpoint.

## What Connect expects

<Steps>

<Step>

### Streamable HTTP, on one URL

Connect POSTs JSON-RPC to the URL you register, with
`accept: application/json, text/event-stream`. Answer with either — a single
JSON body or an SSE stream carrying the response message. Both are compliant
and both are read.

Each exchange is a fresh handshake: `initialize`, then
`notifications/initialized`, then the one request. Connect echoes back
`mcp-session-id` if you set one and sends `mcp-protocol-version` on every
request after `initialize`, but it keeps no session between calls — so a
stateless route handler is a perfectly good server here.

Protocol versions accepted: `2025-11-25`, `2025-06-18`, `2025-03-26`,
`2024-11-05`. Connect asks for `2025-06-18` and takes whatever you negotiate
back from that list.

</Step>

<Step>

### Verify `Connect-Signature`

Every request Connect makes to your endpoint carries
`Connect-Signature: t=<unix>,v1=<a>,v1=<b>` over `${timestamp}.${rawBody}` —
the **same scheme, header and tolerance** as Connect's webhooks.

The key is derived from your API key in two steps, and both sides arrive at the
same value from opposite ends: Connect from the hash it stores, you from the
key in your own environment.

There is **one `v1` per live API key** your application holds, because Connect
has no way to know which one you deployed. Verify against the single key you
have and accept if any signature matches — the same multi-signature shape a
webhook secret rotation uses. Issuing or rotating a key needs no coordination
with us: the new one simply joins the set, and a revoked one leaves it.

```ts
import { createHash, createHmac, timingSafeEqual } from "node:crypto";

// Derived once, at module scope - it is a pure function of an env var.
const hashedKey = createHash("sha256").update(process.env.CONNECT_API_KEY!).digest("hex");
const signingKey = createHmac("sha256", hashedKey)
  .update("connect-mcp-signature-v1")
  .digest("hex");

function isFromConnect(rawBody: string, header: string | null): boolean {
  if (!header) return false;

  const pairs = header
    .split(",")
    .map((part) => part.trim().split("="))
    .filter((pair): pair is [string, string] => pair.length === 2);

  const timestamp = Number(pairs.find(([key]) => key === "t")?.[1]);
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) return false;

  const expected = createHmac("sha256", signingKey).update(`${timestamp}.${rawBody}`).digest("hex");
  const expectedBuffer = Buffer.from(expected, "utf8");

  // Any `v1` may be the one keyed to your key. No early return on a match:
  // that would make the time taken depend on which signature matched.
  let matched = false;
  for (const [key, value] of pairs) {
    if (key !== "v1" || value.length !== expected.length) continue;
    if (timingSafeEqual(Buffer.from(value, "utf8"), expectedBuffer)) matched = true;
  }
  return matched;
}

// The signature is over the *raw* bytes. Read the body as text and verify
// before parsing - `JSON.stringify(await request.json())` is a different
// string and will never match.
const rawBody = await request.text();
if (!isFromConnect(rawBody, request.headers.get("connect-signature"))) {
  return new Response("Unauthorized", { status: 401 });
}
```

<Callout type="warn">
Do **not** use `verifyConnectWebhook` here yet. It reads a single `v1` — correct
for a webhook's one secret, and silently wrong for MCP the moment your
application holds two API keys. The multi-signature form lands in the SDK in the
next release; until then, the function above is the verification to write.
</Callout>

The label is what keeps the signing key distinct from the hash Connect compares
a *presented* key against, so the same value is never doing two jobs. It is
versioned: if the derivation ever changes, every signature fails loudly rather
than some subset succeeding.

Discovery arrives with a signature and **no bearer**, so this is the only thing
standing between your endpoint and anyone who learns the URL. Refuse a request
that carries neither.

### Gate execution, not enumeration

A signed request with no bearer should still get your tool *definitions*, and a
refusal from the first `tools/call`. With `createStoreToolset` that falls out of
`asUser` being resolved per call and never while listing:

```ts
function requireSession(sessionToken: string | null): () => string {
  return () => {
    if (!sessionToken) throw new Error("Sign in to use this tool.");
    return sessionToken;
  };
}
```

That asymmetry is what lets an operator register your endpoint and see its
tools before any member has asked anything.

</Step>

<Step>

### Read the bearer, not just the cookie

`cookieSessionStore().read()` reads the `cookie` header only, which is right
for a browser on your own origin and wrong for Connect — a cross-origin caller
has no cookie to send. Accept both:

```ts
function readSessionToken(request: Request) {
  const authorization = request.headers.get("authorization");
  if (authorization?.toLowerCase().startsWith("bearer ")) {
    return authorization.slice(7).trim() || null;
  }
  return cookieSessionStore().read(request);
}
```

Neither envelope is trusted further than the other. Both carry an opaque
Connect session token, and the store resolves and gates each identically.

</Step>

<Step>

### Declare `readOnlyHint` on anything that only reads

Connect halts a tool call for human approval unless the tool declares
`readOnlyHint: true`. Leaving it off is safe — the member is asked before the
call runs — but it makes every read cost a confirmation, which is the fastest
way to make an assistant tedious.

The hint is advisory in the direction that matters: Connect reads it to decide
whether to *ask a human*, never to widen what a call may reach. That is bounded
by the member's own token and your check of it.

</Step>

</Steps>

## Registering

An operator registers the endpoint in Connect's console, under
**Applications → your application → MCP servers**. Registration takes a key, a
label, the URL, and one line on what the server is for — and runs discovery in
the same act, so the row that appears already says whether your endpoint
answered.

Nothing is handed over out of band and nothing is chosen: a correctly built
endpoint verifies the very first discovery, because you already have everything
you need.

One thing worth knowing: **revoking an API key stops Connect signing with it.**
Connect drops it from the set rather than signing with a dead credential's hash,
which is the only way revocation can mean anything here — the hash of a revoked
key would still verify on your side. If you revoke every live key, Connect
refuses to sign at all and discovery fails with that reason.

The URL has three rules, and all three are refusals rather than warnings:

| Rule | Why |
| --- | --- |
| `https`, except to `localhost` | Plaintext for local development, and nowhere else. |
| No credentials in the URL | Connect authenticates as the member; a URL is stored and shown in the clear. |
| No query string | It is where server tokens end up. Connect has a real credential to present and does not need one smuggled into the path. |

Discovery is a **snapshot**, not a live read. Connect pins what your server
answered and offers exactly that until someone refreshes it — so deploying a
renamed tool changes nothing until an operator re-runs discovery and sees the
difference. Moving the URL drops the snapshot and re-discovers, because a tool
list describes one endpoint.

A discovery that fails records the reason and **keeps the last good tool list**.
An endpoint down for an afternoon does not empty your product's tools; it shows
an operator when they were last confirmed.

## Who can reach your tools

Two conditions, both required, both continuously re-checked at call time rather
than assumed from the tool having been offered:

1. **The member's workspace subscribes** to your application, and the
   subscription is `active` or `paused`.
2. **The member has consented** to your application — they have signed in
   through the ordinary OAuth screen at least once.

The second is the one worth knowing about. A call tells you who is asking, so
it is a disclosure the member makes rather than one their workspace makes for
them. It also means the withdrawal path already exists and is one they control:
revoking your application under **Profile → Security → Connected applications**
takes the assistant's access with it.

An application that is deactivated, a server an operator has disabled, and a
tool absent from the last snapshot are each refused too.

## Tool names and how many are offered

A discovered tool reaches the model as `mcp_<application>_<server>_<tool>` —
`mcp_crm_pipeline_list_deals`. All three segments are needed: two applications
may both publish a `search`, and one application may register two servers. A
name that will not fit 64 characters is skipped and logged rather than
truncated into something untraceable.

**At most 12 application tools are offered in one turn**, across every
application a workspace subscribes to. This is not a quota to fill. Tool-choice
accuracy degrades long before a context window does, and a model given forty
tools chooses worse than one given eight — so `include` a set that matches
questions people actually ask, rather than every collection you have.

## What Connect never sends

- **The conversation.** Your endpoint receives a tool name and its arguments.
- **The member's Connect session cookie.** That authenticates *to Connect*;
  forwarding it would hand over the account rather than a scoped view of it.
- **A long-lived credential.** The forwarded token lives two minutes and dies
  with the browser session that caused it.

## Limits

| | |
| --- | --- |
| MCP servers per application | 10 |
| Tools per server | 100 — a server publishing more fails discovery rather than being truncated |
| Input schema per tool | 16 KB |
| Discovery timeout | 15s |
| Tool call timeout | 20s |
| Forwarded token lifetime | 2 minutes |
| Signature timestamp tolerance | 5 minutes, matching webhooks |
| Signing key | `hmac_sha256(sha256(api_key), "connect-mcp-signature-v1")` |
| Signatures per request | One per live API key, capped at 10 (newest first) |

## Known limits

Connect is not a generic MCP client. It does not implement MCP's OAuth 2.1
authorization spec — it authenticates itself with a signature and identifies
the member with a Connect session token, neither of which is an access token
from a token endpoint you host — and it uses none of sampling, elicitation,
roots, resources or prompts. It declares no capabilities at `initialize`, so a
server should not offer it any.

Redirects are not followed. A moved endpoint is a registration an operator
updates, not something to chase at call time with a bearer token in hand.