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.
Lifecycle Hooks
Hook into the Apollo MCP Server request lifecycle with Rhai
Lifecycle hooks enable your Rhai scripts run at specific points during request processing. To use a hook, define a function in rhai/main.rhai with the hook's name. The server calls that function automatically when it reaches the lifecycle event.
If a hook function isn't defined, the server skips it.
Apollo MCP Server currently provides one hook, on_execute_graphql_operation. It runs once per tool call, before the server sends the tool's GraphQL request to your endpoint, and it can change the request's endpoint and headers. No hook runs after the response arrives. For more information, go to What the hook can't do.
on_execute_graphql_operation
This hook is called before every outgoing GraphQL HTTP request. Use this hook to inspect or modify the request endpoint and headers before they're sent to your GraphQL API.
1fn on_execute_graphql_operation(ctx) {
2 // ctx gives you access to the outgoing request
3}When the hook runs
The server calls the hook once per tool call, before it validates the operation and sends it to your endpoint. If validation fails, the hook has already run, but no request is sent.
| Tool | Hook runs |
|---|---|
| Operation tools | Once per tool call. |
execute | Once per tool call. |
| MCP Apps tools | Once per tool call. Prefetch operations reuse the endpoint and headers that the hook returned. |
introspect, search, validate, explorer | Never. These tools don't send requests to your endpoint. |
Hooks for concurrent tool calls run in parallel. A hook that waits on a slow call, such as an Http request, delays only its own tool call.
Context object
The ctx parameter provides these properties:
| Property | Type | Access | Description |
|---|---|---|---|
endpoint | String | read/write | The URL of the GraphQL endpoint for the request. |
headers | HeaderMap | read/write | The HTTP headers for the request. |
incoming_request | HttpParts | read-only | The original HTTP request received by the MCP server. Only available when using HTTP transport. |
tool_name | String | read-only | The name of the MCP tool that triggered this GraphQL operation. |
trace_id | String | read-only | The current OpenTelemetry trace ID as a 32-character hex string, or an empty string when no trace context is active. |
Working with headers
Headers use index syntax for reading and writing:
1fn on_execute_graphql_operation(ctx) {
2 // Read a header value (returns empty string if not present)
3 let auth = ctx.headers["authorization"];
4
5 // Set a header
6 ctx.headers["x-request-id"] = "abc-123";
7}A hook can add or replace headers, but it can't remove them.
Header order
ctx.headers starts with the headers the server has already assembled for the request. The server builds the final headers in this order, and a later step replaces a header with the same name from an earlier step:
Static
headersfrom the config fileHeaders copied from the client request by
forward_headersThe client's validated OAuth token as
Authorization, when auth is configured anddisable_auth_token_passthroughisn't setThe client's
mcp-session-idheader, when the client sends oneChanges made by
on_execute_graphql_operationHeaders saved with the operation itself, such as headers saved with an operation in a GraphOS operation collection
With the stdio transport, there's no client request, so steps 2 through 4 don't apply and ctx.headers starts with only the static headers.
Authorization header always sends that value, whatever the hook sets. Headers with other names that the hook sets still reach your endpoint. The execute tool has no saved headers, so the hook's headers always apply to it.The incoming_request object
When the MCP server uses HTTP transport (streamable_http), ctx.incoming_request gives you read-only access to the original request that the MCP client sent to the server.
| Property | Type | Description |
|---|---|---|
method | String | The HTTP method (for example, "POST"). |
uri | String | The request URI path (for example, "/mcp"). |
headers | HeaderMap | The HTTP headers from the incoming request. |
stdio transport, incoming_request is empty because there is no HTTP request.Example: Copy a header to a different name
Read a header from the incoming request and write it to the outgoing request under a different name:
1fn on_execute_graphql_operation(ctx) {
2 let token = ctx.incoming_request.headers["authorization"];
3
4 if token != "" {
5 ctx.headers["x-forwarded-auth"] = token;
6 }
7}Example: Route to a different endpoint
Change the target GraphQL endpoint based on request properties:
1fn on_execute_graphql_operation(ctx) {
2 let env = Env::get("GRAPHQL_REGION");
3
4 if env == "eu" {
5 ctx.endpoint = "https://eu.api.example.com/graphql";
6 }
7}Example: Emit structured logs with the current trace ID
Use ctx.trace_id to include the current OpenTelemetry trace ID as a discrete field in your structured log lines so log aggregation platforms can correlate logs with distributed traces:
1fn on_execute_graphql_operation(ctx) {
2 print(`[MCP Request] trace_id=${ctx.trace_id} tool=${ctx.tool_name}`);
3}ctx.trace_id is a 32-character lowercase hex string when an OpenTelemetry trace context is active, and an empty string when the hook runs without an active span. The same trace ID is also prefixed onto server log lines, so you can correlate values emitted from Rhai with the rest of the server's output.
Token exchange
To swap the client's token for one scoped to your GraphQL API before each request, see Token exchange with Rhai.
What the hook can't do
The hook sees and changes only the request's endpoint and headers. It runs before the request is sent and never sees the response.
Validate tool arguments: The hook can't read the operation, its variables, or the tool's arguments. It can reject a request based on headers,
tool_name, orincoming_requestby throwing an error.Redact or reshape the response: The hook has no access to the response. The
@privatedirective hides fields from the content the model sees, but the full response still reaches the MCP client in the result's metadata. To keep a field from leaving the server, remove it from the operation.Chain queries: A hook can call another API with
Http, but it can't add the result to the tool's response.Keep state between requests: Writes to top-level variables are discarded when the hook returns. For more information, go to Global state.
Error handling with throw
Use throw inside a hook to abort the current request and return an error to the MCP client. Throw a map with message and code fields for a structured error response:
1fn on_execute_graphql_operation(ctx) {
2 let token = ctx.incoming_request.headers["authorization"];
3
4 if token == "" {
5 throw #{
6 message: "Missing authorization header",
7 code: ErrorCode::INVALID_REQUEST
8 };
9 }
10}Error codes
| Constant | Description |
|---|---|
ErrorCode::INVALID_REQUEST | The request is invalid. Use this for client errors like missing headers or bad input. |
ErrorCode::INTERNAL_ERROR | An internal server error. This is the default if no code is provided. |
Throw behavior
Throwing a map with
messageandcodereturns a structured error to the client with those values.Throwing a map without a
messagefield defaults the message to"Internal error".Throwing a non-map value (for example, a plain string) returns a generic internal error. The thrown value is logged server-side but not sent to the client.
Throwing a map from a function that the hook calls, including a function in an imported module, returns a generic internal error. Only a map thrown in the hook's own body reaches the client.
Errors thrown by built-in functions, such as
.wait()on a failedHttprequest, are plain strings and return a generic internal error.
message field directly in the hook. A helper can return a value that the hook checks before it throws.Global state
Variables defined at the top level of main.rhai are initialized when the server loads the script, and again after each successful reload. Hooks read the values captured at load time. This is useful for values that don't change between requests, like environment-based configuration:
1let backend_url = Env::get("BACKEND_URL");
2let api_key = Env::get("API_KEY");
3
4fn on_execute_graphql_operation(ctx) {
5 ctx.endpoint = backend_url;
6 ctx.headers["x-api-key"] = api_key;
7}