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}
352
353#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
354struct InitRequest {
355    /// Project type: "rust" (default) or "vite-react".
356    pub project_type: Option<String>,
357    /// Path to a custom template file (must be under session workdir). Overrides TASK_MCP_INIT_TEMPLATE_FILE env.
358    pub template_file: Option<String>,
359}
360
361#[derive(Debug, Clone, Serialize)]
362struct InitResponse {
363    /// Path to the generated justfile.
364    pub justfile: String,
365    /// Project type used for template selection.
366    pub project_type: String,
367    /// Whether a custom template file was used.
368    pub custom_template: bool,
369    /// Present only when this call triggered an automatic session_start because
370    /// no session was active and the server's startup CWD is a ProjectRoot
371    /// (contains `.git` or `justfile`). Contains the resolved workdir, justfile
372    /// path, and mode. Absent on subsequent calls within the same session.
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub auto_session_start: Option<SessionStartResponse>,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
378struct ListRequest {
379    /// Filter recipes by group name. Supports glob wildcards: `*` matches any
380    /// sequence of characters and `?` matches a single character (e.g.
381    /// `prof*`, `ci-?`, `*-release`). A pattern without wildcards is treated as
382    /// an exact match. If omitted, all agent-safe recipes are returned.
383    pub filter: Option<String>,
384    /// Justfile path override. Defaults to `justfile` in the current directory.
385    pub justfile: Option<String>,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
389struct RunRequest {
390    /// Name of the recipe to execute (must appear in `list` output).
391    pub task_name: String,
392    /// Named arguments to pass to the recipe.  Keys must match recipe parameter names.
393    pub args: Option<HashMap<String, String>>,
394    /// Execution timeout in seconds (default: 60).
395    pub timeout_secs: Option<u64>,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
399struct LogsRequest {
400    /// Retrieve the full output of a specific execution by its UUID.
401    /// If omitted, a summary of the 10 most recent executions is returned.
402    pub task_id: Option<String>,
403    /// When a `task_id` is provided, restrict the stdout to the last N lines.
404    /// Ignored when `task_id` is absent.
405    pub tail: Option<usize>,
406}
407
408// =============================================================================
409// Helpers
410// =============================================================================
411
412/// Resolve the justfile path taking a session workdir into account.
413///
414/// If `override_path` is provided it is used as-is (absolute or relative to CWD).
415/// Otherwise `{workdir}/justfile` is returned.
416fn resolve_justfile_with_workdir(
417    override_path: Option<&str>,
418    workdir: &std::path::Path,
419) -> PathBuf {
420    match override_path {
421        Some(p) => PathBuf::from(p),
422        None => workdir.join("justfile"),
423    }
424}
425
426/// Return the last `n` lines of `text`.  If `n` exceeds the line count, the
427/// full text is returned unchanged.
428fn tail_lines(text: &str, n: usize) -> String {
429    let lines: Vec<&str> = text.lines().collect();
430    if n >= lines.len() {
431        return text.to_string();
432    }
433    lines[lines.len() - n..].join("\n")
434}
435
436fn mode_label(config: &Config) -> String {
437    use crate::config::TaskMode;
438    match config.mode {
439        TaskMode::AgentOnly => "agent-only".to_string(),
440        TaskMode::All => "all".to_string(),
441    }
442}
443
444// =============================================================================
445// Tool implementations
446// =============================================================================
447
448#[tool_router]
449impl TaskMcpServer {
450    #[tool(
451        name = "session_start",
452        description = "Set the working directory for this session explicitly. Optional when the server was launched inside a ProjectRoot (directory containing `.git` or `justfile`): the first `init`/`list`/`run` call will auto-start the session using the server's startup CWD. Call this tool to override that default, e.g. when working in a monorepo subdirectory. Subsequent `run` and `list` (without justfile param) use the configured directory.",
453        annotations(
454            read_only_hint = false,
455            destructive_hint = false,
456            idempotent_hint = true,
457            open_world_hint = false
458        )
459    )]
460    async fn session_start(
461        &self,
462        Parameters(req): Parameters<SessionStartRequest>,
463    ) -> Result<CallToolResult, McpError> {
464        // Determine the target directory: use provided path or fall back to server CWD.
465        let raw_path = match req.workdir.as_deref() {
466            Some(s) if !s.trim().is_empty() => PathBuf::from(s),
467            _ => self.server_cwd.clone(),
468        };
469
470        // Canonicalize (resolves symlinks, checks existence).
471        let canonical = tokio::fs::canonicalize(&raw_path).await.map_err(|e| {
472            McpError::invalid_params(
473                format!(
474                    "workdir {:?} does not exist or is not accessible: {e}",
475                    raw_path
476                ),
477                None,
478            )
479        })?;
480
481        // Verify against allowed_dirs.
482        if !self.config.is_workdir_allowed(&canonical) {
483            return Err(McpError::invalid_params(
484                format!(
485                    "workdir {:?} is not in the allowed directories list",
486                    canonical
487                ),
488                None,
489            ));
490        }
491
492        // Persist in session state.
493        *self.workdir.write().await = Some(canonical.clone());
494
495        let justfile =
496            resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), &canonical);
497
498        let response = SessionStartResponse {
499            workdir: canonical.to_string_lossy().into_owned(),
500            justfile: justfile.to_string_lossy().into_owned(),
501            mode: mode_label(&self.config),
502        };
503
504        let output = serde_json::to_string_pretty(&response)
505            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
506
507        Ok(CallToolResult {
508            content: vec![Content::text(output)],
509            structured_content: None,
510            is_error: Some(false),
511            meta: None,
512        })
513    }
514
515    #[tool(
516        name = "init",
517        description = "Generate a justfile with agent-safe recipes in the session working directory. The session is auto-started if the server was launched inside a ProjectRoot; otherwise call `session_start` first. Supports project types: rust (default), vite-react. Custom template files can also be specified. Fails if justfile already exists — delete it first to regenerate.",
518        annotations(
519            read_only_hint = false,
520            destructive_hint = false,
521            idempotent_hint = false,
522            open_world_hint = false
523        )
524    )]
525    async fn init(
526        &self,
527        Parameters(req): Parameters<InitRequest>,
528    ) -> Result<CallToolResult, McpError> {
529        let (workdir, auto) = self.workdir_or_auto().await?;
530
531        // Parse project type
532        let project_type = match req.project_type.as_deref() {
533            Some(s) => s
534                .parse::<template::ProjectType>()
535                .map_err(|e| McpError::invalid_params(e, None))?,
536            None => template::ProjectType::default(),
537        };
538
539        let justfile_path = workdir.join("justfile");
540
541        // Reject if justfile already exists
542        if justfile_path.exists() {
543            return Err(McpError::invalid_params(
544                format!(
545                    "justfile already exists at {}. Delete it first if you want to regenerate.",
546                    justfile_path.display()
547                ),
548                None,
549            ));
550        }
551
552        // Validate template_file is under workdir (prevent path traversal)
553        if let Some(ref tf) = req.template_file {
554            let template_path = std::fs::canonicalize(tf).map_err(|e| {
555                McpError::invalid_params(
556                    format!("template_file {tf:?} is not accessible: {e}"),
557                    None,
558                )
559            })?;
560            if !template_path.starts_with(&workdir) {
561                return Err(McpError::invalid_params(
562                    format!(
563                        "template_file must be under session workdir ({}). Got: {}",
564                        workdir.display(),
565                        template_path.display()
566                    ),
567                    None,
568                ));
569            }
570        }
571
572        // Resolve template content
573        let custom_template_used =
574            req.template_file.is_some() || self.config.init_template_file.is_some();
575
576        let content = template::resolve_template(
577            project_type,
578            req.template_file.as_deref(),
579            self.config.init_template_file.as_deref(),
580        )
581        .await
582        .map_err(|e| McpError::internal_error(e.to_string(), None))?;
583
584        // Write justfile
585        tokio::fs::write(&justfile_path, &content)
586            .await
587            .map_err(|e| {
588                McpError::internal_error(
589                    format!(
590                        "failed to write justfile at {}: {e}",
591                        justfile_path.display()
592                    ),
593                    None,
594                )
595            })?;
596
597        let response = InitResponse {
598            justfile: justfile_path.to_string_lossy().into_owned(),
599            project_type: project_type.to_string(),
600            custom_template: custom_template_used,
601            auto_session_start: auto,
602        };
603
604        let output = serde_json::to_string_pretty(&response)
605            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
606
607        Ok(CallToolResult {
608            content: vec![Content::text(output)],
609            structured_content: None,
610            is_error: Some(false),
611            meta: None,
612        })
613    }
614
615    #[tool(
616        name = "info",
617        description = "Show current session state: workdir, justfile path, mode, and server startup CWD.",
618        annotations(
619            read_only_hint = true,
620            destructive_hint = false,
621            idempotent_hint = true,
622            open_world_hint = false
623        )
624    )]
625    async fn info(&self) -> Result<CallToolResult, McpError> {
626        let current_workdir = self.workdir.read().await.clone();
627
628        let (session_started, workdir_str, justfile_str) = match current_workdir {
629            Some(ref wd) => {
630                let justfile =
631                    resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), wd);
632                (
633                    true,
634                    Some(wd.to_string_lossy().into_owned()),
635                    Some(justfile.to_string_lossy().into_owned()),
636                )
637            }
638            None => (false, None, None),
639        };
640
641        let global_justfile_str = self
642            .config
643            .global_justfile_path
644            .as_ref()
645            .map(|p| p.to_string_lossy().into_owned());
646
647        let response = InfoResponse {
648            session_started,
649            workdir: workdir_str,
650            justfile: justfile_str,
651            mode: mode_label(&self.config),
652            server_cwd: self.server_cwd.to_string_lossy().into_owned(),
653            load_global: self.config.load_global,
654            global_justfile: global_justfile_str,
655        };
656
657        let output = serde_json::to_string_pretty(&response)
658            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
659
660        Ok(CallToolResult {
661            content: vec![Content::text(output)],
662            structured_content: None,
663            is_error: Some(false),
664            meta: None,
665        })
666    }
667
668    #[tool(
669        name = "list",
670        description = "List available tasks from justfile. Returns an object `{\"recipes\": [...]}` containing task names, descriptions, parameters, and groups. When this call triggers an automatic session_start, the response also includes an `auto_session_start` field.",
671        annotations(
672            read_only_hint = true,
673            destructive_hint = false,
674            idempotent_hint = true,
675            open_world_hint = false
676        )
677    )]
678    async fn list(
679        &self,
680        Parameters(req): Parameters<ListRequest>,
681    ) -> Result<CallToolResult, McpError> {
682        let (justfile_path, workdir_opt, auto) = if req.justfile.is_some() {
683            // justfile parameter specified → session_start not required
684            let jp = just::resolve_justfile_path(
685                req.justfile
686                    .as_deref()
687                    .or(self.config.justfile_path.as_deref()),
688                None,
689            );
690            (jp, None, None)
691        } else {
692            // no justfile parameter → session_start is required (or auto-started)
693            let (wd, auto) = self.workdir_or_auto().await?;
694            let jp = just::resolve_justfile_path(self.config.justfile_path.as_deref(), Some(&wd));
695            (jp, Some(wd), auto)
696        };
697
698        // SECURITY GUARD: `list_recipes` / `list_recipes_merged` apply the
699        // allow-agent filter internally based on `self.config.mode`. Any recipe
700        // returned here has already passed the agent-only gate (when active).
701        // Do NOT reorder this with `filter` post-processing — the guard must
702        // run first so that non-allowed recipes never enter the agent context.
703        let recipes = if self.config.load_global {
704            just::list_recipes_merged(
705                &justfile_path,
706                self.config.global_justfile_path.as_deref(),
707                &self.config.mode,
708                workdir_opt.as_deref(),
709            )
710            .await
711            .map_err(|e| McpError::internal_error(e.to_string(), None))?
712        } else {
713            just::list_recipes(&justfile_path, &self.config.mode, workdir_opt.as_deref())
714                .await
715                .map_err(|e| McpError::internal_error(e.to_string(), None))?
716        };
717
718        // Functional group filter (e.g. `profile`, `issue`). Applied AFTER the
719        // security guard above. The filter value `allow-agent` is not
720        // meaningful in agent-only mode (every recipe already carries it).
721        // Apply optional group filter with glob matching (`*`, `?`).
722        let filtered: Vec<_> = match &req.filter {
723            Some(pattern) => {
724                let matcher = GroupMatcher::new(pattern);
725                recipes
726                    .into_iter()
727                    .filter(|r| r.groups.iter().any(|g| matcher.is_match(g)))
728                    .collect()
729            }
730            None => recipes,
731        };
732
733        let mut wrapped = serde_json::json!({ "recipes": filtered });
734        if let Some(auto_response) = auto {
735            wrapped.as_object_mut().expect("json object").insert(
736                "auto_session_start".to_string(),
737                serde_json::to_value(auto_response)
738                    .map_err(|e| McpError::internal_error(e.to_string(), None))?,
739            );
740        }
741        let output = serde_json::to_string_pretty(&wrapped)
742            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
743
744        Ok(CallToolResult {
745            content: vec![Content::text(output)],
746            structured_content: None,
747            is_error: Some(false),
748            meta: None,
749        })
750    }
751
752    #[tool(
753        name = "run",
754        description = "Execute a predefined task. Only tasks visible in `list` can be run.",
755        annotations(
756            read_only_hint = false,
757            destructive_hint = true,
758            idempotent_hint = false,
759            open_world_hint = false
760        )
761    )]
762    async fn run(
763        &self,
764        Parameters(req): Parameters<RunRequest>,
765    ) -> Result<CallToolResult, McpError> {
766        // session_start is required for run (auto-start if ProjectRoot)
767        let (workdir, auto) = self.workdir_or_auto().await?;
768        let justfile_path =
769            just::resolve_justfile_path(self.config.justfile_path.as_deref(), Some(&workdir));
770        let args = req.args.unwrap_or_default();
771        let timeout = Duration::from_secs(req.timeout_secs.unwrap_or(60));
772
773        let execution = if self.config.load_global {
774            just::execute_recipe_merged(
775                &req.task_name,
776                &args,
777                &justfile_path,
778                self.config.global_justfile_path.as_deref(),
779                timeout,
780                &self.config.mode,
781                Some(&workdir),
782            )
783            .await
784            .map_err(|e| McpError::internal_error(e.to_string(), None))?
785        } else {
786            just::execute_recipe(
787                &req.task_name,
788                &args,
789                &justfile_path,
790                timeout,
791                &self.config.mode,
792                Some(&workdir),
793            )
794            .await
795            .map_err(|e| McpError::internal_error(e.to_string(), None))?
796        };
797
798        // Persist to log store
799        self.log_store.push(execution.clone());
800
801        let is_error = execution.exit_code.map(|c| c != 0).unwrap_or(true);
802
803        let output = match auto {
804            Some(auto_response) => {
805                let mut val = serde_json::to_value(&execution)
806                    .map_err(|e| McpError::internal_error(e.to_string(), None))?;
807                if let Some(obj) = val.as_object_mut() {
808                    obj.insert(
809                        "auto_session_start".to_string(),
810                        serde_json::to_value(auto_response)
811                            .map_err(|e| McpError::internal_error(e.to_string(), None))?,
812                    );
813                }
814                serde_json::to_string_pretty(&val)
815                    .map_err(|e| McpError::internal_error(e.to_string(), None))?
816            }
817            None => serde_json::to_string_pretty(&execution)
818                .map_err(|e| McpError::internal_error(e.to_string(), None))?,
819        };
820
821        Ok(CallToolResult {
822            content: vec![Content::text(output)],
823            structured_content: None,
824            is_error: Some(is_error),
825            meta: None,
826        })
827    }
828
829    #[tool(
830        name = "logs",
831        description = "Retrieve execution logs. Returns recent task execution results.",
832        annotations(
833            read_only_hint = true,
834            destructive_hint = false,
835            idempotent_hint = true,
836            open_world_hint = false
837        )
838    )]
839    async fn logs(
840        &self,
841        Parameters(req): Parameters<LogsRequest>,
842    ) -> Result<CallToolResult, McpError> {
843        let output = match req.task_id.as_deref() {
844            Some(id) => {
845                match self.log_store.get(id) {
846                    None => {
847                        return Err(McpError::internal_error(
848                            format!("execution not found: {id}"),
849                            None,
850                        ));
851                    }
852                    Some(mut execution) => {
853                        // Apply tail filter if requested
854                        if let Some(n) = req.tail {
855                            execution.stdout = tail_lines(&execution.stdout, n);
856                        }
857                        serde_json::to_string_pretty(&execution)
858                            .map_err(|e| McpError::internal_error(e.to_string(), None))?
859                    }
860                }
861            }
862            None => {
863                let summaries = self.log_store.recent(10);
864                serde_json::to_string_pretty(&summaries)
865                    .map_err(|e| McpError::internal_error(e.to_string(), None))?
866            }
867        };
868
869        Ok(CallToolResult {
870            content: vec![Content::text(output)],
871            structured_content: None,
872            is_error: Some(false),
873            meta: None,
874        })
875    }
876}
877
878// =============================================================================
879// Tests
880// =============================================================================
881
882#[cfg(test)]
883impl TaskMcpServer {
884    /// Set the workdir directly (test-only helper).
885    pub(crate) async fn set_workdir_for_test(&self, path: PathBuf) {
886        *self.workdir.write().await = Some(path);
887    }
888
889    /// Read the current workdir (test-only helper).
890    pub(crate) async fn current_workdir(&self) -> Option<PathBuf> {
891        self.workdir.read().await.clone()
892    }
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898
899    // -------------------------------------------------------------------------
900    // GroupMatcher
901    // -------------------------------------------------------------------------
902
903    #[test]
904    fn group_matcher_exact() {
905        let m = GroupMatcher::new("profile");
906        assert!(m.is_match("profile"));
907        assert!(!m.is_match("profiler"));
908        assert!(!m.is_match("agent"));
909    }
910
911    #[test]
912    fn group_matcher_star_prefix() {
913        let m = GroupMatcher::new("prof*");
914        assert!(m.is_match("profile"));
915        assert!(m.is_match("profiler"));
916        assert!(m.is_match("prof"));
917        assert!(!m.is_match("agent"));
918    }
919
920    #[test]
921    fn group_matcher_star_suffix() {
922        let m = GroupMatcher::new("*-release");
923        assert!(m.is_match("build-release"));
924        assert!(m.is_match("test-release"));
925        assert!(!m.is_match("release-build"));
926    }
927
928    #[test]
929    fn group_matcher_star_middle() {
930        let m = GroupMatcher::new("ci-*-fast");
931        assert!(m.is_match("ci-build-fast"));
932        assert!(m.is_match("ci--fast"));
933        assert!(!m.is_match("ci-build-slow"));
934    }
935
936    #[test]
937    fn group_matcher_question_mark() {
938        let m = GroupMatcher::new("ci-?");
939        assert!(m.is_match("ci-1"));
940        assert!(m.is_match("ci-a"));
941        assert!(!m.is_match("ci-"));
942        assert!(!m.is_match("ci-12"));
943    }
944
945    #[test]
946    fn group_matcher_special_chars_escaped() {
947        // Regex metacharacters in the pattern must be treated literally.
948        let m = GroupMatcher::new("ci.release+1");
949        assert!(m.is_match("ci.release+1"));
950        assert!(!m.is_match("ciXreleaseX1"));
951    }
952
953    fn make_server(server_cwd: PathBuf) -> TaskMcpServer {
954        TaskMcpServer::new(Config::default(), server_cwd)
955    }
956
957    fn make_server_with_allowed_dirs(
958        server_cwd: PathBuf,
959        allowed_dirs: Vec<PathBuf>,
960    ) -> TaskMcpServer {
961        let config = Config {
962            allowed_dirs,
963            ..Config::default()
964        };
965        TaskMcpServer::new(config, server_cwd)
966    }
967
968    // -------------------------------------------------------------------------
969    // try_auto_session_start
970    // -------------------------------------------------------------------------
971
972    /// .git ディレクトリがある ProjectRoot で auto-start が成功する。
973    #[tokio::test]
974    async fn test_try_auto_session_start_in_project_root() {
975        let tmpdir = tempfile::tempdir().expect("create tempdir");
976        std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
977
978        let server = make_server(tmpdir.path().to_path_buf());
979        let outcome = server.try_auto_session_start().await;
980
981        match outcome {
982            AutoStartOutcome::Started(resp, _wd) => {
983                assert_eq!(resp.mode, "agent-only");
984            }
985            other => panic!("auto-start should succeed in a ProjectRoot (.git), got {other:?}"),
986        }
987        assert!(
988            server.current_workdir().await.is_some(),
989            "workdir should be set after auto-start"
990        );
991    }
992
993    /// 2回目の呼び出しでは auto-start が発生しない (already started)。
994    #[tokio::test]
995    async fn test_second_call_no_auto_start() {
996        let tmpdir = tempfile::tempdir().expect("create tempdir");
997        std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
998
999        let server = make_server(tmpdir.path().to_path_buf());
1000
1001        // 1回目: auto-start 発生
1002        let (_, auto1) = server
1003            .workdir_or_auto()
1004            .await
1005            .expect("first call should succeed");
1006        assert!(auto1.is_some(), "first call should trigger auto-start");
1007
1008        // 2回目: workdir は既に設定済み → auto なし
1009        let (_, auto2) = server
1010            .workdir_or_auto()
1011            .await
1012            .expect("second call should succeed");
1013        assert!(
1014            auto2.is_none(),
1015            "second call must NOT return auto_session_start"
1016        );
1017    }
1018
1019    /// marker なし (非 ProjectRoot) では auto-start しない → error。
1020    #[tokio::test]
1021    async fn test_no_auto_start_in_non_project_root() {
1022        let tmpdir = tempfile::tempdir().expect("create tempdir");
1023        // .git も justfile も作成しない
1024
1025        let server = make_server(tmpdir.path().to_path_buf());
1026        let result = server.workdir_or_auto().await;
1027
1028        let err = result.expect_err("should fail when no ProjectRoot marker");
1029        assert!(
1030            err.message.contains("not a ProjectRoot"),
1031            "error message should identify 'not a ProjectRoot': {err:?}"
1032        );
1033    }
1034
1035    /// justfile のみ存在する場合でも auto-start が成功する。
1036    #[tokio::test]
1037    async fn test_justfile_marker_also_triggers() {
1038        let tmpdir = tempfile::tempdir().expect("create tempdir");
1039        // .git はなく justfile のみ
1040        std::fs::write(tmpdir.path().join("justfile"), "").expect("create justfile");
1041
1042        let server = make_server(tmpdir.path().to_path_buf());
1043        let outcome = server.try_auto_session_start().await;
1044
1045        assert!(
1046            matches!(outcome, AutoStartOutcome::Started(_, _)),
1047            "auto-start should succeed with only justfile marker, got {outcome:?}"
1048        );
1049    }
1050
1051    /// allowed_dirs 違反の場合は auto-start しない → error (原因が区別される)。
1052    #[tokio::test]
1053    async fn test_allowed_dirs_violation_no_auto_start() {
1054        let tmpdir = tempfile::tempdir().expect("create tempdir");
1055        std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
1056
1057        let other_dir = tempfile::tempdir().expect("create other tempdir");
1058        let allowed = vec![other_dir.path().to_path_buf()];
1059
1060        let server = make_server_with_allowed_dirs(tmpdir.path().to_path_buf(), allowed);
1061        let err = server
1062            .workdir_or_auto()
1063            .await
1064            .expect_err("should fail when server_cwd is not in allowed_dirs");
1065        assert!(
1066            err.message.contains("allowed_dirs"),
1067            "error message should identify the allowed_dirs violation: {err:?}"
1068        );
1069    }
1070
1071    /// HIGH-1 regression: `try_auto_session_start` が並行初期化済みの状態で
1072    /// `AlreadyStarted` を返し、`workdir_or_auto` 経由では誤エラーにならない。
1073    #[tokio::test]
1074    async fn test_auto_start_already_started_variant() {
1075        let tmpdir = tempfile::tempdir().expect("create tempdir");
1076        std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
1077
1078        let server = make_server(tmpdir.path().to_path_buf());
1079
1080        // 並行初期化を模倣: 直接 workdir を設定してから slow path を叩く。
1081        let pre_set = tmpdir.path().join("pre-set");
1082        std::fs::create_dir(&pre_set).expect("create pre-set dir");
1083        server.set_workdir_for_test(pre_set.clone()).await;
1084
1085        let outcome = server.try_auto_session_start().await;
1086        match outcome {
1087            AutoStartOutcome::AlreadyStarted(wd) => assert_eq!(wd, pre_set),
1088            other => panic!("expected AlreadyStarted, got {other:?}"),
1089        }
1090    }
1091
1092    /// ProjectRoot でも明示 session_start (workdir=subdir) 後には auto がない。
1093    #[tokio::test]
1094    async fn test_explicit_session_start_overrides() {
1095        let tmpdir = tempfile::tempdir().expect("create tempdir");
1096        std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
1097
1098        // subdir を作って明示的に workdir をセット
1099        let subdir = tmpdir.path().join("subdir");
1100        std::fs::create_dir(&subdir).expect("create subdir");
1101
1102        let server = make_server(tmpdir.path().to_path_buf());
1103        // 明示的な session_start を模倣して workdir を直接セット
1104        server.set_workdir_for_test(subdir.clone()).await;
1105
1106        // workdir_or_auto を呼んでも auto は発生しない (already started)
1107        let result = server.workdir_or_auto().await;
1108        assert!(result.is_ok());
1109        let (wd, auto) = result.unwrap();
1110        assert!(
1111            auto.is_none(),
1112            "after explicit session_start, auto_session_start must be None"
1113        );
1114        // workdir は subdir (server_cwd ではない)
1115        assert_eq!(
1116            wd, subdir,
1117            "workdir should be the explicitly set subdir, not server_cwd"
1118        );
1119    }
1120}