Skip to main content

forge_workspace/mcp/
vault.rs

1//! Vault MCP - the Vaultwarden family (`mcp__forge__vault__*`).
2//!
3//! Thirteen tools, every one held for the reader's approval: the blocked
4//! handler parks a `VaultDraft` on the workspace registry, the dock draws it,
5//! and no operation runs in the vault unless the answer approved it. The card
6//! never carries a secret value; a reveal happens only after approval and
7//! travels in the tool result.
8
9pub mod facade;
10
11use std::sync::Arc;
12
13use forge_primitives::vault::VaultTool;
14use forge_sdk::mcp::server::McpServerBuilder;
15use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput};
16use serde::de::DeserializeOwned;
17use serde_json::Value;
18
19use crate::SessionSlot;
20use crate::mcp::vault::facade::{CreateInput, PatchInput, VaultFacade, VaultToolError};
21
22/// Attach the vault tools to an existing [`McpServerBuilder`]. Called only
23/// when the family is selected AND `[vault]` is configured, the same double
24/// gate systemone uses.
25pub(crate) fn add_tools(
26    builder: McpServerBuilder,
27    facade: &Arc<dyn VaultFacade>,
28    slot: &SessionSlot,
29) -> McpServerBuilder {
30    let mut builder = builder;
31    for tool in VaultTool::ALL {
32        builder = builder.tool(VaultCall { facade: Arc::clone(facade), slot: slot.clone(), tool });
33    }
34    builder
35}
36
37/// One tool instance: the family shares a single `Tool` impl because the
38/// name, description and schema are chosen by the tool the instance names.
39struct VaultCall {
40    facade: Arc<dyn VaultFacade>,
41    slot: SessionSlot,
42    tool: VaultTool,
43}
44
45#[derive(serde::Deserialize)]
46struct SearchArgs {
47    query: String,
48    #[serde(default)]
49    limit: Option<usize>,
50}
51
52#[derive(serde::Deserialize)]
53struct GetArgs {
54    id: String,
55    #[serde(default)]
56    reveal: Option<String>,
57}
58
59#[derive(serde::Deserialize)]
60struct IdArgs {
61    id: String,
62}
63
64#[derive(serde::Deserialize)]
65struct AttachmentGetArgs {
66    item_id: String,
67    attachment_id: String,
68    destination: String,
69}
70
71#[derive(serde::Deserialize)]
72struct AttachmentPutArgs {
73    item_id: String,
74    path: String,
75    #[serde(default)]
76    name: Option<String>,
77    #[serde(default)]
78    replace_attachment_id: Option<String>,
79}
80
81#[derive(serde::Deserialize)]
82struct FolderNameArgs {
83    name: String,
84}
85
86#[derive(serde::Deserialize)]
87struct FolderUpdateArgs {
88    id: String,
89    name: String,
90}
91
92#[async_trait::async_trait]
93impl Tool for VaultCall {
94    fn name(&self) -> &'static str {
95        self.tool.as_str()
96    }
97
98    fn description(&self) -> &'static str {
99        match self.tool {
100            VaultTool::Search => {
101                "Search the vault by item name, username or URI, case-insensitively. Returns \
102                 metadata only: {items: [{id, name, username, uris, folder, collection, \
103                 updated_at}]}. Passwords, notes, TOTP seeds and card numbers are never \
104                 decrypted by a search. The call is held for the reader's approval, and the \
105                 card shows the query and the hit count."
106            }
107            VaultTool::Get => {
108                "Read one vault item. By default returns metadata only: id, name, type, \
109                 username, uris, folder_id, card brand, attachments and dates. Pass `reveal` to \
110                 return one selected value: password, notes, custom:<name>, card.cardholder, \
111                 card.number, card.expiry (an object of expMonth and expYear) or card.code. \
112                 The TOTP seed is never a reveal option; use vault__totp_code. The call is \
113                 held; the card names the item and the field and shows no value."
114            }
115            VaultTool::Create => {
116                "Create a login, secure note or card. `type` defaults to login. A login sets \
117                 username, password, uri and totp (an otpauth:// URI or the bare base32 seed); \
118                 a card sets cardholder_name, number, exp_month, exp_year, code and optionally \
119                 brand; a note uses notes. Pass `custom_fields` as [{name, value, type?}] with \
120                 type text, hidden or boolean. The call is held; the card says what is being \
121                 created and masks every secret value."
122            }
123            VaultTool::Update => {
124                "Change fields on one item. `patch` accepts name, username, password, uri, \
125                 totp, notes, cardholder_name, number, exp_month, exp_year, code, brand, \
126                 folder_id and custom_fields; only the fields present are changed. The call is \
127                 held; the card names the item and a masked diff, never the values."
128            }
129            VaultTool::Trash => {
130                "Move one item to the trash (a soft delete). The call is held; the card names \
131                 the item. Use vault__restore to bring it back."
132            }
133            VaultTool::Restore => {
134                "Restore one item from the trash. The call is held; the card names the item."
135            }
136            VaultTool::TotpCode => {
137                "Compute the current TOTP code for one item, in process. Returns {code, \
138                 period, remaining}; the seed never leaves the process and is never returned. \
139                 The call is held; the card names the item."
140            }
141            VaultTool::AttachmentGet => {
142                "Download and decrypt one attachment to `destination`, which must resolve \
143                 inside this session's working directory. Returns {path, size, sha256}; the \
144                 file is written mode 0600. The call is held; the card names the item, the \
145                 attachment and its size."
146            }
147            VaultTool::AttachmentPut => {
148                "Upload one file from `path`, which must resolve inside this session's working \
149                 directory. Pass `replace_attachment_id` to replace an existing attachment as \
150                 one held intent naming both files; the delete is never a separate tool. \
151                 Returns {id, name, size, sha256}. The card names the file, its size and \
152                 whether it replaces an attachment."
153            }
154            VaultTool::FolderList => {
155                "List the vault's folders as {folders: [{id, name}]}. The call is held."
156            }
157            VaultTool::FolderCreate => {
158                "Create a folder. The call is held; the card names the folder."
159            }
160            VaultTool::FolderUpdate => {
161                "Rename a folder. The call is held; the card names the folder and the new name."
162            }
163            VaultTool::FolderDelete => {
164                "Delete a folder permanently. Its items are not deleted; they move to No \
165                 Folder. The call is held; the card says so plainly."
166            }
167        }
168    }
169
170    fn input_schema(&self) -> Value {
171        match self.tool {
172            VaultTool::Search => serde_json::json!({
173                "type": "object",
174                "properties": {
175                    "query": { "type": "string", "description": "Text to match against item name, username or URI." },
176                    "limit": { "type": "integer", "minimum": 1, "maximum": 100, "description": "Maximum rows to return; defaults to 20." }
177                },
178                "required": ["query"],
179                "additionalProperties": false,
180            }),
181            VaultTool::Get => serde_json::json!({
182                "type": "object",
183                "properties": {
184                    "id": { "type": "string", "description": "The item's id." },
185                    "reveal": {
186                        "type": "string",
187                        "pattern": "^(password|notes|card\\.(cardholder|number|expiry|code)|custom:.+)$",
188                        "description": "One field to reveal: password, notes, custom:<name>, card.cardholder, card.number, card.expiry or card.code."
189                    }
190                },
191                "required": ["id"],
192                "additionalProperties": false,
193            }),
194            VaultTool::Create => {
195                let mut properties = serde_json::Map::new();
196                properties.insert("type".to_owned(), serde_json::json!({ "type": "string", "enum": ["login", "note", "card"], "description": "Item type; defaults to login." }));
197                properties.insert("name".to_owned(), serde_json::json!({ "type": "string" }));
198                for (key, description) in [
199                    ("username", "Login username."),
200                    ("password", "Login password."),
201                    ("uri", "Login URI."),
202                    ("totp", "Login TOTP seed: an otpauth:// URI or the bare base32 seed."),
203                    ("notes", "Notes for any type."),
204                    ("cardholder_name", "Card cardholder."),
205                    ("number", "Card number."),
206                    ("exp_month", "Card expiry month."),
207                    ("exp_year", "Card expiry year."),
208                    ("code", "Card CVV."),
209                    ("brand", "Card brand."),
210                    ("folder_id", "Folder id."),
211                ] {
212                    properties.insert(
213                        key.to_owned(),
214                        serde_json::json!({ "type": "string", "description": description }),
215                    );
216                }
217                properties.insert(
218                    "custom_fields".to_owned(),
219                    serde_json::json!({
220                        "type": "array",
221                        "items": {
222                            "type": "object",
223                            "properties": {
224                                "name": { "type": "string" },
225                                "value": { "type": "string" },
226                                "type": { "type": "string", "enum": ["text", "hidden", "boolean"] }
227                            },
228                            "required": ["name", "value"],
229                            "additionalProperties": false,
230                        }
231                    }),
232                );
233                serde_json::json!({
234                    "type": "object",
235                    "properties": properties,
236                    "required": ["name"],
237                    "additionalProperties": false,
238                })
239            }
240            VaultTool::Update => serde_json::json!({
241                "type": "object",
242                "properties": {
243                    "id": { "type": "string", "description": "The item's id." },
244                    "patch": {
245                        "type": "object",
246                        "properties": {
247                            "name": { "type": "string" },
248                            "username": { "type": "string" },
249                            "password": { "type": "string" },
250                            "uri": { "type": "string" },
251                            "totp": { "type": "string" },
252                            "notes": { "type": "string" },
253                            "cardholder_name": { "type": "string" },
254                            "number": { "type": "string" },
255                            "exp_month": { "type": "string" },
256                            "exp_year": { "type": "string" },
257                            "code": { "type": "string" },
258                            "brand": { "type": "string" },
259                            "folder_id": { "type": "string" },
260                            "custom_fields": {
261                                "type": "array",
262                                "items": {
263                                    "type": "object",
264                                    "properties": {
265                                        "name": { "type": "string" },
266                                        "value": { "type": "string" },
267                                        "type": { "type": "string", "enum": ["text", "hidden", "boolean"] }
268                                    },
269                                    "required": ["name", "value"],
270                                    "additionalProperties": false,
271                                }
272                            }
273                        },
274                        "additionalProperties": false,
275                    }
276                },
277                "required": ["id", "patch"],
278                "additionalProperties": false,
279            }),
280            VaultTool::Trash | VaultTool::Restore | VaultTool::TotpCode => serde_json::json!({
281                "type": "object",
282                "properties": { "id": { "type": "string", "description": "The item's id." } },
283                "required": ["id"],
284                "additionalProperties": false,
285            }),
286            VaultTool::AttachmentGet => serde_json::json!({
287                "type": "object",
288                "properties": {
289                    "item_id": { "type": "string" },
290                    "attachment_id": { "type": "string" },
291                    "destination": { "type": "string", "description": "A path inside this session's working directory." }
292                },
293                "required": ["item_id", "attachment_id", "destination"],
294                "additionalProperties": false,
295            }),
296            VaultTool::AttachmentPut => serde_json::json!({
297                "type": "object",
298                "properties": {
299                    "item_id": { "type": "string" },
300                    "path": { "type": "string", "description": "A file inside this session's working directory." },
301                    "name": { "type": "string", "description": "The attachment name; defaults to the file's own name." },
302                    "replace_attachment_id": { "type": "string", "description": "The attachment to remove after the new one uploads." }
303                },
304                "required": ["item_id", "path"],
305                "additionalProperties": false,
306            }),
307            VaultTool::FolderList => serde_json::json!({
308                "type": "object",
309                "properties": {},
310                "additionalProperties": false,
311            }),
312            VaultTool::FolderCreate => serde_json::json!({
313                "type": "object",
314                "properties": { "name": { "type": "string" } },
315                "required": ["name"],
316                "additionalProperties": false,
317            }),
318            VaultTool::FolderUpdate => serde_json::json!({
319                "type": "object",
320                "properties": {
321                    "id": { "type": "string" },
322                    "name": { "type": "string" }
323                },
324                "required": ["id", "name"],
325                "additionalProperties": false,
326            }),
327            VaultTool::FolderDelete => serde_json::json!({
328                "type": "object",
329                "properties": { "id": { "type": "string" } },
330                "required": ["id"],
331                "additionalProperties": false,
332            }),
333        }
334    }
335
336    async fn call(&self, input: ToolInput) -> ToolOutput {
337        match self.tool {
338            VaultTool::Search => {
339                let args = match parse::<SearchArgs>(input.value) {
340                    Ok(args) => args,
341                    Err(output) => return output,
342                };
343                reply(self.facade.search(&self.slot, &args.query, args.limit).await)
344            }
345            VaultTool::Get => {
346                let args = match parse::<GetArgs>(input.value) {
347                    Ok(args) => args,
348                    Err(output) => return output,
349                };
350                reply(self.facade.get(&self.slot, &args.id, args.reveal.as_deref()).await)
351            }
352            VaultTool::Create => {
353                let args = match parse::<CreateInput>(input.value) {
354                    Ok(args) => args,
355                    Err(output) => return output,
356                };
357                reply(self.facade.create(&self.slot, &args).await)
358            }
359            VaultTool::Update => {
360                #[derive(serde::Deserialize)]
361                struct Args {
362                    id: String,
363                    patch: PatchInput,
364                }
365                let args = match parse::<Args>(input.value) {
366                    Ok(args) => args,
367                    Err(output) => return output,
368                };
369                reply(self.facade.update(&self.slot, &args.id, &args.patch).await)
370            }
371            VaultTool::Trash | VaultTool::Restore | VaultTool::TotpCode => {
372                let args = match parse::<IdArgs>(input.value) {
373                    Ok(args) => args,
374                    Err(output) => return output,
375                };
376                reply(match self.tool {
377                    VaultTool::Trash => self.facade.trash(&self.slot, &args.id).await,
378                    VaultTool::Restore => self.facade.restore(&self.slot, &args.id).await,
379                    _ => self.facade.totp_code(&self.slot, &args.id).await,
380                })
381            }
382            VaultTool::AttachmentGet => {
383                let args = match parse::<AttachmentGetArgs>(input.value) {
384                    Ok(args) => args,
385                    Err(output) => return output,
386                };
387                reply(
388                    self.facade
389                        .attachment_get(
390                            &self.slot,
391                            &args.item_id,
392                            &args.attachment_id,
393                            &args.destination,
394                        )
395                        .await,
396                )
397            }
398            VaultTool::AttachmentPut => {
399                let args = match parse::<AttachmentPutArgs>(input.value) {
400                    Ok(args) => args,
401                    Err(output) => return output,
402                };
403                reply(
404                    self.facade
405                        .attachment_put(
406                            &self.slot,
407                            &args.item_id,
408                            &args.path,
409                            args.name.as_deref(),
410                            args.replace_attachment_id.as_deref(),
411                        )
412                        .await,
413                )
414            }
415            VaultTool::FolderList => reply(self.facade.folder_list(&self.slot).await),
416            VaultTool::FolderCreate => {
417                let args = match parse::<FolderNameArgs>(input.value) {
418                    Ok(args) => args,
419                    Err(output) => return output,
420                };
421                reply(self.facade.folder_create(&self.slot, &args.name).await)
422            }
423            VaultTool::FolderUpdate => {
424                let args = match parse::<FolderUpdateArgs>(input.value) {
425                    Ok(args) => args,
426                    Err(output) => return output,
427                };
428                reply(self.facade.folder_update(&self.slot, &args.id, &args.name).await)
429            }
430            VaultTool::FolderDelete => {
431                let args = match parse::<IdArgs>(input.value) {
432                    Ok(args) => args,
433                    Err(output) => return output,
434                };
435                reply(self.facade.folder_delete(&self.slot, &args.id).await)
436            }
437        }
438    }
439}
440
441fn parse<T: DeserializeOwned>(value: Value) -> Result<T, ToolOutput> {
442    serde_json::from_value(value)
443        .map_err(|error| ToolOutput::error(format!("invalid arguments: {error}")))
444}
445
446fn reply(result: Result<Value, VaultToolError>) -> ToolOutput {
447    match result {
448        Ok(value) => match serde_json::to_string_pretty(&value) {
449            Ok(json) => ToolOutput::text(json),
450            Err(error) => ToolOutput::error(format!("the result did not serialise: {error}")),
451        },
452        Err(error) => ToolOutput::error(error.to_string()),
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use crate::mcp::vault::facade::MockVaultFacade;
460    use serde_json::json;
461
462    async fn call(tool: VaultTool, args: Value) -> (ToolOutput, Vec<String>) {
463        let facade = Arc::new(MockVaultFacade::new());
464        let call = VaultCall {
465            facade: Arc::clone(&facade) as Arc<dyn VaultFacade>,
466            slot: SessionSlot::lead("Default", "forge"),
467            tool,
468        };
469        let output = call.call(ToolInput { value: args }).await;
470        (output, facade.calls())
471    }
472
473    /// Every schema is closed: a field the tool does not name is refused by
474    /// the CLI rather than silently ignored.
475    #[test]
476    fn every_tool_schema_is_closed() {
477        let facade = Arc::new(MockVaultFacade::new());
478        for tool in VaultTool::ALL {
479            let call = VaultCall {
480                facade: Arc::clone(&facade) as Arc<dyn VaultFacade>,
481                slot: SessionSlot::lead("Default", "forge"),
482                tool,
483            };
484            let schema = call.input_schema();
485            assert_eq!(
486                schema["additionalProperties"],
487                json!(false),
488                "{} is not closed",
489                tool.as_str(),
490            );
491            assert_eq!(call.name(), tool.as_str());
492            assert!(!call.description().is_empty());
493        }
494    }
495
496    /// The tool forwards the query and returns the facade's answer.
497    #[tokio::test]
498    async fn search_forwards_the_query() {
499        let (output, calls) = call(VaultTool::Search, json!({ "query": "bank" })).await;
500        assert!(!output.is_error, "{output:?}");
501        assert_eq!(calls, vec!["vault__search"]);
502        let text = format!("{output:?}");
503        assert!(text.contains("vault__search"), "{text}");
504    }
505
506    /// The Get schema admits the `custom:<name>` reveal the tool promises;
507    /// an enum of the fixed names would reject it before the facade could.
508    #[test]
509    fn the_get_schema_admits_a_custom_reveal() {
510        let facade = Arc::new(MockVaultFacade::new());
511        let call = VaultCall {
512            facade: Arc::clone(&facade) as Arc<dyn VaultFacade>,
513            slot: SessionSlot::lead("Default", "forge"),
514            tool: VaultTool::Get,
515        };
516        let schema = call.input_schema();
517        let pattern = schema["properties"]["reveal"]["pattern"]
518            .as_str()
519            .expect("the reveal carries a pattern");
520        assert!(pattern.contains("custom:"), "the custom reveal is admitted: {pattern}");
521        assert!(schema["properties"]["reveal"].get("enum").is_none());
522    }
523
524    /// A seed reaches the facade only if the schema names it: the schemas are
525    /// closed, so an unnamed argument is refused by the CLI before the call.
526    #[test]
527    fn the_create_and_update_schemas_accept_a_totp_seed() {
528        let facade = Arc::new(MockVaultFacade::new());
529        let schema = |tool| {
530            VaultCall {
531                facade: Arc::clone(&facade) as Arc<dyn VaultFacade>,
532                slot: SessionSlot::lead("Default", "forge"),
533                tool,
534            }
535            .input_schema()
536        };
537
538        assert!(schema(VaultTool::Create)["properties"].get("totp").is_some(), "create names totp");
539        assert!(
540            schema(VaultTool::Update)["properties"]["patch"]["properties"].get("totp").is_some(),
541            "update names totp",
542        );
543    }
544
545    /// A malformed argument fails before the facade runs.
546    #[tokio::test]
547    async fn a_malformed_argument_is_a_tool_error() {
548        let (output, calls) = call(VaultTool::Get, json!({ "reveal": "password" })).await;
549        assert!(output.is_error);
550        assert!(format!("{output:?}").contains("invalid arguments"), "{output:?}");
551        assert!(calls.is_empty(), "the facade must not run for a malformed call");
552    }
553
554    /// The facade's refusal is surfaced as an error with its own words.
555    #[tokio::test]
556    async fn a_facade_refusal_is_a_tool_error() {
557        let facade = Arc::new(MockVaultFacade::new());
558        facade.fail_next("the vault is locked");
559        let call = VaultCall {
560            facade: Arc::clone(&facade) as Arc<dyn VaultFacade>,
561            slot: SessionSlot::lead("Default", "forge"),
562            tool: VaultTool::FolderList,
563        };
564        let output = call.call(ToolInput { value: json!({}) }).await;
565        assert!(output.is_error);
566        assert!(format!("{output:?}").contains("the vault is locked"), "{output:?}");
567    }
568
569    /// The family registers all thirteen tools when the builder is handed
570    /// the facade.
571    #[test]
572    fn all_thirteen_tools_register() {
573        let facade = Arc::new(MockVaultFacade::new());
574        let server = add_tools(
575            McpServerBuilder::new("forge", "test"),
576            &(Arc::clone(&facade) as Arc<dyn VaultFacade>),
577            &SessionSlot::lead("Default", "forge"),
578        )
579        .build();
580        let names = server.tool_names();
581        for tool in VaultTool::ALL {
582            assert!(names.contains(&tool.as_str()), "{} missing", tool.as_str());
583        }
584    }
585}