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. 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// =============================================================================
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    /// Content arguments passed as environment variables to the recipe.
403    /// Keys become `TASK_MCP_CONTENT_{KEY}` (uppercased). Values can contain
404    /// any UTF-8 text including newlines — no escaping needed.
405    pub content: Option<HashMap<String, String>>,
406    /// Execution timeout in seconds (default: 60).
407    pub timeout_secs: Option<u64>,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
411struct LogsRequest {
412    /// Retrieve the full output of a specific execution by its UUID.
413    /// If omitted, a summary of the 10 most recent executions is returned.
414    pub task_id: Option<String>,
415    /// When a `task_id` is provided, restrict the stdout to the last N lines.
416    /// Ignored when `task_id` is absent.
417    pub tail: Option<usize>,
418}
419
420// =============================================================================
421// Helpers
422// =============================================================================
423
424/// Resolve the justfile path taking a session workdir into account.
425///
426/// If `override_path` is provided it is used as-is (absolute or relative to CWD).
427/// Otherwise `{workdir}/justfile` is returned.
428fn 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
438/// Return the last `n` lines of `text`.  If `n` exceeds the line count, the
439/// full text is returned unchanged.
440fn 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// =============================================================================
457// Tool implementations
458// =============================================================================
459
460#[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        // Determine the target directory: use provided path or fall back to server CWD.
477        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        // Canonicalize (resolves symlinks, checks existence).
483        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        // Verify against allowed_dirs.
494        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        // Persist in session state.
505        *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        // Parse project type
544        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        // Reject if justfile already exists
554        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        // Validate template_file is under workdir (prevent path traversal)
565        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        // Resolve template content
585        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        // Write justfile
597        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            // justfile parameter specified → session_start not required
699            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            // no justfile parameter → session_start is required (or auto-started)
708            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        // SECURITY GUARD: `list_recipes` / `list_recipes_merged` apply the
714        // allow-agent filter internally based on `self.config.mode`. Any recipe
715        // returned here has already passed the agent-only gate (when active).
716        // Do NOT reorder this with `filter` post-processing — the guard must
717        // run first so that non-allowed recipes never enter the agent context.
718        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        // Functional group filter (e.g. `profile`, `issue`). Applied AFTER the
734        // security guard above. The filter value `allow-agent` is not
735        // meaningful in agent-only mode (every recipe already carries it).
736        // Apply optional group filter with glob matching (`*`, `?`).
737        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        // session_start is required for run (auto-start if ProjectRoot)
782        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        // Persist to log store
817        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                        // Apply tail filter if requested
872                        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// =============================================================================
897// Tests
898// =============================================================================
899
900#[cfg(test)]
901impl TaskMcpServer {
902    /// Set the workdir directly (test-only helper).
903    pub(crate) async fn set_workdir_for_test(&self, path: PathBuf) {
904        *self.workdir.write().await = Some(path);
905    }
906
907    /// Read the current workdir (test-only helper).
908    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    // -------------------------------------------------------------------------
918    // GroupMatcher
919    // -------------------------------------------------------------------------
920
921    #[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        // Regex metacharacters in the pattern must be treated literally.
966        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    // -------------------------------------------------------------------------
987    // try_auto_session_start
988    // -------------------------------------------------------------------------
989
990    /// .git ディレクトリがある ProjectRoot で auto-start が成功する。
991    #[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    /// 2回目の呼び出しでは auto-start が発生しない (already started)。
1012    #[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        // 1回目: auto-start 発生
1020        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        // 2回目: workdir は既に設定済み → auto なし
1027        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    /// marker なし (非 ProjectRoot) では auto-start しない → error。
1038    #[tokio::test]
1039    async fn test_no_auto_start_in_non_project_root() {
1040        let tmpdir = tempfile::tempdir().expect("create tempdir");
1041        // .git も justfile も作成しない
1042
1043        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    /// justfile のみ存在する場合でも auto-start が成功する。
1054    #[tokio::test]
1055    async fn test_justfile_marker_also_triggers() {
1056        let tmpdir = tempfile::tempdir().expect("create tempdir");
1057        // .git はなく justfile のみ
1058        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    /// allowed_dirs 違反の場合は auto-start しない → error (原因が区別される)。
1070    #[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    /// HIGH-1 regression: `try_auto_session_start` が並行初期化済みの状態で
1090    /// `AlreadyStarted` を返し、`workdir_or_auto` 経由では誤エラーにならない。
1091    #[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        // 並行初期化を模倣: 直接 workdir を設定してから slow path を叩く。
1099        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    /// ProjectRoot でも明示 session_start (workdir=subdir) 後には auto がない。
1111    #[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        // subdir を作って明示的に workdir をセット
1117        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        // 明示的な session_start を模倣して workdir を直接セット
1122        server.set_workdir_for_test(subdir.clone()).await;
1123
1124        // workdir_or_auto を呼んでも auto は発生しない (already started)
1125        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        // workdir は subdir (server_cwd ではない)
1133        assert_eq!(
1134            wd, subdir,
1135            "workdir should be the explicitly set subdir, not server_cwd"
1136        );
1137    }
1138}