Skip to main content

forge_workspace/mcp/
settings.rs

1//! Settings MCP - forge's configuration, read and edited through the same
2//! catalogue and writer the settings page uses (`mcp__forge__settings__*`).
3//!
4//! The four tools are ANY-CALLER and always on for every session. The
5//! config is what the session itself runs under, so no project can
6//! withhold the door to it.
7//!
8//! - `settings__index` - the catalogue's shape: type, state and effect
9//!   class per key, no values.
10//! - `settings__get` - one key's value; the caller's own project's keys and
11//!   the global keys read freely, another project's key is refused by name.
12//! - `settings__set` - one catalogue-validated save of one key or a batch
13//!   of them, always held for the reader's approval before anything is
14//!   written; `settings__unset` clears one key on the same terms.
15//!
16//! - [`facade`] - the `SettingsFacade` seam (prod over `Weak<Workspace>` +
17//!   a mock for tool tests).
18
19use 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
31/// Attach the settings tools to an existing [`McpServerBuilder`]. Called for
32/// BOTH lead and worker sessions, unconditionally: the config is every
33/// session's own environment.
34pub(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    /// The one line every settings tool carries: the file is not the door.
337    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    /// The tools are the shipped instruction: every one says the config is
369    /// read and edited here, not through the filesystem, and none names a
370    /// path.
371    #[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    /// The hold is stated before a caller acts, and `__set` says there is no
391    /// unparked write path.
392    #[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    /// The cross-project read rule is stated where a reader reaches for it.
402    #[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    /// One call carries the whole batch, the keys arrive in the order the
459    /// caller wrote them, and the result says which effects it lands with.
460    #[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    /// An empty batch is refused by the tool itself, before the facade: a
496    /// save that changes nothing is not a write.
497    #[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    /// `settings__set` takes one argument - a list of edits - and says why a
512    /// batch exists: a section whose keys are mutually required is one save.
513    #[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}