Skip to content

Add identity and enrichment

Two optional hooks add your own data to each call record: an identity resolver says who made the call, and an enricher adds fields of your choosing. Both may be plain functions or coroutines.

identity_resolver(context) gets the FastMCP MiddlewareContext and returns a dict. Three keys become columns of their own: user_sub, caller_kind and client_id. Anything else goes into the identity JSON column.

With FastMCP’s built-in auth, read the access token:

from fastmcp import FastMCP
from fastmcp.server.dependencies import get_access_token
from fastmcp_feedback.instrumentation import MemorySink, instrument
app = FastMCP("My Server")
def who(context):
"""Who made this call. Return {} for anonymous calls."""
token = get_access_token()
if token is None:
return {}
return {
"user_sub": token.claims.get("sub") or token.client_id,
"client_id": token.client_id,
"caller_kind": "oauth",
"scopes": token.scopes, # stored in the identity column
}
sink = MemorySink()
mw = instrument(app, [sink], identity_resolver=who)

user_sub is what feedback linking uses to match calls from clients that keep no session, so set it on any HTTP server that collects feedback.

The resolver runs once per call, before the tool. Events the tool records with record_event or record_llm_call get the same user_sub, caller_kind and client_id as the call’s row, and the call’s record reuses the result rather than asking twice:

@app.tool
async def render(scene: str) -> dict:
mw.record_event("render.queued", key=scene) # carries the caller's user_sub
return {"queued": scene}

A value passed to record_event explicitly wins. An event recorded outside a call, including from a task the tool started that records after the tool returned, gets only what you pass.

The resolver is also called outside a tool call, when capture_recent_calls needs the current user. There it receives an object whose only useful attribute is fastmcp_context, so read identity from that or from FastMCP’s dependency helpers, as above, rather than from context.message.

enricher(tool, args, result, context) runs after the tool and returns extra fields for the extra JSON column. result is the payload the record stores: the structured content when the tool returned some, otherwise its content items, and None when the tool raised.

Pass it next to the resolver, in place of the earlier instrument call:

async def enrich(tool, args, result, context):
fields = {"server_version": "1.4.0"} # fills the server_version column
if isinstance(result, dict) and "job_id" in result:
fields["job_id"] = result["job_id"] # lets you join events by job id
return fields
mw = instrument(app, [sink], identity_resolver=who, enricher=enrich)

A server_version key goes to its own indexed column instead of extra. To set one version for every record, pass server_version="1.4.0" to instrument instead.

Writing a key such as job_id or speech_id into extra is how you join a call to the events it started.

  • Hook output is redacted like arguments.
  • An async hook that takes longer than hook_timeout (0.25 seconds by default) is skipped for that call. A hook that raises is logged and skipped. Neither changes what the client receives, but the resolver runs before the tool, so a slow async resolver delays the tool by up to hook_timeout. Keep it cheap: read state you already have rather than calling out.
  • Neither hook runs for excluded tools or in mode off. The enricher does not run for calls that are sampled out; the resolver does, since it runs before anyone knows whether the call will fail or record an event.