@@ -196,6 +196,30 @@ type SharedMcpServerConfig = {
196196 * restore. Best-effort: failures are swallowed and never affect the session.
197197 */
198198 readonly onAppsEnabledChange ?: ( appsEnabled : boolean ) => Effect . Effect < void > ;
199+ /**
200+ * The client identity self-reported at a previous `initialize` (or in a
201+ * modern request's `_meta`), restored for the same reason as
202+ * {@link restoredAppsEnabled}. Feeds only the `mcp.client.*` span
203+ * attributes on execution spans, never behavior or security decisions.
204+ */
205+ readonly restoredClientInfo ?: McpClientInfo ;
206+ /**
207+ * Called when `initialize` reports the client identity, so the host can
208+ * persist it for {@link restoredClientInfo} on a later cold restore.
209+ * Best-effort: failures are swallowed and never affect the session.
210+ */
211+ readonly onClientInfoChange ?: ( clientInfo : McpClientInfo ) => Effect . Effect < void > ;
212+ } ;
213+
214+ /**
215+ * Client software identity as self-reported over MCP (`clientInfo` at
216+ * `initialize`, `_meta` on modern requests). Display and telemetry vocabulary
217+ * only: the spec forbids relying on it for behavior or security.
218+ */
219+ export type McpClientInfo = {
220+ readonly name : string ;
221+ readonly version ?: string ;
222+ readonly title ?: string ;
199223} ;
200224
201225/**
@@ -340,6 +364,8 @@ export type ExecutorMcpAssembly<Server, RequestContext extends McpRequestJoinKey
340364 readonly server : Server ;
341365 readonly initialAppsEnabled : boolean ;
342366 readonly getClientCapabilities : ( ) => unknown | null ;
367+ /** The live `initialize`-reported client identity, when the SDK has one. */
368+ readonly getClientInfo : ( ) => McpClientInfo | null ;
343369 readonly getElicitationSupport : ( ) => { readonly form : boolean ; readonly url : boolean } ;
344370 readonly getUiCapability : ( ) => { readonly mimeTypes ?: readonly string [ ] } | undefined ;
345371 readonly onInitialized : ( callback : ( ) => void ) => void ;
@@ -782,6 +808,21 @@ const joinKeyAttributes = (joinKeys: McpRequestJoinKeys): Record<string, unknown
782808 "mcp.request.session_id" : joinKeys . sessionId ?? "" ,
783809} ) ;
784810
811+ // Client identity uses the same `mcp.client.*` vocabulary as the cloud
812+ // worker's `initialize` fingerprint (`annotateMcpRequest`), so execution spans
813+ // segment by client directly instead of through a session join that `initialize`
814+ // (which has no session id yet) cannot satisfy. Absent means no key at all, not
815+ // an empty string, when no `initialize` was seen and nothing was restored:
816+ // unknown is real state here, unlike the always-present session key above.
817+ const clientInfoAttributes = ( clientInfo : McpClientInfo | null ) : Record < string , unknown > =>
818+ clientInfo === null
819+ ? { }
820+ : {
821+ "mcp.client.name" : clientInfo . name ,
822+ ...( clientInfo . version !== undefined ? { "mcp.client.version" : clientInfo . version } : { } ) ,
823+ ...( clientInfo . title !== undefined ? { "mcp.client.title" : clientInfo . title } : { } ) ,
824+ } ;
825+
785826const startMarker = ( name : string , attributes : Record < string , unknown > ) : Effect . Effect < void > =>
786827 Effect . void . pipe ( Effect . withSpan ( name , { attributes } ) ) ;
787828
@@ -1110,6 +1151,15 @@ export const buildExecutorMcpTools = <
11101151 ) ;
11111152 const server = assembly . server ;
11121153
1154+ // Seeded from the host's persisted copy; a live `initialize` on this
1155+ // instance replaces it via `syncClientInfo` below. Read lazily at each
1156+ // tool call so spans always carry the newest identity.
1157+ let clientInfo : McpClientInfo | null = config . restoredClientInfo ?? null ;
1158+ const requestSpanAttributes = ( joinKeys : McpRequestJoinKeys ) : Record < string , unknown > => ( {
1159+ ...joinKeyAttributes ( joinKeys ) ,
1160+ ...clientInfoAttributes ( clientInfo ) ,
1161+ } ) ;
1162+
11131163 const executeWithNativeElicitation = (
11141164 code : string ,
11151165 extra : RequestContext ,
@@ -1179,7 +1229,7 @@ export const buildExecutorMcpTools = <
11791229 "mcp.execute.code_length" : code . length ,
11801230 } ,
11811231 } ) ,
1182- Effect . annotateSpans ( joinKeyAttributes ( extra ) ) ,
1232+ Effect . annotateSpans ( requestSpanAttributes ( extra ) ) ,
11831233 ) ;
11841234
11851235 /** What the caller could bind an unresolved role to. Best effort: the
@@ -1340,7 +1390,7 @@ export const buildExecutorMcpTools = <
13401390 "mcp.execute.execution_id" : executionId ,
13411391 } ,
13421392 } ) ,
1343- Effect . annotateSpans ( joinKeyAttributes ( extra ) ) ,
1393+ Effect . annotateSpans ( requestSpanAttributes ( extra ) ) ,
13441394 ) ;
13451395
13461396 const requireUserResumeApproval = ( executionId : string ) : Effect . Effect < McpToolResult > =>
@@ -1419,7 +1469,7 @@ export const buildExecutorMcpTools = <
14191469 "mcp.execute.execution_id" : executionId ,
14201470 } ,
14211471 } ) ,
1422- Effect . annotateSpans ( joinKeyAttributes ( extra ) ) ,
1472+ Effect . annotateSpans ( requestSpanAttributes ( extra ) ) ,
14231473 ) ;
14241474
14251475 // --- tools ---
@@ -2169,9 +2219,35 @@ export const buildExecutorMcpTools = <
21692219 } ) ;
21702220 } ;
21712221
2222+ // Client identity arrives with the same `initialize` that carries the
2223+ // capabilities above. An absent live value (cold restore, construction
2224+ // time) is not evidence the client changed, so the restored value stands,
2225+ // the same asymmetry `syncToolAvailability` documents for capabilities.
2226+ const syncClientInfo = ( ) => {
2227+ const live = assembly . getClientInfo ( ) ;
2228+ if ( live === null ) return ;
2229+ const changed =
2230+ live . name !== clientInfo ?. name ||
2231+ live . version !== clientInfo ?. version ||
2232+ live . title !== clientInfo ?. title ;
2233+ if ( ! changed ) return ;
2234+ clientInfo = live ;
2235+ const onClientInfoChange = config . onClientInfoChange ;
2236+ if ( onClientInfoChange ) {
2237+ // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: `oninitialized` is a sync SDK hook; persistence is fire-and-forget and its failure must not fail the session
2238+ void Effect . runPromiseWith ( context ) (
2239+ onClientInfoChange ( live ) . pipe ( Effect . ignoreCause ( { log : false } ) ) ,
2240+ ) ;
2241+ }
2242+ } ;
2243+
21722244 yield * Effect . sync ( ( ) => {
21732245 syncToolAvailability ( ) ;
2174- assembly . onInitialized ( syncToolAvailability ) ;
2246+ syncClientInfo ( ) ;
2247+ assembly . onInitialized ( ( ) => {
2248+ syncToolAvailability ( ) ;
2249+ syncClientInfo ( ) ;
2250+ } ) ;
21752251 } ) . pipe ( Effect . withSpan ( "mcp.host.sync_tool_availability" ) ) ;
21762252
21772253 return server ;
0 commit comments