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

  1. Your client calls /mcp and gets 401 with a challenge.

  2. It reads the metadata, and registers itself once.

  3. It sends the person to /oauth/authorize with a PKCE challenge.

  4. The person signs in, picks one organization and what the tool may do, and approves in Halo Data.

  5. Halo sends them back to your redirect with a code.

  6. Your client exchanges the code for tokens within a minute.

1. Discover the endpoints

AddressHolds
https://mcp.usehalo.net/.well-known/oauth-protected-resource/mcpThe resource and its authorization server (RFC 9728)
https://mcp.usehalo.net/.well-known/oauth-authorization-serverThe 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;
FieldRules
client_nameControl and formatting characters are removed, and it's cut to 60 characters. Left out, it's "An AI tool"
redirect_uris1 to 10. See Redirect rules
client_uriOptional. An https address of up to 500 characters
token_endpoint_auth_methodOptional. Only none
grant_typesOptional. Only authorization_code and refresh_token
response_typesOptional. 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}`);
ParameterRules
response_typecode
client_idFrom registration
redirect_uriOne you registered. Optional only if you registered exactly one
scopeSpace-separated. Left out, all three. See Scopes
stateUp to 1,000 bytes. Comes back unchanged
code_challengeBase64url SHA-256 of the verifier, 43 to 128 characters
code_challenge_methodS256. Nothing else is accepted
resourcehttps://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.

CodeMeans
invalid_clientUnknown or malformed client_id
invalid_redirectThe redirect_uri isn't one you registered, or you left it out and registered more than one
unsupported_response_typeresponse_type isn't code
pkce_requiredNo S256 challenge, or one of the wrong shape
invalid_requeststate is too long
invalid_targetresource isn't this server
invalid_scopeA scope Halo doesn't know
rate_limitedOver 60 authorization requests a minute from one address
server_errorSomething went wrong in Halo

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.

ThingLastsNotes
Pending request10 minutes
Authorization code1 minuteSingle use
Access token, hmat_…1 hourNever past the connection's end
Refresh token, hmrt_…30 days from when it was issuedRotated on every use. Never past the connection's end
The connection90 days from the code exchangeHowever 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

StatuserrorMeans
400invalid_requestA missing code or verifier, a verifier of the wrong shape, or a missing refresh token
400invalid_grantThe code or refresh token is invalid, expired, used, or doesn't match this client, redirect or verifier
400invalid_scopeAn unknown scope, or a refresh asking for more than it has
400invalid_targetresource isn't this server
400unsupported_grant_typeOnly authorization_code and refresh_token
401invalid_clientUnknown or malformed client_id
429invalid_requestOver 120 requests a minute from one address
500server_errorTry 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

KindAllowedExample
httpsAny hosthttps://app.example.com/oauth/callback
httpLoopback only: localhost, 127.0.0.0/8, [::1]http://127.0.0.1:33418/callback
An app's own schemeAny 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-desktopcom.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 starting ms- or search.

  • Matching is exact, path and query included. The one exception is http on 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.