# Connect other MCP clients

> Connect ChatGPT or any MCP client to sanda with OAuth or an integration token, including discovery, registration, the token endpoint and a curl example.

sanda is a remote MCP server with its own OAuth 2.1 authorization server, so any client that supports remote MCP servers can connect. A client authenticates in one of two ways: it signs a person in with OAuth, or it sends an [integration token](https://docs.sanda-os.com.au/mcp/agent-tokens) as a bearer credential.

For Claude, see [Connect Claude](https://docs.sanda-os.com.au/mcp/connect-claude). This page covers everything else.

## The endpoint

```text
POST https://console.sanda-os.com.au/api/mcp
Authorization: Bearer <credential>
Content-Type: application/json
```

- **One address, POST only.** A GET answers 405 with `Allow: POST`.
- **Plain JSON-RPC 2.0.** Every message is one request and every answer is one JSON object. There is no streaming, no server-sent events and no session id, so a client needs nothing beyond an HTTP POST. A batch (a JSON array) is refused with `-32600`.
- **Request bodies up to 1,000,000 bytes.** That is enough for about ten thousand short rows in `load_rows`. A larger body is refused with 400 and `-32700`.
- **No browser origins.** A request that carries an `Origin` header other than sanda's own is refused with 403, so a web page on another site cannot call the endpoint.
- **A notification** (a request with no `id`) is answered with 202 and no body.

### Protocol versions

sanda speaks both eras of MCP on the same address, and a client using either works unchanged.

| Era | Revisions | How a client starts |
|---|---|---|
| Handshake | `2024-11-05`, `2025-03-26`, `2025-06-18`, `2025-11-25` | `initialize`. sanda answers with the client's version when it supports it, and with `2025-11-25` otherwise |
| Stateless | `2026-07-28` | No handshake. Every request carries its protocol version and client capabilities in `params._meta`. `server/discover` replaces `initialize` |

For a `2026-07-28` request, the `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` headers are optional, but if sent they must agree with the body, or sanda answers 400 with `-32020`.

### Methods

`initialize`, `server/discover`, `ping`, `tools/list`, `tools/call`, `resources/list` and `resources/read`. sanda offers tools and two resources, `becca://fluid/brief` (the one-page map of the workspace) and `becca://fluid/context` (everything, in full, and large). It does not offer prompts or resource subscriptions. Prefer the brief plus tool calls to the full context. Both resources need `fluid:read`: without it, `resources/list` returns an empty list and `resources/read` is refused like a tool call, with a 403 and `insufficient_scope` for an OAuth client.

## Choose how to authenticate

| | OAuth | Integration token |
|---|---|---|
| Use it when | A person connects their own assistant, and the client can open a browser | A machine runs unattended, or the client has a header field and nothing else |
| The credential | An access token that lasts one hour, refreshed by the client | A `bat_` token that lasts until it expires or is revoked |
| Permissions | Chosen by the person on the consent screen | Chosen by an owner or admin when the token is created |
| Revoke it | **Settings · Integrations**, or `POST /api/oauth/revoke` | **Settings · Integrations** |

If both are on offer, use OAuth: there is no secret to store.

## Connect ChatGPT

ChatGPT can connect to a remote MCP server as a custom connector, on the plans that offer them. Add a connector, give it sanda's address, and choose OAuth as its authentication:

```text
https://console.sanda-os.com.au/api/mcp
```

If the form asks for a client ID or secret, leave them empty. sanda does not issue client secrets: every MCP client is a public client and proves itself with PKCE. When you connect, ChatGPT sends you to the same consent screen described in [Connect Claude](https://docs.sanda-os.com.au/mcp/connect-claude#the-consent-screen). Menu names in ChatGPT differ between plans and change over time, so check ChatGPT's own documentation for where the connector form is and whether your plan offers it.

## Sign in with OAuth from your own client

sanda implements the MCP authorization specification as both the resource server and the authorization server, on one address. All of it is public and needs no key.

| What | Address |
|---|---|
| Protected resource metadata | `https://console.sanda-os.com.au/.well-known/oauth-protected-resource` |
| Authorization server metadata | `https://console.sanda-os.com.au/.well-known/oauth-authorization-server` |
| Authorization endpoint | `https://console.sanda-os.com.au/api/oauth/authorize` |
| Token endpoint | `https://console.sanda-os.com.au/api/oauth/token` |
| Dynamic client registration | `https://console.sanda-os.com.au/api/oauth/register` |
| Revocation endpoint | `https://console.sanda-os.com.au/api/oauth/revoke` |

The discovery documents are served with `Access-Control-Allow-Origin: *` and cached for 60 seconds. The protected resource document is also served at `/.well-known/oauth-protected-resource/api/mcp`.

### Let the 401 point you at discovery

A request with no credential, or an invalid one, is refused with 401. Its `WWW-Authenticate` header names the discovery document and every scope sanda has:

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="sanda",
  resource_metadata="https://console.sanda-os.com.au/.well-known/oauth-protected-resource",
  scope="fluid:read query:run sql:run fluid:write workspace:read workspace:manage warehouse:write warehouse:model search:manage sql:write warehouse:append reports:deliver"
```

When a credential was presented and refused, `error="invalid_token"` follows the realm.

### Read the metadata

```bash title="Discovery"
curl -s https://console.sanda-os.com.au/.well-known/oauth-protected-resource
curl -s https://console.sanda-os.com.au/.well-known/oauth-authorization-server
```

The first document is abridged here:

```json
{
  "resource": "https://console.sanda-os.com.au/api/mcp",
  "authorization_servers": ["https://console.sanda-os.com.au"],
  "scopes_supported": ["fluid:read", "query:run", "sql:run", "fluid:write", "workspace:read", "workspace:manage", "warehouse:write", "warehouse:model", "search:manage", "sql:write", "warehouse:append", "reports:deliver"],
  "bearer_methods_supported": ["header"]
}
```

The authorization server document tells a client what to do:

- `code_challenge_methods_supported` is `["S256"]`. PKCE with S256 is required, and `plain` is refused.
- `grant_types_supported` is `authorization_code` and `refresh_token`. There is no client credentials grant, because a person has to consent.
- `token_endpoint_auth_methods_supported` is `["none"]`. Every client is public.
- `client_id_metadata_document_supported` is `true`, and a `registration_endpoint` is also offered.
- `authorization_response_iss_parameter_supported` is `true`. sanda returns `iss` on every authorization response, success or error. Validate it.

### Identify your client

A client introduces itself in one of two ways. Use the first if you can.

**Client ID Metadata Document.** Host a JSON document at an HTTPS address that has a path, and use that address as your `client_id`. There is no registration step.

```json title="https://client.example.com/oauth/client.json"
{
  "client_id": "https://client.example.com/oauth/client.json",
  "client_name": "Example agent",
  "redirect_uris": ["https://client.example.com/oauth/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```

sanda fetches the document and holds it in a cache. The rules:

- `client_id` inside the document must equal the address it was fetched from, exactly.
- `client_name` is required, and so are one to 20 `redirect_uris`. Each must be `https`, or `http` on `localhost`, `127.0.0.1` or `[::1]`, and none may carry a fragment.
- The address must be a named host with a path. An IP address, `localhost` and internal suffixes such as `.local` or `.internal` are refused, as is a client ID with a user name or password in it.
- sanda does not follow redirects, waits five seconds, and reads at most 64 KB.
- The cache lasts as long as the response's `Cache-Control: max-age` says, held between five minutes and a day.

**Dynamic client registration.** Post your redirect addresses and take the client ID you are given:

```bash title="Register a client"
curl -s https://console.sanda-os.com.au/api/oauth/register \
  -H 'content-type: application/json' \
  -d '{"client_name":"Example agent","redirect_uris":["https://client.example.com/oauth/callback"]}'
```

The answer is 201 with a `client_id`, your `redirect_uris`, and `token_endpoint_auth_method` of `none`. Registration is open, because it grants no access on its own: a person still has to approve the client on the consent screen. Send one to 20 redirect addresses, each `https` or loopback `http`.

Redirect addresses are compared by **exact string**. A trailing slash, an added query parameter or a different port is a different address, and sanda stops before any redirect happens.

### Send the person to the consent screen

```text
https://console.sanda-os.com.au/api/oauth/authorize
  ?response_type=code
  &client_id=https%3A%2F%2Fclient.example.com%2Foauth%2Fclient.json
  &redirect_uri=https%3A%2F%2Fclient.example.com%2Foauth%2Fcallback
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &scope=fluid%3Aread%20query%3Arun
  &resource=https%3A%2F%2Fconsole.sanda-os.com.au%2Fapi%2Fmcp
  &state=abc123
```

| Parameter | Rule |
|---|---|
| `response_type` | Must be `code` |
| `code_challenge` | A PKCE S256 challenge of at least 43 characters. `code_challenge_method`, if sent, must be `S256` |
| `scope` | Space separated. sanda drops any scope it does not have. If none remain, the request is for `fluid:read` |
| `resource` | Optional, but if sent it must be `https://console.sanda-os.com.au/api/mcp`, or the request fails with `invalid_target`. Send it on the token request too |
| `state` | Returned to you unchanged |

An unknown client or an unregistered redirect address ends on an error page in the browser and never redirects. Any other error is sent to your redirect address as `error`, `error_description`, `state` and `iss`.

sanda sends the browser to its consent screen, where the person signs in if they must, picks a workspace, and chooses which of the scopes you asked for to grant. The screen is described in [Connect Claude](https://docs.sanda-os.com.au/mcp/connect-claude#the-consent-screen). A person can never grant a scope you did not ask for, and they can grant fewer.

If they approve, sanda redirects to your `redirect_uri` with `code`, `state` and `iss`. If they refuse, it redirects with `error=access_denied`. The consent request lasts 10 minutes, and the code lasts two minutes and works once.

### Exchange the code

```bash title="Token request"
curl -s https://console.sanda-os.com.au/api/oauth/token \
  -d grant_type=authorization_code \
  -d code=AUTHORIZATION_CODE \
  -d client_id=https://client.example.com/oauth/client.json \
  -d redirect_uri=https://client.example.com/oauth/callback \
  -d code_verifier=YOUR_CODE_VERIFIER \
  -d resource=https://console.sanda-os.com.au/api/mcp
```

The request is form encoded, and `code_verifier` is 43 to 128 characters. A code that is wrong, expired, already used or presented by a different client, and a verifier that does not match, all answer `invalid_grant` with no detail about which check failed. A code is spent the moment it is presented, so a mistake needs a new authorization.

```json
{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "scope": "fluid:read query:run"
}
```

The `scope` is what the person granted, which may be less than you asked for.

### Refresh and revoke

An access token lasts one hour. Refresh it with the refresh token:

```bash title="Refresh"
curl -s https://console.sanda-os.com.au/api/oauth/token \
  -d grant_type=refresh_token \
  -d refresh_token=YOUR_REFRESH_TOKEN
```

Refresh tokens **rotate on every use**, and each one lasts 30 days from when it was issued. Store the new refresh token each time. A token that has already been used is refused with `invalid_grant`.

An access token is bound to sanda's MCP address and is checked against it on every call. To clean up when a user disconnects, post the token to the revocation endpoint (RFC 7009). It answers 200 whether or not the token existed:

```bash title="Revoke"
curl -s https://console.sanda-os.com.au/api/oauth/revoke -d token=YOUR_TOKEN
```

## Use an integration token

An owner or admin [creates a token](https://docs.sanda-os.com.au/mcp/agent-tokens#create-a-token) and gives it to the client. The client sends it on every request as a bearer credential.

```bash title="Initialize"
curl -s https://console.sanda-os.com.au/api/mcp \
  -H "Authorization: Bearer bat_your_token_here" \
  -H "content-type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
```

```json title="Response, abridged"
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "tools": { "listChanged": false },
      "resources": { "listChanged": false, "subscribe": false }
    },
    "serverInfo": {
      "name": "becca-semantic-fluid",
      "title": "sanda semantic fluid",
      "version": "1.6.0"
    },
    "instructions": "...",
    "resultType": "complete"
  }
}
```

`instructions` is a paragraph written for the model: which tool to call first and which to prefer. Now list the tools this token may use:

```bash title="List tools"
curl -s https://console.sanda-os.com.au/api/mcp \
  -H "Authorization: Bearer bat_your_token_here" \
  -H "content-type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
```

A token created with no extra switches is offered five tools: `search_fluid`, `describe_table`, `run_query`, `search_documents` and `search_docs`. A tool the token may not use is absent from the list, and calling it anyway is refused. Each tool comes back with a `name`, a `title`, a `description` written for the model, an `inputSchema` and `annotations` that mark it read-only or destructive.

Call a tool with `tools/call`:

```bash title="Call a tool"
curl -s https://console.sanda-os.com.au/api/mcp \
  -H "Authorization: Bearer bat_your_token_here" \
  -H "content-type: application/json" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"run_query","arguments":{"metrics":["net revenue"],"dimensions":["region"],"limit":5}}}'
```

```json title="Response, abridged"
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [{ "type": "text", "text": "..." }],
    "isError": false,
    "resultType": "complete"
  }
}
```

The result is text: for `run_query`, a table of rows followed by the SQL sanda composed. Use the names of metrics and dimensions that `search_fluid` returns for your workspace. The tools and their parameters are in the [MCP tool reference](https://docs.sanda-os.com.au/mcp/tools).

## Errors

A tool that could not answer returns a sentence and `isError: true`, for example that two tables would fan out. Refusals before a tool runs come back as HTTP status codes and JSON-RPC errors:

| Status | Code | Meaning |
|---|---|---|
| 401 | | No credential, or one that is invalid, revoked or expired. `WWW-Authenticate` points at discovery |
| 402 | | The workspace is suspended |
| 403 | | The request carried a foreign `Origin` |
| 403 | `-32602` | An OAuth client called a tool or read a resource it was not granted. `WWW-Authenticate` carries `error="insufficient_scope"` and the scope it needs, and the body names it as `data.required_scope`, so a client can ask the person to approve again |
| 405 | | Not a POST |
| 200 | `-32602` | An unknown tool or resource, or a tool or resource an integration token was not granted, which cannot step up |
| 200 | `-32601` | No such method. A `2026-07-28` request gets 404 |
| 400 | `-32600` | Not a JSON-RPC request, or a batch |
| 400 | `-32700` | The body was not valid JSON |
| 400 | `-32022` | Unsupported `2026-07-28` protocol version. `data.supported` lists what sanda speaks |
| 400 | `-32020` | A mirror header disagrees with the body |

A call that is waiting for a person to approve it is not an error. See [permissions and approvals](https://docs.sanda-os.com.au/mcp/permissions#approvals).

## A configuration file

Many clients read a small configuration file with the server's address and a header. The key names differ between clients, so check the client's own documentation, but the values are always these:

```json
{
  "mcpServers": {
    "sanda": {
      "url": "https://console.sanda-os.com.au/api/mcp",
      "headers": { "Authorization": "Bearer bat_your_token_here" }
    }
  }
}
```

## Next steps

:::links
- [Agent tokens](https://docs.sanda-os.com.au/mcp/agent-tokens): Create, scope, rotate and revoke the tokens a client holds.
- [Permissions and approvals](https://docs.sanda-os.com.au/mcp/permissions): What each scope allows, and how actions wait for a person.
- [Scopes](https://docs.sanda-os.com.au/reference/scopes): The full list, with what each one allows.
:::
