Token Exchange with Rhai

Swap the client's token for one scoped to your GraphQL API


A client often connects to Apollo MCP Server with a token issued for the MCP server, while your GraphQL API expects a token issued for the API. With OAuth 2.0 Token Exchange (RFC 8693), the server can trade the client's token for an API token at your authorization server before each tool call.

Apollo MCP Server doesn't support token exchange natively. This page shows how to implement it in the on_execute_graphql_operation hook.

How it works

For each tool call, the hook:

  1. Reads the client's bearer token from ctx.incoming_request.

  2. Sends a form-encoded token exchange request to your authorization server with Http::post.

  3. Replaces the outgoing Authorization header with the token it receives.

The client's token is only available with the streamable_http transport. Configure auth so the server validates the client's token before your script sends it to the authorization server.

Example script

The script reads its configuration from environment variables:

VariableDescription
TOKEN_EXCHANGE_URLYour authorization server's token endpoint.
TOKEN_EXCHANGE_CLIENT_IDThe client ID that Apollo MCP Server uses at the token endpoint.
TOKEN_EXCHANGE_CLIENT_SECRETThe secret for that client.
TOKEN_EXCHANGE_AUDIENCEThe audience to request for the new token, such as your API's identifier.
TOKEN_EXCHANGE_BASIC_CREDENTIALSOnly for HTTP Basic client authentication: Base64 of client_id:client_secret, with the client ID and secret each form-encoded before they're joined. Used instead of the client ID and secret in the body. For more information, go to Adapting the script.
Rhai
rhai/main.rhai
1// Configuration only. Top-level statements run again before every hook call,
2// so nothing here sends a request.
3let token_url = Env::get("TOKEN_EXCHANGE_URL");
4let client_id = Env::get("TOKEN_EXCHANGE_CLIENT_ID");
5let client_secret = Env::get("TOKEN_EXCHANGE_CLIENT_SECRET");
6let audience = Env::get("TOKEN_EXCHANGE_AUDIENCE");
7
8// Encodes one character as `%XX` for each of its UTF-8 bytes.
9fn percent_encode_char(ch) {
10    let hex = "0123456789ABCDEF";
11    let bytes = ch.to_string().to_blob();
12    let encoded = "";
13
14    // Indexing a blob yields an integer; iterating one yields a `u8`
15    // that Rhai's arithmetic operators don't accept.
16    for i in 0..bytes.len() {
17        let byte = bytes[i];
18        encoded += "%";
19        encoded += hex[byte / 16];
20        encoded += hex[byte % 16];
21    }
22
23    encoded
24}
25
26// Percent-encodes `text` for an `application/x-www-form-urlencoded` body.
27// Unreserved ASCII characters pass through unchanged.
28fn form_escape(text) {
29    let unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
30    let escaped = "";
31
32    for ch in text {
33        if unreserved.contains(ch) {
34            escaped += ch;
35        } else {
36            escaped += percent_encode_char(ch);
37        }
38    }
39
40    escaped
41}
42
43// Builds a form body from a map of string values.
44fn form_encode(params) {
45    let body = "";
46
47    for key in params.keys() {
48        if body != "" {
49            body += "&";
50        }
51        body += form_escape(key);
52        body += "=";
53        body += form_escape(params[key].to_string());
54    }
55
56    body
57}
58
59fn on_execute_graphql_operation(ctx) {
60    let inbound = ctx.incoming_request.headers["authorization"];
61
62    // The auth scheme is case-insensitive, so accept `bearer` as well as `Bearer`.
63    if inbound.sub_string(0, 7).to_lower() != "bearer " {
64        throw #{
65            message: "Missing bearer token",
66            code: ErrorCode::INVALID_REQUEST
67        };
68    }
69
70    let body = form_encode(#{
71        grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
72        subject_token: inbound.sub_string(7),
73        subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
74        requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
75        audience: audience,
76        client_id: client_id,
77        client_secret: client_secret
78    });
79
80    let response = Http::post(token_url, #{
81        headers: #{
82            "content-type": "application/x-www-form-urlencoded",
83            "accept": "application/json"
84        },
85        body: body,
86        timeout: 5
87    }).wait();
88
89    if response.status != 200 {
90        // Log only a standard OAuth error code. An error response can echo back
91        // what the request sent, including the subject token and client secret.
92        let oauth_errors = [
93            "invalid_request", "invalid_client", "invalid_grant", "unauthorized_client",
94            "unsupported_grant_type", "invalid_scope", "invalid_target"
95        ];
96        let error = "other";
97        try {
98            // Throws if the body isn't JSON, for example an HTML error page from a proxy.
99            let code = response.json().error;
100            if oauth_errors.contains(code) {
101                error = code;
102            }
103        } catch {}
104        print(`token exchange failed: status=${response.status} error=${error} trace_id=${ctx.trace_id}`);
105        throw #{
106            message: "Token exchange failed",
107            code: ErrorCode::INTERNAL_ERROR
108        };
109    }
110
111    let token = response.json().access_token;
112
113    if type_of(token) != "string" {
114        throw #{
115            message: "Token exchange response has no access_token",
116            code: ErrorCode::INTERNAL_ERROR
117        };
118    }
119
120    ctx.headers["authorization"] = "Bearer " + token;
121}

