Skip to main content

subcog/mcp/tools/handlers/
groups.rs

1//! Group management MCP tool handlers.
2//!
3//! Provides handlers for group CRUD operations via MCP tools.
4//!
5//! Provides both consolidated (`execute_groups`) and legacy handlers.
6
7use super::super::{ToolContent, ToolResult};
8use crate::mcp::tool_types::GroupsArgs;
9use crate::models::group::{GroupId, GroupRole};
10use crate::services::group::GroupService;
11use crate::{Error, Result};
12use serde::Deserialize;
13use serde_json::Value;
14
15/// Parses JSON arguments, converting errors to crate Error type.
16fn parse_args<T: for<'de> Deserialize<'de>>(arguments: Value) -> Result<T> {
17    serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))
18}
19
20/// Arguments for `group_create` tool.
21#[derive(Debug, Deserialize)]
22struct GroupCreateArgs {
23    name: String,
24    description: Option<String>,
25}
26
27/// Arguments for `group_get` tool.
28#[derive(Debug, Deserialize)]
29struct GroupGetArgs {
30    group_id: String,
31}
32
33/// Arguments for `group_add_member` tool.
34#[derive(Debug, Deserialize)]
35struct GroupAddMemberArgs {
36    group_id: String,
37    user_id: String,
38    role: Option<String>,
39}
40
41/// Arguments for `group_remove_member` tool.
42#[derive(Debug, Deserialize)]
43struct GroupRemoveMemberArgs {
44    group_id: String,
45    user_id: String,
46}
47
48/// Arguments for `group_update_role` tool.
49#[derive(Debug, Deserialize)]
50struct GroupUpdateRoleArgs {
51    group_id: String,
52    user_id: String,
53    role: String,
54}
55
56/// Arguments for `group_delete` tool.
57#[derive(Debug, Deserialize)]
58struct GroupDeleteArgs {
59    group_id: String,
60}
61
62/// Gets the current user ID from environment.
63fn get_user_id() -> String {
64    std::env::var("SUBCOG_USER_ID").unwrap_or_else(|_| "default-user".to_string())
65}
66
67/// Gets the current organization ID from environment.
68fn get_org_id() -> String {
69    std::env::var("SUBCOG_ORG_ID").unwrap_or_else(|_| "default-org".to_string())
70}
71
72/// Parses a role string into a `GroupRole`.
73fn parse_role(role: Option<&str>) -> GroupRole {
74    match role.map(str::to_lowercase).as_deref() {
75        Some("admin") => GroupRole::Admin,
76        Some("write") => GroupRole::Write,
77        _ => GroupRole::Read,
78    }
79}
80
81/// Creates a success result with text content.
82fn text_result(text: String) -> ToolResult {
83    ToolResult {
84        content: vec![ToolContent::Text { text }],
85        is_error: false,
86    }
87}
88
89/// Creates an error result with text content.
90fn error_result(text: String) -> ToolResult {
91    ToolResult {
92        content: vec![ToolContent::Text { text }],
93        is_error: true,
94    }
95}
96
97/// Returns the description or "(none)" if empty.
98const fn desc_or_none(desc: &str) -> &str {
99    if desc.is_empty() { "(none)" } else { desc }
100}
101
102/// Executes the `group_create` tool.
103pub fn execute_group_create(arguments: Value) -> Result<ToolResult> {
104    let args: GroupCreateArgs = parse_args(arguments)?;
105
106    let service = GroupService::try_default()?;
107    let user_id = get_user_id();
108    let org_id = get_org_id();
109
110    match service.create_group(
111        &org_id,
112        &args.name,
113        args.description.as_deref().unwrap_or(""),
114        &user_id,
115    ) {
116        Ok(group) => Ok(text_result(format!(
117            "## Group Created\n\n\
118             **ID:** {}\n\
119             **Name:** {}\n\
120             **Description:** {}\n\
121             **Your Role:** Admin\n\n\
122             You can now:\n\
123             - Add members with `subcog_group_add_member`\n\
124             - Capture memories to this group with `group_id` parameter",
125            group.id,
126            group.name,
127            desc_or_none(&group.description)
128        ))),
129        Err(e) => Ok(error_result(format!("Failed to create group: {e}"))),
130    }
131}
132
133/// Executes the `group_list` tool.
134pub fn execute_group_list(_arguments: Value) -> Result<ToolResult> {
135    let service = GroupService::try_default()?;
136    let user_id = get_user_id();
137    let org_id = get_org_id();
138
139    match service.get_user_groups(&org_id, &user_id) {
140        Ok(memberships) => {
141            if memberships.is_empty() {
142                return Ok(text_result(
143                    "## Your Groups\n\n\
144                     No groups found. Create one with `subcog_group_create`."
145                        .to_string(),
146                ));
147            }
148
149            let mut output = String::from("## Your Groups\n\n");
150            for membership in memberships {
151                // Get full group details
152                if let Ok(Some(group)) = service.get_group(&membership.group_id) {
153                    output.push_str(&format!(
154                        "### {}\n\
155                         - **ID:** {}\n\
156                         - **Description:** {}\n\
157                         - **Your Role:** {}\n\n",
158                        group.name,
159                        group.id,
160                        desc_or_none(&group.description),
161                        membership.role.as_str(),
162                    ));
163                }
164            }
165
166            Ok(text_result(output))
167        },
168        Err(e) => Ok(error_result(format!("Failed to list groups: {e}"))),
169    }
170}
171
172/// Executes the `group_get` tool.
173pub fn execute_group_get(arguments: Value) -> Result<ToolResult> {
174    let args: GroupGetArgs = parse_args(arguments)?;
175
176    let service = GroupService::try_default()?;
177    let group_id = GroupId::from(args.group_id.as_str());
178
179    match service.get_group(&group_id) {
180        Ok(Some(group)) => {
181            let members = service.list_members(&group_id).unwrap_or_default();
182
183            let mut output = format!(
184                "## Group: {}\n\n\
185                 **ID:** {}\n\
186                 **Description:** {}\n\
187                 **Organization:** {}\n\n\
188                 ### Members ({}):\n\n",
189                group.name,
190                group.id,
191                desc_or_none(&group.description),
192                group.org_id,
193                members.len()
194            );
195
196            for member in members {
197                output.push_str(&format!(
198                    "- **{}** ({})\n",
199                    member.email,
200                    member.role.as_str()
201                ));
202            }
203
204            Ok(text_result(output))
205        },
206        Ok(None) => Ok(error_result(format!("Group not found: {}", args.group_id))),
207        Err(e) => Ok(error_result(format!("Failed to get group: {e}"))),
208    }
209}
210
211/// Executes the `group_add_member` tool.
212pub fn execute_group_add_member(arguments: Value) -> Result<ToolResult> {
213    let args: GroupAddMemberArgs = parse_args(arguments)?;
214
215    let service = GroupService::try_default()?;
216    let acting_user = get_user_id();
217    let group_id = GroupId::from(args.group_id.as_str());
218    let role = parse_role(args.role.as_deref());
219
220    match service.add_member(&group_id, &args.user_id, role, &acting_user) {
221        Ok(_member) => Ok(text_result(format!(
222            "## Member Added\n\n\
223             **User:** {}\n\
224             **Group:** {}\n\
225             **Role:** {}\n\n\
226             The user can now access group memories based on their role.",
227            args.user_id,
228            args.group_id,
229            role.as_str()
230        ))),
231        Err(e) => Ok(error_result(format!("Failed to add member: {e}"))),
232    }
233}
234
235/// Executes the `group_remove_member` tool.
236pub fn execute_group_remove_member(arguments: Value) -> Result<ToolResult> {
237    let args: GroupRemoveMemberArgs = parse_args(arguments)?;
238
239    let service = GroupService::try_default()?;
240    let acting_user = get_user_id();
241    let group_id = GroupId::from(args.group_id.as_str());
242
243    match service.remove_member(&group_id, &args.user_id, &acting_user) {
244        Ok(removed) => {
245            if removed {
246                Ok(text_result(format!(
247                    "## Member Removed\n\n\
248                     **User:** {}\n\
249                     **Group:** {}\n\n\
250                     The user no longer has access to group memories.",
251                    args.user_id, args.group_id
252                )))
253            } else {
254                Ok(error_result(format!(
255                    "Member '{}' not found in group",
256                    args.user_id
257                )))
258            }
259        },
260        Err(e) => Ok(error_result(format!("Failed to remove member: {e}"))),
261    }
262}
263
264/// Executes the `group_update_role` tool.
265pub fn execute_group_update_role(arguments: Value) -> Result<ToolResult> {
266    let args: GroupUpdateRoleArgs = parse_args(arguments)?;
267
268    let service = GroupService::try_default()?;
269    let acting_user = get_user_id();
270    let group_id = GroupId::from(args.group_id.as_str());
271    let role = parse_role(Some(&args.role));
272
273    match service.update_member_role(&group_id, &args.user_id, role, &acting_user) {
274        Ok(updated) => {
275            if updated {
276                Ok(text_result(format!(
277                    "## Role Updated\n\n\
278                     **User:** {}\n\
279                     **Group:** {}\n\
280                     **New Role:** {}",
281                    args.user_id,
282                    args.group_id,
283                    role.as_str()
284                )))
285            } else {
286                Ok(error_result(format!(
287                    "Member '{}' not found in group",
288                    args.user_id
289                )))
290            }
291        },
292        Err(e) => Ok(error_result(format!("Failed to update role: {e}"))),
293    }
294}
295
296/// Executes the `group_delete` tool.
297pub fn execute_group_delete(arguments: Value) -> Result<ToolResult> {
298    let args: GroupDeleteArgs = parse_args(arguments)?;
299
300    let service = GroupService::try_default()?;
301    let acting_user = get_user_id();
302    let group_id = GroupId::from(args.group_id.as_str());
303
304    match service.delete_group(&group_id, &acting_user) {
305        Ok(deleted) => {
306            if deleted {
307                Ok(text_result(format!(
308                    "## Group Deleted\n\n\
309                     **Group ID:** {}\n\n\
310                     The group has been deleted. Existing memories remain but are no longer group-accessible.",
311                    args.group_id
312                )))
313            } else {
314                Ok(error_result(format!("Group not found: {}", args.group_id)))
315            }
316        },
317        Err(e) => Ok(error_result(format!("Failed to delete group: {e}"))),
318    }
319}
320
321// =============================================================================
322// Consolidated Group Handler
323// =============================================================================
324
325/// Executes the consolidated `subcog_groups` tool.
326///
327/// Dispatches to the appropriate action handler based on the `action` field.
328/// Valid actions: create, list, get, `add_member`, `remove_member`, `update_role`, delete.
329pub fn execute_groups(arguments: Value) -> Result<ToolResult> {
330    let args: GroupsArgs =
331        serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))?;
332
333    match args.action.as_str() {
334        "create" => execute_groups_create(&args),
335        "list" => execute_groups_list(),
336        "get" => execute_groups_get(&args),
337        "add_member" => execute_groups_add_member(&args),
338        "remove_member" => execute_groups_remove_member(&args),
339        "update_role" => execute_groups_update_role(&args),
340        "delete" => execute_groups_delete(&args),
341        _ => Err(Error::InvalidInput(format!(
342            "Unknown groups action: '{}'. Valid actions: create, list, get, add_member, remove_member, update_role, delete",
343            args.action
344        ))),
345    }
346}
347
348/// Handles the `create` action for `subcog_groups`.
349fn execute_groups_create(args: &GroupsArgs) -> Result<ToolResult> {
350    let name = args
351        .name
352        .as_ref()
353        .ok_or_else(|| Error::InvalidInput("'name' is required for create action".to_string()))?;
354
355    let service = GroupService::try_default()?;
356    let user_id = get_user_id();
357    let org_id = get_org_id();
358
359    match service.create_group(
360        &org_id,
361        name,
362        args.description.as_deref().unwrap_or(""),
363        &user_id,
364    ) {
365        Ok(group) => Ok(text_result(format!(
366            "## Group Created\n\n\
367             **ID:** {}\n\
368             **Name:** {}\n\
369             **Description:** {}\n\
370             **Your Role:** Admin\n\n\
371             You can now:\n\
372             - Add members with `subcog_groups` action=add_member\n\
373             - Capture memories to this group with `group_id` parameter",
374            group.id,
375            group.name,
376            desc_or_none(&group.description)
377        ))),
378        Err(e) => Ok(error_result(format!("Failed to create group: {e}"))),
379    }
380}
381
382/// Handles the `list` action for `subcog_groups`.
383fn execute_groups_list() -> Result<ToolResult> {
384    let service = GroupService::try_default()?;
385    let user_id = get_user_id();
386    let org_id = get_org_id();
387
388    match service.get_user_groups(&org_id, &user_id) {
389        Ok(memberships) => {
390            if memberships.is_empty() {
391                return Ok(text_result(
392                    "## Your Groups\n\n\
393                     No groups found. Create one with `subcog_groups` action=create."
394                        .to_string(),
395                ));
396            }
397
398            let mut output = String::from("## Your Groups\n\n");
399            for membership in memberships {
400                // Get full group details
401                if let Ok(Some(group)) = service.get_group(&membership.group_id) {
402                    output.push_str(&format!(
403                        "### {}\n\
404                         - **ID:** {}\n\
405                         - **Description:** {}\n\
406                         - **Your Role:** {}\n\n",
407                        group.name,
408                        group.id,
409                        desc_or_none(&group.description),
410                        membership.role.as_str(),
411                    ));
412                }
413            }
414
415            Ok(text_result(output))
416        },
417        Err(e) => Ok(error_result(format!("Failed to list groups: {e}"))),
418    }
419}
420
421/// Handles the `get` action for `subcog_groups`.
422fn execute_groups_get(args: &GroupsArgs) -> Result<ToolResult> {
423    let group_id_str = args
424        .group_id
425        .as_ref()
426        .ok_or_else(|| Error::InvalidInput("'group_id' is required for get action".to_string()))?;
427
428    let service = GroupService::try_default()?;
429    let group_id = GroupId::from(group_id_str.as_str());
430
431    match service.get_group(&group_id) {
432        Ok(Some(group)) => {
433            let members = service.list_members(&group_id).unwrap_or_default();
434
435            let mut output = format!(
436                "## Group: {}\n\n\
437                 **ID:** {}\n\
438                 **Description:** {}\n\
439                 **Organization:** {}\n\n\
440                 ### Members ({}):\n\n",
441                group.name,
442                group.id,
443                desc_or_none(&group.description),
444                group.org_id,
445                members.len()
446            );
447
448            for member in members {
449                output.push_str(&format!(
450                    "- **{}** ({})\n",
451                    member.email,
452                    member.role.as_str()
453                ));
454            }
455
456            Ok(text_result(output))
457        },
458        Ok(None) => Ok(error_result(format!("Group not found: {group_id_str}"))),
459        Err(e) => Ok(error_result(format!("Failed to get group: {e}"))),
460    }
461}
462
463/// Handles the `add_member` action for `subcog_groups`.
464fn execute_groups_add_member(args: &GroupsArgs) -> Result<ToolResult> {
465    let group_id_str = args.group_id.as_ref().ok_or_else(|| {
466        Error::InvalidInput("'group_id' is required for add_member action".to_string())
467    })?;
468
469    let user_id = args.user_id.as_ref().ok_or_else(|| {
470        Error::InvalidInput("'user_id' is required for add_member action".to_string())
471    })?;
472
473    let service = GroupService::try_default()?;
474    let acting_user = get_user_id();
475    let group_id = GroupId::from(group_id_str.as_str());
476    let role = parse_role(args.role.as_deref());
477
478    match service.add_member(&group_id, user_id, role, &acting_user) {
479        Ok(_member) => Ok(text_result(format!(
480            "## Member Added\n\n\
481             **User:** {}\n\
482             **Group:** {}\n\
483             **Role:** {}\n\n\
484             The user can now access group memories based on their role.",
485            user_id,
486            group_id_str,
487            role.as_str()
488        ))),
489        Err(e) => Ok(error_result(format!("Failed to add member: {e}"))),
490    }
491}
492
493/// Handles the `remove_member` action for `subcog_groups`.
494fn execute_groups_remove_member(args: &GroupsArgs) -> Result<ToolResult> {
495    let group_id_str = args.group_id.as_ref().ok_or_else(|| {
496        Error::InvalidInput("'group_id' is required for remove_member action".to_string())
497    })?;
498
499    let user_id = args.user_id.as_ref().ok_or_else(|| {
500        Error::InvalidInput("'user_id' is required for remove_member action".to_string())
501    })?;
502
503    let service = GroupService::try_default()?;
504    let acting_user = get_user_id();
505    let group_id = GroupId::from(group_id_str.as_str());
506
507    match service.remove_member(&group_id, user_id, &acting_user) {
508        Ok(removed) => {
509            if removed {
510                Ok(text_result(format!(
511                    "## Member Removed\n\n\
512                     **User:** {user_id}\n\
513                     **Group:** {group_id_str}\n\n\
514                     The user no longer has access to group memories."
515                )))
516            } else {
517                Ok(error_result(format!(
518                    "Member '{user_id}' not found in group"
519                )))
520            }
521        },
522        Err(e) => Ok(error_result(format!("Failed to remove member: {e}"))),
523    }
524}
525
526/// Handles the `update_role` action for `subcog_groups`.
527fn execute_groups_update_role(args: &GroupsArgs) -> Result<ToolResult> {
528    let group_id_str = args.group_id.as_ref().ok_or_else(|| {
529        Error::InvalidInput("'group_id' is required for update_role action".to_string())
530    })?;
531
532    let user_id = args.user_id.as_ref().ok_or_else(|| {
533        Error::InvalidInput("'user_id' is required for update_role action".to_string())
534    })?;
535
536    let role_str = args.role.as_ref().ok_or_else(|| {
537        Error::InvalidInput("'role' is required for update_role action".to_string())
538    })?;
539
540    let service = GroupService::try_default()?;
541    let acting_user = get_user_id();
542    let group_id = GroupId::from(group_id_str.as_str());
543    let role = parse_role(Some(role_str));
544
545    match service.update_member_role(&group_id, user_id, role, &acting_user) {
546        Ok(updated) => {
547            if updated {
548                Ok(text_result(format!(
549                    "## Role Updated\n\n\
550                     **User:** {}\n\
551                     **Group:** {}\n\
552                     **New Role:** {}",
553                    user_id,
554                    group_id_str,
555                    role.as_str()
556                )))
557            } else {
558                Ok(error_result(format!(
559                    "Member '{user_id}' not found in group"
560                )))
561            }
562        },
563        Err(e) => Ok(error_result(format!("Failed to update role: {e}"))),
564    }
565}
566
567/// Handles the `delete` action for `subcog_groups`.
568fn execute_groups_delete(args: &GroupsArgs) -> Result<ToolResult> {
569    let group_id_str = args.group_id.as_ref().ok_or_else(|| {
570        Error::InvalidInput("'group_id' is required for delete action".to_string())
571    })?;
572
573    let service = GroupService::try_default()?;
574    let acting_user = get_user_id();
575    let group_id = GroupId::from(group_id_str.as_str());
576
577    match service.delete_group(&group_id, &acting_user) {
578        Ok(deleted) => {
579            if deleted {
580                Ok(text_result(format!(
581                    "## Group Deleted\n\n\
582                     **Group ID:** {group_id_str}\n\n\
583                     The group has been deleted. Existing memories remain but are no longer group-accessible."
584                )))
585            } else {
586                Ok(error_result(format!("Group not found: {group_id_str}")))
587            }
588        },
589        Err(e) => Ok(error_result(format!("Failed to delete group: {e}"))),
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    #[test]
598    fn test_parse_role() {
599        assert_eq!(parse_role(Some("admin")), GroupRole::Admin);
600        assert_eq!(parse_role(Some("ADMIN")), GroupRole::Admin);
601        assert_eq!(parse_role(Some("write")), GroupRole::Write);
602        assert_eq!(parse_role(Some("read")), GroupRole::Read);
603        assert_eq!(parse_role(None), GroupRole::Read);
604        assert_eq!(parse_role(Some("invalid")), GroupRole::Read);
605    }
606
607    #[test]
608    fn test_text_result() {
609        let result = text_result("test".to_string());
610        assert!(!result.is_error);
611        assert_eq!(result.content.len(), 1);
612    }
613
614    #[test]
615    fn test_error_result() {
616        let result = error_result("error".to_string());
617        assert!(result.is_error);
618        assert_eq!(result.content.len(), 1);
619    }
620}