1use std::sync::Arc;
20
21use forge_sdk::mcp::server::McpServerBuilder;
22use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput};
23
24use crate::SessionSlot;
25use crate::mcp::settings::facade::{
26 SettingsError, SettingsFacade, SettingsSetItem, SettingsWriteOutcome,
27};
28
29pub(crate) mod facade;
30
31pub(crate) fn add_tools(
35 builder: McpServerBuilder,
36 facade: Arc<dyn SettingsFacade>,
37 slot: SessionSlot,
38) -> McpServerBuilder {
39 let index = Index { facade: facade.clone(), slot: slot.clone() };
40 let get = Get { facade: facade.clone(), slot: slot.clone() };
41 let set = Set { facade: facade.clone(), slot: slot.clone() };
42 let unset = Unset { facade, slot };
43 builder.tool(index).tool(get).tool(set).tool(unset)
44}
45
46fn tool_error(error: &SettingsError) -> ToolOutput {
47 ToolOutput::error(error.message())
48}
49
50fn effect_name(effect: forge_primitives::EffectClass) -> &'static str {
51 effect.as_str()
52}
53
54struct Index {
55 facade: Arc<dyn SettingsFacade>,
56 slot: SessionSlot,
57}
58
59#[async_trait::async_trait]
60impl Tool for Index {
61 fn name(&self) -> &'static str {
62 "settings__index"
63 }
64
65 fn description(&self) -> &'static str {
66 "Read forge's configuration shape: every catalogue key with its type, whether this \
67 config sets it, and when an edit takes effect (`live`, `next session`, `restart`, \
68 `not read yet`). No values cross, so this is the shape of the file without its \
69 contents. A key with instances lists the ones this config declares; \
70 settings__get takes one of those concrete paths (for example \
71 `accounts.<display_name>.token`). forge's configuration is read and edited through \
72 these settings tools, not by opening or editing a file: a direct filesystem read or \
73 edit of the config is the wrong door even when it works, because it skips the \
74 catalogue's validation and the reader's approval. Any session may call this."
75 }
76
77 fn input_schema(&self) -> serde_json::Value {
78 serde_json::json!({
79 "type": "object",
80 "properties": {},
81 "additionalProperties": false,
82 })
83 }
84
85 async fn call(&self, _input: ToolInput) -> ToolOutput {
86 match self.facade.index(&self.slot).await {
87 Ok(text) => ToolOutput::text(text),
88 Err(error) => tool_error(&error),
89 }
90 }
91}
92
93struct Get {
94 facade: Arc<dyn SettingsFacade>,
95 slot: SessionSlot,
96}
97
98#[derive(serde::Deserialize)]
99struct KeyArgs {
100 key: String,
101}
102
103#[async_trait::async_trait]
104impl Tool for Get {
105 fn name(&self) -> &'static str {
106 "settings__get"
107 }
108
109 fn description(&self) -> &'static str {
110 "Read one configuration key's effective value. `key` is a concrete catalogue path as \
111 settings__index lists it, with the instance filled in where the table repeats \
112 (`server.port`, `accounts.<display_name>.token`, \
113 `orgs.<org>.projects.<project>.model`). A project's lead and workers read their own \
114 project's keys and the global keys freely; another project's key is refused by name, \
115 naming the owning project and how to request it - ask that project's lead through the \
116 peer channel (`agents__send_message` to that org and project, label \"lead\"), or, \
117 for a session that has no peer channel, report the need to your reader. Nothing reads \
118 another project's settings silently. forge's configuration is read and edited through \
119 these settings tools, not by opening or editing a file: a direct filesystem read or \
120 edit of the config is the wrong door even when it works."
121 }
122
123 fn input_schema(&self) -> serde_json::Value {
124 serde_json::json!({
125 "type": "object",
126 "properties": {
127 "key": {
128 "type": "string",
129 "description": "The concrete catalogue path, with its instance.",
130 },
131 },
132 "required": ["key"],
133 "additionalProperties": false,
134 })
135 }
136
137 async fn call(&self, input: ToolInput) -> ToolOutput {
138 let args: KeyArgs = match serde_json::from_value(input.value) {
139 Ok(args) => args,
140 Err(err) => return ToolOutput::error(format!("invalid arguments: {err}")),
141 };
142 match self.facade.get(&self.slot, &args.key).await {
143 Ok(value) => {
144 let body = serde_json::json!({
145 "key": value.key,
146 "pattern": value.pattern,
147 "effect": effect_name(value.effect),
148 "set": value.value != serde_json::Value::Null,
149 "value": value.value,
150 });
151 match serde_json::to_string_pretty(&body) {
152 Ok(text) => ToolOutput::text(text),
153 Err(err) => ToolOutput::error(format!("response serialization failed: {err}")),
154 }
155 }
156 Err(error) => tool_error(&error),
157 }
158 }
159}
160
161struct Set {
162 facade: Arc<dyn SettingsFacade>,
163 slot: SessionSlot,
164}
165
166#[derive(serde::Deserialize)]
167struct SetArgs {
168 edits: Vec<SettingsSetItem>,
169}
170
171fn effect_names(effects: &[forge_primitives::EffectClass]) -> String {
172 effects.iter().map(|effect| effect_name(*effect)).collect::<Vec<_>>().join(", ")
173}
174
175#[async_trait::async_trait]
176impl Tool for Set {
177 fn name(&self) -> &'static str {
178 "settings__set"
179 }
180
181 fn description(&self) -> &'static str {
182 "Change one or more configuration keys. `edits` is a list of `{\"key\", \"value\"}` \
183 objects, each key a concrete catalogue path as settings__index lists it and each \
184 value in the key's own type. One call is one validated save, so a section whose keys \
185 are mutually required - the vault's `url` and `email`, say - is created by sending \
186 both in the same call. The whole batch is validated by the catalogue and the loader \
187 first - an unknown key, a wrong type, a save that changes nothing, or a result the \
188 loader would refuse is refused naming what failed before anything is asked - and \
189 then ALWAYS held for the reader's approval: the write parks in the dock with every \
190 key and its current and proposed value, applies through the same validated writer \
191 the settings page uses only when they approve, and this call returns which \
192 happened. There is no unparked write path, for your own project or any other. \
193 forge's configuration is read and edited through these settings tools, not by \
194 opening or editing a file: a direct filesystem read or edit of the config is the \
195 wrong door even when it works."
196 }
197
198 fn input_schema(&self) -> serde_json::Value {
199 serde_json::json!({
200 "type": "object",
201 "properties": {
202 "edits": {
203 "type": "array",
204 "minItems": 1,
205 "description": "The keys and their new values, applied as one validated \
206 save and one held write. Send a section's mutually \
207 required keys together.",
208 "items": {
209 "type": "object",
210 "properties": {
211 "key": {
212 "type": "string",
213 "description": "The concrete catalogue path, with its instance.",
214 },
215 "value": {
216 "description": "The new value in the key's own type (string, \
217 number, boolean or list of strings). \
218 settings__index states it.",
219 },
220 },
221 "required": ["key", "value"],
222 "additionalProperties": false,
223 },
224 },
225 },
226 "required": ["edits"],
227 "additionalProperties": false,
228 })
229 }
230
231 async fn call(&self, input: ToolInput) -> ToolOutput {
232 let args: SetArgs = match serde_json::from_value(input.value) {
233 Ok(args) => args,
234 Err(err) => return ToolOutput::error(format!("invalid arguments: {err}")),
235 };
236 if args.edits.is_empty() {
237 return ToolOutput::error("a write needs at least one edit; nothing was written");
238 }
239 let keys: Vec<String> = args.edits.iter().map(|item| item.key.clone()).collect();
240 let named = keys.iter().map(|key| format!("`{key}`")).collect::<Vec<_>>().join(", ");
241 match self.facade.set(&self.slot, args.edits).await {
242 Ok(SettingsWriteOutcome::Applied { effects }) => {
243 if let [key] = keys.as_slice() {
244 ToolOutput::text(format!(
245 "the reader approved; `{key}` was written and takes effect {}",
246 effect_names(&effects),
247 ))
248 } else {
249 ToolOutput::text(format!(
250 "the reader approved; {} keys were written and take effect {}: {named}",
251 keys.len(),
252 effect_names(&effects),
253 ))
254 }
255 }
256 Ok(SettingsWriteOutcome::Declined) => {
257 if let [key] = keys.as_slice() {
258 ToolOutput::text(format!(
259 "the reader declined; `{key}` is unchanged and nothing was written",
260 ))
261 } else {
262 ToolOutput::text(format!(
263 "the reader declined; none of the {} keys changed and nothing was \
264 written: {named}",
265 keys.len(),
266 ))
267 }
268 }
269 Err(error) => tool_error(&error),
270 }
271 }
272}
273
274struct Unset {
275 facade: Arc<dyn SettingsFacade>,
276 slot: SessionSlot,
277}
278
279#[async_trait::async_trait]
280impl Tool for Unset {
281 fn name(&self) -> &'static str {
282 "settings__unset"
283 }
284
285 fn description(&self) -> &'static str {
286 "Clear one optional configuration key, returning it to its default. `key` is a \
287 concrete catalogue path as settings__index lists it. A required key is refused by \
288 name before anything is asked. Like settings__set the edit is ALWAYS held for the \
289 reader's approval: it parks in the dock with the key, its current value and the \
290 cleared state, applies through the same validated writer the settings page uses only \
291 when they approve, and this call returns which happened. forge's configuration is \
292 read and edited through these settings tools, not by opening or editing a file: a \
293 direct filesystem read or edit of the config is the wrong door even when it works."
294 }
295
296 fn input_schema(&self) -> serde_json::Value {
297 serde_json::json!({
298 "type": "object",
299 "properties": {
300 "key": {
301 "type": "string",
302 "description": "The concrete catalogue path, with its instance.",
303 },
304 },
305 "required": ["key"],
306 "additionalProperties": false,
307 })
308 }
309
310 async fn call(&self, input: ToolInput) -> ToolOutput {
311 let args: KeyArgs = match serde_json::from_value(input.value) {
312 Ok(args) => args,
313 Err(err) => return ToolOutput::error(format!("invalid arguments: {err}")),
314 };
315 match self.facade.unset(&self.slot, &args.key).await {
316 Ok(SettingsWriteOutcome::Applied { effects }) => ToolOutput::text(format!(
317 "the reader approved; `{}` was cleared and takes effect {}",
318 args.key,
319 effect_names(&effects),
320 )),
321 Ok(SettingsWriteOutcome::Declined) => ToolOutput::text(format!(
322 "the reader declined; `{}` is unchanged and nothing was written",
323 args.key,
324 )),
325 Err(error) => tool_error(&error),
326 }
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::mcp::settings::facade::MockSettingsFacade;
334 use forge_sdk::mcp::server::McpServerBuilder;
335
336 const SHIPPED: &str = "forge's configuration is read and edited through these settings \
338 tools, not by opening or editing a file: a direct filesystem read \
339 or edit of the config is the wrong door even when it works";
340
341 fn slot() -> SessionSlot {
342 SessionSlot::lead("Default", "forge")
343 }
344
345 fn server(facade: Arc<dyn SettingsFacade>) -> forge_sdk::mcp::server::McpServer {
346 add_tools(McpServerBuilder::new("forge", "test"), facade, slot()).build()
347 }
348
349 async fn call(
350 server: &forge_sdk::mcp::server::McpServer,
351 name: &str,
352 args: serde_json::Value,
353 ) -> (String, bool) {
354 let request = forge_sdk::mcp::protocol::JsonRpcRequest {
355 jsonrpc: "2.0".to_owned(),
356 id: Some(serde_json::json!(1)),
357 method: "tools/call".to_owned(),
358 params: Some(serde_json::json!({ "name": name, "arguments": args })),
359 };
360 let response = server.dispatch(&request).await.expect("a tools/call is answered");
361 let result = serde_json::to_value(response.result.expect("the call is answered"))
362 .expect("serializes");
363 let text = result["content"][0]["text"].as_str().unwrap_or_default().to_owned();
364 let is_error = result["isError"].as_bool().unwrap_or(false);
365 (text, is_error)
366 }
367
368 #[test]
372 fn every_tool_says_the_config_is_read_and_edited_here() {
373 for description in [
374 Index { facade: MockSettingsFacade::new().into_arc(), slot: slot() }.description(),
375 Get { facade: MockSettingsFacade::new().into_arc(), slot: slot() }.description(),
376 Set { facade: MockSettingsFacade::new().into_arc(), slot: slot() }.description(),
377 Unset { facade: MockSettingsFacade::new().into_arc(), slot: slot() }.description(),
378 ] {
379 assert!(
380 description.contains(SHIPPED),
381 "the shipped text is missing from a tool description: {description}",
382 );
383 assert!(
384 !description.contains("forge.toml") && !description.contains(".toml"),
385 "a tool description advertises the config path: {description}",
386 );
387 }
388 }
389
390 #[test]
393 fn the_write_tools_state_the_hold() {
394 let set = Set { facade: MockSettingsFacade::new().into_arc(), slot: slot() };
395 assert!(set.description().contains("ALWAYS held for the reader's approval"));
396 assert!(set.description().contains("no unparked write path"));
397 let unset = Unset { facade: MockSettingsFacade::new().into_arc(), slot: slot() };
398 assert!(unset.description().contains("ALWAYS held for the reader's approval"));
399 }
400
401 #[test]
403 fn get_states_the_cross_project_rule() {
404 let get = Get { facade: MockSettingsFacade::new().into_arc(), slot: slot() };
405 assert!(get.description().contains("another project's key is refused by name"));
406 assert!(get.description().contains("agents__send_message"));
407 assert!(get.description().contains("report the need to your reader"));
408 }
409
410 #[tokio::test]
411 async fn index_returns_the_facades_text_and_takes_no_arguments() {
412 let facade = MockSettingsFacade::new();
413 *facade.index_text.lock() = "server.port number set restart".to_owned();
414 let server = server(facade.into_arc());
415
416 let (text, is_error) = call(&server, "settings__index", serde_json::json!({})).await;
417
418 assert!(!is_error);
419 assert!(text.contains("server.port"));
420 }
421
422 #[tokio::test]
423 async fn get_renders_the_value_with_its_effect() {
424 let facade = MockSettingsFacade::new();
425 facade.values.lock().insert("server.port".to_owned(), serde_json::json!(8790));
426 let server = server(facade.into_arc());
427
428 let (text, is_error) =
429 call(&server, "settings__get", serde_json::json!({ "key": "server.port" })).await;
430
431 assert!(!is_error, "{text}");
432 assert!(text.contains("8790"), "{text}");
433 assert!(text.contains("live"), "{text}");
434 }
435
436 #[tokio::test]
437 async fn a_refused_read_hands_the_refusal_through() {
438 let facade = MockSettingsFacade::new();
439 *facade.missing.lock() = Some(SettingsError::ForeignProject {
440 key: "orgs.Acme.projects.web.model".to_owned(),
441 org: "Acme".to_owned(),
442 project: "web".to_owned(),
443 });
444 let server = server(facade.into_arc());
445
446 let (text, is_error) = call(
447 &server,
448 "settings__get",
449 serde_json::json!({ "key": "orgs.Acme.projects.web.model" }),
450 )
451 .await;
452
453 assert!(is_error);
454 assert!(text.contains("Acme/web"), "the owner is named: {text}");
455 assert!(text.contains("agents__send_message"), "the request path is named: {text}");
456 }
457
458 #[tokio::test]
461 async fn set_passes_the_batch_and_says_which_happened() {
462 let mock = Arc::new(MockSettingsFacade::new());
463 *mock.write_result.lock() = Some(Ok(SettingsWriteOutcome::Applied {
464 effects: vec![forge_primitives::EffectClass::Live, forge_primitives::EffectClass::Live],
465 }));
466 let facade: Arc<dyn SettingsFacade> = mock.clone();
467 let server = server(facade);
468
469 let (text, is_error) = call(
470 &server,
471 "settings__set",
472 serde_json::json!({
473 "edits": [
474 { "key": "vault.url", "value": "https://vault.test" },
475 { "key": "vault.email", "value": "me@vault.test" },
476 ],
477 }),
478 )
479 .await;
480
481 assert!(!is_error, "{text}");
482 assert!(text.contains("the reader approved"), "{text}");
483 assert!(text.contains("live"), "the effect is stated: {text}");
484 let calls = mock.set_calls.lock();
485 assert_eq!(calls.len(), 1, "the tool handed the batch through once");
486 let (caller, items) = &calls[0];
487 assert_eq!(caller, &slot(), "the write is the caller's");
488 assert_eq!(items.len(), 2);
489 assert_eq!(items[0].key, "vault.url");
490 assert_eq!(items[0].value, serde_json::json!("https://vault.test"));
491 assert_eq!(items[1].key, "vault.email");
492 assert_eq!(items[1].value, serde_json::json!("me@vault.test"));
493 }
494
495 #[tokio::test]
498 async fn set_refuses_an_empty_batch() {
499 let mock = Arc::new(MockSettingsFacade::new());
500 let facade: Arc<dyn SettingsFacade> = mock.clone();
501 let server = server(facade);
502
503 let (text, is_error) =
504 call(&server, "settings__set", serde_json::json!({ "edits": [] })).await;
505
506 assert!(is_error, "{text}");
507 assert!(text.contains("at least one"), "{text}");
508 assert!(mock.set_calls.lock().is_empty(), "nothing reached the facade");
509 }
510
511 #[test]
514 fn set_states_the_batch_form() {
515 let set = Set { facade: MockSettingsFacade::new().into_arc(), slot: slot() };
516 let schema = set.input_schema();
517 assert_eq!(schema["properties"]["edits"]["type"], "array");
518 assert!(schema["properties"].get("key").is_none(), "one shape, not two");
519 assert_eq!(schema["required"], serde_json::json!(["edits"]));
520 let item = &schema["properties"]["edits"]["items"];
521 assert_eq!(item["required"], serde_json::json!(["key", "value"]));
522 let description = set.description();
523 assert!(description.contains("one validated save"), "{description}");
524 assert!(description.contains("mutually required"), "{description}");
525 }
526
527 #[tokio::test]
528 async fn a_declined_write_says_nothing_was_written() {
529 let mock = Arc::new(MockSettingsFacade::new());
530 let facade: Arc<dyn SettingsFacade> = mock.clone();
531 let server = server(facade);
532
533 let (text, is_error) =
534 call(&server, "settings__unset", serde_json::json!({ "key": "client.theme" })).await;
535
536 assert!(!is_error, "{text}");
537 assert!(text.contains("the reader declined"), "{text}");
538 assert!(text.contains("nothing was written"), "{text}");
539 let calls = mock.unset_calls.lock();
540 assert_eq!(calls.len(), 1, "the tool handed the clear through once");
541 assert_eq!(calls[0].1, "client.theme");
542 }
543}