1use codex_protocol::mcp::CallToolResult;
7use serde::Serialize;
8
9pub const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps";
10pub const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure";
11pub const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure";
12pub const CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: &str = "auth_reason";
13pub const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id";
14pub const CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: &str = "link_id";
15pub const CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: &str = "error_code";
16pub const CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: &str = "error_http_status_code";
17pub const CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: &str = "error_action";
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CodexAppsConnectorAuthFailure {
21 pub connector_id: String,
22 pub connector_name: String,
23 pub install_url: String,
24 pub auth_reason: Option<String>,
25 pub link_id: Option<String>,
26 pub error_code: Option<String>,
27 pub error_http_status_code: Option<i64>,
28 pub error_action: Option<String>,
29}
30
31#[derive(Debug, Clone, PartialEq)]
32pub struct CodexAppsAuthElicitation {
33 pub meta: serde_json::Value,
34 pub message: String,
35 pub url: String,
36 pub elicitation_id: String,
37}
38
39#[derive(Debug, Clone, PartialEq)]
40pub struct CodexAppsAuthElicitationPlan {
41 pub auth_failure: CodexAppsConnectorAuthFailure,
42 pub elicitation: CodexAppsAuthElicitation,
43}
44
45#[derive(Serialize)]
46struct CodexAppsConnectorAuthFailureMeta<'a> {
47 is_auth_failure: bool,
48 connector_id: &'a str,
49 connector_name: &'a str,
50 install_url: &'a str,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 auth_reason: Option<&'a str>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 link_id: Option<&'a str>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 error_code: Option<&'a str>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 error_http_status_code: Option<i64>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 error_action: Option<&'a str>,
61}
62
63pub fn connector_auth_failure_from_tool_result(
64 result: &CallToolResult,
65 connector_id: Option<&str>,
66 connector_name: Option<&str>,
67 install_url: Option<String>,
68) -> Option<CodexAppsConnectorAuthFailure> {
69 if result.is_error != Some(true) {
70 return None;
71 }
72
73 let auth_failure = result
74 .meta
75 .as_ref()?
76 .as_object()?
77 .get(MCP_TOOL_CODEX_APPS_META_KEY)?
78 .as_object()?
79 .get(CONNECTOR_AUTH_FAILURE_META_KEY)?
80 .as_object()?;
81 if auth_failure
82 .get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY)
83 .and_then(serde_json::Value::as_bool)
84 != Some(true)
85 {
86 return None;
87 }
88
89 let connector_id = connector_id
90 .map(str::trim)
91 .filter(|connector_id| !connector_id.is_empty())?;
92 if let Some(auth_failure_connector_id) =
93 string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY)
94 && auth_failure_connector_id != connector_id
95 {
96 return None;
97 }
98 let connector_name = connector_name
99 .map(str::trim)
100 .filter(|name| !name.is_empty())
101 .unwrap_or(connector_id)
102 .to_string();
103
104 Some(CodexAppsConnectorAuthFailure {
105 connector_id: connector_id.to_string(),
106 connector_name,
107 install_url: install_url?,
108 auth_reason: string_auth_failure_field(
109 auth_failure,
110 CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY,
111 ),
112 link_id: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_LINK_ID_KEY),
113 error_code: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY),
114 error_http_status_code: auth_failure
115 .get(CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY)
116 .and_then(serde_json::Value::as_i64),
117 error_action: string_auth_failure_field(
118 auth_failure,
119 CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY,
120 ),
121 })
122}
123
124pub fn build_auth_elicitation_plan(
125 call_id: &str,
126 result: &CallToolResult,
127 connector_id: Option<&str>,
128 connector_name: Option<&str>,
129 install_url: Option<String>,
130) -> Option<CodexAppsAuthElicitationPlan> {
131 let auth_failure =
132 connector_auth_failure_from_tool_result(result, connector_id, connector_name, install_url)?;
133 let elicitation = build_auth_elicitation(call_id, &auth_failure);
134 Some(CodexAppsAuthElicitationPlan {
135 auth_failure,
136 elicitation,
137 })
138}
139
140pub fn build_auth_elicitation(
141 call_id: &str,
142 auth_failure: &CodexAppsConnectorAuthFailure,
143) -> CodexAppsAuthElicitation {
144 CodexAppsAuthElicitation {
145 meta: serde_json::json!({
146 MCP_TOOL_CODEX_APPS_META_KEY: {
147 CONNECTOR_AUTH_FAILURE_META_KEY: CodexAppsConnectorAuthFailureMeta {
148 is_auth_failure: true,
149 connector_id: &auth_failure.connector_id,
150 connector_name: &auth_failure.connector_name,
151 install_url: &auth_failure.install_url,
152 auth_reason: auth_failure.auth_reason.as_deref(),
153 link_id: auth_failure.link_id.as_deref(),
154 error_code: auth_failure.error_code.as_deref(),
155 error_http_status_code: auth_failure.error_http_status_code,
156 error_action: auth_failure.error_action.as_deref(),
157 },
158 },
159 }),
160 message: auth_elicitation_message(auth_failure),
161 url: auth_failure.install_url.clone(),
162 elicitation_id: auth_elicitation_id(call_id),
163 }
164}
165
166pub fn auth_elicitation_completed_result(
167 auth_failure: &CodexAppsConnectorAuthFailure,
168 meta: Option<serde_json::Value>,
169) -> CallToolResult {
170 CallToolResult {
171 content: vec![serde_json::json!({
172 "type": "text",
173 "text": format!(
174 "Authentication for {} was requested and accepted. Retry this tool call now.",
175 auth_failure.connector_name
176 ),
177 })],
178 structured_content: None,
179 is_error: Some(true),
180 meta,
181 }
182}
183
184pub fn auth_elicitation_id(call_id: &str) -> String {
185 format!("codex_apps_auth_{call_id}")
186}
187
188fn string_auth_failure_field(
189 auth_failure: &serde_json::Map<String, serde_json::Value>,
190 key: &str,
191) -> Option<String> {
192 auth_failure
193 .get(key)
194 .and_then(serde_json::Value::as_str)
195 .map(str::trim)
196 .filter(|value| !value.is_empty())
197 .map(ToString::to_string)
198}
199
200fn auth_elicitation_message(auth_failure: &CodexAppsConnectorAuthFailure) -> String {
201 match auth_failure.auth_reason.as_deref() {
202 Some("oauth_upgrade_required") => format!(
203 "Reconnect {} on ChatGPT to grant the permissions needed for this request.",
204 auth_failure.connector_name
205 ),
206 Some("reauthentication_required") => format!(
207 "Reconnect {} on ChatGPT to restore access for this request.",
208 auth_failure.connector_name
209 ),
210 Some("missing_link") => format!(
211 "Sign in to {} on ChatGPT to use it in Codex.",
212 auth_failure.connector_name
213 ),
214 _ => format!(
215 "Sign in to {} on ChatGPT to continue.",
216 auth_failure.connector_name
217 ),
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use pretty_assertions::assert_eq;
225
226 fn auth_failure_result() -> CallToolResult {
227 CallToolResult {
228 content: vec![serde_json::json!({
229 "type": "text",
230 "text": "Connector reauthentication required",
231 })],
232 structured_content: None,
233 is_error: Some(true),
234 meta: Some(serde_json::json!({
235 MCP_TOOL_CODEX_APPS_META_KEY: {
236 CONNECTOR_AUTH_FAILURE_META_KEY: {
237 CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true,
238 CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required",
239 CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar",
240 "connector_name": "Untrusted Calendar",
241 CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123",
242 CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED",
243 CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401,
244 CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION",
245 },
246 },
247 })),
248 }
249 }
250
251 #[test]
252 fn parses_auth_failure_from_trusted_connector_metadata() {
253 assert_eq!(
254 connector_auth_failure_from_tool_result(
255 &auth_failure_result(),
256 Some("connector_calendar"),
257 Some("Google Calendar"),
258 Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
259 ),
260 Some(CodexAppsConnectorAuthFailure {
261 connector_id: "connector_calendar".to_string(),
262 connector_name: "Google Calendar".to_string(),
263 install_url: "https://chatgpt.com/apps/google-calendar/connector_calendar"
264 .to_string(),
265 auth_reason: Some("reauthentication_required".to_string()),
266 link_id: Some("link_123".to_string()),
267 error_code: Some("UNAUTHORIZED".to_string()),
268 error_http_status_code: Some(401),
269 error_action: Some("TRIGGER_REAUTHENTICATION".to_string()),
270 })
271 );
272 }
273
274 #[test]
275 fn rejects_missing_or_mismatched_connector_ids() {
276 assert_eq!(
277 connector_auth_failure_from_tool_result(
278 &auth_failure_result(),
279 None,
280 Some("Google Calendar"),
281 Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
282 ),
283 None
284 );
285 assert_eq!(
286 connector_auth_failure_from_tool_result(
287 &auth_failure_result(),
288 Some("connector_drive"),
289 Some("Google Drive"),
290 Some("https://chatgpt.com/apps/google-drive/connector_drive".to_string()),
291 ),
292 None
293 );
294 }
295
296 #[test]
297 fn builds_url_elicitation_payload() {
298 let auth_failure = connector_auth_failure_from_tool_result(
299 &auth_failure_result(),
300 Some("connector_calendar"),
301 Some("Google Calendar"),
302 Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
303 )
304 .expect("auth failure");
305
306 assert_eq!(
307 build_auth_elicitation("call_123", &auth_failure),
308 CodexAppsAuthElicitation {
309 meta: serde_json::json!({
310 MCP_TOOL_CODEX_APPS_META_KEY: {
311 CONNECTOR_AUTH_FAILURE_META_KEY: {
312 CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true,
313 CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar",
314 "connector_name": "Google Calendar",
315 "install_url":
316 "https://chatgpt.com/apps/google-calendar/connector_calendar",
317 CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required",
318 CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123",
319 CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED",
320 CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401,
321 CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION",
322 },
323 },
324 }),
325 message: "Reconnect Google Calendar on ChatGPT to restore access for this request."
326 .to_string(),
327 url: "https://chatgpt.com/apps/google-calendar/connector_calendar".to_string(),
328 elicitation_id: "codex_apps_auth_call_123".to_string(),
329 }
330 );
331 }
332
333 #[test]
334 fn builds_auth_elicitation_plan() {
335 let plan = build_auth_elicitation_plan(
336 "call_123",
337 &auth_failure_result(),
338 Some("connector_calendar"),
339 Some("Google Calendar"),
340 Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
341 )
342 .expect("auth elicitation plan");
343
344 assert_eq!(plan.auth_failure.connector_name, "Google Calendar");
345 assert_eq!(plan.elicitation.elicitation_id, "codex_apps_auth_call_123");
346 }
347}