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, and finish_mcp_tool_call get the custom additional properties mcp_client_name and mcp_client_version. Each event YAML declares them. initialize_mcp_connection also gets declared_client_name and declared_client_version. In Snowflake, the values are in the event's nested extra JSON, not in flat columns. The gitlab_standard context client_type and client_name stay as integration and mcp. Gitlab::Tracking::ClientIdentity sets them, and that field only takes allowlisted values, but MCP clients report free text.
  • mcp.log: the initialize and tool_call lines get mcp_client_name and mcp_client_version as regular fields, which are always indexed. The initialize line also gets declared_client_name and declared_client_version. They are not in expanded. They are client metadata, like the User-Agent that api_json.log already records, not request content.

Where the client identity comes from

Gitlab::Mcp::ClientInfo.from_request uses the first source that has a name:

  1. params._meta["io.modelcontextprotocol/clientInfo"], which the 2026-07-28 revision sends on every request.
  2. The first product token of the User-Agent header, for example Cursor/1.0.0, claude-code/2.1.280, or node.

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 name and version are read. Nothing else from clientInfo, _meta, or the User-Agent is logged or sent. From the User-Agent, only the first product/version token 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: true trailer: the change is in both CE (initialize, logging, ClientInfo) and EE (tool-call events), so the trailer does not apply.

References

Screenshots or screen recordings

Not applicable. This is a backend change with no UI.

How to set up and validate locally

  1. Enable the MCP server and get an OAuth token with the mcp scope. See doc/development/duo_agent_platform/mcp/_index.md.

  2. Send an initialize request 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"}}}'
  3. In log/mcp.log, the initialize line has "mcp_client_name":"Cursor","mcp_client_version":"1.0.0", from the User-Agent in step 2. It also has "declared_client_name":"claude-code","declared_client_version":"2.1.280".

  4. Send a tools/call request with the same User-Agent. The tool_call line has "mcp_client_name":"Cursor","mcp_client_version":"1.0.0", the same client as the initialize line.

  5. Add "_meta":{"io.modelcontextprotocol/clientInfo":{"name":"my-client","version":"9"}} to the tools/call params. The tool_call line now has my-client.

  6. To check Snowplow events, use Snowplow Micro or scripts/internal_events/monitor.rb start_mcp_tool_call. The event's extra has mcp_client_name and mcp_client_version. The gitlab_standard context has client_type: integration and client_name: mcp. The initialize_mcp_connection event also has declared_client_name and declared_client_version in its extra.

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.

Edited by Alex Filatov

Merge request reports

Loading
Loading