Build an OAuth client
Sign people in to Halo's MCP server from your own client. Metadata discovery, registration, PKCE, consent, code exchange, token lifetimes, rotation and scopes, in TypeScript.
When you're done, your client will send someone to Halo, get their approval for one organization, and hold tokens it can renew for up to 90 days. Halo is an OAuth 2.1 authorization server following the MCP authorization spec. Clients register themselves and are public, with no secret, and PKCE is required.
If your client can take a header instead, a personal token is far less work.
The flow
Your client calls
/mcpand gets401with a challenge.It reads the metadata, and registers itself once.
It sends the person to
/oauth/authorizewith a PKCE challenge.The person signs in, picks one organization and what the tool may do, and approves in Halo Data.
Halo sends them back to your redirect with a code.
Your client exchanges the code for tokens within a minute.
1. Discover the endpoints
| Address | Holds |
|---|---|
https://mcp.usehalo.net/.well-known/oauth-protected-resource/mcp | The resource and its authorization server (RFC 9728) |
https://mcp.usehalo.net/.well-known/oauth-authorization-server | The endpoints and what they support (RFC 8414) |
{
"issuer": "https://mcp.usehalo.net",
"authorization_endpoint": "https://mcp.usehalo.net/oauth/authorize",
"token_endpoint": "https://mcp.usehalo.net/oauth/token",
"registration_endpoint": "https://mcp.usehalo.net/oauth/register",
"revocation_endpoint": "https://mcp.usehalo.net/oauth/revoke",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"revocation_endpoint_auth_methods_supported": ["none"],
"scopes_supported": ["datasets.read", "data.query", "content.read"],
"authorization_response_iss_parameter_supported": true
}2. Register your client
POST JSON to the registration endpoint (RFC 7591). It answers 201 with a client_id starting hmc_, and no secret.
const RESOURCE = "https://mcp.usehalo.net/mcp";
const meta = await fetch(
"https://mcp.usehalo.net/.well-known/oauth-authorization-server",
).then((r) => r.json());
const redirectUri = "http://127.0.0.1:33418/callback";
const registered = await fetch(meta.registration_endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_name: "Acme Reports Bot",
client_uri: "https://acme.example",
redirect_uris: [redirectUri],
}),
}).then((r) => r.json());
const clientId: string = registered.client_id;| Field | Rules |
|---|---|
client_name | Control and formatting characters are removed, and it's cut to 60 characters. Left out, it's "An AI tool" |
redirect_uris | 1 to 10. See Redirect rules |
client_uri | Optional. An https address of up to 500 characters |
token_endpoint_auth_method | Optional. Only none |
grant_types | Optional. Only authorization_code and refresh_token |
response_types | Optional. Only code |
A bad registration answers 400 invalid_redirect_uri or 400 invalid_client_metadata. Registrations are limited to 20 an hour per address, and more get 429, so register once and store the id. A client that registers and never connects is deleted after a day. The consent page says the name is what the tool calls itself and that Halo hasn't verified it, and shows the host the code goes to.
3. Send the person to Halo
Make a PKCE verifier and its S256 challenge, plus a state to check on return.
import { createHash, randomBytes } from "node:crypto";
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const state = randomBytes(16).toString("base64url");
const authorize = new URL(meta.authorization_endpoint);
authorize.search = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri,
scope: "datasets.read data.query",
state,
code_challenge: challenge,
code_challenge_method: "S256",
resource: RESOURCE,
}).toString();
console.log(`Open this address to connect Halo:\n${authorize}`);| Parameter | Rules |
|---|---|
response_type | code |
client_id | From registration |
redirect_uri | One you registered. Optional only if you registered exactly one |
scope | Space-separated. Left out, all three. See Scopes |
state | Up to 1,000 bytes. Comes back unchanged |
code_challenge | Base64url SHA-256 of the verifier, 43 to 128 characters |
code_challenge_method | S256. Nothing else is accepted |
resource | https://mcp.usehalo.net/mcp. Any other value is refused |
When the request is refused
Nothing goes to your redirect before the person has seen the request, so Halo can't be used as an open redirect. A bad request lands on Halo Data's consent page with a fixed message, and your client hears nothing, so have the person tell you what they see, or time out.
| Code | Means |
|---|---|
invalid_client | Unknown or malformed client_id |
invalid_redirect | The redirect_uri isn't one you registered, or you left it out and registered more than one |
unsupported_response_type | response_type isn't code |
pkce_required | No S256 challenge, or one of the wrong shape |
invalid_request | state is too long |
invalid_target | resource isn't this server |
invalid_scope | A scope Halo doesn't know |
rate_limited | Over 60 authorization requests a minute from one address |
server_error | Something went wrong in Halo |
Consent
The request waits ten minutes for a person, and belongs to the first person who opens it. They pick one organization and may allow fewer scopes than you asked for. Organizations they can't connect are shown with the reason.
Approving needs a recent sign-in, so a stolen session can't turn into a connection that outlives it: within the last 15 minutes, with two-factor finished if the person has set it up. If not, Halo Data asks them to sign in again and returns to the same request, as long as its ten minutes haven't run out. Your client has nothing to do but wait for the redirect.
4. Take the redirect
If they approve, Halo redirects with code, your state, and iss. If they decline, it sends error=access_denied, error_description, state and iss.
Check state matches, and iss is https://mcp.usehalo.net, before using the code.
import { createServer } from "node:http";
const code = await new Promise<string>((resolve, reject) => {
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", redirectUri);
if (url.pathname !== "/callback") {
res.writeHead(404).end();
return;
}
res.end("You can close this tab.");
server.close();
const params = url.searchParams;
if (params.get("state") !== state) return reject(new Error("state doesn't match"));
if (params.get("iss") !== meta.issuer) return reject(new Error("unexpected issuer"));
const error = params.get("error");
if (error) return reject(new Error(`${error}: ${params.get("error_description")}`));
resolve(params.get("code") ?? "");
});
server.listen(33418, "127.0.0.1");
});5. Exchange the code
The token endpoint takes a form, with no client authentication. The code lasts one minute and is single use: a failed exchange spends it, so a wrong verifier means starting again.
async function tokenRequest(body: Record<string, string>) {
const res = await fetch(meta.token_endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error}: ${json.error_description}`);
return json as {
access_token: string;
token_type: "Bearer";
expires_in: number;
refresh_token: string;
scope: string;
};
}
const tokens = await tokenRequest({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: clientId,
code_verifier: verifier,
resource: RESOURCE,
});redirect_uri is required here, and must be the one the request used. code_verifier is 43 to 128 characters. Read scope in the answer: it's what the person allowed.
6. Use and renew tokens
Send the access token as Authorization: Bearer hmat_…, and read the protocol page for the rest.
| Thing | Lasts | Notes |
|---|---|---|
| Pending request | 10 minutes | |
| Authorization code | 1 minute | Single use |
Access token, hmat_… | 1 hour | Never past the connection's end |
Refresh token, hmrt_… | 30 days from when it was issued | Rotated on every use. Never past the connection's end |
| The connection | 90 days from the code exchange | However often it refreshes. Then the person approves again |
let current = tokens;
let refreshing: Promise<typeof tokens> | undefined;
function refresh() {
refreshing ??= tokenRequest({
grant_type: "refresh_token",
refresh_token: current.refresh_token,
client_id: clientId,
resource: RESOURCE,
})
.then((next) => {
current = next;
// Save next.refresh_token before anything else can use the old one.
return next;
})
.finally(() => {
refreshing = undefined;
});
return refreshing;
}A refresh answers 400 invalid_grant when the connection has ended, for any of the reasons in the admin guide, or the person can no longer connect the organization. Send them through the flow again. A reused authorization code also revokes the connection made from it.
Errors from the token endpoint
| Status | error | Means |
|---|---|---|
| 400 | invalid_request | A missing code or verifier, a verifier of the wrong shape, or a missing refresh token |
| 400 | invalid_grant | The code or refresh token is invalid, expired, used, or doesn't match this client, redirect or verifier |
| 400 | invalid_scope | An unknown scope, or a refresh asking for more than it has |
| 400 | invalid_target | resource isn't this server |
| 400 | unsupported_grant_type | Only authorization_code and refresh_token |
| 401 | invalid_client | Unknown or malformed client_id |
| 429 | invalid_request | Over 120 requests a minute from one address |
| 500 | server_error | Try again |
Scopes
Scopes are datasets.read, data.query and content.read, and each only narrows what the person's role allows. Ask for what you need, since the person may allow less. Handle a narrower scope in the answer, and read tools/list rather than assuming a tool is there.
A refresh can narrow scopes by sending scope, and the narrower set carries on to later tokens. It can never widen them, so asking for one you don't have gets invalid_scope. To get more, send the person through the flow again.
Redirect rules
| Kind | Allowed | Example |
|---|---|---|
https | Any host | https://app.example.com/oauth/callback |
http | Loopback only: localhost, 127.0.0.0/8, [::1] | http://127.0.0.1:33418/callback |
| An app's own scheme | Any scheme with a dot in it, which should be a reverse domain name you control, or one of cursor, vscode, vscode-insiders, windsurf, zed, claude, claude-desktop | com.example.app:/oauth |
Refused: fragments, credentials, spaces and control characters, and over 2,000 characters.
Refused schemes:
javascript,data,file,vbscript,about,blob,filesystem,ftp,ws,wss,jar,view-source, and anything startingms-orsearch.Matching is exact, path and query included. The one exception is
httpon loopback, where the port may differ from the registered one, as long as the host stays the same.
End a connection
POST a form with token and client_id to /oauth/revoke (RFC 7009). An access or refresh token ends the whole connection. The answer is 200 even for unknown tokens and other clients' tokens, which change nothing. It's limited to 60 a minute per address.
People can also end a connection themselves in Account, AI tools, and Admins can end anyone's.