Skip to main content

task_mcp/mcp/
server.rs

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
25// =============================================================================
26// Group filter matcher
27// =============================================================================
28
29/// Glob-style matcher for the `list.filter` field.
30///
31/// Supported wildcards: `*` (zero or more chars), `?` (exactly one char).
32/// Patterns without wildcards fall back to exact string equality so users can
33/// keep specifying a plain group name like `profile`.
34enum 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(&regex::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            // Fallback: treat an unusable pattern as exact match on the raw input.
71            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
83// =============================================================================
84// Public entry point
85// =============================================================================
86
87pub 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// =============================================================================
97// MCP Server
98// =============================================================================
99
100#[derive(Clone)]
101pub struct TaskMcpServer {
102    tool_router: ToolRouter<Self>,
103    config: Config,
104    log_store: Arc<just::TaskLogStore>,
105    /// Runtime working directory set by `session_start`.
106    workdir: Arc<RwLock<Option<PathBuf>>>,
107    /// CWD at server startup (used as default when session_start omits workdir).
108    server_cwd: PathBuf,
109}
110
111/// Outcome of a lazy auto-session-start attempt.
112///
113/// Distinguishes the reasons the server may decline to auto-start so that
114/// callers can produce actionable error messages and — crucially — recover
115/// when a concurrent task has already initialized the session.
116#[derive(Debug)]
117pub(crate) enum AutoStartOutcome {
118    /// Session was newly initialized by this call.
119    Started(SessionStartResponse, PathBuf),
120    /// Session had already been initialized (by a concurrent task) when we
121    /// acquired the write lock. The existing workdir is returned so the
122    /// caller can proceed without a spurious error.
123    AlreadyStarted(PathBuf),
124    /// `server_cwd` is not a ProjectRoot (no `.git`/`justfile` marker).
125    NotProjectRoot,
126    /// `server_cwd` could not be canonicalized.
127    CanonicalizeFailed(std::io::Error),
128    /// `server_cwd` is not in `allowed_dirs`.
129    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    /// Try to auto-start a session using `server_cwd` as the workdir.
144    ///
145    /// See [`AutoStartOutcome`] for the return variants.
146    pub(crate) async fn try_auto_session_start(&self) -> AutoStartOutcome {
147        // Check if server_cwd is a ProjectRoot (.git or justfile must exist)
148        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        // Canonicalize server_cwd
159        let canonical = match tokio::fs::canonicalize(&self.server_cwd).await {
160            Ok(p) => p,
161            Err(e) => return AutoStartOutcome::CanonicalizeFailed(e),
162        };
163
164        // Check allowed_dirs
165        if !self.config.is_workdir_allowed(&canonical) {
166            return AutoStartOutcome::NotAllowed(canonical);
167        }
168
169        // Double-checked locking: acquire write lock, then re-check None.
170        // If another task initialized the session between our fast-path read
171        // and this write lock, return AlreadyStarted so the caller can reuse
172        // the existing workdir instead of reporting a spurious error.
173        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    /// Return the current session workdir (with optional auto-start) and the auto-start response.
194    ///
195    /// - If session is already started, returns `(workdir, None)`.
196    /// - If not started and `server_cwd` is a ProjectRoot, auto-starts and returns `(workdir, Some(response))`.
197    /// - If a concurrent task initialized the session while we were in the slow path, returns `(workdir, None)`.
198    /// - Otherwise returns a specific error describing which precondition failed.
199    pub(crate) async fn workdir_or_auto(
200        &self,
201    ) -> Result<(PathBuf, Option<SessionStartResponse>), McpError> {
202        // Fast path: session already started
203        {
204            let guard = self.workdir.read().await;
205            if let Some(ref wd) = *guard {
206                return Ok((wd.clone(), None));
207            }
208        }
209
210        // Slow path: try auto-start
211        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
239// =============================================================================
240// ServerHandler impl
241// =============================================================================
242
243impl 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// =============================================================================
312// Request / Response types
313// =============================================================================
314
315#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
316struct SessionStartRequest {
317    /// Working directory path. If omitted or empty, defaults to the server's startup CWD.
318    pub workdir: Option<String>,
319}
320
321/// Response payload describing a session's working directory, resolved justfile,
322/// and active task mode. Returned by `session_start`, and also embedded as
323/// `auto_session_start` in other tools' responses when the session was
324/// automatically started by that call.
325#[derive(Debug, Clone, Serialize)]
326pub(crate) struct SessionStartResponse {
327    /// Canonicalized absolute path of the working directory.
328    pub workdir: String,
329    /// Resolved path to the justfile used in this session.
330    pub justfile: String,
331    /// Active task mode: "agent-only" or "all".
332    pub mode: String,
333}
334
335#[derive(Debug, Clone, Serialize)]
336struct InfoResponse {
337    /// Whether session_start has been called.
338    pub session_started: bool,
339    /// Current working directory (None if session_start not yet called).
340    pub workdir: Option<String>,
341    /// Resolved justfile path (None if session_start not yet called).
342    pub justfile: Option<String>,
343    /// Active task mode: "agent-only" or "all".
344    pub mode: String,
345    /// CWD at server startup.
346    pub server_cwd: String,
347    /// Whether global justfile merging is enabled.
348    pub load_global: bool,
349    /// Resolved global justfile path (None when load_global=false or global file not found).
350    pub global_justfile: Option<String>,
351    /// Links to documentation.
352    pub docs: InfoDocs,
353}
354
355#[derive(Debug, Clone, Serialize)]
356struct InfoDocs {
357    /// Execution model guide (how task-mcp processes recipe arguments).
358    pub execution_model: &'static str,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
362struct InitRequest {
363    /// Project type: "rust" (default) or "vite-react".
364    pub project_type: Option<String>,
365    /// Path to a custom template file (must be under session workdir). Overrides TASK_MCP_INIT_TEMPLATE_FILE env.
366    pub template_file: Option<String>,
367}
368
369#[derive(Debug, Clone, Serialize)]
370struct InitResponse {
371    /// Path to the generated justfile.
372    pub justfile: String,
373    /// Project type used for template selection.
374    pub project_type: String,
375    /// Whether a custom template file was used.
376    pub custom_template: bool,
377    /// Present only when this call triggered an automatic session_start because
378    /// no session was active and the server's startup CWD is a ProjectRoot
379    /// (contains `.git` or `justfile`). Contains the resolved workdir, justfile
380    /// path, and mode. Absent on subsequent calls within the same session.
381    #[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    /// Filter recipes by group name. Supports glob wildcards: `*` matches any
388    /// sequence of characters and `?` matches a single character (e.g.
389    /// `prof*`, `ci-?`, `*-release`). A pattern without wildcards is treated as
390    /// an exact match. If omitted, all agent-safe recipes are returned.
391    pub filter: Option<String>,
392    /// Justfile path override. Defaults to `justfile` in the current directory.
393    pub justfile: Option<String>,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
397struct RunRequest {
398    /// Name of the recipe to execute (must appear in `list` output).
399    pub task_name: String,
400    /// Named arguments to pass to the recipe.  Keys must match recipe parameter names.
401    pub args: Option<HashMap<String, String>>,
402    /// Execution timeout in seconds (default: 60).
403    pub timeout_secs: Option<u64>,
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
407struct LogsRequest {
408    /// Retrieve the full output of a specific execution by its UUID.
409    /// If omitted, a summary of the 10 most recent executions is returned.
410    pub task_id: Option<String>,
411    /// When a `task_id` is provided, restrict the stdout to the last N lines.
412    /// Ignored when `task_id` is absent.
413    pub tail: Option<usize>,
414}
415
416// =============================================================================
417// Helpers
418// =============================================================================
419
420/// Resolve the justfile path taking a session workdir into account.
421///
422/// If `override_path` is provided it is used as-is (absolute or relative to CWD).
423/// Otherwise `{workdir}/justfile` is returned.
424fn 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
434/// Return the last `n` lines of `text`.  If `n` exceeds the line count, the
435/// full text is returned unchanged.
436fn 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// =============================================================================
453// Tool implementations
454// =============================================================================
455
456#[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        // Determine the target directory: use provided path or fall back to server CWD.
473        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        // Canonicalize (resolves symlinks, checks existence).
479        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        // Verify against allowed_dirs.
490        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        // Persist in session state.
501        *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        // Parse project type
540        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        // Reject if justfile already exists
550        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        // Validate template_file is under workdir (prevent path traversal)
561        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        // Resolve template content
581        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        // Write justfile
593        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            // justfile parameter specified → session_start not required
695            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            // no justfile parameter → session_start is required (or auto-started)
704            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        // SECURITY GUARD: `list_recipes` / `list_recipes_merged` apply the
710        // allow-agent filter internally based on `self.config.mode`. Any recipe
711        // returned here has already passed the agent-only gate (when active).
712        // Do NOT reorder this with `filter` post-processing — the guard must
713        // run first so that non-allowed recipes never enter the agent context.
714        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        // Functional group filter (e.g. `profile`, `issue`). Applied AFTER the
730        // security guard above. The filter value `allow-agent` is not
731        // meaningful in agent-only mode (every recipe already carries it).
732        // Apply optional group filter with glob matching (`*`, `?`).
733        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        // session_start is required for run (auto-start if ProjectRoot)
778        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        // Persist to log store
810        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                        // Apply tail filter if requested
865                        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// =============================================================================
890// Tests
891// =============================================================================
892
893#[cfg(test)]
894impl TaskMcpServer {
895    /// Set the workdir directly (test-only helper).
896    pub(crate) async fn set_workdir_for_test(&self, path: PathBuf) {
897        *self.workdir.write().await = Some(path);
898    }
899
900    /// Read the current workdir (test-only helper).
901    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    // -------------------------------------------------------------------------
911    // GroupMatcher
912    // -------------------------------------------------------------------------
913
914    #[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        // Regex metacharacters in the pattern must be treated literally.
959        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    // -------------------------------------------------------------------------
980    // try_auto_session_start
981    // -------------------------------------------------------------------------
982
983    /// .git ディレクトリがある ProjectRoot で auto-start が成功する。
984    #[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    /// 2回目の呼び出しでは auto-start が発生しない (already started)。
1005    #[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        // 1回目: auto-start 発生
1013        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        // 2回目: workdir は既に設定済み → auto なし
1020        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    /// marker なし (非 ProjectRoot) では auto-start しない → error。
1031    #[tokio::test]
1032    async fn test_no_auto_start_in_non_project_root() {
1033        let tmpdir = tempfile::tempdir().expect("create tempdir");
1034        // .git も justfile も作成しない
1035
1036        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    /// justfile のみ存在する場合でも auto-start が成功する。
1047    #[tokio::test]
1048    async fn test_justfile_marker_also_triggers() {
1049        let tmpdir = tempfile::tempdir().expect("create tempdir");
1050        // .git はなく justfile のみ
1051        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    /// allowed_dirs 違反の場合は auto-start しない → error (原因が区別される)。
1063    #[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    /// HIGH-1 regression: `try_auto_session_start` が並行初期化済みの状態で
1083    /// `AlreadyStarted` を返し、`workdir_or_auto` 経由では誤エラーにならない。
1084    #[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        // 並行初期化を模倣: 直接 workdir を設定してから slow path を叩く。
1092        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    /// ProjectRoot でも明示 session_start (workdir=subdir) 後には auto がない。
1104    #[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        // subdir を作って明示的に workdir をセット
1110        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        // 明示的な session_start を模倣して workdir を直接セット
1115        server.set_workdir_for_test(subdir.clone()).await;
1116
1117        // workdir_or_auto を呼んでも auto は発生しない (already started)
1118        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        // workdir は subdir (server_cwd ではない)
1126        assert_eq!(
1127            wd, subdir,
1128            "workdir should be the explicitly set subdir, not server_cwd"
1129        );
1130    }
1131}