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 pub docs: InfoDocs,
353}
354
355#[derive(Debug, Clone, Serialize)]
356struct InfoDocs {
357 pub execution_model: &'static str,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
362struct InitRequest {
363 pub project_type: Option<String>,
365 pub template_file: Option<String>,
367}
368
369#[derive(Debug, Clone, Serialize)]
370struct InitResponse {
371 pub justfile: String,
373 pub project_type: String,
375 pub custom_template: bool,
377 #[serde(skip_serializing_if = "Option::is_none")]
382 pub auto_session_start: Option<SessionStartResponse>,
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
386struct ListRequest {
387 pub filter: Option<String>,
392 pub justfile: Option<String>,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
397struct RunRequest {
398 pub task_name: String,
400 pub args: Option<HashMap<String, String>>,
402 pub timeout_secs: Option<u64>,
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
407struct LogsRequest {
408 pub task_id: Option<String>,
411 pub tail: Option<usize>,
414}
415
416fn resolve_justfile_with_workdir(
425 override_path: Option<&str>,
426 workdir: &std::path::Path,
427) -> PathBuf {
428 match override_path {
429 Some(p) => PathBuf::from(p),
430 None => workdir.join("justfile"),
431 }
432}
433
434fn tail_lines(text: &str, n: usize) -> String {
437 let lines: Vec<&str> = text.lines().collect();
438 if n >= lines.len() {
439 return text.to_string();
440 }
441 lines[lines.len() - n..].join("\n")
442}
443
444fn mode_label(config: &Config) -> String {
445 use crate::config::TaskMode;
446 match config.mode {
447 TaskMode::AgentOnly => "agent-only".to_string(),
448 TaskMode::All => "all".to_string(),
449 }
450}
451
452#[tool_router]
457impl TaskMcpServer {
458 #[tool(
459 name = "session_start",
460 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.",
461 annotations(
462 read_only_hint = false,
463 destructive_hint = false,
464 idempotent_hint = true,
465 open_world_hint = false
466 )
467 )]
468 async fn session_start(
469 &self,
470 Parameters(req): Parameters<SessionStartRequest>,
471 ) -> Result<CallToolResult, McpError> {
472 let raw_path = match req.workdir.as_deref() {
474 Some(s) if !s.trim().is_empty() => PathBuf::from(s),
475 _ => self.server_cwd.clone(),
476 };
477
478 let canonical = tokio::fs::canonicalize(&raw_path).await.map_err(|e| {
480 McpError::invalid_params(
481 format!(
482 "workdir {:?} does not exist or is not accessible: {e}",
483 raw_path
484 ),
485 None,
486 )
487 })?;
488
489 if !self.config.is_workdir_allowed(&canonical) {
491 return Err(McpError::invalid_params(
492 format!(
493 "workdir {:?} is not in the allowed directories list",
494 canonical
495 ),
496 None,
497 ));
498 }
499
500 *self.workdir.write().await = Some(canonical.clone());
502
503 let justfile =
504 resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), &canonical);
505
506 let response = SessionStartResponse {
507 workdir: canonical.to_string_lossy().into_owned(),
508 justfile: justfile.to_string_lossy().into_owned(),
509 mode: mode_label(&self.config),
510 };
511
512 let output = serde_json::to_string_pretty(&response)
513 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
514
515 Ok(CallToolResult {
516 content: vec![Content::text(output)],
517 structured_content: None,
518 is_error: Some(false),
519 meta: None,
520 })
521 }
522
523 #[tool(
524 name = "init",
525 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.",
526 annotations(
527 read_only_hint = false,
528 destructive_hint = false,
529 idempotent_hint = false,
530 open_world_hint = false
531 )
532 )]
533 async fn init(
534 &self,
535 Parameters(req): Parameters<InitRequest>,
536 ) -> Result<CallToolResult, McpError> {
537 let (workdir, auto) = self.workdir_or_auto().await?;
538
539 let project_type = match req.project_type.as_deref() {
541 Some(s) => s
542 .parse::<template::ProjectType>()
543 .map_err(|e| McpError::invalid_params(e, None))?,
544 None => template::ProjectType::default(),
545 };
546
547 let justfile_path = workdir.join("justfile");
548
549 if justfile_path.exists() {
551 return Err(McpError::invalid_params(
552 format!(
553 "justfile already exists at {}. Delete it first if you want to regenerate.",
554 justfile_path.display()
555 ),
556 None,
557 ));
558 }
559
560 if let Some(ref tf) = req.template_file {
562 let template_path = std::fs::canonicalize(tf).map_err(|e| {
563 McpError::invalid_params(
564 format!("template_file {tf:?} is not accessible: {e}"),
565 None,
566 )
567 })?;
568 if !template_path.starts_with(&workdir) {
569 return Err(McpError::invalid_params(
570 format!(
571 "template_file must be under session workdir ({}). Got: {}",
572 workdir.display(),
573 template_path.display()
574 ),
575 None,
576 ));
577 }
578 }
579
580 let custom_template_used =
582 req.template_file.is_some() || self.config.init_template_file.is_some();
583
584 let content = template::resolve_template(
585 project_type,
586 req.template_file.as_deref(),
587 self.config.init_template_file.as_deref(),
588 )
589 .await
590 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
591
592 tokio::fs::write(&justfile_path, &content)
594 .await
595 .map_err(|e| {
596 McpError::internal_error(
597 format!(
598 "failed to write justfile at {}: {e}",
599 justfile_path.display()
600 ),
601 None,
602 )
603 })?;
604
605 let response = InitResponse {
606 justfile: justfile_path.to_string_lossy().into_owned(),
607 project_type: project_type.to_string(),
608 custom_template: custom_template_used,
609 auto_session_start: auto,
610 };
611
612 let output = serde_json::to_string_pretty(&response)
613 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
614
615 Ok(CallToolResult {
616 content: vec![Content::text(output)],
617 structured_content: None,
618 is_error: Some(false),
619 meta: None,
620 })
621 }
622
623 #[tool(
624 name = "info",
625 description = "Show current session state: workdir, justfile path, mode, and server startup CWD.",
626 annotations(
627 read_only_hint = true,
628 destructive_hint = false,
629 idempotent_hint = true,
630 open_world_hint = false
631 )
632 )]
633 async fn info(&self) -> Result<CallToolResult, McpError> {
634 let current_workdir = self.workdir.read().await.clone();
635
636 let (session_started, workdir_str, justfile_str) = match current_workdir {
637 Some(ref wd) => {
638 let justfile =
639 resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), wd);
640 (
641 true,
642 Some(wd.to_string_lossy().into_owned()),
643 Some(justfile.to_string_lossy().into_owned()),
644 )
645 }
646 None => (false, None, None),
647 };
648
649 let global_justfile_str = self
650 .config
651 .global_justfile_path
652 .as_ref()
653 .map(|p| p.to_string_lossy().into_owned());
654
655 let response = InfoResponse {
656 session_started,
657 workdir: workdir_str,
658 justfile: justfile_str,
659 mode: mode_label(&self.config),
660 server_cwd: self.server_cwd.to_string_lossy().into_owned(),
661 load_global: self.config.load_global,
662 global_justfile: global_justfile_str,
663 docs: InfoDocs {
664 execution_model: "https://github.com/ynishi/task-mcp/blob/master/docs/execution-model.md",
665 },
666 };
667
668 let output = serde_json::to_string_pretty(&response)
669 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
670
671 Ok(CallToolResult {
672 content: vec![Content::text(output)],
673 structured_content: None,
674 is_error: Some(false),
675 meta: None,
676 })
677 }
678
679 #[tool(
680 name = "list",
681 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.",
682 annotations(
683 read_only_hint = true,
684 destructive_hint = false,
685 idempotent_hint = true,
686 open_world_hint = false
687 )
688 )]
689 async fn list(
690 &self,
691 Parameters(req): Parameters<ListRequest>,
692 ) -> Result<CallToolResult, McpError> {
693 let (justfile_path, workdir_opt, auto) = if req.justfile.is_some() {
694 let jp = just::resolve_justfile_path(
696 req.justfile
697 .as_deref()
698 .or(self.config.justfile_path.as_deref()),
699 None,
700 );
701 (jp, None, None)
702 } else {
703 let (wd, auto) = self.workdir_or_auto().await?;
705 let jp = just::resolve_justfile_path(self.config.justfile_path.as_deref(), Some(&wd));
706 (jp, Some(wd), auto)
707 };
708
709 let recipes = if self.config.load_global {
715 just::list_recipes_merged(
716 &justfile_path,
717 self.config.global_justfile_path.as_deref(),
718 &self.config.mode,
719 workdir_opt.as_deref(),
720 )
721 .await
722 .map_err(|e| McpError::internal_error(e.to_string(), None))?
723 } else {
724 just::list_recipes(&justfile_path, &self.config.mode, workdir_opt.as_deref())
725 .await
726 .map_err(|e| McpError::internal_error(e.to_string(), None))?
727 };
728
729 let filtered: Vec<_> = match &req.filter {
734 Some(pattern) => {
735 let matcher = GroupMatcher::new(pattern);
736 recipes
737 .into_iter()
738 .filter(|r| r.groups.iter().any(|g| matcher.is_match(g)))
739 .collect()
740 }
741 None => recipes,
742 };
743
744 let mut wrapped = serde_json::json!({ "recipes": filtered });
745 if let Some(auto_response) = auto {
746 wrapped.as_object_mut().expect("json object").insert(
747 "auto_session_start".to_string(),
748 serde_json::to_value(auto_response)
749 .map_err(|e| McpError::internal_error(e.to_string(), None))?,
750 );
751 }
752 let output = serde_json::to_string_pretty(&wrapped)
753 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
754
755 Ok(CallToolResult {
756 content: vec![Content::text(output)],
757 structured_content: None,
758 is_error: Some(false),
759 meta: None,
760 })
761 }
762
763 #[tool(
764 name = "run",
765 description = "Execute a predefined task. Only tasks visible in `list` can be run.",
766 annotations(
767 read_only_hint = false,
768 destructive_hint = true,
769 idempotent_hint = false,
770 open_world_hint = false
771 )
772 )]
773 async fn run(
774 &self,
775 Parameters(req): Parameters<RunRequest>,
776 ) -> Result<CallToolResult, McpError> {
777 let (workdir, auto) = self.workdir_or_auto().await?;
779 let justfile_path =
780 just::resolve_justfile_path(self.config.justfile_path.as_deref(), Some(&workdir));
781 let args = req.args.unwrap_or_default();
782 let timeout = Duration::from_secs(req.timeout_secs.unwrap_or(60));
783
784 let execution = if self.config.load_global {
785 just::execute_recipe_merged(
786 &req.task_name,
787 &args,
788 &justfile_path,
789 self.config.global_justfile_path.as_deref(),
790 timeout,
791 &self.config.mode,
792 Some(&workdir),
793 )
794 .await
795 .map_err(|e| McpError::internal_error(e.to_string(), None))?
796 } else {
797 just::execute_recipe(
798 &req.task_name,
799 &args,
800 &justfile_path,
801 timeout,
802 &self.config.mode,
803 Some(&workdir),
804 )
805 .await
806 .map_err(|e| McpError::internal_error(e.to_string(), None))?
807 };
808
809 self.log_store.push(execution.clone());
811
812 let is_error = execution.exit_code.map(|c| c != 0).unwrap_or(true);
813
814 let output = match auto {
815 Some(auto_response) => {
816 let mut val = serde_json::to_value(&execution)
817 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
818 if let Some(obj) = val.as_object_mut() {
819 obj.insert(
820 "auto_session_start".to_string(),
821 serde_json::to_value(auto_response)
822 .map_err(|e| McpError::internal_error(e.to_string(), None))?,
823 );
824 }
825 serde_json::to_string_pretty(&val)
826 .map_err(|e| McpError::internal_error(e.to_string(), None))?
827 }
828 None => serde_json::to_string_pretty(&execution)
829 .map_err(|e| McpError::internal_error(e.to_string(), None))?,
830 };
831
832 Ok(CallToolResult {
833 content: vec![Content::text(output)],
834 structured_content: None,
835 is_error: Some(is_error),
836 meta: None,
837 })
838 }
839
840 #[tool(
841 name = "logs",
842 description = "Retrieve execution logs. Returns recent task execution results.",
843 annotations(
844 read_only_hint = true,
845 destructive_hint = false,
846 idempotent_hint = true,
847 open_world_hint = false
848 )
849 )]
850 async fn logs(
851 &self,
852 Parameters(req): Parameters<LogsRequest>,
853 ) -> Result<CallToolResult, McpError> {
854 let output = match req.task_id.as_deref() {
855 Some(id) => {
856 match self.log_store.get(id) {
857 None => {
858 return Err(McpError::internal_error(
859 format!("execution not found: {id}"),
860 None,
861 ));
862 }
863 Some(mut execution) => {
864 if let Some(n) = req.tail {
866 execution.stdout = tail_lines(&execution.stdout, n);
867 }
868 serde_json::to_string_pretty(&execution)
869 .map_err(|e| McpError::internal_error(e.to_string(), None))?
870 }
871 }
872 }
873 None => {
874 let summaries = self.log_store.recent(10);
875 serde_json::to_string_pretty(&summaries)
876 .map_err(|e| McpError::internal_error(e.to_string(), None))?
877 }
878 };
879
880 Ok(CallToolResult {
881 content: vec![Content::text(output)],
882 structured_content: None,
883 is_error: Some(false),
884 meta: None,
885 })
886 }
887}
888
889#[cfg(test)]
894impl TaskMcpServer {
895 pub(crate) async fn set_workdir_for_test(&self, path: PathBuf) {
897 *self.workdir.write().await = Some(path);
898 }
899
900 pub(crate) async fn current_workdir(&self) -> Option<PathBuf> {
902 self.workdir.read().await.clone()
903 }
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909
910 #[test]
915 fn group_matcher_exact() {
916 let m = GroupMatcher::new("profile");
917 assert!(m.is_match("profile"));
918 assert!(!m.is_match("profiler"));
919 assert!(!m.is_match("agent"));
920 }
921
922 #[test]
923 fn group_matcher_star_prefix() {
924 let m = GroupMatcher::new("prof*");
925 assert!(m.is_match("profile"));
926 assert!(m.is_match("profiler"));
927 assert!(m.is_match("prof"));
928 assert!(!m.is_match("agent"));
929 }
930
931 #[test]
932 fn group_matcher_star_suffix() {
933 let m = GroupMatcher::new("*-release");
934 assert!(m.is_match("build-release"));
935 assert!(m.is_match("test-release"));
936 assert!(!m.is_match("release-build"));
937 }
938
939 #[test]
940 fn group_matcher_star_middle() {
941 let m = GroupMatcher::new("ci-*-fast");
942 assert!(m.is_match("ci-build-fast"));
943 assert!(m.is_match("ci--fast"));
944 assert!(!m.is_match("ci-build-slow"));
945 }
946
947 #[test]
948 fn group_matcher_question_mark() {
949 let m = GroupMatcher::new("ci-?");
950 assert!(m.is_match("ci-1"));
951 assert!(m.is_match("ci-a"));
952 assert!(!m.is_match("ci-"));
953 assert!(!m.is_match("ci-12"));
954 }
955
956 #[test]
957 fn group_matcher_special_chars_escaped() {
958 let m = GroupMatcher::new("ci.release+1");
960 assert!(m.is_match("ci.release+1"));
961 assert!(!m.is_match("ciXreleaseX1"));
962 }
963
964 fn make_server(server_cwd: PathBuf) -> TaskMcpServer {
965 TaskMcpServer::new(Config::default(), server_cwd)
966 }
967
968 fn make_server_with_allowed_dirs(
969 server_cwd: PathBuf,
970 allowed_dirs: Vec<PathBuf>,
971 ) -> TaskMcpServer {
972 let config = Config {
973 allowed_dirs,
974 ..Config::default()
975 };
976 TaskMcpServer::new(config, server_cwd)
977 }
978
979 #[tokio::test]
985 async fn test_try_auto_session_start_in_project_root() {
986 let tmpdir = tempfile::tempdir().expect("create tempdir");
987 std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
988
989 let server = make_server(tmpdir.path().to_path_buf());
990 let outcome = server.try_auto_session_start().await;
991
992 match outcome {
993 AutoStartOutcome::Started(resp, _wd) => {
994 assert_eq!(resp.mode, "agent-only");
995 }
996 other => panic!("auto-start should succeed in a ProjectRoot (.git), got {other:?}"),
997 }
998 assert!(
999 server.current_workdir().await.is_some(),
1000 "workdir should be set after auto-start"
1001 );
1002 }
1003
1004 #[tokio::test]
1006 async fn test_second_call_no_auto_start() {
1007 let tmpdir = tempfile::tempdir().expect("create tempdir");
1008 std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
1009
1010 let server = make_server(tmpdir.path().to_path_buf());
1011
1012 let (_, auto1) = server
1014 .workdir_or_auto()
1015 .await
1016 .expect("first call should succeed");
1017 assert!(auto1.is_some(), "first call should trigger auto-start");
1018
1019 let (_, auto2) = server
1021 .workdir_or_auto()
1022 .await
1023 .expect("second call should succeed");
1024 assert!(
1025 auto2.is_none(),
1026 "second call must NOT return auto_session_start"
1027 );
1028 }
1029
1030 #[tokio::test]
1032 async fn test_no_auto_start_in_non_project_root() {
1033 let tmpdir = tempfile::tempdir().expect("create tempdir");
1034 let server = make_server(tmpdir.path().to_path_buf());
1037 let result = server.workdir_or_auto().await;
1038
1039 let err = result.expect_err("should fail when no ProjectRoot marker");
1040 assert!(
1041 err.message.contains("not a ProjectRoot"),
1042 "error message should identify 'not a ProjectRoot': {err:?}"
1043 );
1044 }
1045
1046 #[tokio::test]
1048 async fn test_justfile_marker_also_triggers() {
1049 let tmpdir = tempfile::tempdir().expect("create tempdir");
1050 std::fs::write(tmpdir.path().join("justfile"), "").expect("create justfile");
1052
1053 let server = make_server(tmpdir.path().to_path_buf());
1054 let outcome = server.try_auto_session_start().await;
1055
1056 assert!(
1057 matches!(outcome, AutoStartOutcome::Started(_, _)),
1058 "auto-start should succeed with only justfile marker, got {outcome:?}"
1059 );
1060 }
1061
1062 #[tokio::test]
1064 async fn test_allowed_dirs_violation_no_auto_start() {
1065 let tmpdir = tempfile::tempdir().expect("create tempdir");
1066 std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
1067
1068 let other_dir = tempfile::tempdir().expect("create other tempdir");
1069 let allowed = vec![other_dir.path().to_path_buf()];
1070
1071 let server = make_server_with_allowed_dirs(tmpdir.path().to_path_buf(), allowed);
1072 let err = server
1073 .workdir_or_auto()
1074 .await
1075 .expect_err("should fail when server_cwd is not in allowed_dirs");
1076 assert!(
1077 err.message.contains("allowed_dirs"),
1078 "error message should identify the allowed_dirs violation: {err:?}"
1079 );
1080 }
1081
1082 #[tokio::test]
1085 async fn test_auto_start_already_started_variant() {
1086 let tmpdir = tempfile::tempdir().expect("create tempdir");
1087 std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
1088
1089 let server = make_server(tmpdir.path().to_path_buf());
1090
1091 let pre_set = tmpdir.path().join("pre-set");
1093 std::fs::create_dir(&pre_set).expect("create pre-set dir");
1094 server.set_workdir_for_test(pre_set.clone()).await;
1095
1096 let outcome = server.try_auto_session_start().await;
1097 match outcome {
1098 AutoStartOutcome::AlreadyStarted(wd) => assert_eq!(wd, pre_set),
1099 other => panic!("expected AlreadyStarted, got {other:?}"),
1100 }
1101 }
1102
1103 #[tokio::test]
1105 async fn test_explicit_session_start_overrides() {
1106 let tmpdir = tempfile::tempdir().expect("create tempdir");
1107 std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
1108
1109 let subdir = tmpdir.path().join("subdir");
1111 std::fs::create_dir(&subdir).expect("create subdir");
1112
1113 let server = make_server(tmpdir.path().to_path_buf());
1114 server.set_workdir_for_test(subdir.clone()).await;
1116
1117 let result = server.workdir_or_auto().await;
1119 assert!(result.is_ok());
1120 let (wd, auto) = result.unwrap();
1121 assert!(
1122 auto.is_none(),
1123 "after explicit session_start, auto_session_start must be None"
1124 );
1125 assert_eq!(
1127 wd, subdir,
1128 "workdir should be the explicitly set subdir, not server_cwd"
1129 );
1130 }
1131}