Adapting the script

  • Form encoding: RFC 8693 requires an application/x-www-form-urlencoded body. Http::post sends body exactly as given, so the script encodes each value with form_escape. Don't build the body by joining raw strings: a token or secret that contains +, &, =, or % would be sent with a different meaning.

  • Client authentication: The script sends client_id and client_secret in the body. If your authorization server requires HTTP Basic client authentication instead, remove them from the body and send an Authorization header. Rhai scripts have no Base64 function, so compute the credential outside the server: Base64-encode client_id:client_secret, with each part form-encoded first, and store the result in an environment variable:

    Rhai
    1let basic_credentials = Env::get("TOKEN_EXCHANGE_BASIC_CREDENTIALS");
    2
    3// In the hook, in the options passed to `Http::post`:
    4headers: #{
    5    "authorization": "Basic " + basic_credentials,
    6    "content-type": "application/x-www-form-urlencoded",
    7    "accept": "application/json"
    8},
  • Request parameters: Authorization servers differ in which optional parameters they accept, such as audience, resource, scope, and requested_token_type. Add or remove entries in the map passed to form_encode to match yours.

  • Errors: Throwing a map in the hook sends its message to the client. A map thrown from a helper function, such as form_encode, reaches the client as a generic Internal error, so keep throw statements in the hook. If the token endpoint can't be reached, .wait() throws a plain string, and the client receives a generic Internal error. The server logs the underlying error.

  • Secrets: Never print the subject token, the exchanged token, the client secret, or the token endpoint's response body. The server writes print output to its logs, and an error response can echo back what the request sent. The script logs only standard OAuth error codes for this reason.

Limitations

One exchange per tool call

The hook runs once per tool call, so every tool call makes an extra request to your token endpoint. This includes calls that then fail operation validation, because the hook runs before validation. An MCP Apps tool call sends its prefetch operations with the same exchanged token, so they don't trigger more exchanges. A script can't cache the exchanged token between requests: writes to top-level variables are discarded when the hook returns. For more information, go to Global state.

Keep the exchange inside the hook. Top-level statements run again before every hook call, so an exchange placed at the top level would run on every request too, and its result would be discarded.

Hooks for concurrent tool calls run in parallel, so a slow token endpoint delays only the tool call waiting on it. Set timeout to bound that delay.

Headers saved with an operation take precedence

An operation saved with its own Authorization header, such as one saved in a GraphOS operation collection, sends that value instead of the exchanged token. The server records this only in a debug-level log. Remove Authorization from operations that should use the exchanged token. For more information, go to Header order.

Expression complexity

Building a request body in one long expression can fail with Expression exceeds maximum complexity. The example builds the body across multiple statements and moves the inner loop into percent_encode_char to stay within Rhai's nesting limit. For more information, go to Script limits.