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.

caution
Rhai scripts run as trusted server-side code. Hooks can use request-derived data and change outbound request destinations or headers, so restrict and review script changes. For more information, go to Rhai security guidance.

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.

Rhai
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.

ToolHook runs
Operation toolsOnce per tool call.
executeOnce per tool call.
MCP Apps toolsOnce per tool call. Prefetch operations reuse the endpoint and headers that the hook returned.
introspect, search, validate, explorerNever. 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:

PropertyTypeAccessDescription
endpointStringread/writeThe URL of the GraphQL endpoint for the request.
headersHeaderMapread/writeThe HTTP headers for the request.
incoming_requestHttpPartsread-onlyThe original HTTP request received by the MCP server. Only available when using HTTP transport.
tool_nameStringread-onlyThe name of the MCP tool that triggered this GraphQL operation.
trace_idStringread-onlyThe 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:

Rhai
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:

  1. Static headers from the config file

  2. Headers copied from the client request by forward_headers

  3. The client's validated OAuth token as Authorization, when auth is configured and disable_auth_token_passthrough isn't set

  4. The client's mcp-session-id header, when the client sends one

  5. Changes made by on_execute_graphql_operation

  6. Headers 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.

caution
Headers saved with an operation replace hook-set headers that have the same name. The server records this only in a debug-level log, so the hook appears to succeed. For example, an operation saved with its own 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.

PropertyTypeDescription
methodStringThe HTTP method (for example, "POST").
uriStringThe request URI path (for example, "/mcp").
headersHeaderMapThe HTTP headers from the incoming request.
note
When using 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:

Rhai
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:

Rhai
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:

Rhai
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, or incoming_request by throwing an error.

  • Redact or reshape the response: The hook has no access to the response. The @private directive 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:

Rhai
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

ConstantDescription
ErrorCode::INVALID_REQUESTThe request is invalid. Use this for client errors like missing headers or bad input.
ErrorCode::INTERNAL_ERRORAn internal server error. This is the default if no code is provided.

Throw behavior

  • Throwing a map with message and code returns a structured error to the client with those values.

  • Throwing a map without a message field 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 failed Http request, are plain strings and return a generic internal error.

caution
When you throw a non-map value, or throw from a helper function, the actual error message isn't forwarded to the MCP client. To send a specific message, throw a map with a 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:

Rhai
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}
caution
Keep top-level code free of side effects. Rhai re-executes the top-level statements before every hook call, so an HTTP request or any other side effect placed there runs once per request, even though hooks read the values captured at load time. Make HTTP requests inside a hook, never at the top level.Each hook invocation also gets its own copy of these variables so that hooks can run concurrently. A value a hook writes to one is discarded when the hook returns, so top-level variables can't carry state from one request to the next. This includes variables a top-level closure captured: the closure keeps the value from load time and doesn't see writes a hook makes.Hooks for concurrent requests run in parallel, so their side effects interleave in any order. Don't write a hook that assumes it runs to completion before the next request's hook starts.