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