fix(api): include OAuth discovery metadata in MCP server card
2 files+48−90/2 viewed
| 137 | 137 | }) | |
| 138 | 138 | }) | |
| 139 | 139 | .collect(); | |
| 140 | − | json!({ | |
| 140 | + | let mut card = json!({ | |
| 141 | 141 | "name": "g1t", | |
| 142 | 142 | "description": "The g1t MCP server: issues, pull requests and sessions for agents.", | |
| 143 | 143 | "endpoint": addresses.mcp, | |
| ⋯ | |||
| 163 | 163 | "documentation_url": "https://docs.g1t.sh/guides/bring-your-own-agent/", | |
| 164 | 164 | "instructions": INSTRUCTIONS, | |
| 165 | 165 | "tools": tools, | |
| 166 | − | }) | |
| 166 | + | }); | |
| 167 | + | // Codex also reads the endpoint itself as protected resource metadata. | |
| 168 | + | // Keep the public card useful to people and valid for OAuth discovery. | |
| 169 | + | if let (Some(card), Value::Object(metadata)) = ( | |
| 170 | + | card.as_object_mut(), | |
| 171 | + | crate::oauth::protected_resource_metadata(addresses), | |
| 172 | + | ) { | |
| 173 | + | card.extend(metadata); | |
| 174 | + | } | |
| 175 | + | card | |
| 167 | 176 | } | |
| 168 | 177 | ||
| 169 | 178 | pub async fn handle( | |
| ⋯ | |||
| 228 | 237 | mod tests { | |
| 229 | 238 | use super::*; | |
| 230 | 239 | ||
| 240 | + | #[test] | |
| 241 | + | fn server_card_supports_oauth_discovery() { | |
| 242 | + | use crate::addresses::Addresses; | |
| 243 | + | ||
| 244 | + | for addresses in [ | |
| 245 | + | Addresses::default(), | |
| 246 | + | Addresses::from_settings( | |
| 247 | + | Some("https://forge.example".into()), | |
| 248 | + | Some("https://api.forge.example".into()), | |
| 249 | + | Some("https://api.forge.example/mcp".into()), | |
| 250 | + | ), | |
| 251 | + | ] { | |
| 252 | + | let card = card(&addresses); | |
| 253 | + | assert_eq!(card["resource"], addresses.mcp); | |
| 254 | + | assert_eq!(card["authorization_servers"], json!([addresses.api])); | |
| 255 | + | assert_eq!(card["bearer_methods_supported"], json!(["header"])); | |
| 256 | + | assert!(card["scopes_supported"].as_array().is_some_and(|scopes| !scopes.is_empty())); | |
| 257 | + | for (key, value) in crate::oauth::protected_resource_metadata(&addresses).as_object().unwrap() { | |
| 258 | + | assert_eq!(&card[key], value, "discovery field {key}"); | |
| 259 | + | } | |
| 260 | + | assert_eq!(card["endpoint"], addresses.mcp); | |
| 261 | + | assert!(card["tools"].as_array().is_some_and(|tools| !tools.is_empty())); | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 231 | 265 | /// The instructions name every tool, in the order `tools/list` gives | |
| 232 | 266 | /// them, so that no tool goes unmentioned. | |
| 233 | 267 | #[test] | |
| 228 | 228 | ||
| 229 | 229 | /// Answers the request if it is for an OAuth endpoint. These are served on | |
| 230 | 230 | /// both hosts: an MCP client looks for the metadata next to the MCP server. | |
| 231 | + | /// Shared by well-known discovery and the MCP endpoint's server card. | |
| 232 | + | pub(crate) fn protected_resource_metadata(addresses: &Addresses) -> Value { | |
| 233 | + | json!({ | |
| 234 | + | "resource": addresses.mcp, | |
| 235 | + | "authorization_servers": [addresses.api], | |
| 236 | + | "bearer_methods_supported": ["header"], | |
| 237 | + | "resource_documentation": "https://docs.g1t.sh/guides/bring-your-own-agent/", | |
| 238 | + | "scopes_supported": g1t_contracts::scopes::offered_scopes().iter().map(|scope| scope.as_str()).collect::<Vec<_>>(), | |
| 239 | + | }) | |
| 240 | + | } | |
| 241 | + | ||
| 231 | 242 | pub async fn handle( | |
| 232 | 243 | request: &mut Request, | |
| 233 | 244 | services: &Services, | |
| ⋯ | |||
| 240 | 251 | } | |
| 241 | 252 | // Asked for with or without the MCP server's path appended. | |
| 242 | 253 | ("GET", path) if path.starts_with("/.well-known/oauth-protected-resource") => { | |
| 243 | − | crate::reply(&json!({ | |
| 244 | − | "resource": services.addresses.mcp, | |
| 245 | − | "authorization_servers": [services.addresses.api], | |
| 246 | − | "bearer_methods_supported": ["header"], | |
| 247 | − | "resource_documentation": "https://docs.g1t.sh/guides/bring-your-own-agent/", | |
| 248 | − | "scopes_supported": g1t_contracts::scopes::offered_scopes().iter().map(|scope| scope.as_str()).collect::<Vec<_>>(), | |
| 249 | − | }))? | |
| 254 | + | crate::reply(&protected_resource_metadata(&services.addresses))? | |
| 250 | 255 | } | |
| 251 | 256 | ("POST", "/oauth/register") => register(request).await?, | |
| 252 | 257 | ("POST", "/oauth/token") => token(request, services).await?, | |
Comments
Sign in to comment.