diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 683685f71e..3d2b32e1c3 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -4292,6 +4292,7 @@ mod tests { ThreadItemPayload::McpToolCall { id: keys[2].item_id.clone(), server: "server".to_string(), + environment_id: None, tool: "tool".to_string(), status: McpToolCallStatus::Failed, arguments: serde_json::json!({}), diff --git a/codex-rs/analytics/src/tests/suite/tools_tests.rs b/codex-rs/analytics/src/tests/suite/tools_tests.rs index d84996e7f3..cdd20d1176 100644 --- a/codex-rs/analytics/src/tests/suite/tools_tests.rs +++ b/codex-rs/analytics/src/tests/suite/tools_tests.rs @@ -237,6 +237,7 @@ async fn cua_tool_call_lifecycle_requires_a_start_and_deduplicates_replay() { let item = |status| ThreadItemPayload::McpToolCall { id: "cua-call".into(), server: "codex_apps".into(), + environment_id: None, tool: "js".into(), status, arguments: json!({"script": "synthetic-private-input"}), @@ -797,6 +798,7 @@ async fn mcp_elicitation_classification_survives_turn_completion_and_preserves_c let item = ThreadItemPayload::McpToolCall { id: item_id.to_string(), server: "server".to_string(), + environment_id: None, tool: "search".to_string(), status: McpToolCallStatus::Completed, arguments: json!({ "token": "synthetic-private-input" }), diff --git a/codex-rs/analytics/src/tests/suite/turns/events_tests.rs b/codex-rs/analytics/src/tests/suite/turns/events_tests.rs index f9534d610b..2513b5da13 100644 --- a/codex-rs/analytics/src/tests/suite/turns/events_tests.rs +++ b/codex-rs/analytics/src/tests/suite/turns/events_tests.rs @@ -490,6 +490,7 @@ async fn turn_event_counts_completed_tool_items() { let mcp_tool_call_item = |status, duration_ms| ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "server".to_string(), + environment_id: None, tool: "search".to_string(), status, arguments: json!({}), diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index e8502e4d5d..aac567aa6c 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1913,6 +1913,13 @@ ], "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." }, + "environmentId": { + "description": "Select this environment's registration. Requires `serverName` and `threadId`. When omitted, a named request uses the same server as older clients.", + "type": [ + "string", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a server-defined value.", "format": "uint32", @@ -2240,6 +2247,13 @@ "null" ] }, + "environmentId": { + "description": "Exact registration scope; omission preserves legacy name-only selection.", + "type": [ + "string", + "null" + ] + }, "originCallId": { "description": "Originating MCP tool call used to select the resource's app.", "type": [ @@ -2328,6 +2342,13 @@ ], "description": "Registration strategy for this login only; omission selects automatic discovery." }, + "environmentId": { + "description": "Exact registration scope; omission preserves legacy name-only selection.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, @@ -2370,6 +2391,13 @@ "properties": { "_meta": true, "arguments": true, + "environmentId": { + "description": "Exact registration scope; omission preserves legacy name-only selection.", + "type": [ + "string", + "null" + ] + }, "server": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json index 7fbc7e988a..f1e1d4897c 100644 --- a/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json +++ b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json @@ -628,6 +628,13 @@ } ], "properties": { + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "serverName": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index f39713d496..3eb4d27eea 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3117,6 +3117,13 @@ }, "McpServerStatusUpdatedNotification": { "properties": { + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "type": [ "string", @@ -5292,6 +5299,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index cf9fb5e86b..e96baf73ce 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -1493,6 +1493,13 @@ } ], "properties": { + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "serverName": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 762933428e..8fa3f62282 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -4349,6 +4349,13 @@ } ], "properties": { + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "serverName": { "type": "string" }, @@ -14991,6 +14998,13 @@ ], "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." }, + "environmentId": { + "description": "Select this environment's registration. Requires `serverName` and `threadId`. When omitted, a named request uses the same server as older clients.", + "type": [ + "string", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a server-defined value.", "format": "uint32", @@ -15730,6 +15744,13 @@ "null" ] }, + "environmentId": { + "description": "Exact registration scope; omission preserves legacy name-only selection.", + "type": [ + "string", + "null" + ] + }, "originCallId": { "description": "Originating MCP tool call used to select the resource's app.", "type": [ @@ -15962,6 +15983,13 @@ ], "description": "Registration strategy for this login only; omission selects automatic discovery." }, + "environmentId": { + "description": "Exact registration scope; omission preserves legacy name-only selection.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, @@ -16040,6 +16068,13 @@ "authStatus": { "$ref": "#/definitions/v2/McpAuthStatus" }, + "environmentId": { + "description": "Environment that contributed this registration; null for a thread-wide server. Older servers omit this field.", + "type": [ + "string", + "null" + ] + }, "httpOrigin": { "description": "HTTP origin of the effective configured endpoint, including plugin servers. Excludes credentials, path, query, and fragment; null for non-HTTP transports.", "type": [ @@ -16128,6 +16163,13 @@ "McpServerStatusUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "type": [ "string", @@ -16169,6 +16211,13 @@ "properties": { "_meta": true, "arguments": true, + "environmentId": { + "description": "Exact registration scope; omission preserves legacy name-only selection.", + "type": [ + "string", + "null" + ] + }, "server": { "type": "string" }, @@ -24111,6 +24160,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index d26f060332..d3964185e0 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -10952,6 +10952,13 @@ ], "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." }, + "environmentId": { + "description": "Select this environment's registration. Requires `serverName` and `threadId`. When omitted, a named request uses the same server as older clients.", + "type": [ + "string", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a server-defined value.", "format": "uint32", @@ -11691,6 +11698,13 @@ "null" ] }, + "environmentId": { + "description": "Exact registration scope; omission preserves legacy name-only selection.", + "type": [ + "string", + "null" + ] + }, "originCallId": { "description": "Originating MCP tool call used to select the resource's app.", "type": [ @@ -11923,6 +11937,13 @@ ], "description": "Registration strategy for this login only; omission selects automatic discovery." }, + "environmentId": { + "description": "Exact registration scope; omission preserves legacy name-only selection.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, @@ -12001,6 +12022,13 @@ "authStatus": { "$ref": "#/definitions/McpAuthStatus" }, + "environmentId": { + "description": "Environment that contributed this registration; null for a thread-wide server. Older servers omit this field.", + "type": [ + "string", + "null" + ] + }, "httpOrigin": { "description": "HTTP origin of the effective configured endpoint, including plugin servers. Excludes credentials, path, query, and fragment; null for non-HTTP transports.", "type": [ @@ -12089,6 +12117,13 @@ "McpServerStatusUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "type": [ "string", @@ -12130,6 +12165,13 @@ "properties": { "_meta": true, "arguments": true, + "environmentId": { + "description": "Exact registration scope; omission preserves legacy name-only selection.", + "type": [ + "string", + "null" + ] + }, "server": { "type": "string" }, @@ -21814,6 +21856,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 3964c269fc..101bfc1039 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -1414,6 +1414,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 861e638fd3..b839fb2e12 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -1414,6 +1414,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json index 437b60b64c..59d4ef8768 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json @@ -28,6 +28,13 @@ ], "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." }, + "environmentId": { + "description": "Select this environment's registration. Requires `serverName` and `threadId`. When omitted, a named request uses the same server as older clients.", + "type": [ + "string", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a server-defined value.", "format": "uint32", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json index f75a3f22ac..497c6361a2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json @@ -69,6 +69,13 @@ "authStatus": { "$ref": "#/definitions/McpAuthStatus" }, + "environmentId": { + "description": "Environment that contributed this registration; null for a thread-wide server. Older servers omit this field.", + "type": [ + "string", + "null" + ] + }, "httpOrigin": { "description": "HTTP origin of the effective configured endpoint, including plugin servers. Excludes credentials, path, query, and fragment; null for non-HTTP transports.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadParams.json index a6e212c3ea..118b23a8f1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadParams.json @@ -36,6 +36,13 @@ "null" ] }, + "environmentId": { + "description": "Exact registration scope; omission preserves legacy name-only selection.", + "type": [ + "string", + "null" + ] + }, "originCallId": { "description": "Originating MCP tool call used to select the resource's app.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginParams.json b/codex-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginParams.json index 387636e7d0..87ababfa00 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginParams.json @@ -22,6 +22,13 @@ ], "description": "Registration strategy for this login only; omission selects automatic discovery." }, + "environmentId": { + "description": "Exact registration scope; omission preserves legacy name-only selection.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/McpServerStatusUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/McpServerStatusUpdatedNotification.json index 8efb36bdad..37cd3fd470 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/McpServerStatusUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/McpServerStatusUpdatedNotification.json @@ -18,6 +18,13 @@ } }, "properties": { + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/McpServerToolCallParams.json b/codex-rs/app-server-protocol/schema/json/v2/McpServerToolCallParams.json index 3465e60c83..7508548031 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/McpServerToolCallParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/McpServerToolCallParams.json @@ -3,6 +3,13 @@ "properties": { "_meta": true, "arguments": true, + "environmentId": { + "description": "Exact registration scope; omission preserves legacy name-only selection.", + "type": [ + "string", + "null" + ] + }, "server": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 0cc138260a..2d57b73e3f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1626,6 +1626,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index bf0f3700cf..d19c190a4f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -2240,6 +2240,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadItemsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadItemsListResponse.json index 586a2aa11e..105c7db09e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadItemsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadItemsListResponse.json @@ -1414,6 +1414,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index ca46c1894f..28c4b5fbe6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -2031,6 +2031,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 414dc05459..1c1521eee3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -2031,6 +2031,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 4b2f012ba7..f86b85814f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -2031,6 +2031,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index f0ea2047d6..b5a90f5690 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -2292,6 +2292,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json index 58ebb693b9..aaba3612d5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json @@ -2031,6 +2031,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 119aa09d6b..3d224ea071 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -2240,6 +2240,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index d4c524913a..da35cc5861 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -2031,6 +2031,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json index 384722f1e7..3f60975956 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json @@ -1626,6 +1626,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 0ee1eda4a7..9007984143 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -2031,6 +2031,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 64552f14c0..fcc6d4ab17 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1626,6 +1626,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 74c05749c0..2e5b2742fc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1626,6 +1626,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index e424f8d17b..3ed4878d06 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1626,6 +1626,13 @@ "null" ] }, + "environmentId": { + "description": "Registration scope, not necessarily the server's execution environment.", + "type": [ + "string", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 9d355f09ca..af91b03abc 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index b9452c7537..39004a5992 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts index 5af67cc097..36d0462e52 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts @@ -20,4 +20,9 @@ detail?: McpServerStatusDetail | null, threadId?: string | null, /** * Limit discovery to one server. With a thread ID, reuse that thread's MCP connection. */ -serverName?: string | null, }; +serverName?: string | null, +/** + * Select this environment's registration. Requires `serverName` and `threadId`. + * When omitted, a named request uses the same server as older clients. + */ +environmentId?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadParams.ts index 839511163b..86873e1aeb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadParams.ts @@ -12,7 +12,11 @@ _meta?: { [key in string]?: JsonValue } | null, threadId?: string | null, /** * Originating MCP tool call used to select the resource's app. */ -originCallId?: string | null, server: string, uri: string, connectorId?: string | null, +originCallId?: string | null, server: string, +/** + * Exact registration scope; omission preserves legacy name-only selection. + */ +environmentId?: string | null, uri: string, connectorId?: string | null, /** * Explicit hosted app/account. Omit to retain legacy resource discovery. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts index 3c86739977..3c05877d62 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts @@ -13,4 +13,8 @@ export type McpServerElicitationRequestParams = { threadId: string, * context is app-server correlation rather than part of the protocol identity of the * elicitation itself. */ -turnId: string | null, serverName: string, } & ({ "mode": "form", _meta: JsonValue | null, message: string, requestedSchema: McpElicitationSchema, } | { "mode": "openai/form", _meta: JsonValue | null, message: string, requestedSchema: JsonValue, } | { "mode": "openaiForm", _meta: JsonValue | null, message: string, requestedSchema: JsonValue, } | { "mode": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, }); +turnId: string | null, serverName: string, +/** + * Registration scope, not necessarily the server's execution environment. + */ +environmentId: string | null, } & ({ "mode": "form", _meta: JsonValue | null, message: string, requestedSchema: McpElicitationSchema, } | { "mode": "openai/form", _meta: JsonValue | null, message: string, requestedSchema: JsonValue, } | { "mode": "openaiForm", _meta: JsonValue | null, message: string, requestedSchema: JsonValue, } | { "mode": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthLoginParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthLoginParams.ts index 3d3aebd43b..967bb610f6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthLoginParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthLoginParams.ts @@ -3,7 +3,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { McpServerOauthClientRegistration } from "./McpServerOauthClientRegistration"; -export type McpServerOauthLoginParams = { name: string, threadId?: string | null, +export type McpServerOauthLoginParams = { name: string, +/** + * Exact registration scope; omission preserves legacy name-only selection. + */ +environmentId?: string | null, threadId?: string | null, /** * Registration strategy for this login only; omission selects automatic discovery. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts index bb33cbe138..49cf69ee6a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts @@ -10,6 +10,11 @@ import type { McpAuthStatus } from "./McpAuthStatus"; import type { McpServerConnectionStatus } from "./McpServerConnectionStatus"; export type McpServerStatus = { name: string, +/** + * Environment that contributed this registration; null for a thread-wide server. + * Older servers omit this field. + */ +environmentId?: string | null, /** * Current thread-runtime connection state; null when unavailable or the configuration changed. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusUpdatedNotification.ts index fd192f22c6..6d21df4d9e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusUpdatedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusUpdatedNotification.ts @@ -4,4 +4,8 @@ import type { McpServerStartupFailureReason } from "./McpServerStartupFailureReason"; import type { McpServerStartupState } from "./McpServerStartupState"; -export type McpServerStatusUpdatedNotification = { threadId: string | null, name: string, status: McpServerStartupState, error: string | null, failureReason: McpServerStartupFailureReason | null, }; +export type McpServerStatusUpdatedNotification = { threadId: string | null, name: string, +/** + * Registration scope, not necessarily the server's execution environment. + */ +environmentId: string | null, status: McpServerStartupState, error: string | null, failureReason: McpServerStartupFailureReason | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerToolCallParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerToolCallParams.ts index 046a3fdc28..612a9dc15d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerToolCallParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerToolCallParams.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { JsonValue } from "../serde_json/JsonValue"; -export type McpServerToolCallParams = { threadId: string, server: string, tool: string, arguments?: JsonValue, _meta?: JsonValue, }; +export type McpServerToolCallParams = { threadId: string, server: string, +/** + * Exact registration scope; omission preserves legacy name-only selection. + */ +environmentId?: string | null, tool: string, arguments?: JsonValue, _meta?: JsonValue, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index cc259ae205..85a7eace9d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -81,7 +81,11 @@ exitCode: number | null, /** * The duration of the command execution in milliseconds. */ -durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, appContext: McpToolCallAppContext | null, +durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, +/** + * Registration scope, not necessarily the server's execution environment. + */ +environmentId: string | null, tool: string, status: McpToolCallStatus, arguments: JsonValue, appContext: McpToolCallAppContext | null, /** * Legacy compatibility field; prefer `mcpAppUi.resourceUri` when available. */ diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 7a8bd79656..979eba47d8 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -2437,6 +2437,8 @@ mod tests { && matches!(field_prefix.trim(), "startedAtMs?" | "completedAtMs?")) || (path == Path::new("v2/McpServerOauthLoginCompletedNotification.ts") && field_prefix.trim() == "loginId?") + || (path == Path::new("v2/McpServerStatus.ts") + && field_prefix.trim() == "environmentId?") || (path == Path::new("v2/ProcessExitedNotification.ts") && field_prefix.trim() == "outputDrainTimedOut?") || (path == Path::new("v2/MisalignmentErrorDetails.ts") diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 52ed76c606..16c96b79ad 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2586,6 +2586,7 @@ mod tests { let mcp_oauth = ClientRequest::McpServerOauthLogin { request_id: request_id(), params: v2::McpServerOauthLoginParams { + environment_id: None, name: "server-a".to_string(), thread_id: None, client_registration: None, @@ -2603,6 +2604,7 @@ mod tests { let mcp_resource_read = ClientRequest::McpResourceRead { request_id: request_id(), params: v2::McpResourceReadParams { + environment_id: None, meta: None, thread_id: Some("thread-1".to_string()), origin_call_id: None, @@ -2799,6 +2801,7 @@ mod tests { let mcp_resource_read = ClientRequest::McpResourceRead { request_id: request_id(), params: v2::McpResourceReadParams { + environment_id: None, meta: None, thread_id: None, origin_call_id: None, @@ -3193,6 +3196,7 @@ mod tests { thread_id: "thr_123".to_string(), turn_id: Some("turn_123".to_string()), server_name: "codex_apps".to_string(), + environment_id: None, request: v2::McpServerElicitationRequest::Form { meta: Some(json!({ "suggestion_id": "request_plugin_install_install-github" @@ -3214,6 +3218,7 @@ mod tests { "threadId": "thr_123", "turnId": "turn_123", "serverName": "codex_apps", + "environmentId": null, "mode": "form", "_meta": { "suggestion_id": "request_plugin_install_install-github" diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index c64c81e11a..3bc2409bba 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -961,6 +961,7 @@ impl ThreadHistoryBuilder { let item = ThreadItemPayload::McpToolCall { id: payload.call_id.clone(), server: payload.invocation.server.clone(), + environment_id: payload.invocation.environment_id.clone(), tool: payload.invocation.tool.clone(), status: McpToolCallStatus::InProgress, arguments: payload @@ -1019,6 +1020,7 @@ impl ThreadHistoryBuilder { let item = ThreadItemPayload::McpToolCall { id: payload.call_id.clone(), server: payload.invocation.server.clone(), + environment_id: payload.invocation.environment_id.clone(), tool: payload.invocation.tool.clone(), status, arguments: payload @@ -3590,6 +3592,7 @@ mod tests { call_id: "mcp-1".into(), invocation: McpInvocation { server: "docs".into(), + environment_id: None, tool: "lookup".into(), arguments: Some(serde_json::json!({"id":"123"})), }, @@ -3655,6 +3658,7 @@ mod tests { ThreadItemPayload::McpToolCall { id: "mcp-1".into(), server: "docs".into(), + environment_id: None, tool: "lookup".into(), status: McpToolCallStatus::Failed, arguments: serde_json::json!({"id":"123"}), @@ -3689,6 +3693,7 @@ mod tests { call_id: "mcp-1".into(), invocation: McpInvocation { server: "docs".into(), + environment_id: None, tool: "lookup".into(), arguments: Some(serde_json::json!({"id":"123"})), }, @@ -3726,6 +3731,7 @@ mod tests { ThreadItemPayload::McpToolCall { id: "mcp-1".into(), server: "docs".into(), + environment_id: None, tool: "lookup".into(), status: McpToolCallStatus::Completed, arguments: serde_json::json!({"id":"123"}), @@ -4135,6 +4141,7 @@ mod tests { item: CoreTurnItem::McpToolCall(McpToolCallItem { id: "mcp-1".into(), server: "slack".into(), + environment_id: None, tool: "search".into(), arguments: serde_json::json!({"query": "incident"}), connector_id: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 932212343c..c4eef0021d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -422,6 +422,8 @@ pub enum ThreadItemPayload { McpToolCall { id: String, server: String, + /// Registration scope, not necessarily the server's execution environment. + environment_id: Option, tool: String, status: McpToolCallStatus, arguments: JsonValue, @@ -1129,6 +1131,7 @@ impl From for ThreadItemPayload { Self::McpToolCall { id: mcp.id, server: mcp.server, + environment_id: mcp.environment_id, tool: mcp.tool, status: McpToolCallStatus::from(mcp.status), arguments: mcp.arguments, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs index d10695dc92..ca4402eb60 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -63,6 +63,10 @@ pub struct ListMcpServerStatusParams { /// Limit discovery to one server. With a thread ID, reuse that thread's MCP connection. #[ts(optional = nullable)] pub server_name: Option, + /// Select this environment's registration. Requires `serverName` and `threadId`. + /// When omitted, a named request uses the same server as older clients. + #[ts(optional = nullable)] + pub environment_id: Option, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] @@ -78,6 +82,10 @@ pub enum McpServerStatusDetail { #[ts(export_to = "v2/")] pub struct McpServerStatus { pub name: String, + /// Environment that contributed this registration; null for a thread-wide server. + /// Older servers omit this field. + #[ts(optional, as = "Option>")] + pub environment_id: Option, /// Current thread-runtime connection state; null when unavailable or the configuration changed. pub runtime_status: Option, pub plugin_id: Option, @@ -122,6 +130,9 @@ pub struct McpResourceReadParams { #[ts(optional = nullable)] pub origin_call_id: Option, pub server: String, + /// Exact registration scope; omission preserves legacy name-only selection. + #[ts(optional = nullable)] + pub environment_id: Option, pub uri: String, #[ts(optional = nullable)] pub connector_id: Option, @@ -207,6 +218,9 @@ pub struct McpServerEventStreamNotification { pub struct McpServerToolCallParams { pub thread_id: String, pub server: String, + /// Exact registration scope; omission preserves legacy name-only selection. + #[ts(optional = nullable)] + pub environment_id: Option, pub tool: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -299,6 +313,9 @@ pub struct McpServerRefreshResponse {} #[ts(export_to = "v2/")] pub struct McpServerOauthLoginParams { pub name: String, + /// Exact registration scope; omission preserves legacy name-only selection. + #[ts(optional = nullable)] + pub environment_id: Option, #[ts(optional = nullable)] pub thread_id: Option, /// Registration strategy for this login only; omission selects automatic discovery. @@ -374,6 +391,8 @@ pub enum McpServerStartupState { pub struct McpServerStatusUpdatedNotification { pub thread_id: Option, pub name: String, + /// Registration scope, not necessarily the server's execution environment. + pub environment_id: Option, pub status: McpServerStartupState, pub error: Option, pub failure_reason: Option, @@ -433,6 +452,8 @@ pub struct McpServerElicitationRequestParams { /// elicitation itself. pub turn_id: Option, pub server_name: String, + /// Registration scope, not necessarily the server's execution environment. + pub environment_id: Option, #[serde(flatten)] #[experimental(nested)] pub request: McpServerElicitationRequest, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index c3b8f01786..ef422c214f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2694,6 +2694,7 @@ fn mcp_server_elicitation_response_serializes_nullable_content() { fn mcp_server_status_serializes_absent_server_info_as_null() { let response = ListMcpServerStatusResponse { data: vec![McpServerStatus { + environment_id: None, tool_list_meta: None, server_capabilities: None, tools_error: None, @@ -2715,6 +2716,7 @@ fn mcp_server_status_serializes_absent_server_info_as_null() { json!({ "data": [{ "name": "not-ready", + "environmentId": null, "runtimeStatus": null, "pluginId": null, "httpOrigin": null, @@ -2747,6 +2749,7 @@ fn mcp_server_status_accepts_older_inventory_without_runtime_status() { assert_eq!( status, McpServerStatus { + environment_id: None, tool_list_meta: None, server_capabilities: None, tools_error: None, @@ -2775,6 +2778,7 @@ fn mcp_server_status_updated_accepts_missing_thread_id() { let expected = McpServerStatusUpdatedNotification { thread_id: None, name: "optional_broken".to_string(), + environment_id: None, status: McpServerStartupState::Failed, error: Some("handshake failed".to_string()), failure_reason: None, @@ -2785,6 +2789,7 @@ fn mcp_server_status_updated_accepts_missing_thread_id() { json!({ "threadId": null, "name": "optional_broken", + "environmentId": null, "status": "failed", "error": "handshake failed", "failureReason": null, @@ -2798,6 +2803,7 @@ fn mcp_server_status_updated_serializes_failure_reason() { ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { thread_id: Some("thread-1".to_string()), name: "expired-oauth".to_string(), + environment_id: Some("remote".to_string()), status: McpServerStartupState::Failed, error: Some("OAuth credentials expired".to_string()), failure_reason: Some(McpServerStartupFailureReason::ReauthenticationRequired), @@ -2810,6 +2816,7 @@ fn mcp_server_status_updated_serializes_failure_reason() { "params": { "threadId": "thread-1", "name": "expired-oauth", + "environmentId": "remote", "status": "failed", "error": "OAuth credentials expired", "failureReason": "reauthenticationRequired", @@ -2822,6 +2829,7 @@ fn mcp_server_status_updated_serializes_failure_reason() { fn mcp_server_status_serializes_absent_server_info_metadata_as_null() { let response = ListMcpServerStatusResponse { data: vec![McpServerStatus { + environment_id: None, tool_list_meta: None, server_capabilities: None, tools_error: None, @@ -2850,6 +2858,7 @@ fn mcp_server_status_serializes_absent_server_info_metadata_as_null() { json!({ "data": [{ "name": "initialized", + "environmentId": null, "runtimeStatus": null, "pluginId": "lookup@test", "httpOrigin": null, @@ -3714,6 +3723,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { let mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem { id: "mcp-1".to_string(), server: "server".to_string(), + environment_id: Some("remote".to_string()), tool: "tool".to_string(), arguments: json!({"arg": "value"}), connector_id: Some("calendar".to_string()), @@ -3735,6 +3745,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "server".to_string(), + environment_id: Some("remote".to_string()), tool: "tool".to_string(), status: McpToolCallStatus::InProgress, arguments: json!({"arg": "value"}), @@ -3758,6 +3769,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { let completed_mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem { id: "mcp-2".to_string(), server: "server".to_string(), + environment_id: None, tool: "tool".to_string(), arguments: JsonValue::Null, connector_id: None, @@ -3784,6 +3796,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { ThreadItemPayload::McpToolCall { id: "mcp-2".to_string(), server: "server".to_string(), + environment_id: None, tool: "tool".to_string(), status: McpToolCallStatus::Completed, arguments: JsonValue::Null, @@ -3821,6 +3834,7 @@ fn mcp_tool_call_app_context_serializes_connector_id() { let item = ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "codex_apps".to_string(), + environment_id: None, tool: "calendar.create_event".to_string(), status: McpToolCallStatus::InProgress, arguments: json!({}), @@ -3846,6 +3860,7 @@ fn mcp_tool_call_app_context_serializes_connector_id() { "type": "mcpToolCall", "id": "mcp-1", "server": "codex_apps", + "environmentId": null, "tool": "calendar.create_event", "status": "inProgress", "arguments": {}, diff --git a/codex-rs/app-server/BUILD.bazel b/codex-rs/app-server/BUILD.bazel index 051a7dd8e4..de87d46d2a 100644 --- a/codex-rs/app-server/BUILD.bazel +++ b/codex-rs/app-server/BUILD.bazel @@ -6,12 +6,10 @@ codex_rust_crate( crate_name = "codex_app_server", extra_binaries = [ "//codex-rs/bwrap:bwrap", + "//codex-rs/cli:codex", "//codex-rs/code-mode-host:codex-code-mode-host", "//codex-rs/rmcp-client:test_stdio_server", ], - extra_binaries_non_windows = [ - "//codex-rs/cli:codex", - ], integration_test_timeout = "long", run_tests_with_wine_exec = True, test_shard_counts = { diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 485f8e1445..dd99b3cd41 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -226,6 +226,7 @@ pub(crate) async fn apply_bespoke_event_handling( let notification = McpServerStatusUpdatedNotification { thread_id: Some(conversation_id.to_string()), name: update.server, + environment_id: update.environment_id, status, error, failure_reason, @@ -941,6 +942,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id, server_name: request.server_name.clone(), + environment_id: request.environment_id.clone(), request: request_body, }; let (pending_request_id, rx) = outgoing diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index 07134fe3ec..bb42671554 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -137,6 +137,7 @@ impl McpRequestProcessor { ) -> Result { let McpServerOauthLoginParams { name, + environment_id, thread_id, client_registration, scopes, @@ -165,13 +166,12 @@ impl McpRequestProcessor { } }; let effective_servers = codex_mcp::effective_mcp_servers(&mcp_config, auth.as_ref()); - let Some((key, server)) = mcp_config + let key = mcp_config .mcp_server_catalog - .resolve_server_name(&name) - .and_then(|key| effective_servers.get(key).map(|server| (key, server))) - else { + .resolve_server_key(&name, environment_id.as_deref()); + let Some(server) = effective_servers.get(&key) else { return Err(invalid_request(format!( - "No MCP server named '{name}' found." + "No MCP server named '{key}' found." ))); }; let redirect_mode = if server.is_agent_plugin() { @@ -417,12 +417,22 @@ impl McpRequestProcessor { (thread.as_ref(), params.server_name.as_deref()) { thread - .mcp_server_status_snapshot(server, detail, /*expected_registration*/ None) + .mcp_server_status_snapshot( + server, + params.environment_id.as_deref(), + detail, + /*expected_registration*/ None, + ) .await .map_err(|error| { internal_error(format!("failed to read MCP server status: {error:#}")) })? } else { + if params.environment_id.is_some() { + return Err(invalid_request( + "environmentId requires serverName and threadId", + )); + } let config = match thread.as_ref() { Some(thread) => { let thread_config = thread.config().await; @@ -455,6 +465,11 @@ impl McpRequestProcessor { ), ), }; + let selected_server = params.server_name.as_deref().map(|name| { + mcp_config + .mcp_server_catalog + .resolve_server_key(name, /*environment_id*/ None) + }); let mut snapshot = collect_mcp_server_status_snapshot_with_detail( &mcp_config, auth.as_ref(), @@ -464,7 +479,7 @@ impl McpRequestProcessor { mcp_manager.codex_apps_tools_cache(), mcp_manager.tool_catalog_cache(), detail, - params.server_name.as_deref(), + selected_server.as_ref(), ) .await; if mcp_config.xaa_enabled @@ -472,16 +487,18 @@ impl McpRequestProcessor { { // EMA can only use its admitted thread connection. Keep ordinary // status discovery unchanged and replace the threadless EMA results. - for (name, server) in mcp_config.mcp_server_catalog.configured_servers() { - if !matches!(server.auth, McpServerAuth::EmaAuth) { + for (key, server) in mcp_config.mcp_server_catalog.servers() { + if !matches!(server.config().auth, McpServerAuth::EmaAuth) { continue; } - let Some(key) = mcp_config.mcp_server_catalog.resolve_server_name(&name) else { - continue; - }; - let registration = mcp_config.mcp_server_catalog.server(key); - let (selected_config, mut selected) = thread - .mcp_server_status_snapshot(&name, detail, registration) + let registration = Some(server); + let (selected_config, selected) = thread + .mcp_server_status_snapshot( + key.name(), + key.scoped_environment_id(), + detail, + registration, + ) .await .map_err(|error| { internal_error(format!("failed to read MCP server status: {error:#}")) @@ -489,27 +506,25 @@ impl McpRequestProcessor { if selected_config.mcp_server_catalog.server(key) != registration { continue; } - snapshot.server_infos.remove(&name); + snapshot.server_infos.remove(key); snapshot.server_infos.extend(selected.server_infos); - snapshot.server_capabilities.remove(&name); - if let Some(capabilities) = selected.server_capabilities.remove(&name) { - snapshot - .server_capabilities - .insert(name.clone(), capabilities); - } - snapshot.tools_by_server.remove(&name); + snapshot.server_capabilities.remove(key); + snapshot + .server_capabilities + .extend(selected.server_capabilities); + snapshot.tools_by_server.remove(key); snapshot.tools_by_server.extend(selected.tools_by_server); - snapshot.tool_list_meta.remove(&name); + snapshot.tool_list_meta.remove(key); snapshot.tool_list_meta.extend(selected.tool_list_meta); - snapshot.tools_errors.remove(&name); + snapshot.tools_errors.remove(key); snapshot.tools_errors.extend(selected.tools_errors); - snapshot.resources.remove(&name); + snapshot.resources.remove(key); snapshot.resources.extend(selected.resources); - snapshot.resource_templates.remove(&name); + snapshot.resource_templates.remove(key); snapshot .resource_templates .extend(selected.resource_templates); - snapshot.auth_statuses.remove(&name); + snapshot.auth_statuses.remove(key); snapshot.auth_statuses.extend(selected.auth_statuses); } } @@ -529,26 +544,27 @@ impl McpRequestProcessor { mut resources, mut resource_templates, mut auth_statuses, - mut server_names, + mut server_keys, } = snapshot; - server_names.extend(runtime_statuses.keys().cloned()); - server_names.extend( + server_keys.extend(runtime_statuses.keys().cloned()); + server_keys.extend( auth_statuses .keys() .cloned() .chain(resources.keys().cloned()) .chain(resource_templates.keys().cloned()), ); - server_names.sort(); - server_names.dedup(); - server_names.retain(|name| { - params - .server_name - .as_deref() - .is_none_or(|server| name == server) + server_keys.sort(); + server_keys.dedup(); + server_keys.retain(|key| { + params.server_name.as_deref().is_none_or(|server| { + *key == mcp_config + .mcp_server_catalog + .resolve_server_key(server, params.environment_id.as_deref()) + }) }); - let total = server_names.len(); + let total = server_keys.len(); let limit = params.limit.unwrap_or(total as u32).max(1) as usize; let effective_limit = limit.min(total); let start = match params.cursor { @@ -567,15 +583,14 @@ impl McpRequestProcessor { let end = start.saturating_add(effective_limit).min(total); - let data: Vec = server_names[start..end] + let data: Vec = server_keys[start..end] .iter() - .map(|name| McpServerStatus { - name: name.clone(), - runtime_status: runtime_statuses.get(name).copied().map(Into::into), - plugin_id: mcp_config - .mcp_server_catalog - .server_by_name(name) - .and_then(|server| match server.source() { + .map(|key| McpServerStatus { + name: key.name().to_owned(), + environment_id: key.scoped_environment_id().map(str::to_owned), + runtime_status: runtime_statuses.get(key).copied().map(Into::into), + plugin_id: mcp_config.mcp_server_catalog.server(key).and_then( + |server| match server.source() { McpServerSource::Plugin(plugin) | McpServerSource::SelectedPlugin(plugin) => { Some(plugin.plugin_id().to_owned()) @@ -583,9 +598,12 @@ impl McpRequestProcessor { McpServerSource::Config | McpServerSource::Compatibility { .. } | McpServerSource::Extension { .. } => None, - }), - http_origin: mcp_config.mcp_server_catalog.server_by_name(name).and_then( - |server| match &server.config().transport { + }, + ), + http_origin: mcp_config + .mcp_server_catalog + .server(key) + .and_then(|server| match &server.config().transport { McpServerTransportConfig::StreamableHttp { url, .. } => { url::Url::parse(url).ok().and_then(|url| { matches!(url.scheme(), "http" | "https") @@ -593,17 +611,16 @@ impl McpRequestProcessor { }) } McpServerTransportConfig::Stdio { .. } => None, - }, - ), - server_info: server_infos.remove(name), - server_capabilities: server_capabilities.remove(name), - tool_list_meta: tool_list_meta.remove(name).map(Arc::unwrap_or_clone), - tools: tools_by_server.remove(name).unwrap_or_default(), - tools_error: tools_errors.remove(name), - resources: resources.remove(name).unwrap_or_default(), - resource_templates: resource_templates.remove(name).unwrap_or_default(), + }), + server_info: server_infos.remove(key), + server_capabilities: server_capabilities.remove(key), + tool_list_meta: tool_list_meta.remove(key).map(Arc::unwrap_or_clone), + tools: tools_by_server.remove(key).unwrap_or_default(), + tools_error: tools_errors.remove(key), + resources: resources.remove(key).unwrap_or_default(), + resource_templates: resource_templates.remove(key).unwrap_or_default(), auth_status: auth_statuses - .remove(name) + .remove(key) .unwrap_or(CoreMcpAuthStatus::Unsupported) .into(), }) @@ -629,6 +646,7 @@ impl McpRequestProcessor { thread_id, origin_call_id, server, + environment_id, uri, connector_id, target, @@ -636,6 +654,11 @@ impl McpRequestProcessor { let mut resource_params = ReadResourceRequestParams::new(uri); let mut meta = meta.unwrap_or_default(); let is_codex_apps = server == codex_mcp::CODEX_APPS_MCP_SERVER_NAME; + if is_codex_apps && origin_call_id.is_some() && environment_id.is_some() { + return Err(invalid_request( + "originCallId requires the unscoped codex_apps registration", + )); + } let reserved_key = meta.iter().find_map(|(key, value)| match key.as_str() { "x-codex-turn-metadata" | MCP_THREAD_ID_META_KEY @@ -664,6 +687,7 @@ impl McpRequestProcessor { && let Some(target) = target { if !is_codex_apps + || environment_id.is_some() || target.connector_id.trim().is_empty() || target .link_id @@ -671,7 +695,7 @@ impl McpRequestProcessor { .is_some_and(|id| id.trim().is_empty() || id.starts_with("synthetic_link::")) { return Err(invalid_request( - "target requires codex_apps, a connectorId, and a real linkId or null", + "target requires unscoped codex_apps, a connectorId, and a real linkId or null", )); } meta.insert( @@ -696,7 +720,11 @@ impl McpRequestProcessor { .read_mcp_resource_for_call(call_id, resource_params) .await } - None => thread.read_mcp_resource(&server, resource_params).await, + None => { + thread + .read_mcp_resource(&server, environment_id.as_deref(), resource_params) + .await + } }; Self::send_mcp_resource_read_response( outgoing, @@ -741,6 +769,7 @@ impl McpRequestProcessor { codex_apps_tools_cache, tool_catalog_cache, &server, + environment_id.as_deref(), resource_params, ) .await @@ -793,7 +822,13 @@ impl McpRequestProcessor { let task = async move { let result = thread - .call_mcp_tool(¶ms.server, ¶ms.tool, params.arguments, meta) + .call_mcp_tool( + ¶ms.server, + params.environment_id.as_deref(), + ¶ms.tool, + params.arguments, + meta, + ) .await .map(McpServerToolCallResponse::from) .map_err(mcp_operation_error); diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index 8ae17d9a13..6c2e876591 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -83,6 +83,7 @@ mod tests { ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "docs".to_string(), + environment_id: None, tool: "lookup".to_string(), status: McpToolCallStatus::Completed, arguments: serde_json::json!({"secret":"argument"}), @@ -147,6 +148,7 @@ mod tests { ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "docs".to_string(), + environment_id: None, tool: "lookup".to_string(), status: McpToolCallStatus::Completed, arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()), @@ -173,6 +175,7 @@ mod tests { let mut thread = test_thread(vec![ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "docs".to_string(), + environment_id: None, tool: "lookup".to_string(), status: McpToolCallStatus::Failed, arguments: serde_json::json!({"secret":"argument"}), @@ -195,6 +198,7 @@ mod tests { ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "docs".to_string(), + environment_id: None, tool: "lookup".to_string(), status: McpToolCallStatus::Failed, arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()), diff --git a/codex-rs/app-server/src/user_verification_activation_tests.rs b/codex-rs/app-server/src/user_verification_activation_tests.rs index 336a581292..18f93cde80 100644 --- a/codex-rs/app-server/src/user_verification_activation_tests.rs +++ b/codex-rs/app-server/src/user_verification_activation_tests.rs @@ -77,6 +77,7 @@ async fn user_verification_initialize_owns_advertisement_and_eligibility() -> Re thread_id: thread_id.to_string(), turn_id: None, server_name: "codex_apps".into(), + environment_id: None, request: rpc::McpServerElicitationRequest::UserVerification { meta: None, challenge: "AQ".into(), diff --git a/codex-rs/app-server/src/user_verification_connection_tests.rs b/codex-rs/app-server/src/user_verification_connection_tests.rs index 15ad705e54..d999cae31b 100644 --- a/codex-rs/app-server/src/user_verification_connection_tests.rs +++ b/codex-rs/app-server/src/user_verification_connection_tests.rs @@ -23,6 +23,7 @@ fn request() -> rpc::ServerRequestPayload { thread_id: ThreadId::new().to_string(), turn_id: None, server_name: "codex_apps".into(), + environment_id: None, request: rpc::McpServerElicitationRequest::UserVerification { meta: None, title: "Approve".into(), diff --git a/codex-rs/app-server/src/user_verification_ownership_tests.rs b/codex-rs/app-server/src/user_verification_ownership_tests.rs index 219868795a..4de68d3af5 100644 --- a/codex-rs/app-server/src/user_verification_ownership_tests.rs +++ b/codex-rs/app-server/src/user_verification_ownership_tests.rs @@ -17,6 +17,7 @@ fn request(thread_id: ThreadId) -> ServerRequestPayload { thread_id: thread_id.to_string(), turn_id: Some("turn-1".into()), server_name: "plugin-service".into(), + environment_id: None, request: McpServerElicitationRequest::UserVerification { meta: None, title: "Approve".into(), diff --git a/codex-rs/app-server/tests/suite/v2/curated_mcp_sync.rs b/codex-rs/app-server/tests/suite/v2/curated_mcp_sync.rs index cecf285620..20a5502b9f 100644 --- a/codex-rs/app-server/tests/suite/v2/curated_mcp_sync.rs +++ b/codex-rs/app-server/tests/suite/v2/curated_mcp_sync.rs @@ -239,6 +239,7 @@ async fn existing_thread_loads_api_curated_mcp_after_auth_switch_sync() -> Resul .request(|request_id| ClientRequest::McpServerToolCall { request_id, params: McpServerToolCallParams { + environment_id: None, thread_id: thread_id.clone(), server: TEST_SERVER_NAME.to_string(), tool: TEST_TOOL_NAME.to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/daybreak_access.rs b/codex-rs/app-server/tests/suite/v2/daybreak_access.rs index c61b973854..21930b3d5a 100644 --- a/codex-rs/app-server/tests/suite/v2/daybreak_access.rs +++ b/codex-rs/app-server/tests/suite/v2/daybreak_access.rs @@ -205,6 +205,7 @@ async fn daybreak_access_respects_plugin_provenance(case: AccessCall) -> Result< .request(|request_id| ClientRequest::McpServerToolCall { request_id, params: McpServerToolCallParams { + environment_id: None, thread_id: thread.id.clone(), server: "sample".to_string(), tool: "get_daybreak_access".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs index bad020ce4b..3c8177a611 100644 --- a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs +++ b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs @@ -1382,6 +1382,7 @@ startup_timeout_sec = 10 let request_id = app_server .send_mcp_server_tool_call_request(McpServerToolCallParams { + environment_id: None, thread_id: selected_thread.clone(), server: HTTP_MCP_SERVER_NAME.to_string(), tool: "echo".to_string(), @@ -1398,6 +1399,7 @@ startup_timeout_sec = 10 let request_id = app_server .send_mcp_server_tool_call_request(McpServerToolCallParams { + environment_id: None, thread_id: selected_thread.clone(), server: OAUTH_MCP_SERVER_NAME.to_string(), tool: "echo".to_string(), @@ -1443,6 +1445,7 @@ startup_timeout_sec = 10 let request_id = app_server .send_mcp_server_tool_call_request(McpServerToolCallParams { + environment_id: None, thread_id: selected_thread.clone(), server: REFRESH_PROBE_SERVER_NAME.to_string(), tool: "echo".to_string(), @@ -1562,6 +1565,7 @@ async fn mcp_server_statuses( ) -> Result> { let request_id = app_server .send_list_mcp_server_status_request(ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, diff --git a/codex-rs/app-server/tests/suite/v2/executor_skills_spawn_tests.rs b/codex-rs/app-server/tests/suite/v2/executor_skills_spawn_tests.rs index 129006b3d2..8f387396cc 100644 --- a/codex-rs/app-server/tests/suite/v2/executor_skills_spawn_tests.rs +++ b/codex-rs/app-server/tests/suite/v2/executor_skills_spawn_tests.rs @@ -230,6 +230,7 @@ async fn spawned_agent_inherits_selected_executor_skill( } let status_id = app_server .send_list_mcp_server_status_request(ListMcpServerStatusParams { + environment_id: None, thread_id: Some(request.header("thread-id").expect("agent thread ID")), server_name: None, cursor: None, diff --git a/codex-rs/app-server/tests/suite/v2/mcp_connection_sharing.rs b/codex-rs/app-server/tests/suite/v2/mcp_connection_sharing.rs index 0c52bc325a..cb00f36a2e 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_connection_sharing.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_connection_sharing.rs @@ -155,6 +155,7 @@ async fn call_servers( .request(|request_id| ClientRequest::McpServerToolCall { request_id, params: McpServerToolCallParams { + environment_id: None, thread_id: thread_id.to_string(), server: name.to_string(), tool: "echo".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_protocol_default.rs b/codex-rs/app-server/tests/suite/v2/mcp_protocol_default.rs index 094ed3a9fa..660ab30ec8 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_protocol_default.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_protocol_default.rs @@ -50,6 +50,7 @@ async fn enabling_hosted_apps_protocol_refreshes_an_existing_thread() -> Result< .id; let call_tool = |thread_id: String| McpServerToolCallParams { + environment_id: None, thread_id, server: "codex_apps".to_string(), tool: "calendar_list_events".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index 0cf3f25e68..9ca684f3cb 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -190,6 +190,7 @@ async fn mcp_resource_read_returns_resource_contents() -> Result<()> { .request(|request_id| ClientRequest::McpResourceRead { request_id, params: McpResourceReadParams { + environment_id: None, meta: Some(serde_json::Map::from_iter([( "example/trace".into(), metadata_value.clone(), @@ -239,6 +240,7 @@ async fn mcp_resource_read_returns_resource_contents() -> Result<()> { ] { let request_id = mcp .send_mcp_resource_read_request(McpResourceReadParams { + environment_id: None, thread_id: Some(thread.id.clone()), origin_call_id, meta: Some(serde_json::Map::from_iter([(key.into(), value)])), @@ -314,6 +316,7 @@ async fn mcp_resource_read_preserves_protocol_errors(protocol: ProtocolVersion) for thread_id in [Some(thread.id.clone()), None] { let request_id = mcp .send_mcp_resource_read_request(McpResourceReadParams { + environment_id: None, meta: None, thread_id, origin_call_id: None, @@ -531,6 +534,7 @@ apps = true .request(|request_id| ClientRequest::McpResourceRead { request_id, params: McpResourceReadParams { + environment_id: None, meta: None, thread_id: None, origin_call_id: None, @@ -546,6 +550,7 @@ apps = true .request(|request_id| ClientRequest::McpResourceRead { request_id, params: McpResourceReadParams { + environment_id: None, meta: None, thread_id: None, origin_call_id: None, @@ -589,6 +594,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { .request(ClientRequest::McpResourceRead { request_id: RequestId::Integer(1), params: McpResourceReadParams { + environment_id: None, meta: None, thread_id: Some("00000000-0000-4000-8000-000000000000".to_string()), origin_call_id: None, @@ -717,6 +723,7 @@ async fn metadata_and_mcp_requests_complete_while_unrelated_resume_loads_config( sender.request(ClientRequest::McpResourceRead { request_id: RequestId::Integer(7), params: McpResourceReadParams { + environment_id: None, meta: None, thread_id: Some(thread.id.clone()), origin_call_id: None, @@ -729,6 +736,7 @@ async fn metadata_and_mcp_requests_complete_while_unrelated_resume_loads_config( sender.request(ClientRequest::McpServerToolCall { request_id: RequestId::Integer(8), params: McpServerToolCallParams { + environment_id: None, thread_id: thread.id.clone(), server: "resource_server".to_string(), tool: "best_buy_product_search".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource/plugin_metadata_refresh.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource/plugin_metadata_refresh.rs index 65ffd90a76..0060cb3354 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource/plugin_metadata_refresh.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource/plugin_metadata_refresh.rs @@ -247,6 +247,7 @@ async fn list_plugin_skills( async fn read_mcp_skill_resource(app_server: &mut TestAppServer, thread_id: &str) -> Result<()> { let request = app_server .send_mcp_resource_read_request(McpResourceReadParams { + environment_id: None, meta: None, thread_id: Some(thread_id.to_string()), origin_call_id: None, diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource_origin.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource_origin.rs index 4d472cd41d..7f454cbe89 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource_origin.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource_origin.rs @@ -173,6 +173,7 @@ async fn widget_reads_survive_history_modes_compaction_restarts_and_app_only_vis ] { let request_id = app_server .send_mcp_resource_read_request(McpResourceReadParams { + environment_id: None, meta: None, thread_id, origin_call_id: Some(call_id.to_string()), @@ -298,6 +299,7 @@ async fn read_widget( .request(|request_id| ClientRequest::McpResourceRead { request_id, params: McpResourceReadParams { + environment_id: None, meta: Some(serde_json::Map::from_iter([( "example/trace".into(), json!(call_id), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index b6cac451d5..4d6cfaad0e 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -200,6 +200,7 @@ async fn assert_standard_form_elicitation_round_trip( thread_id: fixture.thread_id.clone(), turn_id: Some(fixture.turn_id.clone()), server_name: "codex_apps".to_string(), + environment_id: None, request: McpServerElicitationRequest::Form { meta: None, message: ELICITATION_MESSAGE.to_string(), @@ -246,6 +247,7 @@ async fn mcp_server_openai_form_elicitation_round_trip() -> Result<()> { thread_id: fixture.thread_id.clone(), turn_id: Some(fixture.turn_id.clone()), server_name: "codex_apps".to_string(), + environment_id: None, request: McpServerElicitationRequest::OpenAiForm { meta: None, message: OPENAI_FORM_MESSAGE.to_string(), @@ -285,6 +287,7 @@ async fn mcp_server_openai_elicitation_form_round_trip() -> Result<()> { thread_id: fixture.thread_id.clone(), turn_id: Some(fixture.turn_id.clone()), server_name: "codex_apps".to_string(), + environment_id: None, request: McpServerElicitationRequest::OpenAiElicitationForm { meta: Some(json!({ "example/request": "template-picker" })), message: OPENAI_FORM_MESSAGE.to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs index 1f395396ec..19ef0371a9 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs @@ -23,6 +23,7 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; +use codex_app_server_protocol::McpAuthStatus; use codex_app_server_protocol::McpServerConnectionStatus; use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; use codex_app_server_protocol::McpServerOauthLoginResponse; @@ -32,7 +33,9 @@ use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnEnvironmentParams; use codex_core::config::set_project_trust_level; +use codex_features::Feature; use codex_http_client::HttpClientBuilder; use codex_protocol::config_types::TrustLevel; use codex_rmcp_client::McpOAuthCallbackMode; @@ -43,12 +46,18 @@ use core_test_support::skip_if_remote; use core_test_support::stdio_server_bin; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; +use rmcp::model::CallToolRequestParams; +use rmcp::model::CallToolResult; +use rmcp::model::ContentBlock; use rmcp::model::Implementation; use rmcp::model::JsonObject; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; use rmcp::model::ListToolsResult; use rmcp::model::PaginatedRequestParams; +use rmcp::model::ReadResourceRequestParams; +use rmcp::model::ReadResourceResult; +use rmcp::model::ResourceContents; use rmcp::model::ServerCapabilities; use rmcp::model::ServerConfig; use rmcp::model::Tool; @@ -142,6 +151,7 @@ async fn mcp_server_status_list_reuses_thread_connection() -> Result<()> { .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + environment_id: None, thread_id: Some(thread.id.clone()), server_name: Some("widget".to_string()), detail: Some(detail), @@ -203,6 +213,7 @@ async fn full_mcp_server_status_skips_codex_apps_resource_templates( .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + environment_id: None, cursor: None, limit: None, detail: Some(McpServerStatusDetail::Full), @@ -558,11 +569,13 @@ async fn oauth_login_automatically_selects_callback_specific_cimd_without_metada let (token_request_tx, mut token_request_rx) = mpsc::unbounded_channel(); let (mcp_authorization_tx, mut mcp_authorization_rx) = mpsc::unbounded_channel(); let tool_name = Arc::new("cimd".to_string()); + let mcp_origin = base_url.clone(); let mcp_service = StreamableHttpService::new( move || { Ok(McpStatusServer { tool_name: Arc::clone(&tool_name), tools_error: None, + origin: mcp_origin.clone(), }) }, Arc::new(LocalSessionManager::default()), @@ -728,6 +741,7 @@ async fn oauth_login_automatically_selects_callback_specific_cimd_without_metada .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, @@ -818,6 +832,7 @@ async fn mcp_server_status_list_returns_raw_server_and_tool_names(plugin: bool) .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, @@ -925,6 +940,7 @@ MCP_TEST_PID_FILE = {} .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, @@ -939,6 +955,7 @@ MCP_TEST_PID_FILE = {} std::fs::remove_file(&barrier_file)?; let second_request_id = mcp .send_list_mcp_server_status_request(ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, @@ -1003,6 +1020,7 @@ url = "{mcp_server_url}/mcp" .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, @@ -1017,6 +1035,7 @@ url = "{mcp_server_url}/mcp" .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, @@ -1073,6 +1092,7 @@ async fn mcp_server_status_list_reports_thread_runtime_connections() -> Result<( .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, @@ -1112,6 +1132,7 @@ async fn mcp_server_status_list_reports_thread_runtime_connections() -> Result<( .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, @@ -1196,6 +1217,7 @@ async fn mcp_server_status_list_reports_disconnected_stdio_transport() -> Result .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, @@ -1244,6 +1266,7 @@ async fn mcp_server_status_retains_capabilities_when_tool_discovery_fails() -> R .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, @@ -1283,11 +1306,15 @@ async fn mcp_server_status_retains_capabilities_when_tool_discovery_fails() -> R struct McpStatusServer { tool_name: Arc, tools_error: Option, + origin: String, } impl ServerHandler for McpStatusServer { fn get_info(&self) -> ServerConfig { - let mut capabilities = ServerCapabilities::builder().enable_tools().build(); + let mut capabilities = ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(); capabilities.extensions = Some(BTreeMap::from([( "openai/settings".to_string(), serde_json::from_value( @@ -1323,6 +1350,31 @@ impl ServerHandler for McpStatusServer { Ok(ListToolsResult::with_all_items(vec![tool])) } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + assert_eq!(request.name.as_ref(), self.tool_name.as_str()); + Ok(CallToolResult::success(vec![ContentBlock::text(self.origin.clone())]).into()) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + Ok( + ReadResourceResult::new(vec![ResourceContents::TextResourceContents { + uri: request.uri, + mime_type: None, + text: self.origin.clone(), + meta: None, + }]) + .into(), + ) + } } #[derive(Clone)] @@ -1399,6 +1451,7 @@ async fn mcp_server_status_list_tools_and_auth_only_skips_slow_inventory_calls() let request_id = mcp .send_list_mcp_server_status_request(ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, @@ -1454,6 +1507,7 @@ url = "{underscore_server_url}/mcp" .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, @@ -1494,6 +1548,210 @@ url = "{underscore_server_url}/mcp" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_server_status_keeps_same_named_environment_inventory_separate() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let (first_url, first_handle) = start_mcp_server("lookup", /*tools_error*/ None).await?; + let (second_url, second_handle) = start_mcp_server("lookup", /*tools_error*/ None).await?; + let codex_home = TempDir::new()?; + mock_responses_config(&server.uri()) + .enable_feature(Feature::MultiEnvironmentMcp) + .enable_feature(Feature::ExecutorCapabilityDiscovery) + .write(codex_home.path())?; + let executors = [ + ("env-a", TempDir::new()?, &first_url), + ("env-b", TempDir::new()?, &second_url), + ]; + let program = toml::Value::String( + codex_utils_cargo_bin::cargo_bin("codex")? + .to_string_lossy() + .into_owned(), + ); + let mut environment_config = "default = \"env-a\"\ninclude_local = false\n".to_string(); + let mut environments = Vec::new(); + for (id, home, url) in &executors { + let headers = if *id == "env-b" { + "http_headers = { Authorization = \"Bearer test-token\" }\n" + } else { + "" + }; + std::fs::write( + home.path().join("config.toml"), + format!("[mcp_servers.notes]\nurl = \"{url}/mcp\"\n{headers}"), + )?; + let home_value = toml::Value::String(home.path().to_string_lossy().into_owned()); + environment_config.push_str(&format!( + "\n[[environments]]\nid = \"{id}\"\nprogram = {program}\nargs = [\"exec-server\", \"--listen\", \"stdio\"]\n[environments.env]\nCODEX_HOME = {home_value}\n" + )); + environments.push(TurnEnvironmentParams { + environment_id: (*id).to_string(), + cwd: AbsolutePathBuf::try_from(home.path().to_path_buf())?.into(), + runtime_workspace_roots: None, + }); + } + std::fs::write( + codex_home.path().join("environments.toml"), + environment_config, + )?; + let mut app = TestAppServer::builder() + .with_codex_home(codex_home.path()) + // This test owns both executor environments and their configurations. + .without_auto_env() + .build_initialized() + .await?; + let request = app + .send_thread_start_request(ThreadStartParams { + environments: Some(environments), + ..Default::default() + }) + .await?; + let ThreadStartResponse { thread, .. } = app.read_response(request).await?; + let notification = app + .read_stream_until_matching_notification("notes MCP ready", |notification| { + notification.method == "mcpServer/startupStatus/updated" + && notification.params.as_ref().is_some_and(|params| { + params["threadId"] == thread.id + && params["name"] == "notes" + && params["status"] == "ready" + }) + }) + .await?; + assert_eq!( + notification.params, + Some(serde_json::json!({ + "threadId": thread.id, + "name": "notes", + "environmentId": "env-a", + "status": "ready", + "error": null, + "failureReason": null, + })) + ); + let response: ListMcpServerStatusResponse = app + .request(|request_id| ClientRequest::McpServerStatusList { + request_id, + params: ListMcpServerStatusParams { + environment_id: None, + thread_id: Some(thread.id.clone()), + server_name: None, + detail: Some(McpServerStatusDetail::Full), + cursor: None, + limit: None, + }, + }) + .await?; + assert_eq!(response.next_cursor, None); + assert_eq!( + response + .data + .iter() + .map(|status| ( + status.name.as_str(), + status.environment_id.as_deref(), + status + .tools + .keys() + .map(String::as_str) + .collect::>(), + status.tools_error.as_deref(), + status.auth_status, + status.http_origin.as_deref(), + )) + .collect::>(), + vec![ + ( + "notes", + Some("env-a"), + BTreeSet::from(["lookup"]), + None, + McpAuthStatus::Unsupported, + Some(first_url.as_str()) + ), + ( + "notes", + Some("env-b"), + BTreeSet::from(["lookup"]), + None, + McpAuthStatus::BearerToken, + Some(second_url.as_str()) + ), + ], + ); + for (environment_id, expected) in [ + (Some("env-a"), &response.data[0]), + (Some("env-b"), &response.data[1]), + // Omitting the selector keeps the original first-registration lookup. + (None, &response.data[0]), + ] { + let selected: ListMcpServerStatusResponse = app + .request(|request_id| ClientRequest::McpServerStatusList { + request_id, + params: ListMcpServerStatusParams { + thread_id: Some(thread.id.clone()), + server_name: Some("notes".to_string()), + environment_id: environment_id.map(str::to_owned), + detail: Some(McpServerStatusDetail::Full), + cursor: None, + limit: None, + }, + }) + .await?; + // The selected read waits for the thread connection; the bulk read only observed it. + let mut expected = expected.clone(); + expected.runtime_status = Some(McpServerConnectionStatus::Connected); + let origin = expected.http_origin.clone().expect("HTTP MCP origin"); + assert_eq!( + selected, + ListMcpServerStatusResponse { + data: vec![expected], + next_cursor: None + } + ); + // Identical tool names and resource URIs must reach the selected registration. + for (method, mut params, content_key, expected_content) in [ + ( + "mcpServer/tool/call", + json!({"tool": "lookup"}), + "content", + json!([{"type": "text", "text": origin}]), + ), + ( + "mcpServer/resource/read", + json!({"uri": "memo://shared"}), + "contents", + json!([{"uri": "memo://shared", "text": origin}]), + ), + ] { + params["threadId"] = json!(thread.id); + params["server"] = json!("notes"); + if let Some(environment_id) = environment_id { + params["environmentId"] = json!(environment_id); + } + let request = app.send_raw_request(method, Some(params)).await?; + let result: serde_json::Value = app.read_response(request).await?; + assert_eq!(result[content_key], expected_content); + } + } + for (method, mut params) in [ + ("mcpServer/tool/call", json!({"tool": "lookup"})), + ("mcpServer/resource/read", json!({"uri": "memo://shared"})), + ] { + params["threadId"] = json!(thread.id); + params["server"] = json!("notes"); + params["environmentId"] = json!("missing"); + let request = app.send_raw_request(method, Some(params)).await?; + let error = app + .read_stream_until_error_message(RequestId::Integer(request)) + .await?; + assert!(error.error.message.contains("unknown MCP server")); + } + first_handle.abort(); + let _ = first_handle.await; + second_handle.abort(); + let _ = second_handle.await; + Ok(()) +} + async fn start_mcp_server( tool_name: &str, tools_error: Option, @@ -1506,6 +1764,7 @@ async fn start_mcp_server( Ok(McpStatusServer { tool_name: Arc::clone(&tool_name), tools_error: tools_error.clone(), + origin: format!("http://{addr}"), }) }, Arc::new(LocalSessionManager::default()), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index dfc2368555..1aaed0e4af 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -124,6 +124,7 @@ async fn mcp_server_tool_call_returns_tool_result() -> Result<()> { .request(|request_id| ClientRequest::McpServerToolCall { request_id, params: McpServerToolCallParams { + environment_id: None, thread_id: thread_id.clone(), server: TEST_SERVER_NAME.to_string(), tool: TEST_TOOL_NAME.to_string(), @@ -211,6 +212,7 @@ async fn mcp_server_tool_call_preserves_protocol_errors(protocol: ProtocolVersio ] { let request_id = mcp .send_mcp_server_tool_call_request(McpServerToolCallParams { + environment_id: None, thread_id: thread.id.clone(), server: server.to_string(), tool: TEST_TOOL_NAME.to_string(), @@ -288,6 +290,7 @@ async fn mcp_server_tool_call_forwards_only_server_extensions() -> Result<()> { .request(|request_id| ClientRequest::McpServerToolCall { request_id, params: McpServerToolCallParams { + environment_id: None, thread_id: thread_id.clone(), server: TEST_SERVER_NAME.to_string(), tool: TEST_TOOL_NAME.to_string(), @@ -433,6 +436,7 @@ async fn mcp_server_tool_call_returns_error_for_unknown_thread() -> Result<()> { let request_id = mcp .send_mcp_server_tool_call_request(McpServerToolCallParams { + environment_id: None, thread_id: "00000000-0000-4000-8000-000000000000".to_string(), server: TEST_SERVER_NAME.to_string(), tool: TEST_TOOL_NAME.to_string(), @@ -602,6 +606,7 @@ async fn assert_full_access_form_elicitation_is_declined( let request_id = mcp .send_mcp_server_tool_call_request(McpServerToolCallParams { + environment_id: None, thread_id: thread.id, server: TEST_SERVER_NAME.to_string(), tool: TEST_TOOL_NAME.to_string(), @@ -700,6 +705,7 @@ async fn mcp_server_tool_call_round_trips_elicitation_for_thread( let tool_call_request_id = mcp .send_mcp_server_tool_call_request(McpServerToolCallParams { + environment_id: None, thread_id: thread.id.clone(), server: TEST_SERVER_NAME.to_string(), tool: TEST_TOOL_NAME.to_string(), @@ -733,6 +739,7 @@ async fn mcp_server_tool_call_round_trips_elicitation_for_thread( thread_id: thread.id, turn_id: None, server_name: TEST_SERVER_NAME.to_string(), + environment_id: None, request: McpServerElicitationRequest::Form { meta: None, message: ELICITATION_MESSAGE.to_string(), @@ -858,6 +865,7 @@ async fn mcp_server_elicitation_survives_environment_runtime_refresh() -> Result let tool_call_request_id = mcp .send_mcp_server_tool_call_request(McpServerToolCallParams { + environment_id: None, thread_id: thread.id.clone(), server: TEST_SERVER_NAME.to_string(), tool: TEST_TOOL_NAME.to_string(), @@ -942,6 +950,7 @@ async fn mcp_server_tool_call_forwards_url_elicitation() -> Result<()> { let tool_call_request_id = mcp .send_mcp_server_tool_call_request(McpServerToolCallParams { + environment_id: None, thread_id: thread.id.clone(), server: TEST_SERVER_NAME.to_string(), tool: TEST_TOOL_NAME.to_string(), @@ -966,6 +975,7 @@ async fn mcp_server_tool_call_forwards_url_elicitation() -> Result<()> { thread_id: thread.id, turn_id: None, server_name: TEST_SERVER_NAME.to_string(), + environment_id: None, request: McpServerElicitationRequest::Url { meta: None, message: URL_ELICITATION_MESSAGE.to_string(), @@ -1090,6 +1100,7 @@ async fn mcp_tool_call_completion_notification_contains_truncated_large_result() let serialized_item = serde_json::to_string(&ThreadItemPayload::McpToolCall { id, server, + environment_id: None, tool, status, arguments: json!({ "message": LARGE_RESPONSE_MESSAGE }), @@ -1240,6 +1251,7 @@ async fn mcp_tool_call_hint_survives_mid_call_thread_read_and_resume() -> Result let expected_item = ThreadItemPayload::McpToolCall { id: call_id.to_string(), server: TEST_SERVER_NAME.to_string(), + environment_id: None, tool: TEST_TOOL_NAME.to_string(), status: McpToolCallStatus::InProgress, arguments: json!({ "message": ELICITATION_TRIGGER_MESSAGE }), diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 0fafd9cce4..b2df7b1bb7 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -2563,6 +2563,7 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests let request_id = mcp .send_mcp_server_tool_call_request(McpServerToolCallParams { + environment_id: None, thread_id: thread.id, server: "sample-mcp".to_string(), tool: "echo".to_string(), @@ -2579,6 +2580,7 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests let request_id = mcp .send_list_mcp_server_status_request(ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 096ddfedb9..976418dad5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -3095,6 +3095,7 @@ fn append_resume_redaction_history( call_id: "mcp-1".to_string(), invocation: McpInvocation { server: "docs".to_string(), + environment_id: None, tool: "lookup".to_string(), arguments: Some(json!({"secret":"argument"})), }, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index c8956575e0..90a9ed0fb1 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -1345,6 +1345,7 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result< McpServerStatusUpdatedNotification { thread_id: Some(start_response.thread.id.clone()), name: "optional_broken".to_string(), + environment_id: None, status: McpServerStartupState::Starting, error: None, failure_reason: None, @@ -1657,6 +1658,7 @@ required = true .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, diff --git a/codex-rs/app-server/tests/suite/v2/user_verification_mcp.rs b/codex-rs/app-server/tests/suite/v2/user_verification_mcp.rs index 3b6aec4c2d..a6f60c53c1 100644 --- a/codex-rs/app-server/tests/suite/v2/user_verification_mcp.rs +++ b/codex-rs/app-server/tests/suite/v2/user_verification_mcp.rs @@ -166,6 +166,7 @@ async fn user_verification_mcp_round_trip_requires_proof_in_full_access() -> Res caller .call_mcp_tool( CODEX_APPS_MCP_SERVER_NAME, + /*environment_id*/ None, "verify_action", /*arguments*/ None, /*meta*/ None, diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 70cf78283b..173b8bb515 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -710,8 +710,7 @@ async fn run_list(config: &Config, list_args: ListArgs) -> Result<()> { runtime_config .mcp_server_catalog .resolve_server_name(name) - .and_then(|key| effective_mcp_servers.get(key)) - .map(|server| (name, server)) + .and_then(|key| effective_mcp_servers.get_key_value(key)) }), config.mcp_oauth_credentials_store_mode, config.auth_keyring_backend_kind(), @@ -720,7 +719,10 @@ async fn run_list(config: &Config, list_args: ListArgs) -> Result<()> { &runtime_config.ema_state, &runtime_context, ) - .await; + .await + .into_iter() + .map(|(key, status)| (key.name().to_owned(), status)) + .collect::>(); if list_args.json { let json_entries: Vec<_> = entries diff --git a/codex-rs/codex-mcp/src/catalog.rs b/codex-rs/codex-mcp/src/catalog.rs index 76ffff068c..2962a31d9b 100644 --- a/codex-rs/codex-mcp/src/catalog.rs +++ b/codex-rs/codex-mcp/src/catalog.rs @@ -801,7 +801,20 @@ impl ResolvedMcpCatalog { }) } - pub(crate) fn servers(&self) -> impl Iterator { + /// Resolves a client target, preserving name-only routing when the scope is omitted. + /// The operation using this key checks whether the registration exists. + pub fn resolve_server_key(&self, name: &str, environment_id: Option<&str>) -> McpServerKey { + match environment_id { + Some(environment_id) => McpServerKey::in_environment(environment_id, name), + None => self + .resolve_server_name(name) + .cloned() + .unwrap_or_else(|| McpServerKey::unscoped(name)), + } + } + + /// Iterates every resolved registration, including same-named scoped peers. + pub fn servers(&self) -> impl Iterator { self.servers.iter() } diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 072a9d0bd1..87c6d4c931 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -372,7 +372,7 @@ pub(crate) struct McpConnectionSet { network_policy_metadata: Option>, servers: HashMap, pub(crate) event_stream_connection: Option>, - disabled_servers: Vec, + disabled_servers: Vec, required_servers: Vec, optional_startup_deadline: OnceLock, tool_plugin_context: Arc, @@ -402,20 +402,6 @@ impl McpConnectionSet { self.server(self.resolve_server_name(name)?) } - /// Name-only status and resource APIs still describe the original first server. - fn legacy_servers(&self) -> impl Iterator { - self.servers - .iter() - .filter_map(|(key, view)| self.legacy_server_name(key).map(|name| (name, view))) - } - - pub(crate) fn legacy_server_name<'a>(&'a self, key: &'a McpServerKey) -> Option<&'a str> { - match &self.config { - Some(config) => config.mcp_server_catalog.legacy_server_name(key), - None => Some(key.name()), - } - } - /// Creates an MCP connection manager. Threadless callers can pass no `tx_event`; startup /// notifications are then skipped and interactive elicitations are declined. pub async fn new( @@ -466,12 +452,7 @@ impl McpConnectionSet { let disabled_servers = mcp_servers .iter() .filter(|(_, server)| !server.enabled()) - .filter_map(|(key, _)| { - config - .mcp_server_catalog - .legacy_server_name(key) - .map(str::to_owned) - }) + .map(|(key, _)| key.clone()) .collect(); let mut required_servers = mcp_servers .iter() @@ -535,6 +516,8 @@ impl McpConnectionSet { let server_name = registration .map_or(server_key.name(), |server| server.name()) .to_owned(); + // Name-only clients still route startup errors by server name; publish only that + // name's default registration. let report_startup = config .mcp_server_catalog .resolve_server_name(&server_name) @@ -912,7 +895,7 @@ impl McpConnectionSet { ); if let Some(error) = unchanged_auth_failure { if report_startup { - eager_starts.push(server_name.clone()); + eager_starts.push(server_key.clone()); } let reason = connection_identity .oauth_credentials() @@ -938,6 +921,9 @@ impl McpConnectionSet { tx_event, McpStartupUpdateEvent { server: server_name.clone(), + environment_id: server_key + .scoped_environment_id() + .map(str::to_owned), status, }, ) @@ -1067,7 +1053,7 @@ impl McpConnectionSet { let submit_id = startup_submit_id.clone(); if !defer_startup && report_startup { - eager_starts.push(server_name.clone()); + eager_starts.push(server_key.clone()); } let startup = async move { if let Some(mut startup_receiver) = startup_receiver @@ -1084,6 +1070,7 @@ impl McpConnectionSet { tx_event, McpStartupUpdateEvent { server: server_name.clone(), + environment_id: server_key.scoped_environment_id().map(str::to_owned), status: McpStartupStatus::Starting, }, ) @@ -1159,6 +1146,7 @@ impl McpConnectionSet { tx_event, McpStartupUpdateEvent { server: server_name.clone(), + environment_id: server_key.scoped_environment_id().map(str::to_owned), status, }, ) @@ -1210,7 +1198,8 @@ impl McpConnectionSet { startup_submit_id.as_str(), tx_event, McpStartupUpdateEvent { - server, + server: server.name().to_owned(), + environment_id: server.scoped_environment_id().map(str::to_owned), status: McpStartupStatus::Starting, }, ) @@ -1228,16 +1217,18 @@ impl McpConnectionSet { .map(str::to_owned) }; if let Some(tx_event) = tx_event { - let mut summary = McpStartupCompleteEvent { - ready: reused_ready.into_iter().filter_map(&legacy_name).collect(), - ..Default::default() - }; - for server_name in &summary.ready { + let mut summary = McpStartupCompleteEvent::default(); + for key in reused_ready { + let Some(server_name) = legacy_name(key.clone()) else { + continue; + }; + summary.ready.push(server_name); let _ = emit_update( startup_submit_id.as_str(), &tx_event, McpStartupUpdateEvent { - server: server_name.clone(), + server: key.name().to_owned(), + environment_id: key.scoped_environment_id().map(str::to_owned), status: McpStartupStatus::Ready, }, ) @@ -1468,14 +1459,21 @@ impl McpConnectionSet { view.metadata.environment_id ); } - if !view.tool_filter.allows(tool) { - return Err(anyhow!( - "tool '{tool}' is disabled for MCP server '{server}'" - )); - } + self.validate_tool_call(server_key, tool)?; Ok(server_key) } + /// Validates an exact client target before startup or execution. + pub(crate) fn validate_tool_call(&self, server: &McpServerKey, tool: &str) -> Result<()> { + let view = self + .server(server) + .ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?; + if !view.tool_filter.allows(tool) { + bail!("tool '{tool}' is disabled for MCP server '{server}'"); + } + Ok(()) + } + /// Invoke the tool indicated by the (server, tool) pair. #[allow(clippy::too_many_arguments)] pub async fn call_tool( @@ -1487,12 +1485,10 @@ impl McpConnectionSet { requested_timeout: Option, wait_for_server: bool, ) -> Result { + self.validate_tool_call(server_key, tool)?; let view = self .server(server_key) .ok_or_else(|| anyhow!("unknown MCP server '{server_key}'"))?; - if !view.tool_filter.allows(tool) { - bail!("tool '{tool}' is disabled for MCP server '{server_key}'"); - } let client = if wait_for_server { view.connection .client() @@ -1543,9 +1539,10 @@ impl McpConnectionSet { /// Capabilities belong to the initialized connection, never a shared tool cache. pub(crate) fn list_available_server_capabilities( &self, - include_server: impl Fn(&str) -> bool, - ) -> HashMap { - self.legacy_servers() + include_server: impl Fn(&McpServerKey) -> bool, + ) -> HashMap { + self.servers + .iter() .filter(|(name, _)| include_server(name)) .filter_map(|(name, view)| { view.connection @@ -1566,13 +1563,10 @@ impl McpConnectionSet { /// connection-specific, so pending regular clients are awaited. pub(crate) async fn list_available_server_infos( &self, - include_server: impl Fn(&str) -> bool, - ) -> HashMap { + include_server: impl Fn(&McpServerKey) -> bool, + ) -> HashMap { let mut server_infos = HashMap::new(); - for (server_name, view) in self - .legacy_servers() - .filter(|(name, _)| include_server(name)) - { + for (server_name, view) in self.servers.iter().filter(|(name, _)| include_server(name)) { let server_name = server_name.to_owned(); let client = &view.connection.client; if !client.startup_complete.load(Ordering::Acquire) diff --git a/codex-rs/codex-mcp/src/connection_manager/resources.rs b/codex-rs/codex-mcp/src/connection_manager/resources.rs index 39bd08d7bd..a3577a2645 100644 --- a/codex-rs/codex-mcp/src/connection_manager/resources.rs +++ b/codex-rs/codex-mcp/src/connection_manager/resources.rs @@ -45,11 +45,12 @@ impl McpConnectionSet { /// Returns resources from servers selected by `include_server`. pub async fn list_all_resources( &self, - include_server: impl Fn(&str) -> bool, - ) -> HashMap> { + include_server: impl Fn(&McpServerKey) -> bool, + ) -> HashMap> { let mut join_set = JoinSet::new(); for (server_name, view) in self - .legacy_servers() + .servers + .iter() .filter(|(server_name, _)| include_server(server_name)) { let server_name = server_name.to_owned(); @@ -91,10 +92,10 @@ impl McpConnectionSet { /// Returns resource templates from servers selected by `include_server`. pub async fn list_all_resource_templates( &self, - include_server: impl Fn(&str) -> bool, - ) -> HashMap> { + include_server: impl Fn(&McpServerKey) -> bool, + ) -> HashMap> { let mut join_set = JoinSet::new(); - for (server_name, view) in self.legacy_servers().filter(|(server_name, view)| { + for (server_name, view) in self.servers.iter().filter(|(server_name, view)| { include_server(server_name) && !self.skips_resource_template_listing(&view.metadata.server_key) }) { diff --git a/codex-rs/codex-mcp/src/connection_manager/status.rs b/codex-rs/codex-mcp/src/connection_manager/status.rs index 79107328d2..b9a2f8e2b7 100644 --- a/codex-rs/codex-mcp/src/connection_manager/status.rs +++ b/codex-rs/codex-mcp/src/connection_manager/status.rs @@ -5,9 +5,12 @@ use std::collections::HashMap; use codex_protocol::mcp::McpServerConnectionStatus; use super::McpConnectionSet; +use crate::McpServerKey; impl McpConnectionSet { - pub(crate) async fn connection_statuses(&self) -> HashMap { + pub(crate) async fn connection_statuses( + &self, + ) -> HashMap { use McpServerConnectionStatus as Status; let mut statuses = self @@ -15,7 +18,7 @@ impl McpConnectionSet { .iter() .map(|name| (name.clone(), Status::Disabled)) .collect::>(); - for (name, view) in self.legacy_servers() { + for (name, view) in &self.servers { let connection = &view.connection; let client = &connection.client; let status = if connection.startup_is_dormant() && !client.cancel_token.is_cancelled() { diff --git a/codex-rs/codex-mcp/src/connection_manager/tool_catalog.rs b/codex-rs/codex-mcp/src/connection_manager/tool_catalog.rs index 8fe51274df..a2f57bfc99 100644 --- a/codex-rs/codex-mcp/src/connection_manager/tool_catalog.rs +++ b/codex-rs/codex-mcp/src/connection_manager/tool_catalog.rs @@ -198,19 +198,19 @@ impl McpConnectionSet { #[instrument(level = "trace", skip_all, fields(mcp_server_count = self.servers.len()))] pub(crate) async fn list_tools_with_errors( &self, - include_server: impl Fn(&str) -> bool, - ) -> (Vec, HashMap) { + include_server: impl Fn(&McpServerKey) -> bool, + ) -> (Vec, HashMap) { let (tools, errors, _) = self.list_catalogs_with_errors(include_server).await; (tools, errors) } pub(crate) async fn list_catalogs_with_errors( &self, - include_server: impl Fn(&str) -> bool, + include_server: impl Fn(&McpServerKey) -> bool, ) -> ( Vec, - HashMap, - HashMap>, + HashMap, + HashMap>, ) { let mut metadata = HashMap::new(); let mut tools = Vec::new(); @@ -223,7 +223,8 @@ impl McpConnectionSet { .map(|config| config.mcp_server_catalog.exposure()) .unwrap_or_default(); let server_results = join_all( - self.legacy_servers() + self.servers + .iter() .filter(|(name, _)| include_server(name)) .map(|(server_name, view)| async move { view.connection.client.reconnect_failed_startup().await; @@ -234,7 +235,7 @@ impl McpConnectionSet { .startup_complete .load(Ordering::Acquire); let server_tools = view - .listed_catalog(server_name, &self.tool_plugin_context, exposure) + .listed_catalog(server_name.name(), &self.tool_plugin_context, exposure) .instrument(trace_span!( "list_tools_for_server", server_name = %server_name, diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index dd9705c53a..803419255f 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -688,7 +688,9 @@ async fn connection_statuses_observe_clients_without_starting_them() { use codex_protocol::mcp::McpServerConnectionStatus as Status; let mut manager = McpConnectionSet::empty(/*prefix_mcp_tool_names*/ true); - manager.disabled_servers.push("disabled".to_string()); + manager + .disabled_servers + .push(McpServerKey::unscoped("disabled")); let ready = create_ready_async_managed_client(Vec::new()).await; ready.client().await.expect("ready client"); manager.insert_test_client("connected", ready); @@ -727,16 +729,17 @@ async fn connection_statuses_observe_clients_without_starting_them() { pending.cached_server_info = Some(create_test_server_info("Cached")); manager.insert_test_client("starting", pending.clone()); manager.insert_test_client("deferred", pending); + let scoped_key = McpServerKey::in_environment("remote", "connected"); + let mut deferred = manager + .servers + .remove(&McpServerKey::unscoped("deferred")) + .unwrap(); + deferred.metadata.server_key = scoped_key.clone(); + manager.servers.insert(scoped_key.clone(), deferred); let (trigger, _receiver) = watch::channel(/*init*/ false); - Arc::get_mut( - &mut manager - .servers - .get_mut(&McpServerKey::unscoped("deferred")) - .unwrap() - .connection, - ) - .unwrap() - .startup_trigger = Some(trigger.clone()); + Arc::get_mut(&mut manager.servers.get_mut(&scoped_key).unwrap().connection) + .unwrap() + .startup_trigger = Some(trigger.clone()); let statuses = tokio::time::timeout( Duration::from_millis(/*millis*/ 100), @@ -745,19 +748,25 @@ async fn connection_statuses_observe_clients_without_starting_them() { .await .expect("status must not await startup"); let mut expected = HashMap::from([ - ("connected".to_string(), Status::Connected), - ("failed".to_string(), Status::Failed), - ("auth".to_string(), Status::AuthenticationRequired), - ("flattened-auth".to_string(), Status::AuthenticationRequired), - ("cancelled".to_string(), Status::Cancelled), - ("starting".to_string(), Status::Starting), - ("deferred".to_string(), Status::NotStarted), - ("disabled".to_string(), Status::Disabled), + (McpServerKey::unscoped("connected"), Status::Connected), + (McpServerKey::unscoped("failed"), Status::Failed), + ( + McpServerKey::unscoped("auth"), + Status::AuthenticationRequired, + ), + ( + McpServerKey::unscoped("flattened-auth"), + Status::AuthenticationRequired, + ), + (McpServerKey::unscoped("cancelled"), Status::Cancelled), + (McpServerKey::unscoped("starting"), Status::Starting), + (scoped_key, Status::NotStarted), + (McpServerKey::unscoped("disabled"), Status::Disabled), ]); assert_eq!(statuses, expected); assert!(!*trigger.borrow()); manager.test_client("connected").cancel_token.cancel(); - expected.insert("connected".to_string(), Status::Cancelled); + expected.insert(McpServerKey::unscoped("connected"), Status::Cancelled); assert_eq!(manager.connection_statuses().await, expected); } @@ -802,7 +811,8 @@ async fn connection_statuses_follow_latest_reconnect_outcome() { let manager = create_test_manager_with_failed_apps_startup(Vec::new(), factory); let client = manager.test_client(CODEX_APPS_MCP_SERVER_NAME); assert!(client.client().await.is_err()); - let expected = |status| HashMap::from([(CODEX_APPS_MCP_SERVER_NAME.to_string(), status)]); + let expected = + |status| HashMap::from([(McpServerKey::unscoped(CODEX_APPS_MCP_SERVER_NAME), status)]); assert_eq!( manager.connection_statuses().await, expected(Status::Failed) @@ -3063,7 +3073,7 @@ async fn list_available_server_infos_uses_cache_while_client_is_pending() { .await; let server_infos = timeout_result.expect("server info lookup should not block on startup"); assert_eq!( - server_infos.get(CODEX_APPS_MCP_SERVER_NAME), + server_infos.get(&McpServerKey::unscoped(CODEX_APPS_MCP_SERVER_NAME)), Some(&server_info) ); } @@ -4502,7 +4512,7 @@ async fn list_all_tools_uses_shared_codex_apps_cache_when_client_startup_fails() manager .list_available_server_infos(|_| true) .await - .get(CODEX_APPS_MCP_SERVER_NAME), + .get(&McpServerKey::unscoped(CODEX_APPS_MCP_SERVER_NAME)), Some(&server_info) ); } @@ -5170,7 +5180,7 @@ async fn executor_owned_chatgpt_mcp_accepts_only_safe_explicit_authorization() - "{case}: executor-owned servers must not retain hosted OAuth credentials" ); let auth_statuses = crate::compute_auth_statuses( - [(&"fake-first-party".to_string(), remote_server)], + [(&McpServerKey::unscoped("fake-first-party"), remote_server)], OAuthCredentialsStoreMode::default(), AuthKeyringBackendKind::default(), Some(&hosted_auth), @@ -5185,7 +5195,8 @@ async fn executor_owned_chatgpt_mcp_accepts_only_safe_explicit_authorization() - McpAuthState::Unsupported }; assert_eq!( - auth_statuses["fake-first-party"].auth_state, expected_auth_state, + auth_statuses[&McpServerKey::unscoped("fake-first-party")].auth_state, + expected_auth_state, "{case}: auth status must only accept safe executor-owned authorization" ); diff --git a/codex-rs/codex-mcp/src/elicitation.rs b/codex-rs/codex-mcp/src/elicitation.rs index fc4da8cc33..5dbe5cd775 100644 --- a/codex-rs/codex-mcp/src/elicitation.rs +++ b/codex-rs/codex-mcp/src/elicitation.rs @@ -275,9 +275,10 @@ impl ElicitationRequestRouter { &self, events: Option>, authority: &ElicitationAuthority, - server_name: String, + server_key: McpServerKey, request: ElicitationRequest, ) -> Result { + let server_name = server_key.name().to_owned(); let Some(events) = events else { return Ok(ElicitationResponse { action: ElicitationAction::Decline, @@ -319,6 +320,7 @@ impl ElicitationRequestRouter { msg: EventMsg::ElicitationRequest(ElicitationRequestEvent { turn_id: None, server_name, + environment_id: server_key.scoped_environment_id().map(str::to_owned), id: ProtocolRequestId::String(public_request_id), request, }), @@ -684,7 +686,7 @@ impl ElicitationRequestManager { }, }; router - .request_user_interaction(tx_event, &authority, server_name, request) + .request_user_interaction(tx_event, &authority, server_key, request) .await } .boxed() diff --git a/codex-rs/codex-mcp/src/elicitation_tests.rs b/codex-rs/codex-mcp/src/elicitation_tests.rs index 79f1955987..b32953a412 100644 --- a/codex-rs/codex-mcp/src/elicitation_tests.rs +++ b/codex-rs/codex-mcp/src/elicitation_tests.rs @@ -31,13 +31,13 @@ async fn abandonment_is_ordered_and_exactly_once_when_the_native_waiter_is_dropp let mut first = Box::pin(router.request_user_interaction( Some(tx), &authority, - "server".into(), + McpServerKey::in_environment("first", "server"), request.clone(), )); let mut sibling = Box::pin(router.request_user_interaction( Some(replacement_tx), &authority, - "server".into(), + McpServerKey::in_environment("second", "server"), request, )); assert!(futures::poll!(&mut first).is_pending()); @@ -50,6 +50,19 @@ async fn abandonment_is_ordered_and_exactly_once_when_the_native_waiter_is_dropp else { panic!("expected sibling request"); }; + assert_eq!( + [ + ( + first_request.server_name.as_str(), + first_request.environment_id.as_deref() + ), + ( + sibling_request.server_name.as_str(), + sibling_request.environment_id.as_deref() + ), + ], + [("server", Some("first")), ("server", Some("second"))], + ); drop(first); let EventMsg::ElicitationAbandoned(abandoned) = events.recv().await.unwrap().msg else { panic!("expected abandonment"); @@ -107,7 +120,7 @@ async fn bounded_sources_preserve_backpressure_across_close() { let mut pending = Box::pin(router.request_user_interaction( Some(tx), &authority, - "server".into(), + McpServerKey::unscoped("server"), ElicitationRequest::Form { meta: None, message: "Continue".into(), @@ -150,7 +163,7 @@ async fn abandonment_closed_receiver_cleans_up_without_delivery_or_replay() { let mut pending = Box::pin(router.request_user_interaction( Some(tx), &authority, - "server".into(), + McpServerKey::unscoped("server"), ElicitationRequest::Form { meta: None, message: "Continue".into(), @@ -188,7 +201,7 @@ async fn abandonment_close_joins_elected_actions_and_rejects_retained_router_adm let mut pending = Box::pin(router.request_user_interaction( Some(tx.clone()), &authority, - "server".into(), + McpServerKey::unscoped("server"), request.clone(), )); assert!(futures::poll!(&mut pending).is_pending()); @@ -213,7 +226,7 @@ async fn abandonment_close_joins_elected_actions_and_rejects_retained_router_adm .request_user_interaction( Some(tx.clone()), &authority, - "server".into(), + McpServerKey::unscoped("server"), request.clone(), ) .await @@ -240,7 +253,12 @@ async fn abandonment_close_joins_elected_actions_and_rejects_retained_router_adm router.close().await; assert!( retained_router - .request_user_interaction(Some(tx), &authority, "server".into(), request,) + .request_user_interaction( + Some(tx), + &authority, + McpServerKey::unscoped("server"), + request + ) .await .is_err() ); @@ -256,7 +274,7 @@ async fn abandonment_close_invalidates_a_published_responder_before_returning() let mut pending = Box::pin(router.request_user_interaction( Some(tx), &authority, - "server".into(), + McpServerKey::unscoped("server"), ElicitationRequest::Form { meta: None, message: "Continue".into(), diff --git a/codex-rs/codex-mcp/src/mcp/auth.rs b/codex-rs/codex-mcp/src/mcp/auth.rs index d193f1e608..d99a1a3e03 100644 --- a/codex-rs/codex-mcp/src/mcp/auth.rs +++ b/codex-rs/codex-mcp/src/mcp/auth.rs @@ -25,6 +25,7 @@ use futures::future::join_all; use tokio::sync::OnceCell; use tracing::warn; +use crate::McpServerKey; use crate::runtime::McpRuntimeContext; use crate::server::EffectiveMcpServer; use crate::server::has_explicit_http_authorization; @@ -205,9 +206,9 @@ pub async fn compute_auth_statuses<'a, I>( auth_manager: Option<&std::sync::Arc>, ema_state: &crate::EmaState, runtime_context: &McpRuntimeContext, -) -> HashMap +) -> HashMap where - I: IntoIterator, + I: IntoIterator, { // Share successful, missing, and failed reads without starting storage work for non-EMA servers. let account_grant = OnceCell::new(); @@ -223,7 +224,7 @@ where let runtime_context = runtime_context.clone(); async move { let auth_state = match compute_auth_status( - &name, + name.name(), &config, store_mode, keyring_backend_kind, diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 4d5953fce0..130b84dc8e 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -482,10 +482,14 @@ pub async fn read_mcp_resource( codex_apps_tools_cache: ConnectorRuntimeManager, tool_catalog_cache: crate::McpToolCatalogCache, server: &str, + environment_id: Option<&str>, params: ReadResourceRequestParams, ) -> anyhow::Result { + let server_key = config + .mcp_server_catalog + .resolve_server_key(server, environment_id); let mut mcp_servers = effective_mcp_servers(config, auth); - mcp_servers.retain(|key, _| config.mcp_server_catalog.legacy_server_name(key) == Some(server)); + mcp_servers.retain(|key, _| key == &server_key); let cancel_token = CancellationToken::new(); let runtime_config = config.for_threadless_operations(&mcp_servers); let manager = McpConnectionSet::new( @@ -518,25 +522,22 @@ pub async fn read_mcp_resource( ) .await; - let result = match manager.resolve_server_name(server) { - Some(key) => manager.read_resource(key, params).await, - None => Err(anyhow::anyhow!("unknown MCP server '{server}'")), - }; + let result = manager.read_resource(&server_key, params).await; cancel_token.cancel(); result } #[derive(Debug, Clone)] pub struct McpServerStatusSnapshot { - pub server_infos: HashMap, - pub server_capabilities: HashMap, - pub tool_list_meta: HashMap>, - pub tools_by_server: HashMap>, - pub tools_errors: HashMap, - pub resources: HashMap>, - pub resource_templates: HashMap>, - pub auth_statuses: HashMap, - pub server_names: Vec, + pub server_infos: HashMap, + pub server_capabilities: HashMap, + pub tool_list_meta: HashMap>, + pub tools_by_server: HashMap>, + pub tools_errors: HashMap, + pub resources: HashMap>, + pub resource_templates: HashMap>, + pub auth_statuses: HashMap, + pub server_keys: Vec, } #[allow(clippy::too_many_arguments)] @@ -552,15 +553,10 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( codex_apps_tools_cache: ConnectorRuntimeManager, tool_catalog_cache: crate::McpToolCatalogCache, detail: McpSnapshotDetail, - server_name: Option<&str>, + server: Option<&McpServerKey>, ) -> McpServerStatusSnapshot { let mut mcp_servers = effective_mcp_servers(config, auth); - mcp_servers.retain(|key, _| { - config - .mcp_server_catalog - .legacy_server_name(key) - .is_some_and(|name| server_name.is_none_or(|selected| name == selected)) - }); + mcp_servers.retain(|key, _| server.is_none_or(|selected| key == selected)); if mcp_servers.is_empty() { return McpServerStatusSnapshot { server_infos: HashMap::new(), @@ -571,21 +567,12 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( resources: HashMap::new(), resource_templates: HashMap::new(), auth_statuses: HashMap::new(), - server_names: Vec::new(), + server_keys: Vec::new(), }; } - let named_servers = mcp_servers - .iter() - .filter_map(|(key, server)| { - config - .mcp_server_catalog - .legacy_server_name(key) - .map(|name| (name.to_owned(), server.clone())) - }) - .collect::>(); let auth_status_entries = compute_auth_statuses( - named_servers.iter(), + mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, config.auth_keyring_backend_kind, auth, @@ -595,7 +582,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( ) .await; - let server_names = named_servers.into_keys().collect(); + let server_keys = mcp_servers.keys().cloned().collect(); let cancel_token = CancellationToken::new(); let runtime_config = config.for_threadless_operations(&mcp_servers); @@ -632,7 +619,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( let snapshot = collect_mcp_server_status_snapshot_from_manager( &mcp_connection_manager, auth_status_entries, - server_names, + server_keys, detail, ) .await; @@ -774,8 +761,8 @@ fn protocol_tool_from_rmcp_tool(name: &str, tool: &rmcp::model::Tool) -> Option< } fn auth_statuses_from_entries( - auth_status_entries: &HashMap, -) -> HashMap { + auth_status_entries: &HashMap, +) -> HashMap { auth_status_entries .iter() .map(|(name, entry)| (name.clone(), McpAuthStatus::from(entry.auth_state))) @@ -783,8 +770,8 @@ fn auth_statuses_from_entries( } fn convert_mcp_resources( - resources: HashMap>, -) -> HashMap> { + resources: HashMap>, +) -> HashMap> { resources .into_iter() .map(|(name, resources)| { @@ -822,8 +809,8 @@ fn convert_mcp_resources( } fn convert_mcp_resource_templates( - resource_templates: HashMap>, -) -> HashMap> { + resource_templates: HashMap>, +) -> HashMap> { resource_templates .into_iter() .map(|(name, templates)| { @@ -863,12 +850,12 @@ fn convert_mcp_resource_templates( pub(crate) async fn collect_mcp_server_status_snapshot_from_manager( mcp_connection_manager: &McpConnectionSet, - auth_status_entries: HashMap, - server_names: Vec, + auth_status_entries: HashMap, + server_keys: Vec, detail: McpSnapshotDetail, ) -> McpServerStatusSnapshot { - let selected_servers: HashSet<&str> = server_names.iter().map(String::as_str).collect(); - let include_server = |name: &str| selected_servers.contains(name); + let selected_servers: HashSet<&McpServerKey> = server_keys.iter().collect(); + let include_server = |key: &McpServerKey| selected_servers.contains(key); let ((server_infos, (tools, tools_errors, tool_list_meta)), resources, resource_templates) = tokio::join!( async { let server_infos = mcp_connection_manager @@ -901,7 +888,7 @@ pub(crate) async fn collect_mcp_server_status_snapshot_from_manager( let server_capabilities = mcp_connection_manager.list_available_server_capabilities(include_server); - let mut tools_by_server = HashMap::>::new(); + let mut tools_by_server = HashMap::>::new(); for tool_info in tools { let raw_tool_name = tool_info.tool.name.to_string(); let Some(tool) = protocol_tool_from_rmcp_tool(&raw_tool_name, &tool_info.tool) else { @@ -909,7 +896,7 @@ pub(crate) async fn collect_mcp_server_status_snapshot_from_manager( }; let tool_name = tool.name.clone(); tools_by_server - .entry(tool_info.server_name().to_owned()) + .entry(tool_info.server_key) .or_default() .insert(tool_name, tool); } @@ -918,19 +905,13 @@ pub(crate) async fn collect_mcp_server_status_snapshot_from_manager( // connection attempt instead of retaining the credential-presence status read beforehand. let mut auth_statuses = auth_statuses_from_entries(&auth_status_entries); for key in mcp_connection_manager.authentication_failed_servers().await { - let Some(server_name) = mcp_connection_manager - .legacy_server_name(&key) - .map(str::to_owned) - else { - continue; - }; - if auth_statuses.get(&server_name) == Some(&McpAuthStatus::OAuth) + if auth_statuses.get(&key) == Some(&McpAuthStatus::OAuth) || auth_status_entries - .get(&server_name) + .get(&key) .and_then(|entry| entry.config.as_ref()) .is_some_and(|config| matches!(config.auth, McpServerAuth::EmaAuth)) { - auth_statuses.insert(server_name, McpAuthStatus::NotLoggedIn); + auth_statuses.insert(key, McpAuthStatus::NotLoggedIn); } } @@ -943,7 +924,7 @@ pub(crate) async fn collect_mcp_server_status_snapshot_from_manager( resources: convert_mcp_resources(resources), resource_templates: convert_mcp_resource_templates(resource_templates), auth_statuses, - server_names, + server_keys, } } diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index a5389763c8..c0addd6d2e 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -91,7 +91,7 @@ async fn ema_auth_statuses_do_not_wait_for_account_refresh() -> anyhow::Result<( }) .map_err(anyhow::Error::msg)?; servers.insert( - name.to_string(), + McpServerKey::unscoped(name), EffectiveMcpServer::from_host_config(server), ); } @@ -119,8 +119,8 @@ async fn ema_auth_statuses_do_not_wait_for_account_refresh() -> anyhow::Result<( .map(|(name, entry)| (name, entry.auth_state)) .collect::>(), HashMap::from([ - ("first".to_string(), McpAuthState::BearerToken), - ("second".to_string(), McpAuthState::BearerToken), + (McpServerKey::unscoped("first"), McpAuthState::BearerToken), + (McpServerKey::unscoped("second"), McpAuthState::BearerToken), ]), ); drop(lease); @@ -173,7 +173,7 @@ async fn status_snapshot_only_downgrades_oauth_and_ema_authentication_failures() } manager.insert_test_client(name, client); auth_status_entries.insert( - name.to_string(), + McpServerKey::unscoped(name), McpAuthStatusEntry { config: name.starts_with("ema-").then(|| { serde_json::from_value(serde_json::json!({ @@ -186,26 +186,29 @@ async fn status_snapshot_only_downgrades_oauth_and_ema_authentication_failures() ); } - let server_names = auth_status_entries.keys().cloned().collect(); + let server_keys = auth_status_entries.keys().cloned().collect(); let snapshot = collect_mcp_server_status_snapshot_from_manager( &manager, auth_status_entries, - server_names, + server_keys, McpSnapshotDetail::ToolsAndAuthOnly, ) .await; assert_eq!( snapshot.auth_statuses, - HashMap::from([ - ("oauth-failed".to_string(), McpAuthStatus::NotLoggedIn), - ("oauth-ready".to_string(), McpAuthStatus::OAuth), - ("oauth-provider-error".to_string(), McpAuthStatus::OAuth), - ("ema-failed".to_string(), McpAuthStatus::NotLoggedIn), - ("ema-ready".to_string(), McpAuthStatus::BearerToken), - ("ema-provider-error".to_string(), McpAuthStatus::BearerToken), - ("bearer".to_string(), McpAuthStatus::BearerToken), - ]), + HashMap::from( + [ + ("oauth-failed", McpAuthStatus::NotLoggedIn), + ("oauth-ready", McpAuthStatus::OAuth), + ("oauth-provider-error", McpAuthStatus::OAuth), + ("ema-failed", McpAuthStatus::NotLoggedIn), + ("ema-ready", McpAuthStatus::BearerToken), + ("ema-provider-error", McpAuthStatus::BearerToken), + ("bearer", McpAuthStatus::BearerToken), + ] + .map(|(name, status)| (McpServerKey::unscoped(name), status)), + ), ); } diff --git a/codex-rs/codex-mcp/src/resource_client.rs b/codex-rs/codex-mcp/src/resource_client.rs index e84c4c6908..9ce31c3d22 100644 --- a/codex-rs/codex-mcp/src/resource_client.rs +++ b/codex-rs/codex-mcp/src/resource_client.rs @@ -346,7 +346,10 @@ impl McpResourceClient { /// Reads one resource from the named server. pub async fn read_resource(&self, server: &str, uri: &str) -> Result { let params = ReadResourceRequestParams::new(uri.to_string()); - let result = self.runtime.latest_read_resource(server, params).await?; + let result = self + .runtime + .latest_read_resource(server, /*environment_id*/ None, params) + .await?; let contents = result .contents .into_iter() diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index f2b1139d2e..c082941e16 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -151,7 +151,7 @@ struct CodexAppsStartupReconnectState { #[derive(Clone)] struct CodexAppsStartupStatusContext { submit_id: String, - server_name: String, + server_key: McpServerKey, tx_event: Sender, } @@ -173,12 +173,12 @@ impl CodexAppsStartupReconnect { fn with_startup_status_context( mut self, submit_id: String, - server_name: String, + server_key: McpServerKey, tx_event: Option>, ) -> Self { self.startup_status_context = tx_event.map(|tx_event| CodexAppsStartupStatusContext { submit_id, - server_name, + server_key, tx_event, }); self @@ -247,7 +247,11 @@ impl CodexAppsStartupReconnect { .send(Event { id: context.submit_id, msg: EventMsg::McpStartupUpdate(McpStartupUpdateEvent { - server: context.server_name, + server: context.server_key.name().to_owned(), + environment_id: context + .server_key + .scoped_environment_id() + .map(str::to_owned), status: McpStartupStatus::Ready, }), }) @@ -505,7 +509,7 @@ impl AsyncManagedClient { ) -> Self { let server_name = server_key.name().to_owned(); let is_codex_apps_mcp_server = server_name == CODEX_APPS_MCP_SERVER_NAME; - let reconnect_server_name = server_name; + let reconnect_server_key = server_key.clone(); let reconnect_tx_event = tx_event.clone(); let cached_server_info = if is_codex_apps_mcp_server { codex_apps_tools_cache_context @@ -614,7 +618,7 @@ impl AsyncManagedClient { })) .with_startup_status_context( startup_submit_id, - reconnect_server_name, + reconnect_server_key, reconnect_tx_event, ), ) diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index 570ff1aea1..55b3b50551 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -4,7 +4,8 @@ //! environment in which MCP servers execute. Transport startup lives in //! [`crate::rmcp_client`] and connection-set behavior lives in //! [`crate::connection_manager`]. Registry removal revokes EMA authority before -//! the thread becomes untracked. +//! the thread becomes untracked. Client targets resolve against the same runtime +//! publication whose connections execute the operation. #[cfg(test)] #[path = "runtime/apps_tool_refresh_tests.rs"] @@ -829,29 +830,53 @@ impl McpRuntime { pub async fn latest_read_resource( &self, server: &str, + environment_id: Option<&str>, params: ReadResourceRequestParams, ) -> anyhow::Result { - let connections = self.latest_connections(); - let key = connections - .resolve_server_name(server) + let current = self.current.load_full(); + let key = current + .config + .as_ref() + .map(|config| { + config + .mcp_server_catalog + .resolve_server_key(server, environment_id) + }) .ok_or_else(|| anyhow::anyhow!("unknown MCP server '{server}'"))?; - connections.read_resource(key, params).await + current.connections.read_resource(&key, params).await } pub async fn latest_call_tool_without_attribution( &self, server: &str, + environment_id: Option<&str>, tool: &str, arguments: Option, meta: Option, ) -> anyhow::Result { - let connections = self.latest_connections(); - let server_key = - connections.resolve_tool_call_server(server, tool, /*environment_id*/ None)?; - connections.wait_for_server_startup(server_key).await; - connections + let current = self.current.load_full(); + let server_key = current + .config + .as_ref() + .map(|config| { + config + .mcp_server_catalog + .resolve_server_key(server, environment_id) + }) + .ok_or_else(|| anyhow::anyhow!("unknown MCP server '{server}'"))?; + current.connections.validate_tool_call(&server_key, tool)?; + current + .connections + .wait_for_server_startup(&server_key) + .await; + current + .connections .call_tool( - server_key, tool, arguments, meta, /*requested_timeout*/ None, + &server_key, + tool, + arguments, + meta, + /*requested_timeout*/ None, /*wait_for_server*/ true, ) .await @@ -967,7 +992,8 @@ impl McpRuntime { pub async fn connection_statuses( &self, config: &McpConfig, - ) -> std::collections::HashMap { + ) -> std::collections::HashMap + { let current = self.current.load_full(); let Some(published_config) = current.config.as_ref() else { return HashMap::new(); @@ -976,10 +1002,8 @@ impl McpRuntime { statuses.retain(|name, _| { published_config .mcp_server_catalog - .server_by_name(name) - .is_some_and(|server| { - config.mcp_server_catalog.server_by_name(name) == Some(server) - }) + .server(name) + .is_some_and(|server| config.mcp_server_catalog.server(name) == Some(server)) }); statuses } @@ -1342,6 +1366,7 @@ mod tests { runtime .latest_call_tool_without_attribution( CODEX_APPS_MCP_SERVER_NAME, + /*environment_id*/ None, "search", /*arguments*/ None, /*meta*/ None, diff --git a/codex-rs/codex-mcp/src/runtime/status.rs b/codex-rs/codex-mcp/src/runtime/status.rs index bb4b382e62..5a4d393fa0 100644 --- a/codex-rs/codex-mcp/src/runtime/status.rs +++ b/codex-rs/codex-mcp/src/runtime/status.rs @@ -14,11 +14,12 @@ use crate::mcp::effective_mcp_servers; impl McpRuntime { /// Reuses the selected connection and catalog without starting unrelated servers. - /// Configuration and metadata belong to the same captured runtime publication. + /// Resolves the selector, configuration, and metadata in one captured runtime publication. /// An expected registration excludes mismatched connections before discovery. pub async fn server_status_snapshot( &self, server: &str, + environment_id: Option<&str>, detail: McpSnapshotDetail, expected_registration: Option<&ResolvedMcpServer>, runtime_context: &McpRuntimeContext, @@ -34,16 +35,15 @@ impl McpRuntime { .as_ref() .ok_or_else(|| anyhow::anyhow!("MCP runtime is not configured"))?, ); + let server = config + .mcp_server_catalog + .resolve_server_key(server, environment_id); let mut servers = effective_mcp_servers(&config, current.auth.as_ref()); - servers.retain(|name, _| { - Some(name) == config.mcp_server_catalog.resolve_server_name(server) + servers.retain(|key, _| { + key == &server && expected_registration - .is_none_or(|expected| config.mcp_server_catalog.server(name) == Some(expected)) + .is_none_or(|expected| config.mcp_server_catalog.server(key) == Some(expected)) }); - let servers = servers - .into_values() - .map(|value| (server.to_owned(), value)) - .collect::>(); if config.xaa_enabled && servers .values() @@ -52,7 +52,7 @@ impl McpRuntime { // Finish selected startup and any rejected-grant cleanup before reading auth. current .connections - .list_available_server_infos(|name| name == server) + .list_available_server_infos(|name| name == &server) .await; } let auth_statuses = compute_auth_statuses( diff --git a/codex-rs/codex-mcp/src/user_verification_elicitation.rs b/codex-rs/codex-mcp/src/user_verification_elicitation.rs index 984e7d3384..af83f2ceb0 100644 --- a/codex-rs/codex-mcp/src/user_verification_elicitation.rs +++ b/codex-rs/codex-mcp/src/user_verification_elicitation.rs @@ -28,11 +28,6 @@ pub(super) async fn route( }); }; router - .request_user_interaction( - Some(events), - &authority, - server_key.name().to_owned(), - request, - ) + .request_user_interaction(Some(events), &authority, server_key, request) .await } diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index ab7e698585..ed83029dbd 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -76,6 +76,7 @@ async fn forward_events_filters_private_events_before_blocked_send_is_cancelled( for msg in [ EventMsg::McpStartupUpdate(McpStartupUpdateEvent { server: "pending".to_string(), + environment_id: None, status: McpStartupStatus::Starting, }), EventMsg::McpStartupComplete(McpStartupCompleteEvent::default()), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 1ad0ce3d68..9222dff46e 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1264,7 +1264,10 @@ impl CodexThread { pub async fn mcp_connection_statuses( &self, config: &codex_mcp::McpConfig, - ) -> std::collections::HashMap { + ) -> std::collections::HashMap< + codex_mcp::McpServerKey, + codex_protocol::mcp::McpServerConnectionStatus, + > { self.session .services .mcp_runtime @@ -1388,6 +1391,7 @@ impl CodexThread { pub async fn read_mcp_resource( &self, server: &str, + environment_id: Option<&str>, params: ReadResourceRequestParams, ) -> anyhow::Result { self.session.refresh_mcp_if_dirty().await; @@ -1395,7 +1399,7 @@ impl CodexThread { .session .services .mcp_runtime - .latest_read_resource(server, params) + .latest_read_resource(server, environment_id, params) .await?; Ok(serde_json::to_value(result)?) @@ -1405,6 +1409,7 @@ impl CodexThread { pub async fn mcp_server_status_snapshot( &self, server: &str, + environment_id: Option<&str>, detail: codex_mcp::McpSnapshotDetail, expected_registration: Option<&codex_mcp::ResolvedMcpServer>, ) -> anyhow::Result<( @@ -1416,7 +1421,13 @@ impl CodexThread { self.session .services .mcp_runtime - .server_status_snapshot(server, detail, expected_registration, &runtime_context) + .server_status_snapshot( + server, + environment_id, + detail, + expected_registration, + &runtime_context, + ) .await } @@ -1460,6 +1471,7 @@ impl CodexThread { pub async fn call_mcp_tool( &self, server: &str, + environment_id: Option<&str>, tool: &str, arguments: Option, meta: Option, @@ -1468,7 +1480,7 @@ impl CodexThread { self.session .services .mcp_runtime - .latest_call_tool_without_attribution(server, tool, arguments, meta) + .latest_call_tool_without_attribution(server, environment_id, tool, arguments, meta) .await } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index cb62c150c3..4084f21dbd 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -184,6 +184,10 @@ pub(crate) async fn handle_mcp_tool_call( let invocation = McpInvocation { server: server.clone(), + environment_id: advertised_tool + .server_key() + .scoped_environment_id() + .map(str::to_owned), tool: tool_name.clone(), arguments: arguments_value.clone(), }; @@ -959,7 +963,7 @@ async fn maybe_request_codex_apps_auth_elicitation( let response = sess .request_mcp_server_elicitation( turn_context, - CODEX_APPS_MCP_SERVER_NAME.to_string(), + McpServerKey::unscoped(CODEX_APPS_MCP_SERVER_NAME), request_id, request, ) @@ -1136,12 +1140,14 @@ async fn notify_mcp_tool_call_started( ) { let McpInvocation { server, + environment_id, tool, arguments, } = invocation; let item = TurnItem::McpToolCall(McpToolCallItem { id: call_id.to_string(), server, + environment_id, tool, arguments: arguments.unwrap_or(JsonValue::Null), connector_id: item_metadata.connector_id, @@ -1182,12 +1188,14 @@ async fn notify_mcp_tool_call_completed( }; let McpInvocation { server, + environment_id, tool, arguments, } = invocation; let item = TurnItem::McpToolCall(McpToolCallItem { id: call_id.to_string(), server, + environment_id, tool, arguments: arguments.unwrap_or(JsonValue::Null), connector_id: item_metadata.connector_id, @@ -1926,7 +1934,7 @@ pub(crate) async fn request_mcp_tool_user_approval( prompt_options, }); let response = sess - .request_mcp_server_elicitation(turn_context, server.clone(), request_id, request) + .request_mcp_server_elicitation(turn_context, server_key.clone(), request_id, request) .await; // Missing replies are interruptions. A received Cancel needs provenance // from its source; RPC success alone does not establish a user action. diff --git a/codex-rs/core/src/mcp_tool_call/recorded.rs b/codex-rs/core/src/mcp_tool_call/recorded.rs index 3e55e63f68..e083176641 100644 --- a/codex-rs/core/src/mcp_tool_call/recorded.rs +++ b/codex-rs/core/src/mcp_tool_call/recorded.rs @@ -32,6 +32,10 @@ pub(crate) async fn prepare_approval_action( .map_err(|err| FunctionCallError::RespondToModel(format!("{err:#}")))?; let mcp_invocation = McpInvocation { server: advertised_tool.server_name().to_owned(), + environment_id: advertised_tool + .server_key() + .scoped_environment_id() + .map(str::to_owned), tool: advertised_tool.tool.name.to_string(), arguments, }; diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index f48097c04f..e11bf8e32a 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -987,6 +987,7 @@ fn custom_mcp_tool_question_offers_session_remember_and_always_allow() { fn custom_servers_support_session_and_persistent_approval() { let invocation = McpInvocation { server: "custom_server".to_string(), + environment_id: None, tool: "run_action".to_string(), arguments: None, }; @@ -1042,6 +1043,7 @@ fn custom_servers_support_session_and_persistent_approval() { fn codex_apps_connectors_support_persistent_approval() { let invocation = McpInvocation { server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + environment_id: None, tool: "calendar/list_events".to_string(), arguments: None, }; @@ -1592,6 +1594,7 @@ async fn mcp_tool_call_item_includes_app_identity() { "call-plugin", McpInvocation { server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + environment_id: None, tool: "echo".to_string(), arguments: None, }, @@ -2104,6 +2107,7 @@ fn approval_elicitation_meta_merges_session_and_always_persist_for_custom_server fn guardian_mcp_review_request_includes_invocation_metadata() { let invocation = McpInvocation { server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + environment_id: None, tool: "browser_navigate".to_string(), arguments: Some(serde_json::json!({ "url": "https://example.com", @@ -2145,6 +2149,7 @@ fn guardian_mcp_review_request_includes_invocation_metadata() { fn guardian_mcp_review_request_includes_annotations_when_present() { let invocation = McpInvocation { server: "custom_server".to_string(), + environment_id: None, tool: "dangerous_tool".to_string(), arguments: None, }; @@ -2193,6 +2198,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() { fn guardian_mcp_review_request_ignores_untrusted_connected_account_email() { let invocation = McpInvocation { server: "custom_server".to_string(), + environment_id: None, tool: "dangerous_tool".to_string(), arguments: None, }; @@ -2972,6 +2978,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() { let turn_context = Arc::new(turn_context); let invocation = McpInvocation { server: "custom_server".to_string(), + environment_id: None, tool: "read_only_tool".to_string(), arguments: None, }; @@ -3056,6 +3063,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() { let turn_context = Arc::new(turn_context); let invocation = McpInvocation { server: "custom_server".to_string(), + environment_id: None, tool: "read_only_tool".to_string(), arguments: None, }; @@ -3117,6 +3125,7 @@ async fn permission_request_hook_allows_mcp_tool_call() { let turn_context = Arc::new(turn_context); let invocation = McpInvocation { server: "memory".to_string(), + environment_id: None, tool: "create_entities".to_string(), arguments: Some(serde_json::json!({ "entities": [{ @@ -3216,6 +3225,7 @@ async fn permission_request_hook_uses_hook_tool_name_without_metadata() { let turn_context = Arc::new(turn_context); let invocation = McpInvocation { server: "memory".to_string(), + environment_id: None, tool: "create_entities".to_string(), arguments: Some(serde_json::json!({ "entities": [] })), }; @@ -3292,6 +3302,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() { ); let invocation = McpInvocation { server: "memory".to_string(), + environment_id: None, tool: "create_entities".to_string(), arguments: Some(serde_json::json!({ "entities": [] })), }; @@ -3409,6 +3420,7 @@ async fn strict_auto_review_forces_guardian_for_mcp_policy_skip( let turn_context = Arc::new(turn_context); let invocation = McpInvocation { server: "custom_server".to_string(), + environment_id: None, tool: "dangerous_tool".to_string(), arguments: Some(serde_json::json!({ "calendar_id": "primary" })), }; @@ -3501,6 +3513,7 @@ async fn assert_mcp_user_approval_persistence( *session.active_turn.lock().await = Some(ActiveTurn::default()); let invocation = McpInvocation { server: "memory".to_string(), + environment_id: None, tool: "create_entities".to_string(), arguments: Some(serde_json::json!({ "entities": [] })), }; @@ -3594,6 +3607,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval } let invocation = McpInvocation { server: "custom_server".to_string(), + environment_id: None, tool: "read_only_tool".to_string(), arguments: None, }; @@ -3662,6 +3676,7 @@ async fn full_access_mode_skips_mcp_tool_approval_for_all_approval_modes() { let turn_context = Arc::new(turn_context); let invocation = McpInvocation { server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + environment_id: None, tool: "dangerous_tool".to_string(), arguments: Some(serde_json::json!({ "id": 1 })), }; @@ -3724,6 +3739,7 @@ async fn approve_mode_skips_guardian_in_every_permission_mode() { let invocation = McpInvocation { server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + environment_id: None, tool: "dangerous_tool".to_string(), arguments: Some(serde_json::json!({ "id": 1 })), }; @@ -3810,6 +3826,7 @@ async fn approval_metadata_is_released_when_the_invocation_future_is_dropped() { let review_context = GuardianReviewContext::from(Arc::new(turn_context)); let invocation = McpInvocation { server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + environment_id: None, tool: "write_record".to_string(), arguments: Some(serde_json::json!({"value": 42})), }; diff --git a/codex-rs/core/src/realtime_history_tests.rs b/codex-rs/core/src/realtime_history_tests.rs index dca8f02ffc..c11a087416 100644 --- a/codex-rs/core/src/realtime_history_tests.rs +++ b/codex-rs/core/src/realtime_history_tests.rs @@ -93,6 +93,7 @@ fn mcp_tool_call(id: &str, server: &str, status: McpToolCallStatus) -> TurnItem TurnItem::McpToolCall(McpToolCallItem { id: id.to_string(), server: server.to_string(), + environment_id: None, tool: "create_thread".to_string(), arguments: serde_json::Value::Null, connector_id: None, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index eaad9b74d8..69ce341eb8 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -22,6 +22,7 @@ use codex_mcp::ElicitationReviewRequest; use codex_mcp::ElicitationReviewer; use codex_mcp::ElicitationReviewerHandle; use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY; +use codex_mcp::McpServerKey; use codex_otel::auth_storage::AuthStorageOriginator; use codex_protocol::capabilities::CapabilityRootLocation; use codex_protocol::capabilities::SelectedCapabilityRoot; @@ -782,10 +783,11 @@ impl Session { pub async fn request_mcp_server_elicitation( &self, turn_context: &TurnContext, - server_name: String, + server_key: McpServerKey, request_id: RequestId, request: ElicitationRequest, ) -> Option { + let server_name = server_key.name().to_owned(); // This callback carries a request ID, not a reliable tool invocation owner. // Do not report its human wait as ordinary tool execution. if turn_context @@ -827,6 +829,7 @@ impl Session { let event = EventMsg::ElicitationRequest(ElicitationRequestEvent { turn_id: Some(turn_context.sub_id.clone()), server_name, + environment_id: server_key.scoped_environment_id().map(str::to_owned), id, request, }); diff --git a/codex-rs/core/src/session/mcp_tests.rs b/codex-rs/core/src/session/mcp_tests.rs index a6dd60fc13..fc35545194 100644 --- a/codex-rs/core/src/session/mcp_tests.rs +++ b/codex-rs/core/src/session/mcp_tests.rs @@ -7,6 +7,7 @@ fn plugin_install_elicitation_telemetry_metadata_requires_install_tool_suggestio let event = EventMsg::ElicitationRequest(ElicitationRequestEvent { turn_id: Some("turn-1".to_string()), server_name: "codex_apps".to_string(), + environment_id: None, id: codex_protocol::mcp::RequestId::String("request-1".to_string()), request: codex_protocol::approvals::ElicitationRequest::Form { meta: Some(json!({ @@ -36,6 +37,7 @@ fn plugin_install_elicitation_telemetry_metadata_requires_install_tool_suggestio let enable_event = EventMsg::ElicitationRequest(ElicitationRequestEvent { turn_id: Some("turn-1".to_string()), server_name: "codex_apps".to_string(), + environment_id: None, id: codex_protocol::mcp::RequestId::String("request-2".to_string()), request: codex_protocol::approvals::ElicitationRequest::Form { meta: Some(json!({ diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 743b949807..df78439180 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -731,7 +731,7 @@ async fn request_mcp_server_elicitation_waits_for_user_response( session .request_mcp_server_elicitation( &turn_context, - "codex_apps".to_string(), + codex_mcp::McpServerKey::unscoped("codex_apps"), RequestId::String("request-1".into()), request, ) @@ -750,6 +750,7 @@ async fn request_mcp_server_elicitation_waits_for_user_response( codex_protocol::approvals::ElicitationRequestEvent { turn_id: Some(turn_context.sub_id.clone()), server_name: "codex_apps".to_string(), + environment_id: None, id: codex_protocol::mcp::RequestId::String("request-1".to_string()), request, } diff --git a/codex-rs/core/src/session/turn_tests.rs b/codex-rs/core/src/session/turn_tests.rs index 5ba9b18821..8d81482b38 100644 --- a/codex-rs/core/src/session/turn_tests.rs +++ b/codex-rs/core/src/session/turn_tests.rs @@ -164,6 +164,7 @@ fn realtime_user_verification_notice_excludes_request_payload() { let event = EventMsg::ElicitationRequest(codex_protocol::approvals::ElicitationRequestEvent { turn_id: None, server_name: "private-server-name".to_string(), + environment_id: None, id: codex_protocol::mcp::RequestId::String("private-request-id".to_string()), request: codex_protocol::approvals::ElicitationRequest::UserVerification { meta: None, diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs index 4746b000cc..a5f8614c01 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs @@ -410,12 +410,14 @@ async fn emit_tool_call_begin( ) { let McpInvocation { server, + environment_id, tool, arguments, } = invocation; let item = TurnItem::McpToolCall(McpToolCallItem { id: call_id.to_string(), server, + environment_id, tool, arguments: arguments.unwrap_or(Value::Null), connector_id: None, @@ -455,12 +457,14 @@ async fn emit_tool_call_end( }; let McpInvocation { server, + environment_id, tool, arguments, } = invocation; let item = TurnItem::McpToolCall(McpToolCallItem { id: call_id.to_string(), server, + environment_id, tool, arguments: arguments.unwrap_or(Value::Null), connector_id: None, diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs index f93be9ee82..1ebdde81ab 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs @@ -66,15 +66,22 @@ impl ListMcpResourceTemplatesHandler { let arguments = resource_arguments(&payload)?; let args = ListResourceArgs::parse(arguments.clone())?; + let target = args.target(&step_context); let invocation = McpInvocation { server: args.server.clone().unwrap_or_else(|| "codex".to_string()), + environment_id: target + .as_ref() + .ok() + .and_then(Option::as_ref) + .and_then(|(key, _)| key.scoped_environment_id()) + .map(str::to_owned), tool: "list_mcp_resource_templates".to_string(), arguments: arguments.clone(), }; run_resource_operation(&session, &step_context, &call_id, invocation, async { - if let Some((server_key, params)) = args.target(&step_context)? { + if let Some((server_key, params)) = target? { let result = mcp .list_resource_templates(server_key, params) .await diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs index f4ebc7e956..7b57a57140 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs @@ -66,15 +66,22 @@ impl ListMcpResourcesHandler { let arguments = resource_arguments(&payload)?; let args = ListResourceArgs::parse(arguments.clone())?; + let target = args.target(&step_context); let invocation = McpInvocation { server: args.server.clone().unwrap_or_else(|| "codex".to_string()), + environment_id: target + .as_ref() + .ok() + .and_then(Option::as_ref) + .and_then(|(key, _)| key.scoped_environment_id()) + .map(str::to_owned), tool: "list_mcp_resources".to_string(), arguments: arguments.clone(), }; run_resource_operation(&session, &step_context, &call_id, invocation, async { - if let Some((server_key, params)) = args.target(&step_context)? { + if let Some((server_key, params)) = target? { let result = mcp .list_resources(server_key, params) .await diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs index a38c5dee17..cae7d4d8f4 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs @@ -70,16 +70,21 @@ impl ReadMcpResourceHandler { uri, environment_id, } = ReadResourceArgs::parse(arguments.clone())?; + let server_key = resolve_resource_server(&step_context, &server, environment_id.as_deref()); let invocation = McpInvocation { server: server.clone(), + environment_id: server_key + .as_ref() + .ok() + .and_then(|key| key.scoped_environment_id()) + .map(str::to_owned), tool: "read_mcp_resource".to_string(), arguments: arguments.clone(), }; run_resource_operation(&session, &step_context, &call_id, invocation, async { - let server_key = - resolve_resource_server(&step_context, &server, environment_id.as_deref())?; + let server_key = server_key?; let result = mcp .read_resource(server_key, ReadResourceRequestParams::new(uri.clone())) .await diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index a79fbaa983..84f2f875d3 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -8,6 +8,7 @@ use codex_analytics::build_track_events_context; use codex_config::types::ToolSuggestDisabledTool; use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::McpServerKey; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; use codex_tools::DiscoverableTool; @@ -254,7 +255,7 @@ impl RequestPluginInstallHandler { let response = session .request_mcp_server_elicitation( turn.as_ref(), - CODEX_APPS_MCP_SERVER_NAME.to_string(), + McpServerKey::unscoped(CODEX_APPS_MCP_SERVER_NAME), request_id, request, ) diff --git a/codex-rs/core/tests/suite/codex_apps_protocol.rs b/codex-rs/core/tests/suite/codex_apps_protocol.rs index 687cd25c9b..b1b646b184 100644 --- a/codex-rs/core/tests/suite/codex_apps_protocol.rs +++ b/codex-rs/core/tests/suite/codex_apps_protocol.rs @@ -285,6 +285,7 @@ async fn next_model_step_uses_the_in_flight_apps_refresh() -> anyhow::Result<()> Duration::from_secs(10), fixture.codex.mcp_server_status_snapshot( CODEX_APPS_MCP_SERVER_NAME, + /*environment_id*/ None, McpSnapshotDetail::ToolsAndAuthOnly, /*expected_registration*/ None, ), diff --git a/codex-rs/core/tests/suite/hooks_executor.rs b/codex-rs/core/tests/suite/hooks_executor.rs index 09837754aa..e2c81bab84 100644 --- a/codex-rs/core/tests/suite/hooks_executor.rs +++ b/codex-rs/core/tests/suite/hooks_executor.rs @@ -868,6 +868,7 @@ async fn thread_plugin_selection_disables_executor_hooks_without_disabling_their .codex .call_mcp_tool( "node_repl", + /*environment_id*/ None, "js", Some(json!({"code": "1 + 1"})), /*meta*/ None, @@ -1385,6 +1386,7 @@ async fn executor_stop_hook_rejects_mismatched_environment() -> Result<()> { .codex .call_mcp_tool( "node_repl", + /*environment_id*/ None, "js", Some(json!({"code": "1 + 1"})), /*meta*/ None, @@ -1550,6 +1552,7 @@ async fn executor_browser_and_computer_use_cleanup_hooks_use_separate_mcp_routes .codex .call_mcp_tool( "node_repl", + /*environment_id*/ None, "js", Some(json!({ "code": "1 + 1" })), /*meta*/ None, diff --git a/codex-rs/core/tests/suite/hooks_mcp.rs b/codex-rs/core/tests/suite/hooks_mcp.rs index 3f44235564..b14bfd4521 100644 --- a/codex-rs/core/tests/suite/hooks_mcp.rs +++ b/codex-rs/core/tests/suite/hooks_mcp.rs @@ -1,3 +1,4 @@ +use codex_mcp::McpServerKey; use std::collections::HashMap; use std::fs; use std::path::Path; @@ -602,7 +603,7 @@ async fn mcp_tool_hook_starts_cached_model_hidden_tools( .thread .mcp_connection_statuses(&mcp_config) .await - .get(RMCP_SERVER), + .get(&McpServerKey::unscoped(RMCP_SERVER)), Some(&McpServerConnectionStatus::NotStarted), ); subagent @@ -630,7 +631,7 @@ async fn mcp_tool_hook_starts_cached_model_hidden_tools( .thread .mcp_connection_statuses(&mcp_config) .await - .get("unused"), + .get(&McpServerKey::unscoped("unused")), Some(&McpServerConnectionStatus::NotStarted), "a hook must leave unrelated cached servers dormant", ); @@ -748,7 +749,7 @@ async fn mcp_tool_hook_timeout_includes_cached_server_startup() -> Result<()> { .thread .mcp_connection_statuses(&mcp_config) .await - .get(RMCP_SERVER), + .get(&McpServerKey::unscoped(RMCP_SERVER)), Some(&McpServerConnectionStatus::NotStarted), ); subagent diff --git a/codex-rs/core/tests/suite/hooks_plugin_policy_refresh.rs b/codex-rs/core/tests/suite/hooks_plugin_policy_refresh.rs index 6cb93812ce..04583d40a8 100644 --- a/codex-rs/core/tests/suite/hooks_plugin_policy_refresh.rs +++ b/codex-rs/core/tests/suite/hooks_plugin_policy_refresh.rs @@ -152,6 +152,7 @@ async fn native_stop_hook_uses_plugin_policy_refreshed_during_turn( test.codex .call_mcp_tool( "plugin_tools", + /*environment_id*/ None, "calendar_list_events", /*arguments*/ None, /*meta*/ None, @@ -227,6 +228,7 @@ async fn native_stop_hook_uses_plugin_policy_refreshed_during_turn( test.codex .call_mcp_tool( "plugin_tools", + /*environment_id*/ None, "calendar_list_events", /*arguments*/ None, /*meta*/ None, @@ -239,6 +241,7 @@ async fn native_stop_hook_uses_plugin_policy_refreshed_during_turn( test.codex .call_mcp_tool( "hook_observer", + /*environment_id*/ None, "calendar_list_events", /*arguments*/ None, /*meta*/ None, @@ -273,6 +276,7 @@ async fn native_stop_hook_uses_plugin_policy_refreshed_during_turn( test.codex .call_mcp_tool( "plugin_tools", + /*environment_id*/ None, "calendar_list_events", /*arguments*/ None, /*meta*/ None, diff --git a/codex-rs/core/tests/suite/mcp_extension_protocol.rs b/codex-rs/core/tests/suite/mcp_extension_protocol.rs index 2a8b9ad32c..1409e24607 100644 --- a/codex-rs/core/tests/suite/mcp_extension_protocol.rs +++ b/codex-rs/core/tests/suite/mcp_extension_protocol.rs @@ -415,6 +415,7 @@ async fn hosted_apps_protocol_override_preserves_native_verification() -> anyhow caller .call_mcp_tool( CODEX_APPS_MCP_SERVER_NAME, + /*environment_id*/ None, "verify_action", /*arguments*/ None, /*meta*/ None, diff --git a/codex-rs/core/tests/suite/mcp_network_policy_compat_tests.rs b/codex-rs/core/tests/suite/mcp_network_policy_compat_tests.rs index 2a54e9cdca..1fffc26dc0 100644 --- a/codex-rs/core/tests/suite/mcp_network_policy_compat_tests.rs +++ b/codex-rs/core/tests/suite/mcp_network_policy_compat_tests.rs @@ -34,6 +34,7 @@ async fn trusted_server_without_policy_support_keeps_caller_metadata() -> anyhow .codex .call_mcp_tool( "legacy", + /*environment_id*/ None, "network_policy_meta", /*arguments*/ None, Some(json!({"caller": "keep", "codex/network-policy-meta": {"enabled": true}})), diff --git a/codex-rs/core/tests/suite/mcp_oauth_refresh_tests.rs b/codex-rs/core/tests/suite/mcp_oauth_refresh_tests.rs index c0207b95ee..d5a879b8e5 100644 --- a/codex-rs/core/tests/suite/mcp_oauth_refresh_tests.rs +++ b/codex-rs/core/tests/suite/mcp_oauth_refresh_tests.rs @@ -316,6 +316,7 @@ async fn oauth_mode_refresh_replaces_the_live_connection( .codex .call_mcp_tool( server_name, + /*environment_id*/ None, "calendar_list_events", /*arguments*/ None, /*meta*/ None, @@ -348,6 +349,7 @@ async fn oauth_mode_refresh_replaces_the_live_connection( .codex .call_mcp_tool( server_name, + /*environment_id*/ None, "calendar_list_events", /*arguments*/ None, /*meta*/ None, diff --git a/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs b/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs index 9a278f88be..103e1fa4a0 100644 --- a/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs +++ b/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs @@ -190,6 +190,7 @@ async fn refresh_keeps_superseded_mcp_server_alive_for_in_flight_calls() -> anyh codex .call_mcp_tool( "refresh_cleanup", + /*environment_id*/ None, "sync", Some(serde_json::json!({ "barrier": barrier, @@ -204,6 +205,7 @@ async fn refresh_keeps_superseded_mcp_server_alive_for_in_flight_calls() -> anyh .codex .call_mcp_tool( "refresh_cleanup", + /*environment_id*/ None, "sync", Some(serde_json::json!({ "barrier": barrier })), /*meta*/ None, diff --git a/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs b/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs index 82c2aa4286..d750952cf3 100644 --- a/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs +++ b/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs @@ -186,6 +186,7 @@ async fn local_mcp_startup_and_refresh_use_configured_http_client() -> Result<() .codex .call_mcp_tool( SERVER_NAME, + /*environment_id*/ None, "calendar_create_event", Some(json!({ "title": "Proxy refresh", diff --git a/codex-rs/core/tests/suite/mcp_tool_cache.rs b/codex-rs/core/tests/suite/mcp_tool_cache.rs index c1008cfb7d..f62ced8732 100644 --- a/codex-rs/core/tests/suite/mcp_tool_cache.rs +++ b/codex-rs/core/tests/suite/mcp_tool_cache.rs @@ -20,6 +20,7 @@ use codex_extension_api::McpServerContributionContext; use codex_extension_api::McpServerContributor; use codex_features::Feature; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::McpServerKey; use codex_protocol::mcp::McpServerConnectionStatus; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -224,6 +225,7 @@ async fn mcp_calls_stay_bound_to_each_thread() -> anyhow::Result<()> { end.invocation, McpInvocation { server: SERVER_NAME.to_string(), + environment_id: None, tool: "echo".to_string(), arguments: Some(json!({ "message": call_id })), } @@ -788,7 +790,7 @@ pub(super) async fn cached_stdio_first_use_scenario() -> anyhow::Result anyhow::Result anyhow::Result<()> { thread .mcp_connection_statuses(&config) .await - .get(SERVER_NAME), + .get(&McpServerKey::unscoped(SERVER_NAME)), Some(&McpServerConnectionStatus::NotStarted) ); } @@ -1179,6 +1187,7 @@ async fn cached_stdio_negotiates_sharing_on_first_use() -> anyhow::Result<()> { let result = thread .call_mcp_tool( SERVER_NAME, + /*environment_id*/ None, "echo", Some(json!({"message":"identify process"})), /*meta*/ None, @@ -1206,7 +1215,7 @@ async fn cached_stdio_negotiates_sharing_on_first_use() -> anyhow::Result<()> { legacy_sibling .mcp_connection_statuses(&config) .await - .get(SERVER_NAME), + .get(&McpServerKey::unscoped(SERVER_NAME)), Some(&McpServerConnectionStatus::NotStarted) ); let legacy = &processes[0]; diff --git a/codex-rs/core/tests/suite/mcp_tool_exposure.rs b/codex-rs/core/tests/suite/mcp_tool_exposure.rs index c5f868974e..59d01135fa 100644 --- a/codex-rs/core/tests/suite/mcp_tool_exposure.rs +++ b/codex-rs/core/tests/suite/mcp_tool_exposure.rs @@ -655,6 +655,7 @@ async fn timeout_refresh_replaces_pending_startup_and_reuses_ready_connection() .codex .call_mcp_tool( "ready", + /*environment_id*/ None, "calendar_list_events", /*arguments*/ None, /*meta*/ None, @@ -675,7 +676,11 @@ async fn timeout_refresh_replaces_pending_startup_and_reuses_ready_connection() // Publish without waiting for the held initialize to finish. let error = test .codex - .read_mcp_resource("unknown", ReadResourceRequestParams::new("test://resource")) + .read_mcp_resource( + "unknown", + /*environment_id*/ None, + ReadResourceRequestParams::new("test://resource"), + ) .await .expect_err("the unknown server should not exist"); assert_eq!(error.to_string(), "unknown MCP server 'unknown'"); @@ -688,6 +693,7 @@ async fn timeout_refresh_replaces_pending_startup_and_reuses_ready_connection() .codex .call_mcp_tool( name, + /*environment_id*/ None, "calendar_list_events", /*arguments*/ None, /*meta*/ None, @@ -913,6 +919,7 @@ startup_timeout_sec = 0.1 .codex .read_mcp_resource( "refreshed", + /*environment_id*/ None, ReadResourceRequestParams::new("test://resource"), ) .await; diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 47fd6f4fbc..1a9aa9e4d1 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -937,7 +937,13 @@ async fn thread_disabled_plugins_filter_skills_and_tools_without_changing_shared // Saving pending settings must not change the admitted runtime. for (server_name, tool, arguments) in &tool_calls { test.codex - .call_mcp_tool(server_name, tool, arguments.clone(), /*meta*/ None) + .call_mcp_tool( + server_name, + /*environment_id*/ None, + tool, + arguments.clone(), + /*meta*/ None, + ) .await?; } } @@ -1015,7 +1021,13 @@ async fn thread_disabled_plugins_filter_skills_and_tools_without_changing_shared for (server_name, tool, arguments) in &tool_calls { let result = test .codex - .call_mcp_tool(server_name, tool, arguments.clone(), /*meta*/ None) + .call_mcp_tool( + server_name, + /*environment_id*/ None, + tool, + arguments.clone(), + /*meta*/ None, + ) .await; assert_eq!( result.is_ok(), diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 45c13ab02b..0fb8ec0d77 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -978,6 +978,7 @@ async fn environment_mcp_policy_filters_runtime_config_and_model_tools( .codex .call_mcp_tool( "allowed", + /*environment_id*/ None, "echo", Some(json!({ "message": "ready" })), /*meta*/ None, @@ -3369,6 +3370,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { call_id: call_id.to_string(), invocation: McpInvocation { server: server_name.to_string(), + environment_id: None, tool: "image".to_string(), arguments: Some(json!({})), }, @@ -3396,6 +3398,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { end.invocation, McpInvocation { server: server_name.to_string(), + environment_id: None, tool: "image".to_string(), arguments: Some(json!({})), } diff --git a/codex-rs/core/tests/suite/scenarios_exec_tools.rs b/codex-rs/core/tests/suite/scenarios_exec_tools.rs index 51a62ba101..b211a211bf 100644 --- a/codex-rs/core/tests/suite/scenarios_exec_tools.rs +++ b/codex-rs/core/tests/suite/scenarios_exec_tools.rs @@ -182,7 +182,13 @@ async fn changed_sections_and_entries_append_without_repeating_the_catalog( )]))?; let _ = test.codex.refresh_mcp_config(current, updated).await; test.codex - .call_mcp_tool("echo_service", "echo", ready.clone(), /*meta*/ None) + .call_mcp_tool( + "echo_service", + /*environment_id*/ None, + "echo", + ready.clone(), + /*meta*/ None, + ) .await?; test.submit_text_turn(prompt).await?; } @@ -240,7 +246,13 @@ async fn changed_sections_and_entries_append_without_repeating_the_catalog( let _ = test.codex.refresh_mcp_config(current, updated).await; let result = test .codex - .call_mcp_tool("echo_service", "echo", ready.clone(), /*meta*/ None) + .call_mcp_tool( + "echo_service", + /*environment_id*/ None, + "echo", + ready.clone(), + /*meta*/ None, + ) .await; if tools.is_empty() { assert!(result.is_err(), "removed tools must not remain callable"); diff --git a/codex-rs/core/tests/suite/scenarios_incremental_tools.rs b/codex-rs/core/tests/suite/scenarios_incremental_tools.rs index ddebaf69c1..663b320760 100644 --- a/codex-rs/core/tests/suite/scenarios_incremental_tools.rs +++ b/codex-rs/core/tests/suite/scenarios_incremental_tools.rs @@ -573,6 +573,7 @@ async fn tool_removal_appends_a_standalone_notice_without_rewriting_history( test.codex .call_mcp_tool( "echo_service", + /*environment_id*/ None, "echo", Some(json!({"message": "ready after removal"})), /*meta*/ None, @@ -595,6 +596,7 @@ async fn tool_removal_appends_a_standalone_notice_without_rewriting_history( .codex .call_mcp_tool( "echo_service", + /*environment_id*/ None, "echo", Some(json!({"message": "unavailable"})), /*meta*/ None, @@ -623,7 +625,13 @@ async fn tool_removal_appends_a_standalone_notice_without_rewriting_history( ("cwd", json!({})), ] { test.codex - .call_mcp_tool("echo_service", tool, Some(arguments), /*meta*/ None) + .call_mcp_tool( + "echo_service", + /*environment_id*/ None, + tool, + Some(arguments), + /*meta*/ None, + ) .await?; } test.submit_text_turn("Continue with the restored echo service.") @@ -660,6 +668,7 @@ async fn tool_removal_appends_a_standalone_notice_without_rewriting_history( test.codex .call_mcp_tool( "functions", + /*environment_id*/ None, "echo", Some(json!({"message": "ready after instructions refresh"})), /*meta*/ None, diff --git a/codex-rs/core/tests/suite/scenarios_mcp_ema.rs b/codex-rs/core/tests/suite/scenarios_mcp_ema.rs index 16c9c8baa3..1cd4611c68 100644 --- a/codex-rs/core/tests/suite/scenarios_mcp_ema.rs +++ b/codex-rs/core/tests/suite/scenarios_mcp_ema.rs @@ -361,6 +361,7 @@ http_headers = {{ Authorization = "Bearer ordinary-access" }} test.codex .mcp_server_status_snapshot( "ordinary", + /*environment_id*/ None, codex_mcp::McpSnapshotDetail::ToolsAndAuthOnly, /*expected_registration*/ None, ) @@ -434,6 +435,7 @@ http_headers = {{ Authorization = "Bearer ordinary-access" }} .await; let mut call = Box::pin(test.codex.call_mcp_tool( "enterprise", + /*environment_id*/ None, "calendar_list_events", Some(json!({"query": "permission"})), /*meta*/ None, @@ -479,6 +481,7 @@ http_headers = {{ Authorization = "Bearer ordinary-access" }} .codex .call_mcp_tool( "enterprise", + /*environment_id*/ None, "calendar_create_event", /*arguments*/ None, /*meta*/ None, @@ -563,6 +566,7 @@ http_headers = {{ Authorization = "Bearer ordinary-access" }} .codex .call_mcp_tool( "ordinary", + /*environment_id*/ None, "calendar_create_event", Some(json!({"title": "control", "starts_at": "2026-06-18T12:00:00Z"})), /*meta*/ None, @@ -633,6 +637,7 @@ http_headers = {{ Authorization = "Bearer ordinary-access" }} thread .call_mcp_tool( "enterprise", + /*environment_id*/ None, "calendar_list_events", Some(json!({"query":"blocked"})), /*meta*/ None, @@ -665,6 +670,7 @@ http_headers = {{ Authorization = "Bearer ordinary-access" }} let result = removed .call_mcp_tool( "enterprise", + /*environment_id*/ None, "calendar_list_events", /*arguments*/ None, /*meta*/ None, diff --git a/codex-rs/core/tests/suite/scenarios_mcp_resource_messages.rs b/codex-rs/core/tests/suite/scenarios_mcp_resource_messages.rs index 2e1fe2fab7..c48b3a4e4d 100644 --- a/codex-rs/core/tests/suite/scenarios_mcp_resource_messages.rs +++ b/codex-rs/core/tests/suite/scenarios_mcp_resource_messages.rs @@ -5,6 +5,7 @@ use super::super::rmcp_client::remote_aware_stdio_server_bin; use anyhow::Result; use codex_core::ConfigRefreshOutcome; use codex_features::Feature; +use codex_mcp::McpServerKey; use codex_protocol::mcp::McpServerConnectionStatus; use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::EventMsg; @@ -142,7 +143,7 @@ async fn mcp_resource_messages(tool_mode: ToolMode) -> Result<()> { test.codex .mcp_connection_statuses(&config) .await - .get("resources"), + .get(&McpServerKey::unscoped("resources")), Some(&McpServerConnectionStatus::NotStarted) ); test.submit_turn("Query the deferred server by name and read its example note.") diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index a917925c02..280fa33ee2 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -649,6 +649,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - end.invocation, McpInvocation { server: "codex_apps".to_string(), + environment_id: None, tool: "calendar_create_event".to_string(), arguments: Some(json!({ "title": "Lunch", diff --git a/codex-rs/core/tests/suite/skills_extension/discovery_grants_tests.rs b/codex-rs/core/tests/suite/skills_extension/discovery_grants_tests.rs index 3d79904697..88671e9b38 100644 --- a/codex-rs/core/tests/suite/skills_extension/discovery_grants_tests.rs +++ b/codex-rs/core/tests/suite/skills_extension/discovery_grants_tests.rs @@ -2,6 +2,7 @@ //! Exercises skill and plugin discovery before approval, after approval, and next turn. use super::*; +use codex_mcp::McpServerKey; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; use core_test_support::exec_server::ExecServerProcess; use core_test_support::test_codex::TestEnv; @@ -316,6 +317,7 @@ async fn executor_discovery_uses_current_environment_grants( }, }]); } + let server_key = McpServerKey::in_environment(&selection.environment_id, plugin_mcp_server); let thread = test .thread_manager .start_thread(StartThreadOptions { @@ -331,18 +333,16 @@ async fn executor_discovery_uses_current_environment_grants( let plugin_is_published = async || -> Result { let (config, status) = thread .mcp_server_status_snapshot( - plugin_mcp_server, + server_key.name(), + server_key.scoped_environment_id(), codex_mcp::McpSnapshotDetail::ToolsAndAuthOnly, /*expected_registration*/ None, ) .await?; - let published = config - .mcp_server_catalog - .server_by_name(plugin_mcp_server) - .is_some(); + let published = config.mcp_server_catalog.server(&server_key).is_some(); let has_echo = status .tools_by_server - .get(plugin_mcp_server) + .get(&server_key) .is_some_and(|tools| tools.contains_key("echo")); assert_eq!(has_echo, published); Ok(published) diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 06489bd53b..b379f95575 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -8,6 +8,7 @@ use codex_core::config::TokenBudgetConfig; use codex_extension_api::ExtensionRegistryBuilder; use codex_features::Feature; use codex_login::CodexAuth; +use codex_mcp::McpServerKey; use codex_model_provider_info::built_in_model_providers; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::items::TurnItem; @@ -834,7 +835,7 @@ async fn token_budget_context_injects_plain_thread_hint_text(cached_subagent: bo .thread .mcp_connection_statuses(&mcp_config) .await - .get("notes"), + .get(&McpServerKey::unscoped("notes")), Some(&McpServerConnectionStatus::NotStarted), ); (subagent.thread, subagent.thread_id) diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index b1809c88d0..5b3f655c62 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -104,6 +104,7 @@ fn mcp_tool_call_result_preserves_meta_in_jsonl_event() { ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "search service".to_string(), + environment_id: None, tool: "web_run".to_string(), status: McpToolCallStatus::Completed, arguments: json!({"search_query": [{"q": "OpenAI Codex CLI documentation"}]}), diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 56909a5f7d..45ffea39c2 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -562,6 +562,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "server_a".to_string(), + environment_id: None, tool: "tool_x".to_string(), status: ApiMcpToolCallStatus::InProgress, arguments: json!({ "key": "value" }), @@ -584,6 +585,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "server_a".to_string(), + environment_id: None, tool: "tool_x".to_string(), status: ApiMcpToolCallStatus::Completed, arguments: json!({ "key": "value" }), @@ -660,6 +662,7 @@ fn mcp_tool_call_failure_sets_failed_status() { ThreadItemPayload::McpToolCall { id: "mcp-2".to_string(), server: "server_b".to_string(), + environment_id: None, tool: "tool_y".to_string(), status: ApiMcpToolCallStatus::Failed, arguments: json!({ "param": 42 }), @@ -713,6 +716,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { ThreadItemPayload::McpToolCall { id: "mcp-3".to_string(), server: "server_c".to_string(), + environment_id: None, tool: "tool_z".to_string(), status: ApiMcpToolCallStatus::InProgress, arguments: serde_json::Value::Null, @@ -735,6 +739,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { ThreadItemPayload::McpToolCall { id: "mcp-3".to_string(), server: "server_c".to_string(), + environment_id: None, tool: "tool_z".to_string(), status: ApiMcpToolCallStatus::Completed, arguments: serde_json::Value::Null, diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index b651d55ee7..6b1f0ef34e 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -476,6 +476,10 @@ pub struct ElicitationRequestEvent { #[ts(optional)] pub turn_id: Option, pub server_name: String, + /// Registration scope, not necessarily the server's execution environment. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub environment_id: Option, #[ts(type = "string | number")] pub id: RequestId, pub request: ElicitationRequest, diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 12d96c56e3..424ba6e562 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -481,6 +481,10 @@ pub enum McpAppDisplayMode { pub struct McpToolCallItem { pub id: String, pub server: String, + /// Registration scope, not necessarily the server's execution environment. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub environment_id: Option, pub tool: String, pub arguments: serde_json::Value, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/protocol/src/legacy_events.rs b/codex-rs/protocol/src/legacy_events.rs index 3dc1775968..6f38619550 100644 --- a/codex-rs/protocol/src/legacy_events.rs +++ b/codex-rs/protocol/src/legacy_events.rs @@ -522,6 +522,7 @@ impl McpToolCallItem { turn_id, invocation: McpInvocation { server: self.server.clone(), + environment_id: self.environment_id.clone(), tool: self.tool.clone(), arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()), }, @@ -548,6 +549,7 @@ impl McpToolCallItem { turn_id, invocation: McpInvocation { server: self.server.clone(), + environment_id: self.environment_id.clone(), tool: self.tool.clone(), arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()), }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index cf4d3d83cf..6aeba01792 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3034,6 +3034,10 @@ pub struct AgentReasoningSectionBreakEvent { pub struct McpInvocation { /// Name of the MCP server as defined in the config. pub server: String, + /// Registration scope, not necessarily the server's execution environment. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub environment_id: Option, /// Name of the tool as given by the MCP server. pub tool: String, /// Arguments to the tool call. @@ -4199,6 +4203,10 @@ pub struct TurnDiffEvent { pub struct McpStartupUpdateEvent { /// Server name being started. pub server: String, + /// Registration scope, not necessarily the server's execution environment. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub environment_id: Option, /// Current startup status. pub status: McpStartupStatus, } @@ -5844,6 +5852,7 @@ mod tests { item: TurnItem::McpToolCall(McpToolCallItem { id: "mcp-1".into(), server: "server".into(), + environment_id: None, tool: "tool".into(), arguments: json!({"arg": "value"}), connector_id: Some("connector".into()), @@ -5974,6 +5983,7 @@ mod tests { item: TurnItem::McpToolCall(McpToolCallItem { id: "mcp-1".into(), server: "server".into(), + environment_id: None, tool: "tool".into(), arguments: json!({"arg": "value"}), connector_id: Some("connector".into()), @@ -6853,6 +6863,7 @@ mod tests { id: "init".to_string(), msg: EventMsg::McpStartupUpdate(McpStartupUpdateEvent { server: "srv".to_string(), + environment_id: None, status: McpStartupStatus::Failed { error: "boom".to_string(), reason: Some(McpStartupFailureReason::ReauthenticationRequired), diff --git a/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs b/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs index e5c8d442f0..af6d199931 100644 --- a/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs +++ b/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs @@ -203,6 +203,7 @@ pub(super) fn completed_item( TurnItem::McpToolCall(McpToolCallItem { id: event.call_id.clone(), server: event.invocation.server.clone(), + environment_id: event.invocation.environment_id.clone(), tool: event.invocation.tool.clone(), arguments: event .invocation diff --git a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs index a12bb69fa1..14799e401f 100644 --- a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs +++ b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs @@ -526,6 +526,7 @@ async fn paginated_mcp_history_caps_large_results() { let tool_call = McpToolCallItem { id: "mcp-1".to_string(), server: "test".to_string(), + environment_id: None, tool: "large_result".to_string(), arguments: arguments.clone(), connector_id: None, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 972bfb2f04..fd43c036dc 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -719,8 +719,8 @@ pub(crate) struct App { rate_limit_hard_stop_generation: u64, rate_limit_refresh_state: rate_limit_refresh::RateLimitRefreshState, pending_mcp_login_start: Option, - // Latest accepted attempt per server; stale retry completions must not update the UI. - active_mcp_login_ids: HashMap, + // Login ID -> requested server and environment. Retrying one target keeps other logins alive. + active_mcp_login_ids: HashMap)>, // Serialize plugin enablement writes per plugin so stale completions cannot // overwrite a newer toggle, even if the plugin is toggled from different // cwd contexts. @@ -737,6 +737,7 @@ pub(crate) struct App { struct PendingMcpLoginStart { request_id: String, name: String, + environment_id: Option, thread_id: ThreadId, completions: Vec, } diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index cc5d3d8fe2..3fc59064a4 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -331,6 +331,7 @@ mod tests { let thread_id = ThreadId::new(); let notification = ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some(thread_id.to_string()), name: "sentry".to_string(), status: McpServerStartupState::Failed, @@ -347,6 +348,7 @@ mod tests { fn mcp_startup_notifications_without_threads_are_app_scoped() { let notification = ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: None, name: "sentry".to_string(), status: McpServerStartupState::Failed, diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 997c85bcd4..c931e3f2c6 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -367,13 +367,10 @@ impl App { pending.completions.push(notification.clone()); return; } - if notification.login_id.is_some() { - if notification.login_id.as_ref() - != self.active_mcp_login_ids.get(¬ification.name) - { - return; - } - self.active_mcp_login_ids.remove(¬ification.name); + if let Some(login_id) = ¬ification.login_id + && self.active_mcp_login_ids.remove(login_id).is_none() + { + return; } } diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index be5c776085..c31fdc6eb3 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -957,6 +957,7 @@ mod tests { pending.note_server_request(&ServerRequest::McpServerElicitationRequest { request_id: AppServerRequestId::Integer(12), params: McpServerElicitationRequestParams { + environment_id: None, thread_id: "thread-1".to_string(), turn_id: Some("turn-1".to_string()), server_name: "example".to_string(), @@ -1104,6 +1105,7 @@ mod tests { pending.note_server_request(&ServerRequest::McpServerElicitationRequest { request_id: AppServerRequestId::Integer(12), params: McpServerElicitationRequestParams { + environment_id: None, thread_id: "thread-1".to_string(), turn_id: Some("turn-1".to_string()), server_name: "example".to_string(), diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index c9d4585375..1eba13498f 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -48,6 +48,7 @@ impl App { app_server: &AppServerSession, request_id: String, name: String, + environment_id: Option, thread_id: ThreadId, ) { let request_handle = app_server.request_handle(); @@ -57,6 +58,7 @@ impl App { .request_typed(ClientRequest::McpServerOauthLogin { request_id: RequestId::String(request_id.clone()), params: McpServerOauthLoginParams { + environment_id, name, thread_id: Some(thread_id.to_string()), client_registration: None, @@ -821,6 +823,7 @@ pub(super) async fn fetch_all_mcp_server_statuses( .request_typed(ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + environment_id: None, cursor: cursor.clone(), limit: Some(100), detail: Some(detail), @@ -1629,6 +1632,7 @@ mod tests { fn mcp_inventory_maps_prefix_tool_names_by_server() { let statuses = vec![ McpServerStatus { + environment_id: None, server_capabilities: None, tool_list_meta: None, tools_error: None, @@ -1655,6 +1659,7 @@ mod tests { auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, }, McpServerStatus { + environment_id: None, server_capabilities: None, tool_list_meta: None, tools_error: None, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 06dad6766d..89b502af3f 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1900,7 +1900,7 @@ impl App { AppEvent::FetchMcpInventory { detail, thread_id } => { self.fetch_mcp_inventory(app_server, detail, thread_id); } - AppEvent::StartMcpLogin { name, thread_id } => { + AppEvent::StartMcpLogin { name, environment_id, thread_id } => { if self.pending_mcp_login_start.is_some() { self.chat_widget.add_info_message( "MCP sign-in is starting. Wait for it to finish before trying again.".to_string(), @@ -1912,10 +1912,11 @@ impl App { self.pending_mcp_login_start = Some(PendingMcpLoginStart { request_id: request_id.clone(), name: name.clone(), + environment_id: environment_id.clone(), thread_id, completions: Vec::new(), }); - self.start_mcp_login(app_server, request_id, name, thread_id); + self.start_mcp_login(app_server, request_id, name, environment_id, thread_id); } AppEvent::McpLoginStarted { request_id, result } => { if let Some(pending) = self @@ -1924,11 +1925,13 @@ impl App { { match result { Ok(response) => { - // Track the latest attempt per server: unrelated OAuth logins can - // remain open while a user retries this server. + // Replace this target's old attempt, not another environment's + // login to a server with the same name. if let Some(login_id) = response.login_id.as_ref() { + let target = (pending.name.clone(), pending.environment_id); + self.active_mcp_login_ids.retain(|_, current| current != &target); self.active_mcp_login_ids - .insert(pending.name.clone(), login_id.clone()); + .insert(login_id.clone(), target); } let completed = pending.completions.iter().any(|completion| { completion.login_id == response.login_id @@ -1956,13 +1959,10 @@ impl App { // A rejected start leaves the old attempt current. A successful replacement // has changed its ID, so the old cancellation is discarded here. for completion in pending.completions { - if completion.login_id.is_some() { - if completion.login_id.as_ref() - != self.active_mcp_login_ids.get(&completion.name) - { - continue; - } - self.active_mcp_login_ids.remove(&completion.name); + if let Some(login_id) = &completion.login_id + && self.active_mcp_login_ids.remove(login_id).is_none() + { + continue; } if let Some(thread_id) = completion.thread_id.as_deref() .and_then(|id| ThreadId::from_string(id).ok()) diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index e4818be701..fdcdaf1812 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -301,6 +301,7 @@ mod tests { ServerRequest::McpServerElicitationRequest { request_id: AppServerRequestId::String(request_id.to_string()), params: McpServerElicitationRequestParams { + environment_id: None, thread_id: "thread-1".to_string(), turn_id: Some(turn_id.to_string()), server_name: server_name.to_string(), diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__slash_picker_overlays_history.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__slash_picker_overlays_history.snap index 20e5d5c268..9e1233a9aa 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__slash_picker_overlays_history.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__slash_picker_overlays_history.snap @@ -6,11 +6,11 @@ Transcript row 30: content behind the menu Transcript row 31: content behind the menu Transcript row 32: content behind the menu Transcript row 33: content behind the menu -Transcript row 34: content behind the menu › /model choose what model and reasoning effort to use /memories configure memory use and generation /mention mention a file - /mcp list MCP tools; use /mcp verbose or /mcp login + /mcp list MCP tools; /mcp verbose or /mcp login [--environment + ] › /m diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 0544ded881..8168755975 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -520,6 +520,7 @@ async fn handle_mcp_inventory_result_respects_origin_thread() { app.handle_mcp_inventory_result( Ok(vec![McpServerStatus { + environment_id: None, server_capabilities: None, tool_list_meta: None, tools_error: None, @@ -7324,6 +7325,7 @@ async fn inactive_thread_url_elicitation_routes_to_app_link() { let request = ServerRequest::McpServerElicitationRequest { request_id: AppServerRequestId::Integer(9), params: McpServerElicitationRequestParams { + environment_id: None, thread_id: thread_id.to_string(), turn_id: Some("turn-auth".to_string()), server_name: "payments".to_string(), @@ -7364,6 +7366,7 @@ async fn inactive_thread_invalid_url_elicitation_is_declined() { let request = ServerRequest::McpServerElicitationRequest { request_id: AppServerRequestId::Integer(10), params: McpServerElicitationRequestParams { + environment_id: None, thread_id: thread_id.to_string(), turn_id: Some("turn-auth".to_string()), server_name: "payments".to_string(), @@ -8293,6 +8296,7 @@ async fn primary_thread_ignores_child_mcp_startup_notifications() { &app_server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some(child_thread_id.to_string()), name: "sentry".to_string(), status: McpServerStartupState::Failed, @@ -8371,6 +8375,7 @@ async fn app_scoped_mcp_startup_notifications_do_not_render_in_active_thread() { &app_server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: None, name: "sentry".to_string(), status: McpServerStartupState::Failed, @@ -8451,6 +8456,7 @@ async fn active_side_thread_renders_live_mcp_startup_notifications() { &app_server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some(side_thread_id.to_string()), name: name.to_string(), status, diff --git a/codex-rs/tui/src/app/tests/backend_sessions_tests.rs b/codex-rs/tui/src/app/tests/backend_sessions_tests.rs index a6f3162ded..f791054aea 100644 --- a/codex-rs/tui/src/app/tests/backend_sessions_tests.rs +++ b/codex-rs/tui/src/app/tests/backend_sessions_tests.rs @@ -63,6 +63,7 @@ async fn backend_sessions_parked_request_does_not_hide_disconnect() -> Result<() }); let failure = ServerNotification::McpServerStatusUpdated( codex_app_server_protocol::McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some(thread_id.to_string()), name: "tools".into(), status: McpServerStartupState::Failed, diff --git a/codex-rs/tui/src/app/tests/background_exit_tests.rs b/codex-rs/tui/src/app/tests/background_exit_tests.rs index e6be606923..5960485a77 100644 --- a/codex-rs/tui/src/app/tests/background_exit_tests.rs +++ b/codex-rs/tui/src/app/tests/background_exit_tests.rs @@ -742,6 +742,7 @@ async fn daemon_ctrl_c_during_mcp_startup_does_not_show_background_exit_menu() - let thread_id = prepare_local_daemon_thread(&mut app)?; app.chat_widget.handle_server_notification( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some(thread_id.to_string()), name: "slow".to_string(), status: McpServerStartupState::Starting, diff --git a/codex-rs/tui/src/app/tests/buffered_replay.rs b/codex-rs/tui/src/app/tests/buffered_replay.rs index 7952114d09..4412dab108 100644 --- a/codex-rs/tui/src/app/tests/buffered_replay.rs +++ b/codex-rs/tui/src/app/tests/buffered_replay.rs @@ -781,6 +781,7 @@ async fn misalignment_buffered_replay_preserves_input_after_continuation() { let failed_turn = Turn { error: Some(error.clone()), items: vec![ThreadItem::untimed(ThreadItemPayload::McpToolCall { + environment_id: None, id: "tool-call".into(), server: "sample".into(), tool: "inspect".into(), diff --git a/codex-rs/tui/src/app/tests/mcp_login_tests.rs b/codex-rs/tui/src/app/tests/mcp_login_tests.rs index f6ace33306..6054708c85 100644 --- a/codex-rs/tui/src/app/tests/mcp_login_tests.rs +++ b/codex-rs/tui/src/app/tests/mcp_login_tests.rs @@ -16,6 +16,7 @@ async fn overlapping_mcp_login_preserves_first_start() { app.pending_mcp_login_start = Some(PendingMcpLoginStart { request_id: "first".into(), name: "enterprise".into(), + environment_id: None, thread_id, completions: Vec::new(), }); @@ -24,6 +25,7 @@ async fn overlapping_mcp_login_preserves_first_start() { &mut server, AppEvent::StartMcpLogin { name: "typo".into(), + environment_id: None, thread_id, }, ) @@ -68,17 +70,27 @@ async fn mcp_login_retry_suppresses_old_completion_and_late_browser_open() { let thread_id = ThreadId::new(); app.primary_thread_id = Some(thread_id); app.active_thread_id = Some(thread_id); - app.active_mcp_login_ids - .insert("enterprise".into(), "old".into()); - app.active_mcp_login_ids - .insert("other".into(), "other-login".into()); + app.active_mcp_login_ids = HashMap::from([ + ("old".into(), ("enterprise".into(), Some("env-a".into()))), + ( + "early-peer".into(), + ("enterprise".into(), Some("env-b".into())), + ), + ( + "late-peer".into(), + ("enterprise".into(), Some("env-c".into())), + ), + ("other-login".into(), ("other".into(), None)), + ]); app.pending_mcp_login_start = Some(PendingMcpLoginStart { request_id: "retry".into(), name: "enterprise".into(), + environment_id: Some("env-a".into()), thread_id, completions: Vec::new(), }); - for (login_id, success) in [("old", false), ("new", true)] { + // A same-named environment's completion is buffered alongside the retry's completion. + for (login_id, success) in [("old", false), ("new", true), ("early-peer", true)] { app.handle_app_server_event( &server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( @@ -114,14 +126,14 @@ async fn mcp_login_retry_suppresses_old_completion_and_late_browser_open() { .lock() .await .snapshot(); - assert_eq!(snapshot.events.len(), 1); + assert_eq!(snapshot.events.len(), 2); for event in snapshot.events { app.handle_thread_event_replay(event); + assert_eq!( + next_history_message(&mut events), + "• Signed in to enterprise." + ); } - assert_eq!( - next_history_message(&mut events), - "• Signed in to enterprise." - ); assert!(events.try_recv().is_err()); // A cancellation delivered after the replacement response is stale as well. app.handle_app_server_event( @@ -147,32 +159,39 @@ async fn mcp_login_retry_suppresses_old_completion_and_late_browser_open() { .snapshot() .events .len(), - 1 + 2 ); - // Retrying one server must not discard a different server's outstanding OAuth result. - app.handle_app_server_event( - &server, - codex_app_server_client::AppServerEvent::ServerNotification(Box::new( - ServerNotification::McpServerOauthLoginCompleted( - McpServerOauthLoginCompletedNotification { - name: "other".into(), - thread_id: Some(thread_id.to_string()), - login_id: Some("other-login".into()), - success: true, - error: None, - }, - ), - )), - ) - .await; - let snapshot = app.thread_event_channels[&thread_id] - .store - .lock() - .await - .snapshot(); - assert_eq!(snapshot.events.len(), 2); - app.handle_thread_event_replay(snapshot.events.last().unwrap().clone()); - assert!(next_history_message(&mut events).contains("Signed in to other.")); + // Both differently named servers and same-named environments retain later completions. + for (name, login_id, expected_events) in + [("other", "other-login", 3), ("enterprise", "late-peer", 4)] + { + app.handle_app_server_event( + &server, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::McpServerOauthLoginCompleted( + McpServerOauthLoginCompletedNotification { + name: name.into(), + thread_id: Some(thread_id.to_string()), + login_id: Some(login_id.into()), + success: true, + error: None, + }, + ), + )), + ) + .await; + let snapshot = app.thread_event_channels[&thread_id] + .store + .lock() + .await + .snapshot(); + assert_eq!(snapshot.events.len(), expected_events); + app.handle_thread_event_replay(snapshot.events.last().unwrap().clone()); + assert_eq!( + next_history_message(&mut events), + format!("• Signed in to {name}.") + ); + } server.shutdown().await.unwrap(); } @@ -188,7 +207,7 @@ async fn mcp_login_results_survive_thread_switch_and_session_refresh() { app.primary_thread_id = Some(origin); app.active_thread_id = Some(ThreadId::new()); app.active_mcp_login_ids - .insert("enterprise".into(), "login".into()); + .insert("login".into(), ("enterprise".into(), None)); app.handle_app_server_event( &server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( @@ -235,10 +254,11 @@ async fn rejected_mcp_login_retry_preserves_previous_result() { app.primary_thread_id = Some(thread_id); app.active_thread_id = Some(thread_id); app.active_mcp_login_ids - .insert("enterprise".into(), "old".into()); + .insert("old".into(), ("enterprise".into(), None)); app.pending_mcp_login_start = Some(PendingMcpLoginStart { request_id: "typo".into(), name: "typo".into(), + environment_id: None, thread_id, completions: Vec::new(), }); diff --git a/codex-rs/tui/src/app/tests/mcp_startup.rs b/codex-rs/tui/src/app/tests/mcp_startup.rs index 462983a7b1..cf649b0cd0 100644 --- a/codex-rs/tui/src/app/tests/mcp_startup.rs +++ b/codex-rs/tui/src/app/tests/mcp_startup.rs @@ -41,6 +41,7 @@ async fn remote_mcp_startup_settles_when_attaching_mid_round() -> Result<()> { let update = |name: &str, status| { ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some(id.to_string()), name: name.to_string(), status, @@ -158,6 +159,7 @@ async fn remote_mcp_startup_reports_terminal_only_failures_and_cancellations() - ] { let notification = ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some(id.to_string()), name: name.to_string(), error: (status == Failed).then(|| error.to_string()), @@ -261,6 +263,7 @@ async fn subagent_mcp_startup_settles_while_cached_servers_remain_deferred() { &app_server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some(subagent_thread_id.to_string()), name: name.to_string(), status, @@ -311,6 +314,7 @@ async fn resumed_subagent_mcp_startup_settles_while_cached_servers_remain_deferr ] { app.chat_widget.handle_server_notification( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some(subagent_thread_id.to_string()), name: "eager".to_string(), status, @@ -341,6 +345,7 @@ async fn side_conversations_settle_despite_unreported_locally_configured_servers ] { app.chat_widget.handle_server_notification( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some(side_thread_id.to_string()), name: "eager".to_string(), status, diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 0e3a2b6094..6230e2e30e 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -2529,6 +2529,7 @@ async fn local_daemon_registers_approval_gated_mcp_tools_for_both_start_paths_sc .request_typed(ClientRequest::McpServerStatusList { request_id: AppServerRequestId::String("tui-tool-inventory".to_string()), params: codex_app_server_protocol::ListMcpServerStatusParams { + environment_id: None, server_name: None, cursor: None, limit: None, diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__inactive_thread_user_verification_prompt.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__inactive_thread_user_verification_prompt.snap index c8de6a573e..3c600dde7a 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__inactive_thread_user_verification_prompt.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__inactive_thread_user_verification_prompt.snap @@ -6,7 +6,7 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" Thread: Verifier [worker] - Server: deployments + Server: deployments (environment env-b) Verify the production deployment. diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index 4954bf3ff7..d465cc0c53 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -1053,6 +1053,7 @@ async fn auto_declined_mcp_elicitations_do_not_leave_startup_quarantine_armed() let request = ServerRequest::McpServerElicitationRequest { request_id: AppServerRequestId::Integer(10), params: McpServerElicitationRequestParams { + environment_id: None, thread_id: thread_id.to_string(), turn_id: Some("turn-auth".to_string()), server_name: "payments".to_string(), @@ -1544,6 +1545,7 @@ async fn startup_thread_started_discards_another_threads_buffered_events() { let other_thread_id = ThreadId::new(); app.enqueue_primary_thread_notification(ServerNotification::McpServerStatusUpdated( McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some(other_thread_id.to_string()), name: "other".to_string(), status: McpServerStartupState::Starting, @@ -1758,6 +1760,7 @@ async fn send_failed_mcp_startup( app_server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some(thread_id.to_string()), name: "fixture".to_string(), status: McpServerStartupState::Failed, diff --git a/codex-rs/tui/src/app/tests/startup_warnings_tests.rs b/codex-rs/tui/src/app/tests/startup_warnings_tests.rs index 85caff3418..47c251339a 100644 --- a/codex-rs/tui/src/app/tests/startup_warnings_tests.rs +++ b/codex-rs/tui/src/app/tests/startup_warnings_tests.rs @@ -199,6 +199,7 @@ async fn mcp_startup_summary_counts_servers_and_sign_in_subset() -> Result<()> { ] { app.chat_widget.handle_server_notification( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: None, name: name.into(), status, diff --git a/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs b/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs index 478514670d..b501425a16 100644 --- a/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs +++ b/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs @@ -216,6 +216,7 @@ async fn unavailable_thread_new_and_clear_start_a_writable_session() -> Result<( ), "mcp" => app.chat_widget.handle_server_notification( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some(id.to_string()), name: "slow".into(), status: McpServerStartupState::Starting, diff --git a/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs b/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs index af9ba4eec7..ef49e991de 100644 --- a/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs +++ b/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs @@ -5,6 +5,7 @@ fn user_verification_server_request(thread_id: ThreadId, request_id: i64) -> Ser ServerRequest::McpServerElicitationRequest { request_id: AppServerRequestId::Integer(request_id), params: McpServerElicitationRequestParams { + environment_id: None, thread_id: thread_id.to_string(), turn_id: Some("turn-verification".to_string()), server_name: "deployments".to_string(), @@ -40,7 +41,11 @@ async fn inactive_thread_user_verification_replays_queued_request_once() -> Resu let primary_thread_id = ThreadId::new(); let side_thread_id = ThreadId::new(); let thread_id = ThreadId::new(); - let request = user_verification_server_request(thread_id, /*request_id*/ 11); + let mut request = user_verification_server_request(thread_id, /*request_id*/ 11); + let ServerRequest::McpServerElicitationRequest { params, .. } = &mut request else { + panic!("expected MCP elicitation request"); + }; + params.environment_id = Some("env-b".to_string()); app.primary_thread_id = Some(primary_thread_id); app.active_thread_id = Some(side_thread_id); diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 3d0079c2a4..7d2aab9e93 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -1112,6 +1112,7 @@ mod tests { let thread_id = ThreadId::new(); let notification = ServerNotification::McpServerStatusUpdated( codex_app_server_protocol::McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some(thread_id.to_string()), name: "sentry".to_string(), status: codex_app_server_protocol::McpServerStartupState::Failed, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index c579aef87d..1c21ebebc1 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -397,6 +397,7 @@ impl App { if let Some(params) = AppLinkViewParams::from_url_app_server_request( thread_id, ¶ms.server_name, + params.environment_id.as_deref(), request_id.clone(), ¶ms.request, ) { @@ -419,6 +420,7 @@ impl App { thread_id, thread_label, server_name: params.server_name.clone(), + environment_id: params.environment_id.clone(), request_id: request_id.clone(), message: message.clone(), }), @@ -430,6 +432,7 @@ impl App { request: crate::bottom_pane::user_verification::UserVerificationRequest { thread_label, server_name: params.server_name.clone(), + environment_id: params.environment_id.clone(), request_id: request_id.clone(), title: title.clone(), description: description.clone(), @@ -2060,6 +2063,7 @@ impl App { AppLinkViewParams::from_url_app_server_request( thread_id, ¶ms.server_name, + params.environment_id.as_deref(), request_id.clone(), request, ) diff --git a/codex-rs/tui/src/app/user_verification_requests_tests.rs b/codex-rs/tui/src/app/user_verification_requests_tests.rs index 654b5b5272..3b75e8923d 100644 --- a/codex-rs/tui/src/app/user_verification_requests_tests.rs +++ b/codex-rs/tui/src/app/user_verification_requests_tests.rs @@ -16,6 +16,7 @@ fn verification_request() -> ServerRequest { ServerRequest::McpServerElicitationRequest { request_id: RequestId::Integer(7), params: McpServerElicitationRequestParams { + environment_id: None, thread_id: ThreadId::new().to_string(), turn_id: None, server_name: "deployments".to_string(), diff --git a/codex-rs/tui/src/app/user_verification_tests.rs b/codex-rs/tui/src/app/user_verification_tests.rs index 9bf779f36d..ab8c53627b 100644 --- a/codex-rs/tui/src/app/user_verification_tests.rs +++ b/codex-rs/tui/src/app/user_verification_tests.rs @@ -12,6 +12,7 @@ fn verification_request() -> ServerRequest { ServerRequest::McpServerElicitationRequest { request_id: RequestId::Integer(7), params: McpServerElicitationRequestParams { + environment_id: None, thread_id: ThreadId::new().to_string(), turn_id: None, server_name: "deployments".to_string(), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index ab8735faad..aae7d5f5bb 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1286,6 +1286,7 @@ pub(crate) enum AppEvent { StartMcpLogin { name: String, + environment_id: Option, thread_id: ThreadId, }, diff --git a/codex-rs/tui/src/app_server_approval_conversions.rs b/codex-rs/tui/src/app_server_approval_conversions.rs index 1f0153cd2d..fdd77603d2 100644 --- a/codex-rs/tui/src/app_server_approval_conversions.rs +++ b/codex-rs/tui/src/app_server_approval_conversions.rs @@ -3,7 +3,7 @@ //! The TUI mostly keeps app-server approval types intact. These helpers cover //! the remaining cases where the UI consumes a private file-change display //! model or needs to translate a granted permission response for outbound -//! submission. +//! submission. Server labels include registration scope without changing routing names. use crate::diff_model::FileChange; use codex_app_server_protocol::AdditionalNetworkPermissions; @@ -14,6 +14,13 @@ use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequest use std::collections::HashMap; use std::path::PathBuf; +pub(crate) fn mcp_server_label(server_name: &str, environment_id: Option<&str>) -> String { + match environment_id { + Some(environment_id) => format!("{server_name} (environment {environment_id})"), + None => server_name.to_string(), + } +} + pub(crate) fn granted_permission_profile_from_request( value: CoreRequestPermissionProfile, ) -> GrantedPermissionProfile { diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index e78b2ff249..ec15355fa4 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -83,6 +83,7 @@ impl AppLinkViewParams { pub(crate) fn from_url_app_server_request( thread_id: ThreadId, server_name: &str, + environment_id: Option<&str>, request_id: AppServerRequestId, request: &codex_app_server_protocol::McpServerElicitationRequest, ) -> Option { @@ -95,7 +96,7 @@ impl AppLinkViewParams { else { return None; }; - if server_name == MCP_CODEX_APPS_SERVER_NAME { + if server_name == MCP_CODEX_APPS_SERVER_NAME && environment_id.is_none() { let url = validate_external_url(url, /*require_chatgpt_host*/ true)?; return Self::from_codex_apps_auth_url_parts( thread_id, @@ -112,6 +113,7 @@ impl AppLinkViewParams { Some(Self::from_generic_url_parts( thread_id, server_name, + environment_id, request_id, message, url.as_str(), @@ -178,6 +180,7 @@ impl AppLinkViewParams { fn from_generic_url_parts( thread_id: ThreadId, server_name: &str, + environment_id: Option<&str>, request_id: AppServerRequestId, message: &str, url: &str, @@ -186,7 +189,13 @@ impl AppLinkViewParams { Self { app_id: elicitation_id.to_string(), title: "Action required".to_string(), - description: Some(format!("Server: {server_name}")), + description: Some(format!( + "Server: {}", + crate::app_server_approval_conversions::mcp_server_label( + server_name, + environment_id + ) + )), instructions: "Complete the requested action in your browser, then return here." .to_string(), url: url.to_string(), @@ -954,6 +963,7 @@ mod tests { let params = AppLinkViewParams::from_url_app_server_request( target.thread_id, &target.server_name, + /*environment_id*/ None, target.request_id.clone(), &request, ) @@ -982,6 +992,7 @@ mod tests { let params = AppLinkViewParams::from_url_app_server_request( target.thread_id, &target.server_name, + /*environment_id*/ None, target.request_id.clone(), &request, ) @@ -1018,6 +1029,7 @@ mod tests { let params = AppLinkViewParams::from_url_app_server_request( target.thread_id, &target.server_name, + /*environment_id*/ None, target.request_id.clone(), &request, ); @@ -1041,6 +1053,7 @@ mod tests { let params = AppLinkViewParams::from_url_app_server_request( target.thread_id, &target.server_name, + /*environment_id*/ None, target.request_id.clone(), &request, ); @@ -1264,6 +1277,7 @@ mod tests { let params = AppLinkViewParams::from_url_app_server_request( target.thread_id, &target.server_name, + /*environment_id*/ None, target.request_id.clone(), &request, ) @@ -1709,6 +1723,7 @@ mod tests { let params = AppLinkViewParams::from_url_app_server_request( target.thread_id, &target.server_name, + Some("env-b"), target.request_id.clone(), &request, ) @@ -1738,6 +1753,7 @@ mod tests { let params = AppLinkViewParams::from_url_app_server_request( target.thread_id, &target.server_name, + /*environment_id*/ None, target.request_id.clone(), &request, ) diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 46fd8ef491..a0ba70a7b1 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -117,6 +117,7 @@ pub(crate) struct McpElicitationApprovalRequest { pub thread_id: ThreadId, pub thread_label: Option, pub server_name: String, + pub environment_id: Option, pub request_id: RequestId, pub message: String, } @@ -303,7 +304,13 @@ impl ApprovalOverlay { ), ApprovalRequest::McpElicitation(request) => ( elicitation_options(approval_keymap), - format!("{} needs your approval.", request.server_name), + format!( + "{} needs your approval.", + crate::app_server_approval_conversions::mcp_server_label( + &request.server_name, + request.environment_id.as_deref() + ) + ), ), }; @@ -803,7 +810,14 @@ fn build_header(request: &ApprovalRequest) -> Box { lines.push(Line::from("")); } lines.extend([ - Line::from(vec!["Server: ".into(), request.server_name.clone().bold()]), + Line::from(vec![ + "Server: ".into(), + crate::app_server_approval_conversions::mcp_server_label( + &request.server_name, + request.environment_id.as_deref(), + ) + .bold(), + ]), Line::from(""), Line::from(request.message.clone()), ]); @@ -1273,6 +1287,7 @@ mod tests { fn make_elicitation_request() -> ApprovalRequest { ApprovalRequest::McpElicitation(McpElicitationApprovalRequest { + environment_id: None, thread_id: ThreadId::new(), thread_label: None, server_name: "test-server".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_popup_footer_wide.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_popup_footer_wide.snap index bb446cf9e1..1367c6450d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_popup_footer_wide.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_popup_footer_wide.snap @@ -1,15 +1,15 @@ --- source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs -assertion_line: 344 expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 8 }, + area: Rect { x: 0, y: 0, width: 80, height: 9 }, content: [ " /model choose what model and reasoning effort to use ", "› /memories configure memory use and generation ", " /mention mention a file ", - " /mcp list MCP tools; use /mcp verbose or /mcp login ", + " /mcp list MCP tools; /mcp verbose or /mcp login [--environment ", + " ] ", " ", "› /m ", " ", @@ -32,8 +32,10 @@ Buffer { x: 3, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 13, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 66, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 1, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 77, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 13, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 18, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 1, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs index 3239844997..eb18d98e71 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -173,6 +173,7 @@ struct McpToolApprovalDisplayParam { pub(crate) struct McpServerElicitationFormRequest { thread_id: ThreadId, server_name: String, + environment_id: Option, request_id: AppServerRequestId, message: String, approval_display_params: Vec, @@ -197,6 +198,7 @@ impl McpServerElicitationFormRequest { ) -> Option { let McpServerElicitationRequestParams { server_name, + environment_id, request, .. } = request; @@ -210,24 +212,7 @@ impl McpServerElicitationFormRequest { }; let requested_schema = serde_json::to_value(requested_schema).ok()?; - Self::from_parts( - thread_id, - server_name.clone(), - request_id, - meta.as_ref(), - message.clone(), - requested_schema, - ) - } - - fn from_parts( - thread_id: ThreadId, - server_name: String, - request_id: AppServerRequestId, - meta: Option<&Value>, - message: String, - requested_schema: Value, - ) -> Option { + let meta = meta.as_ref(); let tool_suggestion = parse_tool_suggestion_request(meta); let is_tool_approval = meta .and_then(Value::as_object) @@ -328,9 +313,10 @@ impl McpServerElicitationFormRequest { Some(Self { thread_id, - server_name, + server_name: server_name.clone(), + environment_id: environment_id.clone(), request_id, - message, + message: message.clone(), approval_display_params, response_mode, is_tool_approval, @@ -951,10 +937,17 @@ impl McpServerElicitationOverlay { } fn current_prompt_text(&self) -> String { - let request_message = format_tool_approval_display_message( + let mut request_message = format_tool_approval_display_message( &self.request.message, &self.request.approval_display_params, ); + if self.request.environment_id.is_some() { + let server = crate::app_server_approval_conversions::mcp_server_label( + &self.request.server_name, + self.request.environment_id.as_deref(), + ); + request_message = format!("Server: {server}\n\n{request_message}"); + } let Some(field) = self.current_field() else { return request_message; }; @@ -1748,6 +1741,7 @@ mod tests { meta: Option, ) -> McpServerElicitationRequestParams { McpServerElicitationRequestParams { + environment_id: None, thread_id: "thread-1".to_string(), turn_id: Some("turn-1".to_string()), server_name: "server-1".to_string(), @@ -1874,6 +1868,7 @@ mod tests { assert_eq!( request, McpServerElicitationFormRequest { + environment_id: None, thread_id, server_name: "server-1".to_string(), request_id: request_id("request-1"), @@ -1945,6 +1940,7 @@ mod tests { assert_eq!( request, McpServerElicitationFormRequest { + environment_id: None, thread_id, server_name: "server-1".to_string(), request_id: request_id("request-1"), @@ -2003,6 +1999,7 @@ mod tests { assert_eq!( request, McpServerElicitationFormRequest { + environment_id: None, thread_id, server_name: "server-1".to_string(), request_id: request_id("request-1"), @@ -2594,6 +2591,7 @@ mod tests { thread_id, request_id("request-2"), &McpServerElicitationRequestParams { + environment_id: None, thread_id: "thread-1".to_string(), turn_id: Some("turn-2".to_string()), server_name: "server-1".to_string(), @@ -2675,21 +2673,24 @@ mod tests { let (tx, _rx) = test_sender(); let request = from_form_request( ThreadId::default(), - form_request( - "Allow this request?", - serde_json::json!({ - "type": "object", - "properties": { - "confirmed": { - "type": "boolean", - "title": "Confirm", - "description": "Approve the pending action.", - } - }, - "required": ["confirmed"], - }), - /*meta*/ None, - ), + McpServerElicitationRequestParams { + environment_id: Some("env-b".to_string()), + ..form_request( + "Allow this request?", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + "description": "Approve the pending action.", + } + }, + "required": ["confirmed"], + }), + /*meta*/ None, + ) + }, ) .expect("expected supported form"); let overlay = McpServerElicitationOverlay::new( diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap index 540d511551..92ba0954f6 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap @@ -4,7 +4,7 @@ expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)) --- Action required - Server: payments + Server: payments (environment env-b) Review the payment details to continue. diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_boolean_form.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_boolean_form.snap index 0415c24071..3eb39ee707 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_boolean_form.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_boolean_form.snap @@ -4,6 +4,8 @@ expression: "render_snapshot(&overlay, Rect::new(0, 0, 120, 16))" --- Field 1/1 (1 required unanswered) + Server: server-1 (environment env-b) + Allow this request? Confirm @@ -14,6 +16,4 @@ expression: "render_snapshot(&overlay, Rect::new(0, 0, 120, 16))" - - enter to submit | esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/user_verification.rs b/codex-rs/tui/src/bottom_pane/user_verification.rs index 8edae9bf61..cf9bc7ccf4 100644 --- a/codex-rs/tui/src/bottom_pane/user_verification.rs +++ b/codex-rs/tui/src/bottom_pane/user_verification.rs @@ -36,6 +36,7 @@ pub(crate) struct UserVerificationRequest { pub description: String, pub thread_label: Option, pub server_name: String, + pub environment_id: Option, pub request_id: RequestId, } @@ -285,7 +286,14 @@ fn request_details(request: &UserVerificationRequest) -> Box { lines.push(Line::from("")); } lines.extend([ - Line::from(vec!["Server: ".into(), request.server_name.clone().bold()]), + Line::from(vec![ + "Server: ".into(), + crate::app_server_approval_conversions::mcp_server_label( + &request.server_name, + request.environment_id.as_deref(), + ) + .bold(), + ]), Line::from(""), Line::from(request.description.clone()), ]); diff --git a/codex-rs/tui/src/bottom_pane/user_verification_tests.rs b/codex-rs/tui/src/bottom_pane/user_verification_tests.rs index 351f909d55..ee36bd8837 100644 --- a/codex-rs/tui/src/bottom_pane/user_verification_tests.rs +++ b/codex-rs/tui/src/bottom_pane/user_verification_tests.rs @@ -25,6 +25,7 @@ fn make_view() -> ( tokio::sync::mpsc::UnboundedReceiver, ) { make_view_for_request(UserVerificationRequest { + environment_id: None, title: "Production deployment needs your approval.".to_string(), description: "Approve deploying the reviewed release?".to_string(), thread_label: None, @@ -158,6 +159,7 @@ fn full_screen_request_is_available_before_and_during_verification() { #[test] fn long_url_tokens_remain_visible_in_a_narrow_verification_prompt() { let (mut view, _) = make_view_for_request(UserVerificationRequest { + environment_id: None, title: "Approve https://example.com/production/release/candidate?".to_string(), description: "Deploy https://example.com/production/critical-release-candidate".to_string(), thread_label: None, @@ -205,6 +207,7 @@ fn long_url_tokens_remain_visible_in_a_narrow_verification_prompt() { #[tokio::test] async fn full_screen_pager_reaches_the_end_of_a_long_request() -> std::io::Result<()> { let (view, _) = make_view_for_request(UserVerificationRequest { + environment_id: None, title: "Approve release?".to_string(), description: format!( "{}Final approval details.", diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4380cb2353..dccbfb3e48 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -642,11 +642,11 @@ pub(crate) struct ChatWidget { unified_exec_processes: Vec, /// Exits observed during the pending list request take precedence over its response. pending_terminal_restore: Option<(uuid::Uuid, HashSet)>, - /// Latest status per server in the active startup round. Its presence keeps + /// Latest status per (server name, environment ID) in the active startup round. Its presence keeps /// the composer busy, and its terminal outcomes determine the round's summary. - mcp_startup_status: Option>, + mcp_startup_status: Option), McpStartupStatus>>, /// Last diagnostic per server, so replayed terminal updates are only reported once. - mcp_startup_last_diagnostics: HashMap, + mcp_startup_last_diagnostics: HashMap<(String, Option), McpStartupStatus>, connectors: ConnectorsState, ide_context: IdeContextState, plugins_cache: PluginsCacheState, diff --git a/codex-rs/tui/src/chatwidget/mcp_startup.rs b/codex-rs/tui/src/chatwidget/mcp_startup.rs index e373b1982b..69d1971ae5 100644 --- a/codex-rs/tui/src/chatwidget/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/mcp_startup.rs @@ -7,7 +7,9 @@ //! already shown the same diagnostic in an earlier round. //! Initial and runtime diagnostics remain in retained history and the warnings viewer; //! compact history hides their full text behind the persistent warning notice. +//! Server-name/environment pairs keep same-named registrations independent. +use crate::app_server_approval_conversions::mcp_server_label; use codex_app_server_protocol::McpServerStartupFailureReason; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; @@ -37,7 +39,11 @@ impl McpStartupStatus { impl ChatWidget { /// The server announces eager starts before outcomes. On attach or after lag, /// an outcome can arrive without its corresponding start. - fn update_mcp_startup_status(&mut self, server: String, status: McpStartupStatus) { + fn update_mcp_startup_status( + &mut self, + server: (String, Option), + status: McpStartupStatus, + ) { let report_diagnostic = if status.is_diagnostic() { self.mcp_startup_last_diagnostics .insert(server.clone(), status.clone()) @@ -74,26 +80,28 @@ impl ChatWidget { let total = current.len(); let mut starting: Vec<_> = current .iter() - .filter_map(|(name, state)| { + .filter_map(|(server, state)| { if matches!(state, McpStartupStatus::Starting) { - Some(name) + Some(server) } else { None } }) .collect(); starting.sort(); - let first = starting.first()?; let completed = total.saturating_sub(starting.len()); let max_to_show = 3; let mut to_show: Vec = starting .iter() .take(max_to_show) - .map(ToString::to_string) + .map(|(server_name, environment_id)| { + mcp_server_label(server_name, environment_id.as_deref()) + }) .collect(); if starting.len() > max_to_show { to_show.push("…".to_string()); } + let first = to_show.first()?; Some(if total > 1 { format!( "{MCP_STARTUP_MULTI_HEADER_PREFIX} ({completed}/{total}): {}", @@ -104,26 +112,35 @@ impl ChatWidget { }) } - fn report_mcp_startup_failure(&mut self, server: &str, status: &McpStartupStatus) { + fn report_mcp_startup_failure( + &mut self, + (server_name, environment_id): &(String, Option), + status: &McpStartupStatus, + ) { if let McpStartupStatus::Failed { error, failure_reason, } = status { - self.add_mcp_startup_warning(vec![error.clone()], [server.to_owned()], *failure_reason); + self.add_mcp_startup_warning( + vec![error.clone()], + [mcp_server_label(server_name, environment_id.as_deref())], + *failure_reason, + ); } } fn report_mcp_startup_summary<'a>( &mut self, - outcomes: impl IntoIterator, + outcomes: impl IntoIterator), &'a McpStartupStatus)>, ) { let mut failed = Vec::new(); let mut cancelled = Vec::new(); - for (server, status) in outcomes { + for ((server_name, environment_id), status) in outcomes { + let server = mcp_server_label(server_name, environment_id.as_deref()); match status { - McpStartupStatus::Failed { .. } => failed.push(server.clone()), - McpStartupStatus::Cancelled => cancelled.push(server.clone()), + McpStartupStatus::Failed { .. } => failed.push(server), + McpStartupStatus::Cancelled => cancelled.push(server), McpStartupStatus::Starting | McpStartupStatus::Ready => {} } } @@ -206,19 +223,28 @@ impl ChatWidget { notification: McpServerStatusUpdatedNotification, ) { let refresh_connector_mentions = notification.name == "codex_apps" + && notification.environment_id.is_none() && notification.status == McpServerStartupState::Ready; let status = match notification.status { McpServerStartupState::Starting => McpStartupStatus::Starting, McpServerStartupState::Ready => McpStartupStatus::Ready, - McpServerStartupState::Failed => McpStartupStatus::Failed { - failure_reason: notification.failure_reason, - error: notification.error.unwrap_or_else(|| { - format!("MCP client for `{}` failed to start", notification.name) - }), - }, + McpServerStartupState::Failed => { + let server = + mcp_server_label(¬ification.name, notification.environment_id.as_deref()); + McpStartupStatus::Failed { + failure_reason: notification.failure_reason, + error: match notification.error { + Some(error) if notification.environment_id.is_some() => { + format!("{server}: {error}") + } + Some(error) => error, + None => format!("MCP client for `{server}` failed to start"), + }, + } + } McpServerStartupState::Cancelled => McpStartupStatus::Cancelled, }; - self.update_mcp_startup_status(notification.name, status); + self.update_mcp_startup_status((notification.name, notification.environment_id), status); if refresh_connector_mentions { self.refresh_connector_mentions(/*force_refresh*/ false); } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index d28b12f6e3..4523395a3b 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -873,8 +873,38 @@ impl ChatWidget { && !name.trim().is_empty() { if let Some(thread_id) = self.thread_id { + // Keep the old name-only syntax intact, including spaces and quotes. + let (name, environment_id) = if name.split_whitespace().any(|word| { + word == "--environment" || word.starts_with("--environment=") + }) { + let args = match super::slash_command_args::parse( + name, + &["--environment"], + &[], + &[], + ) { + Ok(args) => args, + Err(error) => { + self.add_error_message(error); + return; + } + }; + if args.positionals.is_empty() { + self.add_error_message( + "Usage: /mcp login [--environment ]".to_string(), + ); + return; + } + ( + args.positionals.join(" "), + args.value("--environment").map(str::to_owned), + ) + } else { + (name.trim().to_string(), None) + }; self.app_event_tx.send(AppEvent::StartMcpLogin { - name: name.trim().to_string(), + name, + environment_id, thread_id, }); } else { @@ -883,7 +913,9 @@ impl ChatWidget { ); } } else { - self.add_error_message("Usage: /mcp [verbose | login ]".to_string()); + self.add_error_message( + "Usage: /mcp [verbose | login [--environment ]]".to_string(), + ); } } SlashCommand::Keymap => match trimmed.to_ascii_lowercase().as_str() { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_mcp_startup_failure_renders_warning_history.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_mcp_startup_failure_renders_warning_history.snap index 079e5e0c65..fd8ac7caf4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_mcp_startup_failure_renders_warning_history.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_mcp_startup_failure_renders_warning_history.snap @@ -4,8 +4,8 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) --- -⚠ MCP client for `alpha` failed to start: handshake failed -⚠ MCP startup incomplete (failed: alpha) +⚠ notes (environment env-a): MCP client for `notes` failed to start: handshake failed +⚠ MCP startup incomplete (failed: notes (environment env-a)) › Ask Codex to do anything diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index b282c88491..e7ffb560cb 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -590,6 +590,7 @@ async fn tool_suggestion_install_url_is_validated_before_opening() { chat.handle_elicitation_request_now( codex_app_server_protocol::RequestId::Integer(9), codex_app_server_protocol::McpServerElicitationRequestParams { + environment_id: None, thread_id: request_thread_id.to_string(), turn_id: Some("turn-install".to_string()), server_name: "connector-server".to_string(), @@ -654,6 +655,7 @@ async fn invalid_url_elicitation_is_declined() { chat.handle_elicitation_request_now( codex_app_server_protocol::RequestId::Integer(9), codex_app_server_protocol::McpServerElicitationRequestParams { + environment_id: Some("env-a".to_string()), thread_id: request_thread_id.to_string(), turn_id: Some("turn-auth".to_string()), server_name: "payments".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/computer_activity_tests.rs b/codex-rs/tui/src/chatwidget/tests/computer_activity_tests.rs index e291b4206c..7a8f0fe2ea 100644 --- a/codex-rs/tui/src/chatwidget/tests/computer_activity_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/computer_activity_tests.rs @@ -6,6 +6,7 @@ fn computer_item( status: codex_app_server_protocol::McpToolCallStatus, ) -> AppServerThreadItemPayload { AppServerThreadItemPayload::McpToolCall { + environment_id: None, id: id.to_string(), server: "cua_repl".to_string(), tool: "js".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 229667c4af..a36592d215 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -13,6 +13,7 @@ async fn external_writer_snapshot_freezes_active_command_and_mcp_rows() { chat.transcript.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( "mcp-running".to_string(), McpInvocation { + environment_id: None, server: "server".to_string(), tool: "tool".to_string(), arguments: None, @@ -134,6 +135,7 @@ async fn replayed_completion_preserves_unrelated_running_command() { chat.transcript.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( "mcp-running".to_string(), McpInvocation { + environment_id: None, server: "server".to_string(), tool: "tool".to_string(), arguments: None, diff --git a/codex-rs/tui/src/chatwidget/tests/guardian.rs b/codex-rs/tui/src/chatwidget/tests/guardian.rs index 75ce2e316d..96b52e812f 100644 --- a/codex-rs/tui/src/chatwidget/tests/guardian.rs +++ b/codex-rs/tui/src/chatwidget/tests/guardian.rs @@ -804,6 +804,7 @@ async fn guardian_cleanup_drops_stale_reviews_and_restores_mcp_status() { handle_turn_started(&mut chat, "turn-1"); chat.on_mcp_server_status_updated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: None, name: "alpha".to_string(), status: McpServerStartupState::Starting, diff --git a/codex-rs/tui/src/chatwidget/tests/history_projection.rs b/codex-rs/tui/src/chatwidget/tests/history_projection.rs index edd451f65c..edf5f4fc5a 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_projection.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_projection.rs @@ -77,6 +77,7 @@ async fn older_tool_projection_matches_initial_replay() { }); } items.push(AppServerThreadItemPayload::McpToolCall { + environment_id: None, id: id.to_string(), server: server.to_string(), tool: "js".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 59ac521103..b3a68bee25 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -1389,6 +1389,7 @@ async fn replayed_mcp_tool_call_stays_active_until_completion() { let _ = drain_insert_history(&mut rx); let mut item = AppServerThreadItemPayload::McpToolCall { + environment_id: None, id: "mcp-1".to_string(), server: "copilot-bridge".to_string(), tool: "copilot".to_string(), @@ -1434,6 +1435,7 @@ async fn failed_repl_mcp_tool_call_preserves_status_and_result() { chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification::new("thread-1".to_string(), "turn-1".to_string(), AppServerThreadItemPayload::McpToolCall { + environment_id: None, id: "mcp-failed".to_string(), server: server.to_string(), tool: "js".to_string(), @@ -1500,6 +1502,7 @@ async fn deferred_mcp_lifecycle_events_keep_fifo_after_stream_finishes() { )); chat.on_mcp_tool_call_started(AppServerThreadItemPayload::McpToolCall { + environment_id: None, id: "mcp-deferred".to_string(), server: "copilot-bridge".to_string(), tool: "copilot".to_string(), @@ -1518,6 +1521,7 @@ async fn deferred_mcp_lifecycle_events_keep_fifo_after_stream_finishes() { chat.stream_controller = None; chat.on_mcp_tool_call_completed(AppServerThreadItemPayload::McpToolCall { + environment_id: None, id: "mcp-deferred".to_string(), server: "copilot-bridge".to_string(), tool: "copilot".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 3460c975d0..eeb2c59f49 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -5,6 +5,7 @@ use pretty_assertions::assert_eq; fn notify_mcp_status(chat: &mut ChatWidget, name: &str, status: McpServerStartupState) { chat.handle_server_notification( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some("thread-1".to_string()), name: name.to_string(), status, @@ -18,6 +19,7 @@ fn notify_mcp_status(chat: &mut ChatWidget, name: &str, status: McpServerStartup fn notify_mcp_status_error(chat: &mut ChatWidget, name: &str, error: &str) { chat.handle_server_notification( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some("thread-1".to_string()), name: name.to_string(), status: McpServerStartupState::Failed, @@ -48,6 +50,7 @@ async fn mcp_startup_ignores_status_for_other_thread() { ] { chat.handle_server_notification( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: Some(child_thread_id.to_string()), name: "sentry".to_string(), status, @@ -78,6 +81,7 @@ async fn mcp_apps_readiness_retries_an_in_flight_installed_mention_lookup() { chat.refresh_connector_mentions(/*force_refresh*/ false); chat.on_mcp_server_status_updated(McpServerStatusUpdatedNotification { + environment_id: None, thread_id: None, name: "codex_apps".to_string(), status: McpServerStartupState::Ready, @@ -540,47 +544,72 @@ async fn turn_start_replaces_idle_completed_mcp_startup_header() { async fn app_server_mcp_startup_failure_renders_warning_history() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_welcome_banner = false; + let width: u16 = 120; - notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting); - notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting); + for environment_id in ["env-a", "env-b"] { + chat.on_mcp_server_status_updated(McpServerStatusUpdatedNotification { + thread_id: None, + name: "notes".to_string(), + environment_id: Some(environment_id.to_string()), + status: McpServerStartupState::Starting, + error: None, + failure_reason: None, + }); + } + assert_eq!( + chat.status_state.current_status.header, + "Starting MCP servers (0/2): notes (environment env-a), notes (environment env-b)" + ); assert!(drain_insert_history(&mut rx).is_empty()); assert!(chat.bottom_pane.is_task_running()); - notify_mcp_status_error( - &mut chat, - "alpha", - "MCP client for `alpha` failed to start: handshake failed", - ); - notify_mcp_status_error( - &mut chat, - "alpha", - "MCP client for `alpha` failed to start: handshake failed", - ); + for _ in 0..2 { + chat.on_mcp_server_status_updated(McpServerStatusUpdatedNotification { + thread_id: None, + name: "notes".to_string(), + environment_id: Some("env-a".to_string()), + status: McpServerStartupState::Failed, + error: Some("MCP client for `notes` failed to start: handshake failed".to_string()), + failure_reason: None, + }); + } - let failure_cells = drain_insert_history(&mut rx); + let failure_cells = drain_insert_history_with(&mut rx, |cell| cell.transcript_lines(width)); let failure_text = failure_cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::(); - assert!(failure_text.contains("MCP client for `alpha` failed to start: handshake failed")); + assert_eq!( + failure_text, + "⚠ notes (environment env-a): MCP client for `notes` failed to start: handshake failed\n" + ); assert!(!failure_text.contains("MCP startup incomplete")); assert!(chat.bottom_pane.is_task_running()); assert!(drain_insert_history(&mut rx).is_empty()); assert!(chat.bottom_pane.is_task_running()); - notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready); + chat.on_mcp_server_status_updated(McpServerStatusUpdatedNotification { + thread_id: None, + name: "notes".to_string(), + environment_id: Some("env-b".to_string()), + status: McpServerStartupState::Ready, + error: None, + failure_reason: None, + }); let summary_cells = drain_insert_history(&mut rx); let summary_text = summary_cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::(); - assert_eq!(summary_text, "⚠ MCP startup incomplete (failed: alpha)\n"); + assert_eq!( + summary_text, + "⚠ MCP startup incomplete (failed: notes (environment env-a))\n" + ); assert!(!chat.bottom_pane.is_task_running()); - let width: u16 = 120; let ui_height: u16 = chat.desired_height(width); let vt_height: u16 = ui_height.saturating_add(1).max(10); let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 36b7ffeae0..f7959d50cd 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -3026,14 +3026,24 @@ async fn slash_mcp_login_targets_the_active_thread() { let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); - submit_composer_text(&mut chat, "/mcp login enterprise"); - - assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); - assert_matches!( - rx.try_recv(), - Ok(AppEvent::StartMcpLogin { name, thread_id: actual_thread_id }) - if name == "enterprise" && actual_thread_id == thread_id - ); + for (args, expected_name, expected_environment_id) in [ + ("enterprise", "enterprise", None), + ("Team's notes", "Team's notes", None), + ( + "\"Team notes\" --environment env-b", + "Team notes", + Some("env-b"), + ), + ] { + submit_composer_text(&mut chat, &format!("/mcp login {args}")); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::StartMcpLogin { name, environment_id, thread_id: actual_thread_id }) + if name == expected_name && environment_id.as_deref() == expected_environment_id + && actual_thread_id == thread_id + ); + } assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); } @@ -3050,7 +3060,7 @@ async fn slash_mcp_invalid_args_show_usage() { .collect::>() .join("\n"); assert!( - rendered.contains("Usage: /mcp [verbose | login ]"), + rendered.contains("Usage: /mcp [verbose | login [--environment ]]"), "expected usage message, got: {rendered:?}" ); assert_eq!(recall_latest_after_clearing(&mut chat), "/mcp full"); diff --git a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs index 04fbbc9d6f..830b81c83d 100644 --- a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs @@ -178,6 +178,7 @@ impl ChatWidget { let ThreadItemPayload::McpToolCall { id, server, + environment_id, tool, arguments, .. @@ -188,6 +189,7 @@ impl ChatWidget { self.flush_answer_stream_with_separator(); let invocation = McpInvocation { server, + environment_id, tool, arguments: Some(arguments), }; diff --git a/codex-rs/tui/src/chatwidget/tool_requests.rs b/codex-rs/tui/src/chatwidget/tool_requests.rs index 1382fbf550..2c0fca1424 100644 --- a/codex-rs/tui/src/chatwidget/tool_requests.rs +++ b/codex-rs/tui/src/chatwidget/tool_requests.rs @@ -355,6 +355,7 @@ impl ChatWidget { if let Some(params) = crate::bottom_pane::AppLinkViewParams::from_url_app_server_request( thread_id, ¶ms.server_name, + params.environment_id.as_deref(), request_id.clone(), ¶ms.request, ) { @@ -373,6 +374,7 @@ impl ChatWidget { thread_id, thread_label: None, server_name: params.server_name, + environment_id: params.environment_id, request_id, message, }); @@ -387,6 +389,7 @@ impl ChatWidget { crate::bottom_pane::user_verification::UserVerificationRequest { thread_label: None, server_name: params.server_name, + environment_id: params.environment_id, request_id, title, description, diff --git a/codex-rs/tui/src/history_cell/computer_activity_tests.rs b/codex-rs/tui/src/history_cell/computer_activity_tests.rs index c6b9766fc8..bab07a41b6 100644 --- a/codex-rs/tui/src/history_cell/computer_activity_tests.rs +++ b/codex-rs/tui/src/history_cell/computer_activity_tests.rs @@ -7,6 +7,7 @@ fn call(id: &str, title: &str) -> McpToolCallCell { McpToolCallCell::new( id.to_string(), McpInvocation { + environment_id: None, server: "cua_repl".to_string(), tool: "js".to_string(), arguments: Some(json!({"title": title, "code": "await cua.getState()"})), diff --git a/codex-rs/tui/src/history_cell/mcp.rs b/codex-rs/tui/src/history_cell/mcp.rs index 6aa4e712e6..4939d18a0d 100644 --- a/codex-rs/tui/src/history_cell/mcp.rs +++ b/codex-rs/tui/src/history_cell/mcp.rs @@ -2,6 +2,7 @@ //! Tool output previews share a three-row budget across all result blocks; //! the expanded transcript retains the full text. //! Invocation and result rows retain exact logical source ranges through wrapping and gutters. +//! Environment ownership is rendered separately from raw server names used by special handlers. use super::*; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; @@ -60,6 +61,7 @@ pub(crate) struct McpToolCallCell { #[derive(Debug, Clone)] pub(crate) struct McpInvocation { pub(crate) server: String, + pub(crate) environment_id: Option, pub(crate) tool: String, pub(crate) arguments: Option, } @@ -177,14 +179,7 @@ impl McpToolCallCell { .map(|title| title.split_whitespace().collect::>().join(" ")) .filter(|title| !title.is_empty()); let invocation_line = if compact { - Line::from( - title - .clone() - .unwrap_or_else(|| { - format!("{}.{}", self.invocation.server, self.invocation.tool) - }) - .fg(accent_color()), - ) + Line::from(self.compact_tool_name().fg(accent_color())) } else { line_to_static(&format_mcp_invocation(&self.invocation)) }; @@ -661,7 +656,7 @@ pub(crate) fn new_mcp_tools_output_from_statuses( ]; let mut statuses = statuses.iter().collect::>(); - statuses.sort_by(|a, b| a.name.cmp(&b.name)); + statuses.sort_by(|a, b| (&a.name, &a.environment_id).cmp(&(&b.name, &b.environment_id))); let has_any_tools = statuses.iter().any(|status| !status.tools.is_empty()); if !has_any_tools && matches!(detail, McpServerStatusDetail::Full) { @@ -699,10 +694,14 @@ pub(crate) fn new_mcp_tools_output_from_statuses( }; let count = status.tools.len(); let unit = if count == 1 { "tool" } else { "tools" }; + let name = match &status.environment_id { + Some(environment_id) => format!("{} (environment {environment_id})", status.name), + None => status.name.clone(), + }; lines.push( vec![ " • ".set_style(style), - status.name.clone().bold(), + name.bold(), ": ".into(), label.set_style(style), format!(" ({count} {unit})").dim(), @@ -853,7 +852,7 @@ fn format_mcp_invocation(invocation: &McpInvocation) -> Line<'_> { }) .unwrap_or_default(); - let invocation_spans = vec![ + let mut invocation_spans = vec![ invocation.server.as_str().fg(accent_color()), ".".into(), invocation.tool.as_str().fg(accent_color()), @@ -861,5 +860,8 @@ fn format_mcp_invocation(invocation: &McpInvocation) -> Line<'_> { args_str.dim(), ")".into(), ]; + if let Some(environment_id) = &invocation.environment_id { + invocation_spans.push(format!(" (environment {environment_id})").dim()); + } invocation_spans.into() } diff --git a/codex-rs/tui/src/history_cell/mcp_preview.rs b/codex-rs/tui/src/history_cell/mcp_preview.rs index dbdfe85a34..6879b943d4 100644 --- a/codex-rs/tui/src/history_cell/mcp_preview.rs +++ b/codex-rs/tui/src/history_cell/mcp_preview.rs @@ -18,7 +18,8 @@ use std::collections::VecDeque; impl McpToolCallCell { pub(super) fn compact_tool_name(&self) -> String { - self.invocation + let mut label = self + .invocation .arguments .as_ref() .filter(|_| self.result_kind() == McpResultKind::NodeRepl) @@ -26,7 +27,11 @@ impl McpToolCallCell { .and_then(serde_json::Value::as_str) .map(|title| title.split_whitespace().collect::>().join(" ")) .filter(|title| !title.is_empty()) - .unwrap_or_else(|| format!("{}.{}", self.invocation.server, self.invocation.tool)) + .unwrap_or_else(|| format!("{}.{}", self.invocation.server, self.invocation.tool)); + if let Some(environment_id) = &self.invocation.environment_id { + label.push_str(&format!(" (environment {environment_id})")); + } + label } pub(super) fn compact_mcp_lines(&self, width: u16) -> Vec { diff --git a/codex-rs/tui/src/history_cell/mcp_tests.rs b/codex-rs/tui/src/history_cell/mcp_tests.rs index 7bb789edd8..fb3f3d79bb 100644 --- a/codex-rs/tui/src/history_cell/mcp_tests.rs +++ b/codex-rs/tui/src/history_cell/mcp_tests.rs @@ -12,7 +12,7 @@ const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z fn mcp_inventory_connection_states() { use McpServerConnectionStatus as Status; - let statuses = [ + let mut statuses = [ ("unknown", None), ("starting", Some(Status::Starting)), ("failed", Some(Status::Failed)), @@ -24,6 +24,7 @@ fn mcp_inventory_connection_states() { ] .into_iter() .map(|(name, runtime_status)| McpServerStatus { + environment_id: None, server_capabilities: None, tool_list_meta: None, name: name.to_string(), @@ -38,6 +39,12 @@ fn mcp_inventory_connection_states() { auth_status: McpAuthStatus::Unknown, }) .collect::>(); + for environment in ["env-a", "env-b"] { + let mut status = statuses[0].clone(); + status.name = "notes".to_string(); + status.environment_id = Some(environment.to_string()); + statuses.push(status); + } let cell = new_mcp_tools_output_from_statuses(&statuses, McpServerStatusDetail::ToolsAndAuthOnly); let rendered = cell @@ -141,6 +148,7 @@ fn mcp_preview_shares_one_limit_across_blocks_and_preserves_transcript() { let mut cell = new_active_mcp_tool_call( "call-preview".into(), McpInvocation { + environment_id: None, server: "search".into(), tool: "lookup".into(), arguments: Some(json!({"title": "Search query"})), @@ -196,6 +204,7 @@ fn code_mode_output_shares_a_row_budget_across_blocks() { let mut cell = new_active_mcp_tool_call( "browser-call".to_string(), McpInvocation { + environment_id: None, server: "node_repl".to_string(), tool: "js".to_string(), arguments: Some(json!({"title": "Inspect page", "code": "await tab.snapshot()"})), @@ -239,6 +248,7 @@ fn code_mode_output_preserves_trailing_failure_diagnostics_in_transcript() { let mut cell = new_active_mcp_tool_call( "browser-error".to_string(), McpInvocation { + environment_id: None, server: "node_repl".to_string(), tool: "js".to_string(), arguments: Some(json!({"title": "Inspect page"})), @@ -297,6 +307,7 @@ fn code_mode_output_row_budget_applies_after_wrapping_and_to_errors() { let mut cell = new_active_mcp_tool_call( "browser-call".to_string(), McpInvocation { + environment_id: None, server: server.to_string(), tool: "js".to_string(), arguments: Some(json!({"title": "Inspect"})), @@ -406,6 +417,7 @@ fn code_mode_preserves_text_fields_on_nontext_and_unknown_blocks() { let mut cell = new_active_mcp_tool_call( "call-code-mode".to_string(), McpInvocation { + environment_id: None, server: "node_repl".to_string(), tool: "js".to_string(), arguments: Some(json!({"title": "Inspect results"})), @@ -492,6 +504,7 @@ fn code_mode_preserves_text_fields_on_nontext_and_unknown_blocks() { let mut cua_cell = new_active_mcp_tool_call( "call-cua-repl".to_string(), McpInvocation { + environment_id: None, server: "cua_repl".to_string(), tool: "js".to_string(), arguments: None, @@ -536,6 +549,7 @@ fn titled_image_call_keeps_error_and_full_title_when_narrow() { let mut cell = new_active_mcp_tool_call( "call".into(), McpInvocation { + environment_id: None, server: "node_repl".into(), tool: "js".into(), arguments: Some(json!({"title": title})), @@ -606,6 +620,7 @@ fn code_mode_transcript_preserves_long_code_and_indentation_across_wrapping() { let mut cell = new_active_mcp_tool_call( "call-code".to_owned(), McpInvocation { + environment_id: None, server: "node_repl".to_owned(), tool: "js".to_owned(), arguments: None, @@ -634,6 +649,7 @@ fn mcp_result_preview_preserves_source_text_and_excludes_tree_gutters() { let mut cell = new_active_mcp_tool_call( "call-mcp".to_owned(), McpInvocation { + environment_id: None, server: "docs".to_owned(), tool: "read".to_owned(), arguments: None, diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__tests__mcp_inventory_connection_states.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__tests__mcp_inventory_connection_states.snap index 345ecd348b..b838219f58 100644 --- a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__tests__mcp_inventory_connection_states.snap +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__tests__mcp_inventory_connection_states.snap @@ -12,6 +12,8 @@ expression: rendered • deferred: not started (0 tools) • disabled: disabled (0 tools) • failed: failed (0 tools) + • notes (environment env-a): unknown (0 tools) + • notes (environment env-b): unknown (0 tools) • starting: starting (0 tools) • unknown: unknown (0 tools) diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap index 2077da659f..c8ff140910 100644 --- a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap @@ -1,5 +1,6 @@ --- -source: tui/src/history_cell.rs +source: tui/src/history_cell/tests.rs expression: rendered --- -• Calling search.find_docs({"query":"ratatui styling","limit":3}) +• Calling + └ search.find_docs({"query":"ratatui styling","limit":3}) (environment a) diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index dc317ebbad..cd06cf6d53 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -494,6 +494,7 @@ fn proposed_plan_cell_unwraps_markdown_fenced_table() { #[test] fn structured_tool_cell_renders_raw_plain_text_without_prefix_or_style() { let invocation = McpInvocation { + environment_id: None, server: "search".into(), tool: "find_docs".into(), arguments: Some(json!({"query": "raw mode"})), @@ -544,6 +545,7 @@ fn raw_mode_toggle_transcript_snapshot() { let mut tool_cell = new_active_mcp_tool_call( "call-snapshot".to_string(), McpInvocation { + environment_id: None, server: "workspace".to_string(), tool: "inspect".to_string(), arguments: Some(json!({"path": "README.md"})), @@ -1162,6 +1164,7 @@ async fn mcp_tools_output_lists_tools_for_hyphenated_server_names() { #[test] fn mcp_tools_output_from_statuses_renders_status_only_servers() { let statuses = vec![McpServerStatus { + environment_id: None, server_capabilities: None, tool_list_meta: None, tools_error: None, @@ -1198,6 +1201,7 @@ fn mcp_tools_output_from_statuses_renders_status_only_servers() { #[test] fn mcp_tools_output_from_statuses_renders_verbose_inventory() { let statuses = vec![McpServerStatus { + environment_id: None, server_capabilities: None, tool_list_meta: None, tools_error: None, @@ -1491,6 +1495,7 @@ fn web_search_history_cell_transcript_snapshot() { #[test] fn active_mcp_tool_call_snapshot() { let invocation = McpInvocation { + environment_id: Some("a".to_string()), server: "search".into(), tool: "find_docs".into(), arguments: Some(json!({ @@ -1515,6 +1520,7 @@ fn code_mode_tool_call_uses_title_and_preserves_full_transcript() { let mut cell = new_active_mcp_tool_call( "call-code-mode".into(), McpInvocation { + environment_id: None, server: "node_repl".into(), tool: "js".into(), arguments: Some(json!({ @@ -1565,6 +1571,7 @@ fn code_mode_tool_call_preserves_failure_details() { let mut cell = new_active_mcp_tool_call( "call-code-mode-failed".into(), McpInvocation { + environment_id: None, server: "node_repl".into(), tool: "js".into(), arguments: Some(json!({"title": "Inspect workspace", "code": "throw Error('denied')"})), @@ -1627,6 +1634,7 @@ fn thread_recap_loading_without_animations_snapshot() { #[test] fn completed_mcp_tool_call_success_snapshot() { let invocation = McpInvocation { + environment_id: None, server: "search".into(), tool: "find_docs".into(), arguments: Some(json!({ @@ -1657,6 +1665,7 @@ fn completed_mcp_tool_call_success_snapshot() { #[test] fn completed_mcp_tool_call_image_after_text_snapshot() { let invocation = McpInvocation { + environment_id: None, server: "image".into(), tool: "generate".into(), arguments: Some(json!({ @@ -1688,6 +1697,7 @@ fn completed_mcp_tool_call_image_after_text_snapshot() { #[test] fn completed_mcp_tool_call_accepts_data_url_image_blocks() { let invocation = McpInvocation { + environment_id: None, server: "image".into(), tool: "generate".into(), arguments: Some(json!({ @@ -1717,6 +1727,7 @@ fn completed_mcp_tool_call_accepts_data_url_image_blocks() { #[test] fn completed_mcp_tool_call_multiple_image_blocks_snapshot() { let invocation = McpInvocation { + environment_id: None, server: "image".into(), tool: "generate".into(), arguments: Some(json!({ @@ -1745,6 +1756,7 @@ fn completed_mcp_tool_call_multiple_image_blocks_snapshot() { #[test] fn completed_mcp_tool_call_error_snapshot() { let invocation = McpInvocation { + environment_id: None, server: "search".into(), tool: "find_docs".into(), arguments: Some(json!({ @@ -1768,6 +1780,7 @@ fn completed_mcp_tool_call_error_snapshot() { #[test] fn completed_mcp_tool_call_multiple_outputs_snapshot() { let invocation = McpInvocation { + environment_id: None, server: "search".into(), tool: "find_docs".into(), arguments: Some(json!({ @@ -1808,6 +1821,7 @@ fn completed_mcp_tool_call_multiple_outputs_snapshot() { #[test] fn completed_mcp_tool_call_wrapped_outputs_snapshot() { let invocation = McpInvocation { + environment_id: None, server: "metrics".into(), tool: "get_nearby_metric".into(), arguments: Some(json!({ @@ -1840,6 +1854,7 @@ fn completed_mcp_tool_call_wrapped_outputs_snapshot() { #[test] fn completed_mcp_tool_call_multiple_outputs_inline_snapshot() { let invocation = McpInvocation { + environment_id: None, server: "metrics".into(), tool: "summary".into(), arguments: Some(json!({ diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 42f90a6b4c..368d61a92f 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -151,7 +151,9 @@ impl SlashCommand { SlashCommand::Experimental => "toggle experimental features", SlashCommand::AutoReview => "approve one retry of a recent auto-review denial", SlashCommand::Memories => "configure memory use and generation", - SlashCommand::Mcp => "list MCP tools; use /mcp verbose or /mcp login ", + SlashCommand::Mcp => { + "list MCP tools; /mcp verbose or /mcp login [--environment ]" + } SlashCommand::Apps => "manage apps", SlashCommand::Plugins => "browse plugins", SlashCommand::Logout => "log out of Codex", diff --git a/codex-rs/tui/src/thread_transcript/tools.rs b/codex-rs/tui/src/thread_transcript/tools.rs index 44c012063e..74e6eec9bf 100644 --- a/codex-rs/tui/src/thread_transcript/tools.rs +++ b/codex-rs/tui/src/thread_transcript/tools.rs @@ -77,6 +77,7 @@ pub(crate) fn historical_tool_fallback( } ThreadItemPayload::McpToolCall { server, + environment_id, tool, status, result, @@ -88,6 +89,10 @@ pub(crate) fn historical_tool_fallback( McpToolCallStatus::Completed => "Completed · result unavailable", McpToolCallStatus::Failed => "Failed · result unavailable", }; + let server = crate::app_server_approval_conversions::mcp_server_label( + server, + environment_id.as_deref(), + ); vec![format!("mcp tool: {server}/{tool} · {status}").dim().into()] } ThreadItemPayload::CollabAgentToolCall { @@ -207,6 +212,7 @@ impl McpHistory { let ThreadItemPayload::McpToolCall { id, server, + environment_id, tool, status, arguments, @@ -238,6 +244,7 @@ impl McpHistory { id, invocation: McpInvocation { server, + environment_id, tool, arguments: Some(arguments), }, diff --git a/codex-rs/tui/src/thread_transcript/tools_tests.rs b/codex-rs/tui/src/thread_transcript/tools_tests.rs index 8e9038067d..d6b924b164 100644 --- a/codex-rs/tui/src/thread_transcript/tools_tests.rs +++ b/codex-rs/tui/src/thread_transcript/tools_tests.rs @@ -37,6 +37,7 @@ fn command_item(status: CommandExecutionStatus) -> ThreadItemPayload { fn mcp_item(server: &str, id: &str) -> ThreadItemPayload { ThreadItemPayload::McpToolCall { + environment_id: None, id: id.to_string(), server: server.to_string(), tool: "js".to_string(), diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index cc2a104d2f..55e776a26b 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -2749,6 +2749,13 @@ class McpServerOauthLoginParams(BaseModel): description="Registration strategy for this login only; omission selects automatic discovery.", ), ] = None + environment_id: Annotated[ + str | None, + Field( + alias="environmentId", + description="Exact registration scope; omission preserves legacy name-only selection.", + ), + ] = None name: str scopes: list[str] | None = None thread_id: Annotated[str | None, Field(alias="threadId")] = None @@ -2795,6 +2802,13 @@ class McpServerStatusUpdatedNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + environment_id: Annotated[ + str | None, + Field( + alias="environmentId", + description="Registration scope, not necessarily the server's execution environment.", + ), + ] = None error: str | None = None failure_reason: Annotated[ McpServerStartupFailureReason | None, Field(alias="failureReason") @@ -2810,6 +2824,13 @@ class McpServerToolCallParams(BaseModel): ) field_meta: Annotated[Any | None, Field(alias="_meta")] = None arguments: Any | None = None + environment_id: Annotated[ + str | None, + Field( + alias="environmentId", + description="Exact registration scope; omission preserves legacy name-only selection.", + ), + ] = None server: str thread_id: Annotated[str, Field(alias="threadId")] tool: str @@ -5775,6 +5796,13 @@ class McpToolCallThreadItem(BaseModel): int | None, Field(alias="durationMs", description="The duration of the MCP tool call in milliseconds."), ] = None + environment_id: Annotated[ + str | None, + Field( + alias="environmentId", + description="Registration scope, not necessarily the server's execution environment.", + ), + ] = None error: McpToolCallError | None = None id: str mcp_app_resource_uri: Annotated[ @@ -9358,6 +9386,13 @@ class ListMcpServerStatusParams(BaseModel): description="Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." ), ] = None + environment_id: Annotated[ + str | None, + Field( + alias="environmentId", + description="Select this environment's registration. Requires `serverName` and `threadId`. When omitted, a named request uses the same server as older clients.", + ), + ] = None limit: Annotated[ int | None, Field(description="Optional page size; defaults to a server-defined value.", ge=0), @@ -9417,6 +9452,13 @@ class McpResourceReadParams(BaseModel): ), ] = None connector_id: Annotated[str | None, Field(alias="connectorId")] = None + environment_id: Annotated[ + str | None, + Field( + alias="environmentId", + description="Exact registration scope; omission preserves legacy name-only selection.", + ), + ] = None origin_call_id: Annotated[ str | None, Field( @@ -9452,6 +9494,13 @@ class McpServerStatus(BaseModel): populate_by_name=True, ) auth_status: Annotated[McpAuthStatus, Field(alias="authStatus")] + environment_id: Annotated[ + str | None, + Field( + alias="environmentId", + description="Environment that contributed this registration; null for a thread-wide server. Older servers omit this field.", + ), + ] = None http_origin: Annotated[ str | None, Field(