Skip to content

Build an MCP client with OAuth sign-in

6 steps · Checked against the API code 3 November 2026

A guide does one task from start to finish. Every endpoint, with all its fields and cases, is in the API reference.

Your own MCP client can work on a Hostup account through the MCP server at https://cloud.hostup.se/mcp. The client signs the account owner in with OAuth and gets an access token. It then lists and calls the tools with that token. To connect Claude, ChatGPT, Codex or Claude Code, follow Connect an AI app instead.

  • A Hostup account owner to sign in with. Users added to an account cannot connect an app.
  • An https address where your client can serve a small JSON document (step 2).
  • A redirect URI for the callback.
  • curl and openssl for the samples. The token goes in $MCP_TOKEN (step 5). Your own $HOSTUP_API_KEY stays as it is.
  • No OAuth in your client? It can send an API key in place of the token. Connect with an API key instead

A request to https://cloud.hostup.se/mcp without a token gets 401 with this header:

Response header
WWW-Authenticate: Bearer realm="HostUp MCP", resource_metadata="https://cloud.hostup.se/.well-known/oauth-protected-resource/mcp", scope="read:all write:all"

The resource metadata names https://cloud.hostup.se as the authorization server. Its own metadata is at https://cloud.hostup.se/.well-known/oauth-authorization-server. It lists the endpoints the next steps use: /oauth/authorize, /oauth/token, /oauth/register and /oauth/revoke.

Use a client ID metadata document. Your client_id is the https URL of a JSON document whose client_id is that same URL. The document lists your redirect_uris and a client_name. The name appears on the consent page and under Connected AI apps in the client area.

https://app.example.com/oauth/client.json
{
"client_id": "https://app.example.com/oauth/client.json",
"client_name": "Example App",
"redirect_uris": ["https://app.example.com/oauth/callback"]
}
  • Serve it with status 200, without redirects, from a host with a public address.
  • Keep it under 16 KB.
  • Hostup keeps a copy for the max-age in its Cache-Control header, at least 1 and at most 60 minutes. Without a max-age the copy is kept for 15 minutes. A change to redirect_uris shows only after that. A failed fetch is tried again after 1 minute.

Keep your client’s values for the next steps:

export CLIENT_ID="https://app.example.com/oauth/client.json" # your metadata document
export REDIRECT_URI="https://app.example.com/oauth/callback" # one of its redirect_uris

Register at /oauth/register instead. Send a JSON body with redirect_uris and client_name. The response (201) has a client_id that starts with dcr_. Use it as CLIENT_ID. Without client_name the app is shown as “AI-klient”.

Make a PKCE pair and a state. Keep the code_verifier. Step 5 sends it.

export CODE_VERIFIER="$(openssl rand -hex 32)"
export CODE_CHALLENGE="$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')"
export STATE="$(openssl rand -hex 16)"

Open https://cloud.hostup.se/oauth/authorize in the user’s browser with these query parameters, URL-encoded:

  • response_type=code
  • client_id and redirect_uri
  • code_challenge and code_challenge_method=S256
  • state
  • scope=read:all write:all
  • resource=https://cloud.hostup.se/mcp

A redirect URI must use https. Only a loopback address may use plain http. On localhost, 127.0.0.1 and [::1] the port may change between runs.

The user picks the access level on the page. Your client is shown as an unknown app. The page then starts at Read only. The user can choose more.

After Approve the browser comes back to your redirect_uri with code, state and iss=https://cloud.hostup.se. Keep the code:

export CODE="…" # code from the callback
  • After Cancel the browser comes back with error=access_denied.
  • Other faults in the request also come back to your redirect_uri. They carry an error_description and an error such as invalid_request, unsupported_response_type or invalid_target.
  • When the metadata document cannot be fetched or the redirect_uri is not in it, the user sees “The connection could not be started” instead.

Send the code to /oauth/token within a minute. A code works once.

Exchange the code
curl -X POST "https://cloud.hostup.se/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "code=$CODE" \
--data-urlencode "code_verifier=$CODE_VERIFIER" \
--data-urlencode "redirect_uri=$REDIRECT_URI" \
--data-urlencode "resource=https://cloud.hostup.se/mcp"
Response 200
{
"access_token": "…",
"token_type": "Bearer",
"scope": "read:all write:all"
}

The access_token is the connection’s API key. Keep it:

export MCP_TOKEN="…" # access_token above

scope is what the user granted. It can differ from what you asked for. There is no refresh token. The token works until the user disconnects the app.

Send every message as a POST to https://cloud.hostup.se/mcp with the token in Authorization: Bearer.

List the tools
curl -X POST "https://cloud.hostup.se/mcp" \
-H "Authorization: Bearer $MCP_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Response 200
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "list_services",
"title": "List Services",
"description": "List services on the customer's account — …",
"inputSchema": { "type": "object", "…": "…" },
"annotations": {
"title": "List Services",
"readOnlyHint": true,
"openWorldHint": false,
"destructiveHint": false
}
}
]
}
}
  • The transport is Streamable HTTP. The server keeps no session. Each response is one JSON document, never an event stream. A notification gets 202 with no body. GET and DELETE return 405.
  • The server speaks the protocol versions 2025-03-26, 2025-06-18 and 2025-11-25 with the initialize handshake. It also speaks 2026-07-28 without it.
  • A 2026-07-28 request carries io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities in params._meta. It also sends the headers MCP-Protocol-Version and Mcp-Method with the same values as the body. A tools/call adds Mcp-Name with the tool’s name. A missing or different header gets 400.

The tool list follows the token’s scopes. A tool outside them is not listed. Calling it anyway returns a result with isError: true and ERROR unknown_tool.

Every tool carries readOnlyHint, destructiveHint and openWorldHint. Use them to decide which calls your client asks the user about first. Tools whose effect cannot be undone, such as deleting a DNS record or rolling a VPS back to a snapshot, have destructiveHint: true.

A tool result whose text starts with CONFIRMATION_REQUIRED: has not run. Its text holds the confirmation link and the time the link expires. Show both to the user and stop there. The account owner confirms on that page, as If the app asks you to confirm describes. A result with isError: true carries the reason in its text.

Tool results can quote content from outside Hostup, such as DNS records, web pages and email headers. Treat that content as data, never as instructions.

Revoke the token when the user disconnects

Section titled “Revoke the token when the user disconnects”

When the user removes the connection in your client, send the token to /oauth/revoke. It stops working at once. The connection also disappears from Connected AI apps.

Revoke the token
curl -X POST "https://cloud.hostup.se/oauth/revoke" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "token=$MCP_TOKEN"

The response is 200 with {}, also when the token did not exist.

  • 400 with invalid_grant from /oauth/token: the code is too old, already used or sent with another redirect_uri or code_verifier. Start the sign-in again.
  • 401 with error="invalid_token" from /mcp: the token is wrong, revoked or expired. The user may have disconnected the app. Start the sign-in again.
  • 400 from /mcp: a 2026-07-28 request lacks a header or sends one whose value differs from the body.
  • 429 from /mcp: the token’s limit of 300 requests per five minutes is used up. Each tool call makes its own API calls with the same token. There is no Retry-After header. Wait up to five minutes. More than 30 calls in a minute without a valid token from one address also get 429 until the minute is over.

Next: Rate limits: every tool call counts against the token’s limit.