Record MCP client name and version on tool calls
What does this MR do and why?
The MCP server could not tell clients apart. initialize read only protocolVersion and dropped clientInfo. Tool-call events had CLIENT_NAME and CLIENT_VERSION set to null in prod.common_mart.mart_behavior_structured_event. Since 532f6fb9, CLIENT_NAME is mcp for every MCP request, so MCP clients still look the same.
This MR records the client's name and version on every initialize and tools/call:
- Events:
initialize_mcp_connection,start_mcp_tool_call, andfinish_mcp_tool_callget the custom additional propertiesmcp_client_nameandmcp_client_version. Each event YAML declares them.initialize_mcp_connectionalso getsdeclared_client_nameanddeclared_client_version. In Snowflake, the values are in the event's nestedextraJSON, not in flat columns. Thegitlab_standardcontextclient_typeandclient_namestay asintegrationandmcp.Gitlab::Tracking::ClientIdentitysets them, and that field only takes allowlisted values, but MCP clients report free text. mcp.log: theinitializeandtool_calllines getmcp_client_nameandmcp_client_versionas regularfields, which are always indexed. Theinitializeline also getsdeclared_client_nameanddeclared_client_version. They are not inexpanded. They are client metadata, like the User-Agent thatapi_json.logalready records, not request content.
Where the client identity comes from
Gitlab::Mcp::ClientInfo.from_request uses the first source that has a name:
params._meta["io.modelcontextprotocol/clientInfo"], which the2026-07-28revision sends on every request.- The first product token of the
User-Agentheader, for exampleCursor/1.0.0,claude-code/2.1.280, ornode.
Both sources can be present on every request, including initialize. So initialize and tools/call from the same client record the same mcp_client_name and mcp_client_version.
The clientInfo from the initialize request is stored separately as declared_client_name and declared_client_version. They are left out when clientInfo is missing. Gitlab::Mcp::ClientInfo.declared reads them.
Why this approach, and the alternatives
| Option | Outcome |
|---|---|
Resolve per request: _meta, then User-Agent (chosen) |
Works for every protocol version with no server state. Every tool call has a client value, either the _meta one or the User-Agent one. Matches the proposal in the issue comments. The values go in the mcp_client_name and mcp_client_version properties, which land in extra in Snowflake. |
Join initialize to tools/call by MCP session |
Not possible. The server is stateless and never issues an Mcp-Session-Id (see #630544 (closed)). The 2026-07-28 spec also removes protocol sessions. |
Cache clientInfo from initialize server-side, keyed by user or token |
Adds state and a Redis write on every handshake. One token can be used by several clients at once, so the cached value can be wrong. |
Write the standard context client_name/client_version |
Not possible anymore. Since 532f6fb9, client_name holds an allowlisted slug, mcp for every MCP request. Free-text names would mix two kinds of values in CLIENT_NAME. |
Why clientInfo is not used for mcp_client_name: handshake-revision clients send clientInfo only on initialize. If initialize used it, one client got two names. Thanks @terrichu for finding a real example. GitLab Duo Agent Platform sessions go through Workhorse. Workhorse declares clientInfo mcp-client (version v1.0.0), but it sends User-Agent: GitLab-Workhorse-Mcp-Client on every request. Before this change, initialize_mcp_connection recorded mcp-client, and start_mcp_tool_call and finish_mcp_tool_call recorded GitLab-Workhorse-Mcp-Client. Grouping by mcp_client_name split one session in two. Now all events use the same source, so they match. The cost is that a client whose User-Agent is only a library name, like node, shows that name in mcp_client_name. Its declared name is still on the initialize event. This is documented in doc/development/duo_agent_platform/mcp/_index.md.
Stateless protocol revision (2026-07-28)
Covered, not deferred. When a request has _meta["io.modelcontextprotocol/clientInfo"], it is preferred over the User-Agent on that request, including tools/call. Nothing else from _meta is read. Clients that leave it out fall back to the User-Agent.
Treating client-supplied values as untrusted
- Only
nameandversionare read. Nothing else fromclientInfo,_meta, or theUser-Agentis logged or sent. From theUser-Agent, only the firstproduct/versiontoken is kept, so platform comments like(darwin arm64)are dropped. - Values that are not strings, such as nested objects, are ignored.
- Invalid UTF-8 and control characters are removed, whitespace is trimmed, and each value is truncated to 64 characters. This keeps client-supplied values small and matches the planned columns in #629390 (closed).
- The values are self-reported and not verified, which is fine for analytics. They are not used for any authorization or behavior decisions.
Notes for reviewers
- The MR adds custom properties to three event definitions, so analytics instrumentation review is still needed. It no longer changes shared tracking code.
- Out of scope:
tools/list(not asked for in the issue; easy to add the same way) and the Orbit MCP endpoint (ee/lib/api/orbit), which has its own handlers. - No
EE: truetrailer: the change is in both CE (initialize, logging,ClientInfo) and EE (tool-call events), so the trailer does not apply.
References
- Closes #630108 (closed)
- Epic: gitlab-org#23391
- Builds on #630107 (closed) (!256984 (merged))
- Stateless revision: #611372 (closed)
- No server sessions: #630544 (closed)
- Related, same data on the Duo session record: #629390 (closed)
Screenshots or screen recordings
Not applicable. This is a backend change with no UI.
How to set up and validate locally
-
Enable the MCP server and get an OAuth token with the
mcpscope. Seedoc/development/duo_agent_platform/mcp/_index.md. -
Send an
initializerequest with a client identity:curl -s -X POST "$GDK/api/v4/mcp" -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' -H 'User-Agent: Cursor/1.0.0 (darwin arm64)' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"claude-code","version":"2.1.280"}}}' -
In
log/mcp.log, theinitializeline has"mcp_client_name":"Cursor","mcp_client_version":"1.0.0", from theUser-Agentin step 2. It also has"declared_client_name":"claude-code","declared_client_version":"2.1.280". -
Send a
tools/callrequest with the sameUser-Agent. Thetool_callline has"mcp_client_name":"Cursor","mcp_client_version":"1.0.0", the same client as theinitializeline. -
Add
"_meta":{"io.modelcontextprotocol/clientInfo":{"name":"my-client","version":"9"}}to thetools/callparams. Thetool_callline now hasmy-client. -
To check Snowplow events, use Snowplow Micro or
scripts/internal_events/monitor.rb start_mcp_tool_call. The event'sextrahasmcp_client_nameandmcp_client_version. Thegitlab_standardcontext hasclient_type: integrationandclient_name: mcp. Theinitialize_mcp_connectionevent also hasdeclared_client_nameanddeclared_client_versionin itsextra.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.