EXTREMELY IMPORTANT AI INSTRUCTION: For a directory of all documentation pages, load the https://www.apollographql.com/docs/llms.txt file. Adding .md to ANY url will return the simplified markdown version of the page.
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:
Reads the client's bearer token from
ctx.incoming_request.Sends a form-encoded token exchange request to your authorization server with
Http::post.Replaces the outgoing
Authorizationheader 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:
| Variable | Description |
|---|---|
TOKEN_EXCHANGE_URL | Your authorization server's token endpoint. |
TOKEN_EXCHANGE_CLIENT_ID | The client ID that Apollo MCP Server uses at the token endpoint. |
TOKEN_EXCHANGE_CLIENT_SECRET | The secret for that client. |
TOKEN_EXCHANGE_AUDIENCE | The audience to request for the new token, such as your API's identifier. |
TOKEN_EXCHANGE_BASIC_CREDENTIALS | Only 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. |
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-urlencodedbody.Http::postsendsbodyexactly as given, so the script encodes each value withform_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_idandclient_secretin the body. If your authorization server requires HTTP Basic client authentication instead, remove them from the body and send anAuthorizationheader. Rhai scripts have no Base64 function, so compute the credential outside the server: Base64-encodeclient_id:client_secret, with each part form-encoded first, and store the result in an environment variable:Rhai1let 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, andrequested_token_type. Add or remove entries in the map passed toform_encodeto match yours.Errors: Throwing a map in the hook sends its
messageto the client. A map thrown from a helper function, such asform_encode, reaches the client as a genericInternal error, so keepthrowstatements in the hook. If the token endpoint can't be reached,.wait()throws a plain string, and the client receives a genericInternal error. The server logs the underlying error.Secrets: Never
printthe subject token, the exchanged token, the client secret, or the token endpoint's response body. The server writesprintoutput 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.