Skip to main content

claude_api/managed_agents/
vaults.rs

1//! Vaults: per-user collections of MCP credentials.
2//!
3//! Vaults are workspace-scoped and reference credentials by ID at session
4//! creation time. Credentials are write-only: secret fields are never
5//! returned in API responses.
6//!
7//! Constraints:
8//! - One active credential per `mcp_server_url` per vault.
9//! - `mcp_server_url` is immutable after creation; archive and replace.
10//! - Maximum 20 credentials per vault.
11
12use std::collections::HashMap;
13
14use serde::{Deserialize, Serialize};
15
16use crate::client::Client;
17use crate::error::Result;
18use crate::pagination::Paginated;
19
20use super::MANAGED_AGENTS_BETA;
21
22// =====================================================================
23// Vault types
24// =====================================================================
25
26/// A vault: collection of MCP credentials, typically scoped to one
27/// end-user.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[non_exhaustive]
30pub struct Vault {
31    /// Stable identifier (`vlt_...`).
32    pub id: String,
33    /// Wire type tag (always `"vault"`).
34    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
35    pub ty: Option<String>,
36    /// Human-readable display name.
37    pub display_name: String,
38    /// Free-form metadata for mapping back to caller-side records.
39    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
40    pub metadata: HashMap<String, String>,
41    /// Creation timestamp.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub created_at: Option<String>,
44    /// Last-modified timestamp.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub updated_at: Option<String>,
47    /// Set when archived.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub archived_at: Option<String>,
50}
51
52/// Request body for `POST /v1/vaults`.
53#[derive(Debug, Clone, Serialize)]
54#[non_exhaustive]
55pub struct CreateVaultRequest {
56    /// Required.
57    pub display_name: String,
58    /// Optional caller-side metadata.
59    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
60    pub metadata: HashMap<String, String>,
61}
62
63impl CreateVaultRequest {
64    /// Build a request with the given display name.
65    #[must_use]
66    pub fn new(display_name: impl Into<String>) -> Self {
67        Self {
68            display_name: display_name.into(),
69            metadata: HashMap::new(),
70        }
71    }
72
73    /// Attach a metadata entry.
74    #[must_use]
75    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
76        self.metadata.insert(key.into(), value.into());
77        self
78    }
79}
80
81/// Optional knobs for [`Vaults::list`].
82#[derive(Debug, Clone, Default)]
83#[non_exhaustive]
84pub struct ListVaultsParams {
85    /// Pagination cursor.
86    pub after: Option<String>,
87    /// Pagination cursor.
88    pub before: Option<String>,
89    /// Page size.
90    pub limit: Option<u32>,
91    /// Whether to include archived vaults.
92    pub include_archived: Option<bool>,
93}
94
95impl ListVaultsParams {
96    fn to_query(&self) -> Vec<(&'static str, String)> {
97        let mut q = Vec::new();
98        if let Some(a) = &self.after {
99            q.push(("after", a.clone()));
100        }
101        if let Some(b) = &self.before {
102            q.push(("before", b.clone()));
103        }
104        if let Some(l) = self.limit {
105            q.push(("limit", l.to_string()));
106        }
107        if let Some(ia) = self.include_archived {
108            q.push(("include_archived", ia.to_string()));
109        }
110        q
111    }
112}
113
114// =====================================================================
115// Credential types
116// =====================================================================
117
118/// Token-endpoint authentication scheme for refreshing OAuth credentials.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(tag = "type", rename_all = "snake_case")]
121#[non_exhaustive]
122pub enum TokenEndpointAuth {
123    /// Public client; no credential sent on refresh.
124    None,
125    /// HTTP Basic auth with the client secret.
126    ClientSecretBasic {
127        /// Client secret (write-only).
128        client_secret: String,
129    },
130    /// Client secret sent in the POST body.
131    ClientSecretPost {
132        /// Client secret (write-only).
133        client_secret: String,
134    },
135}
136
137/// OAuth refresh configuration on an `mcp_oauth` credential.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[non_exhaustive]
140pub struct OAuthRefresh {
141    /// OAuth token endpoint URL.
142    pub token_endpoint: String,
143    /// Client ID registered with the OAuth provider.
144    pub client_id: String,
145    /// Refresh token (write-only).
146    pub refresh_token: String,
147    /// Token-endpoint authentication scheme.
148    pub token_endpoint_auth: TokenEndpointAuth,
149    /// Optional scope string.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub scope: Option<String>,
152}
153
154/// Credential authentication payload.
155///
156/// Forward-compatible: unknown wire `type` tags fall through to
157/// [`Self::Other`] preserving the raw JSON.
158#[derive(Debug, Clone, PartialEq)]
159pub enum CredentialAuth {
160    /// MCP OAuth token, optionally with a refresh block. Anthropic
161    /// refreshes on your behalf when `refresh` is configured.
162    McpOauth(McpOauthAuth),
163    /// Static bearer token (API key, PAT). No refresh flow.
164    StaticBearer(StaticBearerAuth),
165    /// Unknown auth type; raw JSON preserved.
166    Other(serde_json::Value),
167}
168
169/// `mcp_oauth` credential body.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[non_exhaustive]
172pub struct McpOauthAuth {
173    /// MCP server endpoint this credential authenticates against.
174    /// Immutable after creation.
175    pub mcp_server_url: String,
176    /// Access token (write-only).
177    pub access_token: String,
178    /// Token expiration (RFC3339).
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub expires_at: Option<String>,
181    /// Refresh configuration; if present, Anthropic refreshes on your
182    /// behalf.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub refresh: Option<OAuthRefresh>,
185}
186
187/// `static_bearer` credential body.
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[non_exhaustive]
190pub struct StaticBearerAuth {
191    /// MCP server endpoint. Immutable after creation.
192    pub mcp_server_url: String,
193    /// Bearer token (write-only).
194    pub token: String,
195}
196
197const KNOWN_CREDENTIAL_AUTH_TAGS: &[&str] = &["mcp_oauth", "static_bearer"];
198
199impl Serialize for CredentialAuth {
200    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
201        match self {
202            Self::McpOauth(v) => {
203                use serde::ser::SerializeMap;
204                let mut map = s.serialize_map(None)?;
205                map.serialize_entry("type", "mcp_oauth")?;
206                map.serialize_entry("mcp_server_url", &v.mcp_server_url)?;
207                map.serialize_entry("access_token", &v.access_token)?;
208                if let Some(e) = &v.expires_at {
209                    map.serialize_entry("expires_at", e)?;
210                }
211                if let Some(r) = &v.refresh {
212                    map.serialize_entry("refresh", r)?;
213                }
214                map.end()
215            }
216            Self::StaticBearer(v) => {
217                use serde::ser::SerializeMap;
218                let mut map = s.serialize_map(None)?;
219                map.serialize_entry("type", "static_bearer")?;
220                map.serialize_entry("mcp_server_url", &v.mcp_server_url)?;
221                map.serialize_entry("token", &v.token)?;
222                map.end()
223            }
224            Self::Other(v) => v.serialize(s),
225        }
226    }
227}
228
229impl<'de> Deserialize<'de> for CredentialAuth {
230    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
231        let raw = serde_json::Value::deserialize(d)?;
232        let tag = raw.get("type").and_then(serde_json::Value::as_str);
233        match tag {
234            Some("mcp_oauth") if KNOWN_CREDENTIAL_AUTH_TAGS.contains(&"mcp_oauth") => {
235                let body = serde_json::from_value::<McpOauthAuth>(raw)
236                    .map_err(serde::de::Error::custom)?;
237                Ok(Self::McpOauth(body))
238            }
239            Some("static_bearer") if KNOWN_CREDENTIAL_AUTH_TAGS.contains(&"static_bearer") => {
240                let body = serde_json::from_value::<StaticBearerAuth>(raw)
241                    .map_err(serde::de::Error::custom)?;
242                Ok(Self::StaticBearer(body))
243            }
244            _ => Ok(Self::Other(raw)),
245        }
246    }
247}
248
249impl CredentialAuth {
250    /// Build an [`CredentialAuth::McpOauth`] credential.
251    #[must_use]
252    pub fn mcp_oauth(
253        mcp_server_url: impl Into<String>,
254        access_token: impl Into<String>,
255    ) -> McpOauthBuilder {
256        McpOauthBuilder {
257            mcp_server_url: mcp_server_url.into(),
258            access_token: access_token.into(),
259            expires_at: None,
260            refresh: None,
261        }
262    }
263
264    /// Build a [`CredentialAuth::StaticBearer`] credential.
265    #[must_use]
266    pub fn static_bearer(mcp_server_url: impl Into<String>, token: impl Into<String>) -> Self {
267        Self::StaticBearer(StaticBearerAuth {
268            mcp_server_url: mcp_server_url.into(),
269            token: token.into(),
270        })
271    }
272}
273
274/// Builder for [`McpOauthAuth`] credentials.
275#[derive(Debug, Clone)]
276pub struct McpOauthBuilder {
277    mcp_server_url: String,
278    access_token: String,
279    expires_at: Option<String>,
280    refresh: Option<OAuthRefresh>,
281}
282
283impl McpOauthBuilder {
284    /// Set token expiration (RFC3339).
285    #[must_use]
286    pub fn expires_at(mut self, when: impl Into<String>) -> Self {
287        self.expires_at = Some(when.into());
288        self
289    }
290
291    /// Attach refresh configuration.
292    #[must_use]
293    pub fn refresh(mut self, refresh: OAuthRefresh) -> Self {
294        self.refresh = Some(refresh);
295        self
296    }
297
298    /// Finalize as a [`CredentialAuth::McpOauth`].
299    #[must_use]
300    pub fn build(self) -> CredentialAuth {
301        CredentialAuth::McpOauth(McpOauthAuth {
302            mcp_server_url: self.mcp_server_url,
303            access_token: self.access_token,
304            expires_at: self.expires_at,
305            refresh: self.refresh,
306        })
307    }
308}
309
310/// A stored credential. Secret fields are never echoed in API
311/// responses; the [`auth`](Self::auth) object carries only the
312/// non-secret metadata (server URL, expiry, etc.).
313#[derive(Debug, Clone, Serialize, Deserialize)]
314#[non_exhaustive]
315pub struct Credential {
316    /// Stable identifier (`cred_...`).
317    pub id: String,
318    /// Wire type tag (`"credential"`).
319    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
320    pub ty: Option<String>,
321    /// Parent vault ID.
322    pub vault_id: String,
323    /// Optional display name.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub display_name: Option<String>,
326    /// Free-form metadata for mapping back to caller-side records.
327    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
328    pub metadata: HashMap<String, String>,
329    /// Auth shape with non-secret fields populated. `None` if the
330    /// server doesn't return an auth block.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub auth: Option<CredentialAuthResponse>,
333    /// Creation timestamp.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub created_at: Option<String>,
336    /// Last-modified timestamp.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub updated_at: Option<String>,
339    /// Set when the credential is archived.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub archived_at: Option<String>,
342}
343
344/// Auth payload as returned on a credential **response**. Mirrors
345/// [`CredentialAuth`] but never carries the secret token fields.
346///
347/// Forward-compatible: unknown wire `type` tags fall through to
348/// [`Self::Other`].
349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
350#[serde(tag = "type", rename_all = "snake_case")]
351#[non_exhaustive]
352pub enum CredentialAuthResponse {
353    /// MCP OAuth credential metadata (no token).
354    McpOauth {
355        /// MCP server URL.
356        mcp_server_url: String,
357        /// Token expiration (RFC3339).
358        #[serde(default, skip_serializing_if = "Option::is_none")]
359        expires_at: Option<String>,
360        /// Refresh configuration, when configured.
361        #[serde(default, skip_serializing_if = "Option::is_none")]
362        refresh: Option<serde_json::Value>,
363    },
364    /// Static-bearer credential metadata (no token).
365    StaticBearer {
366        /// MCP server URL.
367        mcp_server_url: String,
368    },
369    /// Forward-compat fallback for unknown auth `type` values.
370    #[serde(other)]
371    Other,
372}
373
374/// Request body for `POST /v1/vaults/{id}/credentials`.
375#[derive(Debug, Clone, Serialize)]
376#[non_exhaustive]
377pub struct CreateCredentialRequest {
378    /// Auth payload (write-only secrets).
379    pub auth: CredentialAuth,
380    /// Optional display name.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub display_name: Option<String>,
383}
384
385impl CreateCredentialRequest {
386    /// Build a credential-creation request with the given auth payload.
387    #[must_use]
388    pub fn new(auth: CredentialAuth) -> Self {
389        Self {
390            auth,
391            display_name: None,
392        }
393    }
394
395    /// Attach a display name.
396    #[must_use]
397    pub fn with_display_name(mut self, name: impl Into<String>) -> Self {
398        self.display_name = Some(name.into());
399        self
400    }
401}
402
403/// Patch applied to an existing credential. Only the secret payload and
404/// a few metadata fields are mutable; `mcp_server_url`, `token_endpoint`,
405/// and `client_id` are locked after creation.
406#[derive(Debug, Clone, Default, Serialize)]
407#[non_exhaustive]
408pub struct UpdateCredentialRequest {
409    /// New auth payload. Use [`CredentialAuthPatch`] for partial
410    /// updates that don't replace the entire shape.
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub auth: Option<CredentialAuthPatch>,
413    /// New display name.
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub display_name: Option<String>,
416}
417
418/// Partial auth update for [`UpdateCredentialRequest`]. Pass only the
419/// fields you want to rotate; immutable fields (`mcp_server_url`,
420/// `token_endpoint`, `client_id`) cannot be changed.
421#[derive(Debug, Clone, Serialize)]
422#[serde(tag = "type", rename_all = "snake_case")]
423#[non_exhaustive]
424pub enum CredentialAuthPatch {
425    /// Rotate an `mcp_oauth` credential's tokens / expiry.
426    McpOauth {
427        /// New access token.
428        #[serde(skip_serializing_if = "Option::is_none")]
429        access_token: Option<String>,
430        /// New expiry.
431        #[serde(skip_serializing_if = "Option::is_none")]
432        expires_at: Option<String>,
433        /// Refresh-block patch (e.g. new `refresh_token`).
434        #[serde(skip_serializing_if = "Option::is_none")]
435        refresh: Option<OAuthRefreshPatch>,
436    },
437    /// Rotate a `static_bearer` credential's token.
438    StaticBearer {
439        /// New token.
440        #[serde(skip_serializing_if = "Option::is_none")]
441        token: Option<String>,
442    },
443}
444
445/// Partial refresh-block patch for [`CredentialAuthPatch::McpOauth`].
446#[derive(Debug, Clone, Default, Serialize)]
447#[non_exhaustive]
448pub struct OAuthRefreshPatch {
449    /// New refresh token.
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub refresh_token: Option<String>,
452    /// New scope string.
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub scope: Option<String>,
455    /// New token-endpoint auth.
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub token_endpoint_auth: Option<TokenEndpointAuth>,
458}
459
460// =====================================================================
461// Namespace handles
462// =====================================================================
463
464/// Namespace handle for the Vaults API.
465pub struct Vaults<'a> {
466    client: &'a Client,
467}
468
469impl<'a> Vaults<'a> {
470    pub(crate) fn new(client: &'a Client) -> Self {
471        Self { client }
472    }
473
474    /// `POST /v1/vaults`.
475    pub async fn create(&self, request: CreateVaultRequest) -> Result<Vault> {
476        let body = &request;
477        self.client
478            .execute_with_retry(
479                || {
480                    self.client
481                        .request_builder(reqwest::Method::POST, "/v1/vaults")
482                        .json(body)
483                },
484                &[MANAGED_AGENTS_BETA],
485            )
486            .await
487    }
488
489    /// `GET /v1/vaults/{id}`.
490    pub async fn retrieve(&self, vault_id: &str) -> Result<Vault> {
491        let path = format!("/v1/vaults/{vault_id}");
492        self.client
493            .execute_with_retry(
494                || self.client.request_builder(reqwest::Method::GET, &path),
495                &[MANAGED_AGENTS_BETA],
496            )
497            .await
498    }
499
500    /// `GET /v1/vaults`.
501    pub async fn list(&self, params: ListVaultsParams) -> Result<Paginated<Vault>> {
502        let query = params.to_query();
503        self.client
504            .execute_with_retry(
505                || {
506                    let mut req = self
507                        .client
508                        .request_builder(reqwest::Method::GET, "/v1/vaults");
509                    for (k, v) in &query {
510                        req = req.query(&[(k, v)]);
511                    }
512                    req
513                },
514                &[MANAGED_AGENTS_BETA],
515            )
516            .await
517    }
518
519    /// `POST /v1/vaults/{id}/archive`.
520    pub async fn archive(&self, vault_id: &str) -> Result<Vault> {
521        let path = format!("/v1/vaults/{vault_id}/archive");
522        self.client
523            .execute_with_retry(
524                || self.client.request_builder(reqwest::Method::POST, &path),
525                &[MANAGED_AGENTS_BETA],
526            )
527            .await
528    }
529
530    /// `DELETE /v1/vaults/{id}`. Hard delete; no audit trail. Use
531    /// archive if you need one.
532    pub async fn delete(&self, vault_id: &str) -> Result<()> {
533        let path = format!("/v1/vaults/{vault_id}");
534        let _: serde_json::Value = self
535            .client
536            .execute_with_retry(
537                || self.client.request_builder(reqwest::Method::DELETE, &path),
538                &[MANAGED_AGENTS_BETA],
539            )
540            .await?;
541        Ok(())
542    }
543
544    /// Sub-namespace for credential operations on a single vault.
545    #[must_use]
546    pub fn credentials(&self, vault_id: impl Into<String>) -> Credentials<'_> {
547        Credentials {
548            client: self.client,
549            vault_id: vault_id.into(),
550        }
551    }
552}
553
554/// Namespace handle for credential operations on a single vault.
555pub struct Credentials<'a> {
556    client: &'a Client,
557    vault_id: String,
558}
559
560impl Credentials<'_> {
561    /// `POST /v1/vaults/{vault_id}/credentials`.
562    pub async fn create(&self, request: CreateCredentialRequest) -> Result<Credential> {
563        let path = format!("/v1/vaults/{}/credentials", self.vault_id);
564        let body = &request;
565        self.client
566            .execute_with_retry(
567                || {
568                    self.client
569                        .request_builder(reqwest::Method::POST, &path)
570                        .json(body)
571                },
572                &[MANAGED_AGENTS_BETA],
573            )
574            .await
575    }
576
577    /// `POST /v1/vaults/{vault_id}/credentials/{cred_id}` (update;
578    /// rotate tokens, change display name).
579    pub async fn update(
580        &self,
581        credential_id: &str,
582        request: UpdateCredentialRequest,
583    ) -> Result<Credential> {
584        let path = format!("/v1/vaults/{}/credentials/{credential_id}", self.vault_id);
585        let body = &request;
586        self.client
587            .execute_with_retry(
588                || {
589                    self.client
590                        .request_builder(reqwest::Method::POST, &path)
591                        .json(body)
592                },
593                &[MANAGED_AGENTS_BETA],
594            )
595            .await
596    }
597
598    /// `GET /v1/vaults/{vault_id}/credentials/{cred_id}`.
599    pub async fn retrieve(&self, credential_id: &str) -> Result<Credential> {
600        let path = format!("/v1/vaults/{}/credentials/{credential_id}", self.vault_id);
601        self.client
602            .execute_with_retry(
603                || self.client.request_builder(reqwest::Method::GET, &path),
604                &[MANAGED_AGENTS_BETA],
605            )
606            .await
607    }
608
609    /// `GET /v1/vaults/{vault_id}/credentials`.
610    pub async fn list(&self, params: ListVaultsParams) -> Result<Paginated<Credential>> {
611        let path = format!("/v1/vaults/{}/credentials", self.vault_id);
612        let query = params.to_query();
613        self.client
614            .execute_with_retry(
615                || {
616                    let mut req = self.client.request_builder(reqwest::Method::GET, &path);
617                    for (k, v) in &query {
618                        req = req.query(&[(k, v)]);
619                    }
620                    req
621                },
622                &[MANAGED_AGENTS_BETA],
623            )
624            .await
625    }
626
627    /// `POST /v1/vaults/{vault_id}/credentials/{cred_id}/archive`.
628    /// Frees the `mcp_server_url` slot for a replacement credential.
629    pub async fn archive(&self, credential_id: &str) -> Result<Credential> {
630        let path = format!(
631            "/v1/vaults/{}/credentials/{credential_id}/archive",
632            self.vault_id
633        );
634        self.client
635            .execute_with_retry(
636                || self.client.request_builder(reqwest::Method::POST, &path),
637                &[MANAGED_AGENTS_BETA],
638            )
639            .await
640    }
641
642    /// `DELETE /v1/vaults/{vault_id}/credentials/{cred_id}`.
643    pub async fn delete(&self, credential_id: &str) -> Result<()> {
644        let path = format!("/v1/vaults/{}/credentials/{credential_id}", self.vault_id);
645        let _: serde_json::Value = self
646            .client
647            .execute_with_retry(
648                || self.client.request_builder(reqwest::Method::DELETE, &path),
649                &[MANAGED_AGENTS_BETA],
650            )
651            .await?;
652        Ok(())
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use pretty_assertions::assert_eq;
660    use serde_json::json;
661    use wiremock::matchers::{body_partial_json, method, path};
662    use wiremock::{Mock, MockServer, ResponseTemplate};
663
664    fn client_for(mock: &MockServer) -> Client {
665        Client::builder()
666            .api_key("sk-ant-test")
667            .base_url(mock.uri())
668            .build()
669            .unwrap()
670    }
671
672    #[test]
673    fn mcp_oauth_round_trips_via_credential_auth() {
674        let auth = CredentialAuth::mcp_oauth("https://mcp.slack.com/mcp", "xoxp-token")
675            .expires_at("2026-04-15T00:00:00Z")
676            .refresh(OAuthRefresh {
677                token_endpoint: "https://slack.com/api/oauth.v2.access".into(),
678                client_id: "1234567890".into(),
679                refresh_token: "xoxe-refresh".into(),
680                token_endpoint_auth: TokenEndpointAuth::ClientSecretPost {
681                    client_secret: "abc123".into(),
682                },
683                scope: Some("channels:read".into()),
684            })
685            .build();
686        let v = serde_json::to_value(&auth).unwrap();
687        assert_eq!(v["type"], "mcp_oauth");
688        assert_eq!(v["mcp_server_url"], "https://mcp.slack.com/mcp");
689        assert_eq!(v["access_token"], "xoxp-token");
690        assert_eq!(v["refresh"]["client_id"], "1234567890");
691        assert_eq!(
692            v["refresh"]["token_endpoint_auth"]["type"],
693            "client_secret_post"
694        );
695    }
696
697    #[test]
698    fn static_bearer_round_trips() {
699        let auth = CredentialAuth::static_bearer("https://mcp.linear.app/mcp", "lin_api_x");
700        let v = serde_json::to_value(&auth).unwrap();
701        assert_eq!(
702            v,
703            json!({
704                "type": "static_bearer",
705                "mcp_server_url": "https://mcp.linear.app/mcp",
706                "token": "lin_api_x"
707            })
708        );
709    }
710
711    #[test]
712    fn unknown_auth_type_falls_through_to_other() {
713        let raw = json!({"type": "future_auth", "x": 1});
714        let parsed: CredentialAuth = serde_json::from_value(raw.clone()).unwrap();
715        match parsed {
716            CredentialAuth::Other(v) => assert_eq!(v, raw),
717            CredentialAuth::McpOauth(_) | CredentialAuth::StaticBearer(_) => {
718                panic!("expected Other")
719            }
720        }
721    }
722
723    #[tokio::test]
724    async fn create_vault_posts_with_metadata() {
725        let mock = MockServer::start().await;
726        Mock::given(method("POST"))
727            .and(path("/v1/vaults"))
728            .and(body_partial_json(json!({
729                "display_name": "Alice",
730                "metadata": {"external_user_id": "usr_abc"}
731            })))
732            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
733                "id": "vlt_01",
734                "type": "vault",
735                "display_name": "Alice",
736                "metadata": {"external_user_id": "usr_abc"},
737                "created_at": "2026-04-30T12:00:00Z"
738            })))
739            .mount(&mock)
740            .await;
741
742        let client = client_for(&mock);
743        let req = CreateVaultRequest::new("Alice").with_metadata("external_user_id", "usr_abc");
744        let v = client.managed_agents().vaults().create(req).await.unwrap();
745        assert_eq!(v.id, "vlt_01");
746        assert_eq!(
747            v.metadata.get("external_user_id").map(String::as_str),
748            Some("usr_abc")
749        );
750    }
751
752    #[tokio::test]
753    async fn create_credential_serializes_static_bearer_body() {
754        let mock = MockServer::start().await;
755        Mock::given(method("POST"))
756            .and(path("/v1/vaults/vlt_01/credentials"))
757            .and(body_partial_json(json!({
758                "auth": {
759                    "type": "static_bearer",
760                    "mcp_server_url": "https://mcp.linear.app/mcp",
761                    "token": "lin_api_x"
762                },
763                "display_name": "Linear API key"
764            })))
765            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
766                "id": "cred_01",
767                "type": "credential",
768                "vault_id": "vlt_01",
769                "auth": {
770                    "type": "static_bearer",
771                    "mcp_server_url": "https://mcp.linear.app/mcp"
772                }
773            })))
774            .mount(&mock)
775            .await;
776
777        let client = client_for(&mock);
778        let req = CreateCredentialRequest::new(CredentialAuth::static_bearer(
779            "https://mcp.linear.app/mcp",
780            "lin_api_x",
781        ))
782        .with_display_name("Linear API key");
783        let c = client
784            .managed_agents()
785            .vaults()
786            .credentials("vlt_01")
787            .create(req)
788            .await
789            .unwrap();
790        assert_eq!(c.id, "cred_01");
791    }
792
793    #[tokio::test]
794    async fn update_credential_rotates_token_via_patch() {
795        let mock = MockServer::start().await;
796        Mock::given(method("POST"))
797            .and(path("/v1/vaults/vlt_01/credentials/cred_01"))
798            .and(body_partial_json(json!({
799                "auth": {
800                    "type": "mcp_oauth",
801                    "access_token": "xoxp-new",
802                    "refresh": {"refresh_token": "xoxe-new"}
803                }
804            })))
805            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
806                "id": "cred_01",
807                "type": "credential",
808                "vault_id": "vlt_01",
809                "auth": {
810                    "type": "mcp_oauth",
811                    "mcp_server_url": "https://mcp.slack.com/mcp"
812                }
813            })))
814            .mount(&mock)
815            .await;
816
817        let client = client_for(&mock);
818        let patch = UpdateCredentialRequest {
819            auth: Some(CredentialAuthPatch::McpOauth {
820                access_token: Some("xoxp-new".into()),
821                expires_at: None,
822                refresh: Some(OAuthRefreshPatch {
823                    refresh_token: Some("xoxe-new".into()),
824                    ..Default::default()
825                }),
826            }),
827            display_name: None,
828        };
829        client
830            .managed_agents()
831            .vaults()
832            .credentials("vlt_01")
833            .update("cred_01", patch)
834            .await
835            .unwrap();
836    }
837
838    #[tokio::test]
839    async fn retrieve_vault_returns_typed_record() {
840        let mock = MockServer::start().await;
841        Mock::given(method("GET"))
842            .and(path("/v1/vaults/vlt_01"))
843            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
844                "id": "vlt_01",
845                "type": "vault",
846                "display_name": "Alice"
847            })))
848            .mount(&mock)
849            .await;
850        let client = client_for(&mock);
851        let v = client
852            .managed_agents()
853            .vaults()
854            .retrieve("vlt_01")
855            .await
856            .unwrap();
857        assert_eq!(v.id, "vlt_01");
858    }
859
860    #[tokio::test]
861    async fn list_vaults_passes_pagination_query_params() {
862        let mock = MockServer::start().await;
863        Mock::given(method("GET"))
864            .and(path("/v1/vaults"))
865            .and(wiremock::matchers::query_param("limit", "10"))
866            .and(wiremock::matchers::query_param("after", "vlt_x"))
867            .and(wiremock::matchers::query_param("include_archived", "true"))
868            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
869                "data": [{"id": "vlt_01", "display_name": "Alice"}],
870                "has_more": false
871            })))
872            .mount(&mock)
873            .await;
874        let client = client_for(&mock);
875        let page = client
876            .managed_agents()
877            .vaults()
878            .list(ListVaultsParams {
879                after: Some("vlt_x".into()),
880                limit: Some(10),
881                include_archived: Some(true),
882                ..Default::default()
883            })
884            .await
885            .unwrap();
886        assert_eq!(page.data.len(), 1);
887    }
888
889    #[tokio::test]
890    async fn archive_vault_posts_to_archive_subpath() {
891        let mock = MockServer::start().await;
892        Mock::given(method("POST"))
893            .and(path("/v1/vaults/vlt_01/archive"))
894            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
895                "id": "vlt_01",
896                "display_name": "Alice",
897                "archived_at": "2026-04-30T12:00:00Z"
898            })))
899            .mount(&mock)
900            .await;
901        let client = client_for(&mock);
902        let v = client
903            .managed_agents()
904            .vaults()
905            .archive("vlt_01")
906            .await
907            .unwrap();
908        assert!(v.archived_at.is_some());
909    }
910
911    #[tokio::test]
912    async fn delete_vault_returns_unit() {
913        let mock = MockServer::start().await;
914        Mock::given(method("DELETE"))
915            .and(path("/v1/vaults/vlt_01"))
916            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
917            .mount(&mock)
918            .await;
919        let client = client_for(&mock);
920        client
921            .managed_agents()
922            .vaults()
923            .delete("vlt_01")
924            .await
925            .unwrap();
926    }
927
928    #[tokio::test]
929    async fn retrieve_credential_returns_record_without_secrets() {
930        let mock = MockServer::start().await;
931        Mock::given(method("GET"))
932            .and(path("/v1/vaults/vlt_01/credentials/cred_01"))
933            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
934                "id": "cred_01",
935                "type": "credential",
936                "vault_id": "vlt_01",
937                "display_name": "Linear",
938                "auth": {
939                    "type": "static_bearer",
940                    "mcp_server_url": "https://mcp.linear.app/mcp"
941                }
942            })))
943            .mount(&mock)
944            .await;
945        let client = client_for(&mock);
946        let c = client
947            .managed_agents()
948            .vaults()
949            .credentials("vlt_01")
950            .retrieve("cred_01")
951            .await
952            .unwrap();
953        assert_eq!(c.vault_id, "vlt_01");
954        match c.auth.unwrap() {
955            CredentialAuthResponse::StaticBearer { mcp_server_url } => {
956                assert_eq!(mcp_server_url, "https://mcp.linear.app/mcp");
957            }
958            _ => panic!("expected StaticBearer auth"),
959        }
960    }
961
962    #[tokio::test]
963    async fn list_credentials_paginates_under_vault() {
964        let mock = MockServer::start().await;
965        Mock::given(method("GET"))
966            .and(path("/v1/vaults/vlt_01/credentials"))
967            .and(wiremock::matchers::query_param("limit", "5"))
968            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
969                "data": [{"id": "cred_01", "vault_id": "vlt_01"}],
970                "has_more": false
971            })))
972            .mount(&mock)
973            .await;
974        let client = client_for(&mock);
975        let page = client
976            .managed_agents()
977            .vaults()
978            .credentials("vlt_01")
979            .list(ListVaultsParams {
980                limit: Some(5),
981                ..Default::default()
982            })
983            .await
984            .unwrap();
985        assert_eq!(page.data.len(), 1);
986    }
987
988    #[tokio::test]
989    async fn delete_credential_returns_unit() {
990        let mock = MockServer::start().await;
991        Mock::given(method("DELETE"))
992            .and(path("/v1/vaults/vlt_01/credentials/cred_01"))
993            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
994            .mount(&mock)
995            .await;
996        let client = client_for(&mock);
997        client
998            .managed_agents()
999            .vaults()
1000            .credentials("vlt_01")
1001            .delete("cred_01")
1002            .await
1003            .unwrap();
1004    }
1005
1006    #[test]
1007    fn token_endpoint_auth_round_trips_all_three_variants() {
1008        for auth in [
1009            TokenEndpointAuth::None,
1010            TokenEndpointAuth::ClientSecretBasic {
1011                client_secret: "abc".into(),
1012            },
1013            TokenEndpointAuth::ClientSecretPost {
1014                client_secret: "def".into(),
1015            },
1016        ] {
1017            let v = serde_json::to_value(&auth).unwrap();
1018            let parsed: TokenEndpointAuth = serde_json::from_value(v).unwrap();
1019            assert_eq!(parsed, auth);
1020        }
1021    }
1022
1023    #[test]
1024    fn credential_auth_response_round_trips_known_variants() {
1025        let oauth = CredentialAuthResponse::McpOauth {
1026            mcp_server_url: "https://mcp.x/mcp".into(),
1027            expires_at: Some("2026-05-01T00:00:00Z".into()),
1028            refresh: None,
1029        };
1030        let v = serde_json::to_value(&oauth).unwrap();
1031        assert_eq!(v["type"], "mcp_oauth");
1032        assert_eq!(v["mcp_server_url"], "https://mcp.x/mcp");
1033        let parsed: CredentialAuthResponse = serde_json::from_value(v).unwrap();
1034        assert_eq!(parsed, oauth);
1035
1036        let bearer = CredentialAuthResponse::StaticBearer {
1037            mcp_server_url: "https://mcp.y/mcp".into(),
1038        };
1039        let v = serde_json::to_value(&bearer).unwrap();
1040        assert_eq!(
1041            v,
1042            json!({"type": "static_bearer", "mcp_server_url": "https://mcp.y/mcp"})
1043        );
1044        let parsed: CredentialAuthResponse = serde_json::from_value(v).unwrap();
1045        assert_eq!(parsed, bearer);
1046
1047        // Unknown auth type falls through to Other.
1048        let unknown: CredentialAuthResponse =
1049            serde_json::from_value(json!({"type": "future_kind", "x": 1})).unwrap();
1050        assert!(matches!(unknown, CredentialAuthResponse::Other));
1051    }
1052
1053    #[tokio::test]
1054    async fn archive_credential_posts_to_archive_subpath() {
1055        let mock = MockServer::start().await;
1056        Mock::given(method("POST"))
1057            .and(path("/v1/vaults/vlt_01/credentials/cred_01/archive"))
1058            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1059                "id": "cred_01",
1060                "type": "credential",
1061                "vault_id": "vlt_01",
1062                "archived_at": "2026-04-30T12:00:00Z"
1063            })))
1064            .mount(&mock)
1065            .await;
1066
1067        let client = client_for(&mock);
1068        let c = client
1069            .managed_agents()
1070            .vaults()
1071            .credentials("vlt_01")
1072            .archive("cred_01")
1073            .await
1074            .unwrap();
1075        assert!(c.archived_at.is_some());
1076    }
1077}