> ## Documentation Index
> Fetch the complete documentation index at: https://ngquct-docs-fix-500-query-results.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Pairing

> One-click flow that issues a scoped MCP token to an extension through a PKCE-style code exchange

Four steps: generate a verifier, open a deep link, catch the one-time code, trade it for a token over
localhost. TablePro releases the token only to the caller that can produce the verifier, so an app
that intercepts the redirect holds a code it cannot spend.

Nothing about this is Raycast-specific. Any client that can receive a callback, through its own URL
scheme or a loopback HTTP listener, can pair.

## Sequence

```mermaid theme={null}
sequenceDiagram
    participant E as Extension
    participant T as TablePro app
    participant U as User
    participant M as MCP server (HTTP)

    E->>E: verifier = randomBytes(32).base64url
    E->>E: challenge = base64url(SHA-256(verifier))
    E->>T: open tablepro://integrations/pair<br/>?client=…&challenge=…&redirect=…&scopes=…
    T->>M: lazy-start server
    T->>U: Approval sheet (client, scopes, connections, expiry)
    U->>T: Approve
    T->>T: mint token, hold pending exchange<br/>{ code, plaintext, challenge }, 5 min
    T->>E: open redirect URL with code<br/>(context JSON for raycast://, ?code= otherwise)
    E->>M: POST /v1/integrations/exchange<br/>{ code, code_verifier: verifier }
    M->>M: SHA-256(verifier) == challenge ?
    M->>E: 200 { token: "tp_…" }
    E->>E: store token in the Keychain
```

<Frame caption="The pairing approval sheet">
  <img className="block dark:hidden" src="https://mintcdn.com/ngquct-docs-fix-500-query-results/HJY892UtvXUv1PFn/images/mcp-pairing-approval-sheet.png?fit=max&auto=format&n=HJY892UtvXUv1PFn&q=85&s=3b249ed5e25f2aeedc27471a6e59b928" alt="TablePro sheet asking to approve a pairing request with scope, connection, and expiry pickers" width="1560" height="960" data-path="images/mcp-pairing-approval-sheet.png" />

  <img className="hidden dark:block" src="https://mintcdn.com/ngquct-docs-fix-500-query-results/HJY892UtvXUv1PFn/images/mcp-pairing-approval-sheet-dark.png?fit=max&auto=format&n=HJY892UtvXUv1PFn&q=85&s=714006d9186c85ab40da954a0e69a9cf" alt="TablePro sheet asking to approve a pairing request with scope, connection, and expiry pickers" width="1560" height="960" data-path="images/mcp-pairing-approval-sheet-dark.png" />
</Frame>

## The whole client, in one file

```ts theme={null}
import { randomBytes, createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";

const b64url = (b: Buffer) =>
  b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

// Step 1. Keep the verifier in memory until the exchange. Never log it.
const verifier = b64url(randomBytes(32));
const challenge = b64url(createHash("sha256").update(verifier).digest());

// Step 2. Open the deep link. The parameters are on the URL scheme page.
const params = new URLSearchParams({
  client: "My Editor on macbook-pro",
  challenge,
  redirect: "http://127.0.0.1:7391/callback",
  scopes: "readWrite",
});
await openUrl(`tablepro://integrations/pair?${params}`); // however your host opens a URL

// Step 3. Your callback receives ?code=<uuid>, or ?error=denied when the user says no.
export async function exchange(code: string): Promise<string> {
  const handshakePath = `${homedir()}/Library/Application Support/TablePro/mcp-handshake.json`;
  const { port } = JSON.parse(await readFile(handshakePath, "utf8"));

  const res = await fetch(`http://127.0.0.1:${port}/v1/integrations/exchange`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ code, code_verifier: verifier }),
  });
  if (!res.ok) throw new Error(`pairing failed: ${res.status}`);

  const { token } = await res.json();
  return token; // Step 4. Straight into the Keychain, never a plain file.
}
```

Three constraints decide whether TablePro accepts the request at all:

* The **verifier** is 43 to 128 characters from `A-Z a-z 0-9 - . _ ~`. 32 random bytes in base64url
  is 43.
* The **challenge** is its base64url SHA-256, so exactly 43 base64url characters.
* The **redirect** is a loopback `http` or `https` URL (`127.0.0.1`, `localhost`, `::1`), or a
  private-use scheme an installed app has registered. Anything else, or one carrying credentials, is
  refused with *"The redirect address is not a local callback, so pairing was refused."*

One delivery detail: a `raycast://` redirect gets the code wrapped as `?context={"code":"<uuid>"}`,
Raycast's launch-context convention, and every other scheme gets a flat `?code=<uuid>`.

The exchange endpoint takes no bearer token: the single-use code plus the verifier is the credential.
It and `/mcp` are the only paths the server serves, and both accept `POST` only.

## What the user approves

The sheet names the client and counts down the five minutes the request is good for, dimming
**Approve** when it runs out. Three controls sit under that: **Permission Level** (starting at what
the link asked for, movable in either direction), **Allowed Connections** (all, or a checked subset),
and **Expiration** (never, 1, 7, 30 or 90 days). The link's parameters are a request, not a grant.

Approving mints the token, holds the plaintext against a one-time code for 5 minutes, and opens the
redirect. Pairing again mints a second token rather than replacing the first, so an extension that
re-pairs should stop using its old one. Revoke either under
**Settings > Integrations > Authentication**.

## Security properties

| Property                             | How                                                                |
| ------------------------------------ | ------------------------------------------------------------------ |
| The token is never in a URL          | It travels over localhost HTTP; the deep link carries only a code. |
| Intercepting the redirect is useless | The code cannot be exchanged without the verifier.                 |
| A code is single-use                 | Success, a failed verification, or 5 minutes deletes it.           |
| The plaintext token is not persisted | Only a salted SHA-256 hash is saved, in the login keychain.        |

## Errors

A failed verification burns the code, so retrying with a guessed verifier is not an option: start a
new pair request.

| Code                         | Meaning                                                                                                                                   |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `400`                        | Malformed body, a missing `code` or `code_verifier`, a field over 1,024 bytes, or a verifier that is not 43 to 128 unreserved characters. |
| `403 Challenge mismatch`     | The verifier does not hash to the stored challenge.                                                                                       |
| `404 Pairing code not found` | The code never existed, or was already exchanged.                                                                                         |
| `410 Pairing code expired`   | The pending exchange is older than 5 minutes.                                                                                             |
| `429 Too Many Requests`      | Five failed exchanges from this address within 5 minutes. Locked out for 15, with `Retry-After` on the response.                          |

Every failed exchange lands in the activity log under the `auth` category with outcome `denied`.

Clicking **Deny** opens the redirect with `error=denied` and `error_description=user_denied`, wrapped
in the `context` JSON for `raycast://` and appended as flat parameters otherwise.
