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 as a bearer credential.
For Claude, see Connect Claude. This page covers everything else.
The endpoint
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
Originheader 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:
https://console.sanda-os.com.au/api/mcpIf 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. 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/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
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-serverThe first document is abridged here:
{
"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_supportedis["S256"]. PKCE with S256 is required, andplainis refused.grant_types_supportedisauthorization_codeandrefresh_token. There is no client credentials grant, because a person has to consent.token_endpoint_auth_methods_supportedis["none"]. Every client is public.client_id_metadata_document_supportedistrue, and aregistration_endpointis also offered.authorization_response_iss_parameter_supportedistrue. sanda returnsisson 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.
{
"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_idinside the document must equal the address it was fetched from, exactly.client_nameis required, and so are one to 20redirect_uris. Each must behttps, orhttponlocalhost,127.0.0.1or[::1], and none may carry a fragment.- The address must be a named host with a path. An IP address,
localhostand internal suffixes such as.localor.internalare 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-agesays, held between five minutes and a day.
Dynamic client registration. Post your redirect addresses and take the client ID you are given:
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
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. 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
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/mcpThe 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.
{
"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:
curl -s https://console.sanda-os.com.au/api/oauth/token \
-d grant_type=refresh_token \
-d refresh_token=YOUR_REFRESH_TOKENRefresh 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:
curl -s https://console.sanda-os.com.au/api/oauth/revoke -d token=YOUR_TOKENUse an integration token
An owner or admin creates a token and gives it to the client. The client sends it on every request as a bearer credential.
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"}}}'{
"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:
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:
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}}}'{
"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.
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.
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:
{
"mcpServers": {
"sanda": {
"url": "https://console.sanda-os.com.au/api/mcp",
"headers": { "Authorization": "Bearer bat_your_token_here" }
}
}
}Next steps
Something unclear or out of date? Tell us, and we will fix the page.