1pub 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
22pub(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
37struct 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}