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.

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}

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}

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.

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.

caution
When you throw a non-map value, the actual error message isn't forwarded to the MCP client. If you want to expose a specific error message to the client, throw a map with a message field.

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