1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::Duration;
5
6use rmcp::{
7 ErrorData as McpError, ServerHandler, ServiceExt,
8 handler::server::{tool::ToolRouter, wrapper::Parameters},
9 model::{
10 CallToolRequestParams, CallToolResult, Content, Implementation, ListToolsResult,
11 PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo,
12 },
13 service::{RequestContext, RoleServer},
14 tool, tool_router,
15 transport::stdio,
16};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use tokio::sync::RwLock;
20
21use crate::config::Config;
22use crate::just;
23use crate::template;
24
25enum GroupMatcher {
35 Exact(String),
36 Glob(regex::Regex),
37}
38
39impl GroupMatcher {
40 fn new(pattern: &str) -> Self {
41 if !pattern.contains('*') && !pattern.contains('?') {
42 return Self::Exact(pattern.to_string());
43 }
44 let mut re = String::with_capacity(pattern.len() + 2);
45 re.push('^');
46 let mut literal = String::new();
47 let flush = |re: &mut String, literal: &mut String| {
48 if !literal.is_empty() {
49 re.push_str(®ex::escape(literal));
50 literal.clear();
51 }
52 };
53 for c in pattern.chars() {
54 match c {
55 '*' => {
56 flush(&mut re, &mut literal);
57 re.push_str(".*");
58 }
59 '?' => {
60 flush(&mut re, &mut literal);
61 re.push('.');
62 }
63 c => literal.push(c),
64 }
65 }
66 flush(&mut re, &mut literal);
67 re.push('$');
68 match regex::Regex::new(&re) {
69 Ok(r) => Self::Glob(r),
70 Err(_) => Self::Exact(pattern.to_string()),
72 }
73 }
74
75 fn is_match(&self, group: &str) -> bool {
76 match self {
77 Self::Exact(s) => s == group,
78 Self::Glob(r) => r.is_match(group),
79 }
80 }
81}
82
83pub async fn run() -> anyhow::Result<()> {
88 let config = Config::load();
89 let server_cwd = std::env::current_dir()?;
90 let server = TaskMcpServer::new(config, server_cwd);
91 let service = server.serve(stdio()).await?;
92 service.waiting().await?;
93 Ok(())
94}
95
96#[derive(Clone)]
101pub struct TaskMcpServer {
102 tool_router: ToolRouter<Self>,
103 config: Config,
104 log_store: Arc<just::TaskLogStore>,
105 workdir: Arc<RwLock<Option<PathBuf>>>,
107 server_cwd: PathBuf,
109}
110
111#[derive(Debug)]
117pub(crate) enum AutoStartOutcome {
118 Started(SessionStartResponse, PathBuf),
120 AlreadyStarted(PathBuf),
124 NotProjectRoot,
126 CanonicalizeFailed(std::io::Error),
128 NotAllowed(PathBuf),
130}
131
132impl TaskMcpServer {
133 pub fn new(config: Config, server_cwd: PathBuf) -> Self {
134 Self {
135 tool_router: Self::tool_router(),
136 config,
137 log_store: Arc::new(just::TaskLogStore::new(10)),
138 workdir: Arc::new(RwLock::new(None)),
139 server_cwd,
140 }
141 }
142
143 pub(crate) async fn try_auto_session_start(&self) -> AutoStartOutcome {
147 let has_git = tokio::fs::try_exists(self.server_cwd.join(".git"))
149 .await
150 .unwrap_or(false);
151 let has_justfile = tokio::fs::try_exists(self.server_cwd.join("justfile"))
152 .await
153 .unwrap_or(false);
154 if !has_git && !has_justfile {
155 return AutoStartOutcome::NotProjectRoot;
156 }
157
158 let canonical = match tokio::fs::canonicalize(&self.server_cwd).await {
160 Ok(p) => p,
161 Err(e) => return AutoStartOutcome::CanonicalizeFailed(e),
162 };
163
164 if !self.config.is_workdir_allowed(&canonical) {
166 return AutoStartOutcome::NotAllowed(canonical);
167 }
168
169 let mut guard = self.workdir.write().await;
174 if let Some(ref existing) = *guard {
175 return AutoStartOutcome::AlreadyStarted(existing.clone());
176 }
177 *guard = Some(canonical.clone());
178 drop(guard);
179
180 let justfile =
181 resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), &canonical);
182
183 AutoStartOutcome::Started(
184 SessionStartResponse {
185 workdir: canonical.to_string_lossy().into_owned(),
186 justfile: justfile.to_string_lossy().into_owned(),
187 mode: mode_label(&self.config),
188 },
189 canonical,
190 )
191 }
192
193 pub(crate) async fn workdir_or_auto(
200 &self,
201 ) -> Result<(PathBuf, Option<SessionStartResponse>), McpError> {
202 {
204 let guard = self.workdir.read().await;
205 if let Some(ref wd) = *guard {
206 return Ok((wd.clone(), None));
207 }
208 }
209
210 match self.try_auto_session_start().await {
212 AutoStartOutcome::Started(resp, wd) => Ok((wd, Some(resp))),
213 AutoStartOutcome::AlreadyStarted(wd) => Ok((wd, None)),
214 AutoStartOutcome::NotProjectRoot => Err(McpError::internal_error(
215 format!(
216 "session not started. server startup CWD {:?} is not a ProjectRoot (no .git or justfile). Call session_start with an explicit workdir.",
217 self.server_cwd
218 ),
219 None,
220 )),
221 AutoStartOutcome::CanonicalizeFailed(e) => Err(McpError::internal_error(
222 format!(
223 "session not started. failed to canonicalize server startup CWD {:?}: {e}. Call session_start with an explicit workdir.",
224 self.server_cwd
225 ),
226 None,
227 )),
228 AutoStartOutcome::NotAllowed(path) => Err(McpError::internal_error(
229 format!(
230 "session not started. server startup CWD {:?} is not in allowed_dirs. Call session_start with an allowed workdir.",
231 path
232 ),
233 None,
234 )),
235 }
236 }
237}
238
239impl ServerHandler for TaskMcpServer {
244 fn get_info(&self) -> ServerInfo {
245 ServerInfo {
246 protocol_version: ProtocolVersion::V_2025_03_26,
247 capabilities: ServerCapabilities::builder().enable_tools().build(),
248 server_info: Implementation {
249 name: "task-mcp".to_string(),
250 title: Some("Task MCP — Agent-safe Task Runner".to_string()),
251 description: Some(
252 "Execute predefined justfile tasks safely. \
253 6 tools: session_start, info, init, list, run, logs."
254 .to_string(),
255 ),
256 version: env!("CARGO_PKG_VERSION").to_string(),
257 icons: None,
258 website_url: None,
259 },
260 instructions: Some(
261 "Agent-safe task runner backed by just.\n\
262 \n\
263 - `session_start`: Set working directory explicitly. Optional when the \
264 server was launched inside a ProjectRoot (a directory containing `.git` \
265 or `justfile`) — in that case the first `init`/`list`/`run` call auto-starts \
266 the session using the server's startup CWD. Call `session_start` explicitly \
267 only when you need a different workdir (e.g. a subdirectory in a monorepo).\n\
268 - `info`: Show current session state (workdir, mode, etc).\n\
269 - `init`: Generate a justfile in the working directory.\n\
270 - `list`: Show available tasks filtered by the allow-agent marker.\n\
271 - `run`: Execute a named task.\n\
272 - `logs`: Retrieve execution logs of recent runs.\n\
273 \n\
274 When a call auto-starts the session, the response includes an \
275 `auto_session_start` field with the chosen workdir, justfile, and mode. \
276 Subsequent calls in the same session do not include this field.\n\
277 \n\
278 Allow-agent is a security boundary: in the default `agent-only` mode, \
279 recipes without the `[group('allow-agent')]` attribute (or the legacy \
280 `# [allow-agent]` doc comment) are NEVER exposed via MCP. The mode is \
281 controlled by the `TASK_MCP_MODE` environment variable, set OUTSIDE \
282 the MCP. Reading the justfile directly bypasses this guard, but is \
283 not the canonical path."
284 .to_string(),
285 ),
286 }
287 }
288
289 async fn list_tools(
290 &self,
291 _request: Option<PaginatedRequestParams>,
292 _context: RequestContext<RoleServer>,
293 ) -> Result<ListToolsResult, McpError> {
294 Ok(ListToolsResult {
295 tools: self.tool_router.list_all(),
296 next_cursor: None,
297 meta: None,
298 })
299 }
300
301 async fn call_tool(
302 &self,
303 request: CallToolRequestParams,
304 context: RequestContext<RoleServer>,
305 ) -> Result<CallToolResult, McpError> {
306 let tool_ctx = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
307 self.tool_router.call(tool_ctx).await
308 }
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
316struct SessionStartRequest {
317 pub workdir: Option<String>,
319}
320
321#[derive(Debug, Clone, Serialize)]
326pub(crate) struct SessionStartResponse {
327 pub workdir: String,
329 pub justfile: String,
331 pub mode: String,
333}
334
335#[derive(Debug, Clone, Serialize)]
336struct InfoResponse {
337 pub session_started: bool,
339 pub workdir: Option<String>,
341 pub justfile: Option<String>,
343 pub mode: String,
345 pub server_cwd: String,
347 pub load_global: bool,
349 pub global_justfile: Option<String>,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
354struct InitRequest {
355 pub project_type: Option<String>,
357 pub template_file: Option<String>,
359}
360
361#[derive(Debug, Clone, Serialize)]
362struct InitResponse {
363 pub justfile: String,
365 pub project_type: String,
367 pub custom_template: bool,
369 #[serde(skip_serializing_if = "Option::is_none")]
374 pub auto_session_start: Option<SessionStartResponse>,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
378struct ListRequest {
379 pub filter: Option<String>,
384 pub justfile: Option<String>,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
389struct RunRequest {
390 pub task_name: String,
392 pub args: Option<HashMap<String, String>>,
394 pub timeout_secs: Option<u64>,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
399struct LogsRequest {
400 pub task_id: Option<String>,
403 pub tail: Option<usize>,
406}
407
408fn resolve_justfile_with_workdir(
417 override_path: Option<&str>,
418 workdir: &std::path::Path,
419) -> PathBuf {
420 match override_path {
421 Some(p) => PathBuf::from(p),
422 None => workdir.join("justfile"),
423 }
424}
425
426fn tail_lines(text: &str, n: usize) -> String {
429 let lines: Vec<&str> = text.lines().collect();
430 if n >= lines.len() {
431 return text.to_string();
432 }
433 lines[lines.len() - n..].join("\n")
434}
435
436fn mode_label(config: &Config) -> String {
437 use crate::config::TaskMode;
438 match config.mode {
439 TaskMode::AgentOnly => "agent-only".to_string(),
440 TaskMode::All => "all".to_string(),
441 }
442}
443
444#[tool_router]
449impl TaskMcpServer {
450 #[tool(
451 name = "session_start",
452 description = "Set the working directory for this session explicitly. Optional when the server was launched inside a ProjectRoot (directory containing `.git` or `justfile`): the first `init`/`list`/`run` call will auto-start the session using the server's startup CWD. Call this tool to override that default, e.g. when working in a monorepo subdirectory. Subsequent `run` and `list` (without justfile param) use the configured directory.",
453 annotations(
454 read_only_hint = false,
455 destructive_hint = false,
456 idempotent_hint = true,
457 open_world_hint = false
458 )
459 )]
460 async fn session_start(
461 &self,
462 Parameters(req): Parameters<SessionStartRequest>,
463 ) -> Result<CallToolResult, McpError> {
464 let raw_path = match req.workdir.as_deref() {
466 Some(s) if !s.trim().is_empty() => PathBuf::from(s),
467 _ => self.server_cwd.clone(),
468 };
469
470 let canonical = tokio::fs::canonicalize(&raw_path).await.map_err(|e| {
472 McpError::invalid_params(
473 format!(
474 "workdir {:?} does not exist or is not accessible: {e}",
475 raw_path
476 ),
477 None,
478 )
479 })?;
480
481 if !self.config.is_workdir_allowed(&canonical) {
483 return Err(McpError::invalid_params(
484 format!(
485 "workdir {:?} is not in the allowed directories list",
486 canonical
487 ),
488 None,
489 ));
490 }
491
492 *self.workdir.write().await = Some(canonical.clone());
494
495 let justfile =
496 resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), &canonical);
497
498 let response = SessionStartResponse {
499 workdir: canonical.to_string_lossy().into_owned(),
500 justfile: justfile.to_string_lossy().into_owned(),
501 mode: mode_label(&self.config),
502 };
503
504 let output = serde_json::to_string_pretty(&response)
505 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
506
507 Ok(CallToolResult {
508 content: vec![Content::text(output)],
509 structured_content: None,
510 is_error: Some(false),
511 meta: None,
512 })
513 }
514
515 #[tool(
516 name = "init",
517 description = "Generate a justfile with agent-safe recipes in the session working directory. The session is auto-started if the server was launched inside a ProjectRoot; otherwise call `session_start` first. Supports project types: rust (default), vite-react. Custom template files can also be specified. Fails if justfile already exists — delete it first to regenerate.",
518 annotations(
519 read_only_hint = false,
520 destructive_hint = false,
521 idempotent_hint = false,
522 open_world_hint = false
523 )
524 )]
525 async fn init(
526 &self,
527 Parameters(req): Parameters<InitRequest>,
528 ) -> Result<CallToolResult, McpError> {
529 let (workdir, auto) = self.workdir_or_auto().await?;
530
531 let project_type = match req.project_type.as_deref() {
533 Some(s) => s
534 .parse::<template::ProjectType>()
535 .map_err(|e| McpError::invalid_params(e, None))?,
536 None => template::ProjectType::default(),
537 };
538
539 let justfile_path = workdir.join("justfile");
540
541 if justfile_path.exists() {
543 return Err(McpError::invalid_params(
544 format!(
545 "justfile already exists at {}. Delete it first if you want to regenerate.",
546 justfile_path.display()
547 ),
548 None,
549 ));
550 }
551
552 if let Some(ref tf) = req.template_file {
554 let template_path = std::fs::canonicalize(tf).map_err(|e| {
555 McpError::invalid_params(
556 format!("template_file {tf:?} is not accessible: {e}"),
557 None,
558 )
559 })?;
560 if !template_path.starts_with(&workdir) {
561 return Err(McpError::invalid_params(
562 format!(
563 "template_file must be under session workdir ({}). Got: {}",
564 workdir.display(),
565 template_path.display()
566 ),
567 None,
568 ));
569 }
570 }
571
572 let custom_template_used =
574 req.template_file.is_some() || self.config.init_template_file.is_some();
575
576 let content = template::resolve_template(
577 project_type,
578 req.template_file.as_deref(),
579 self.config.init_template_file.as_deref(),
580 )
581 .await
582 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
583
584 tokio::fs::write(&justfile_path, &content)
586 .await
587 .map_err(|e| {
588 McpError::internal_error(
589 format!(
590 "failed to write justfile at {}: {e}",
591 justfile_path.display()
592 ),
593 None,
594 )
595 })?;
596
597 let response = InitResponse {
598 justfile: justfile_path.to_string_lossy().into_owned(),
599 project_type: project_type.to_string(),
600 custom_template: custom_template_used,
601 auto_session_start: auto,
602 };
603
604 let output = serde_json::to_string_pretty(&response)
605 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
606
607 Ok(CallToolResult {
608 content: vec![Content::text(output)],
609 structured_content: None,
610 is_error: Some(false),
611 meta: None,
612 })
613 }
614
615 #[tool(
616 name = "info",
617 description = "Show current session state: workdir, justfile path, mode, and server startup CWD.",
618 annotations(
619 read_only_hint = true,
620 destructive_hint = false,
621 idempotent_hint = true,
622 open_world_hint = false
623 )
624 )]
625 async fn info(&self) -> Result<CallToolResult, McpError> {
626 let current_workdir = self.workdir.read().await.clone();
627
628 let (session_started, workdir_str, justfile_str) = match current_workdir {
629 Some(ref wd) => {
630 let justfile =
631 resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), wd);
632 (
633 true,
634 Some(wd.to_string_lossy().into_owned()),
635 Some(justfile.to_string_lossy().into_owned()),
636 )
637 }
638 None => (false, None, None),
639 };
640
641 let global_justfile_str = self
642 .config
643 .global_justfile_path
644 .as_ref()
645 .map(|p| p.to_string_lossy().into_owned());
646
647 let response = InfoResponse {
648 session_started,
649 workdir: workdir_str,
650 justfile: justfile_str,
651 mode: mode_label(&self.config),
652 server_cwd: self.server_cwd.to_string_lossy().into_owned(),
653 load_global: self.config.load_global,
654 global_justfile: global_justfile_str,
655 };
656
657 let output = serde_json::to_string_pretty(&response)
658 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
659
660 Ok(CallToolResult {
661 content: vec![Content::text(output)],
662 structured_content: None,
663 is_error: Some(false),
664 meta: None,
665 })
666 }
667
668 #[tool(
669 name = "list",
670 description = "List available tasks from justfile. Returns an object `{\"recipes\": [...]}` containing task names, descriptions, parameters, and groups. When this call triggers an automatic session_start, the response also includes an `auto_session_start` field.",
671 annotations(
672 read_only_hint = true,
673 destructive_hint = false,
674 idempotent_hint = true,
675 open_world_hint = false
676 )
677 )]
678 async fn list(
679 &self,
680 Parameters(req): Parameters<ListRequest>,
681 ) -> Result<CallToolResult, McpError> {
682 let (justfile_path, workdir_opt, auto) = if req.justfile.is_some() {
683 let jp = just::resolve_justfile_path(
685 req.justfile
686 .as_deref()
687 .or(self.config.justfile_path.as_deref()),
688 None,
689 );
690 (jp, None, None)
691 } else {
692 let (wd, auto) = self.workdir_or_auto().await?;
694 let jp = just::resolve_justfile_path(self.config.justfile_path.as_deref(), Some(&wd));
695 (jp, Some(wd), auto)
696 };
697
698 let recipes = if self.config.load_global {
704 just::list_recipes_merged(
705 &justfile_path,
706 self.config.global_justfile_path.as_deref(),
707 &self.config.mode,
708 workdir_opt.as_deref(),
709 )
710 .await
711 .map_err(|e| McpError::internal_error(e.to_string(), None))?
712 } else {
713 just::list_recipes(&justfile_path, &self.config.mode, workdir_opt.as_deref())
714 .await
715 .map_err(|e| McpError::internal_error(e.to_string(), None))?
716 };
717
718 let filtered: Vec<_> = match &req.filter {
723 Some(pattern) => {
724 let matcher = GroupMatcher::new(pattern);
725 recipes
726 .into_iter()
727 .filter(|r| r.groups.iter().any(|g| matcher.is_match(g)))
728 .collect()
729 }
730 None => recipes,
731 };
732
733 let mut wrapped = serde_json::json!({ "recipes": filtered });
734 if let Some(auto_response) = auto {
735 wrapped.as_object_mut().expect("json object").insert(
736 "auto_session_start".to_string(),
737 serde_json::to_value(auto_response)
738 .map_err(|e| McpError::internal_error(e.to_string(), None))?,
739 );
740 }
741 let output = serde_json::to_string_pretty(&wrapped)
742 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
743
744 Ok(CallToolResult {
745 content: vec![Content::text(output)],
746 structured_content: None,
747 is_error: Some(false),
748 meta: None,
749 })
750 }
751
752 #[tool(
753 name = "run",
754 description = "Execute a predefined task. Only tasks visible in `list` can be run.",
755 annotations(
756 read_only_hint = false,
757 destructive_hint = true,
758 idempotent_hint = false,
759 open_world_hint = false
760 )
761 )]
762 async fn run(
763 &self,
764 Parameters(req): Parameters<RunRequest>,
765 ) -> Result<CallToolResult, McpError> {
766 let (workdir, auto) = self.workdir_or_auto().await?;
768 let justfile_path =
769 just::resolve_justfile_path(self.config.justfile_path.as_deref(), Some(&workdir));
770 let args = req.args.unwrap_or_default();
771 let timeout = Duration::from_secs(req.timeout_secs.unwrap_or(60));
772
773 let execution = if self.config.load_global {
774 just::execute_recipe_merged(
775 &req.task_name,
776 &args,
777 &justfile_path,
778 self.config.global_justfile_path.as_deref(),
779 timeout,
780 &self.config.mode,
781 Some(&workdir),
782 )
783 .await
784 .map_err(|e| McpError::internal_error(e.to_string(), None))?
785 } else {
786 just::execute_recipe(
787 &req.task_name,
788 &args,
789 &justfile_path,
790 timeout,
791 &self.config.mode,
792 Some(&workdir),
793 )
794 .await
795 .map_err(|e| McpError::internal_error(e.to_string(), None))?
796 };
797
798 self.log_store.push(execution.clone());
800
801 let is_error = execution.exit_code.map(|c| c != 0).unwrap_or(true);
802
803 let output = match auto {
804 Some(auto_response) => {
805 let mut val = serde_json::to_value(&execution)
806 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
807 if let Some(obj) = val.as_object_mut() {
808 obj.insert(
809 "auto_session_start".to_string(),
810 serde_json::to_value(auto_response)
811 .map_err(|e| McpError::internal_error(e.to_string(), None))?,
812 );
813 }
814 serde_json::to_string_pretty(&val)
815 .map_err(|e| McpError::internal_error(e.to_string(), None))?
816 }
817 None => serde_json::to_string_pretty(&execution)
818 .map_err(|e| McpError::internal_error(e.to_string(), None))?,
819 };
820
821 Ok(CallToolResult {
822 content: vec![Content::text(output)],
823 structured_content: None,
824 is_error: Some(is_error),
825 meta: None,
826 })
827 }
828
829 #[tool(
830 name = "logs",
831 description = "Retrieve execution logs. Returns recent task execution results.",
832 annotations(
833 read_only_hint = true,
834 destructive_hint = false,
835 idempotent_hint = true,
836 open_world_hint = false
837 )
838 )]
839 async fn logs(
840 &self,
841 Parameters(req): Parameters<LogsRequest>,
842 ) -> Result<CallToolResult, McpError> {
843 let output = match req.task_id.as_deref() {
844 Some(id) => {
845 match self.log_store.get(id) {
846 None => {
847 return Err(McpError::internal_error(
848 format!("execution not found: {id}"),
849 None,
850 ));
851 }
852 Some(mut execution) => {
853 if let Some(n) = req.tail {
855 execution.stdout = tail_lines(&execution.stdout, n);
856 }
857 serde_json::to_string_pretty(&execution)
858 .map_err(|e| McpError::internal_error(e.to_string(), None))?
859 }
860 }
861 }
862 None => {
863 let summaries = self.log_store.recent(10);
864 serde_json::to_string_pretty(&summaries)
865 .map_err(|e| McpError::internal_error(e.to_string(), None))?
866 }
867 };
868
869 Ok(CallToolResult {
870 content: vec![Content::text(output)],
871 structured_content: None,
872 is_error: Some(false),
873 meta: None,
874 })
875 }
876}
877
878#[cfg(test)]
883impl TaskMcpServer {
884 pub(crate) async fn set_workdir_for_test(&self, path: PathBuf) {
886 *self.workdir.write().await = Some(path);
887 }
888
889 pub(crate) async fn current_workdir(&self) -> Option<PathBuf> {
891 self.workdir.read().await.clone()
892 }
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898
899 #[test]
904 fn group_matcher_exact() {
905 let m = GroupMatcher::new("profile");
906 assert!(m.is_match("profile"));
907 assert!(!m.is_match("profiler"));
908 assert!(!m.is_match("agent"));
909 }
910
911 #[test]
912 fn group_matcher_star_prefix() {
913 let m = GroupMatcher::new("prof*");
914 assert!(m.is_match("profile"));
915 assert!(m.is_match("profiler"));
916 assert!(m.is_match("prof"));
917 assert!(!m.is_match("agent"));
918 }
919
920 #[test]
921 fn group_matcher_star_suffix() {
922 let m = GroupMatcher::new("*-release");
923 assert!(m.is_match("build-release"));
924 assert!(m.is_match("test-release"));
925 assert!(!m.is_match("release-build"));
926 }
927
928 #[test]
929 fn group_matcher_star_middle() {
930 let m = GroupMatcher::new("ci-*-fast");
931 assert!(m.is_match("ci-build-fast"));
932 assert!(m.is_match("ci--fast"));
933 assert!(!m.is_match("ci-build-slow"));
934 }
935
936 #[test]
937 fn group_matcher_question_mark() {
938 let m = GroupMatcher::new("ci-?");
939 assert!(m.is_match("ci-1"));
940 assert!(m.is_match("ci-a"));
941 assert!(!m.is_match("ci-"));
942 assert!(!m.is_match("ci-12"));
943 }
944
945 #[test]
946 fn group_matcher_special_chars_escaped() {
947 let m = GroupMatcher::new("ci.release+1");
949 assert!(m.is_match("ci.release+1"));
950 assert!(!m.is_match("ciXreleaseX1"));
951 }
952
953 fn make_server(server_cwd: PathBuf) -> TaskMcpServer {
954 TaskMcpServer::new(Config::default(), server_cwd)
955 }
956
957 fn make_server_with_allowed_dirs(
958 server_cwd: PathBuf,
959 allowed_dirs: Vec<PathBuf>,
960 ) -> TaskMcpServer {
961 let config = Config {
962 allowed_dirs,
963 ..Config::default()
964 };
965 TaskMcpServer::new(config, server_cwd)
966 }
967
968 #[tokio::test]
974 async fn test_try_auto_session_start_in_project_root() {
975 let tmpdir = tempfile::tempdir().expect("create tempdir");
976 std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
977
978 let server = make_server(tmpdir.path().to_path_buf());
979 let outcome = server.try_auto_session_start().await;
980
981 match outcome {
982 AutoStartOutcome::Started(resp, _wd) => {
983 assert_eq!(resp.mode, "agent-only");
984 }
985 other => panic!("auto-start should succeed in a ProjectRoot (.git), got {other:?}"),
986 }
987 assert!(
988 server.current_workdir().await.is_some(),
989 "workdir should be set after auto-start"
990 );
991 }
992
993 #[tokio::test]
995 async fn test_second_call_no_auto_start() {
996 let tmpdir = tempfile::tempdir().expect("create tempdir");
997 std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
998
999 let server = make_server(tmpdir.path().to_path_buf());
1000
1001 let (_, auto1) = server
1003 .workdir_or_auto()
1004 .await
1005 .expect("first call should succeed");
1006 assert!(auto1.is_some(), "first call should trigger auto-start");
1007
1008 let (_, auto2) = server
1010 .workdir_or_auto()
1011 .await
1012 .expect("second call should succeed");
1013 assert!(
1014 auto2.is_none(),
1015 "second call must NOT return auto_session_start"
1016 );
1017 }
1018
1019 #[tokio::test]
1021 async fn test_no_auto_start_in_non_project_root() {
1022 let tmpdir = tempfile::tempdir().expect("create tempdir");
1023 let server = make_server(tmpdir.path().to_path_buf());
1026 let result = server.workdir_or_auto().await;
1027
1028 let err = result.expect_err("should fail when no ProjectRoot marker");
1029 assert!(
1030 err.message.contains("not a ProjectRoot"),
1031 "error message should identify 'not a ProjectRoot': {err:?}"
1032 );
1033 }
1034
1035 #[tokio::test]
1037 async fn test_justfile_marker_also_triggers() {
1038 let tmpdir = tempfile::tempdir().expect("create tempdir");
1039 std::fs::write(tmpdir.path().join("justfile"), "").expect("create justfile");
1041
1042 let server = make_server(tmpdir.path().to_path_buf());
1043 let outcome = server.try_auto_session_start().await;
1044
1045 assert!(
1046 matches!(outcome, AutoStartOutcome::Started(_, _)),
1047 "auto-start should succeed with only justfile marker, got {outcome:?}"
1048 );
1049 }
1050
1051 #[tokio::test]
1053 async fn test_allowed_dirs_violation_no_auto_start() {
1054 let tmpdir = tempfile::tempdir().expect("create tempdir");
1055 std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
1056
1057 let other_dir = tempfile::tempdir().expect("create other tempdir");
1058 let allowed = vec![other_dir.path().to_path_buf()];
1059
1060 let server = make_server_with_allowed_dirs(tmpdir.path().to_path_buf(), allowed);
1061 let err = server
1062 .workdir_or_auto()
1063 .await
1064 .expect_err("should fail when server_cwd is not in allowed_dirs");
1065 assert!(
1066 err.message.contains("allowed_dirs"),
1067 "error message should identify the allowed_dirs violation: {err:?}"
1068 );
1069 }
1070
1071 #[tokio::test]
1074 async fn test_auto_start_already_started_variant() {
1075 let tmpdir = tempfile::tempdir().expect("create tempdir");
1076 std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
1077
1078 let server = make_server(tmpdir.path().to_path_buf());
1079
1080 let pre_set = tmpdir.path().join("pre-set");
1082 std::fs::create_dir(&pre_set).expect("create pre-set dir");
1083 server.set_workdir_for_test(pre_set.clone()).await;
1084
1085 let outcome = server.try_auto_session_start().await;
1086 match outcome {
1087 AutoStartOutcome::AlreadyStarted(wd) => assert_eq!(wd, pre_set),
1088 other => panic!("expected AlreadyStarted, got {other:?}"),
1089 }
1090 }
1091
1092 #[tokio::test]
1094 async fn test_explicit_session_start_overrides() {
1095 let tmpdir = tempfile::tempdir().expect("create tempdir");
1096 std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
1097
1098 let subdir = tmpdir.path().join("subdir");
1100 std::fs::create_dir(&subdir).expect("create subdir");
1101
1102 let server = make_server(tmpdir.path().to_path_buf());
1103 server.set_workdir_for_test(subdir.clone()).await;
1105
1106 let result = server.workdir_or_auto().await;
1108 assert!(result.is_ok());
1109 let (wd, auto) = result.unwrap();
1110 assert!(
1111 auto.is_none(),
1112 "after explicit session_start, auto_session_start must be None"
1113 );
1114 assert_eq!(
1116 wd, subdir,
1117 "workdir should be the explicitly set subdir, not server_cwd"
1118 );
1119 }
1120}