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// Public entry point
27// =============================================================================
28
29pub async fn run() -> anyhow::Result<()> {
30    let config = Config::load();
31    let server_cwd = std::env::current_dir()?;
32    let server = TaskMcpServer::new(config, server_cwd);
33    let service = server.serve(stdio()).await?;
34    service.waiting().await?;
35    Ok(())
36}
37
38// =============================================================================
39// MCP Server
40// =============================================================================
41
42#[derive(Clone)]
43pub struct TaskMcpServer {
44    tool_router: ToolRouter<Self>,
45    config: Config,
46    log_store: Arc<just::TaskLogStore>,
47    /// Runtime working directory set by `session_start`.
48    workdir: Arc<RwLock<Option<PathBuf>>>,
49    /// CWD at server startup (used as default when session_start omits workdir).
50    server_cwd: PathBuf,
51}
52
53/// Outcome of a lazy auto-session-start attempt.
54///
55/// Distinguishes the reasons the server may decline to auto-start so that
56/// callers can produce actionable error messages and — crucially — recover
57/// when a concurrent task has already initialized the session.
58#[derive(Debug)]
59pub(crate) enum AutoStartOutcome {
60    /// Session was newly initialized by this call.
61    Started(SessionStartResponse, PathBuf),
62    /// Session had already been initialized (by a concurrent task) when we
63    /// acquired the write lock. The existing workdir is returned so the
64    /// caller can proceed without a spurious error.
65    AlreadyStarted(PathBuf),
66    /// `server_cwd` is not a ProjectRoot (no `.git`/`justfile` marker).
67    NotProjectRoot,
68    /// `server_cwd` could not be canonicalized.
69    CanonicalizeFailed(std::io::Error),
70    /// `server_cwd` is not in `allowed_dirs`.
71    NotAllowed(PathBuf),
72}
73
74impl TaskMcpServer {
75    pub fn new(config: Config, server_cwd: PathBuf) -> Self {
76        Self {
77            tool_router: Self::tool_router(),
78            config,
79            log_store: Arc::new(just::TaskLogStore::new(10)),
80            workdir: Arc::new(RwLock::new(None)),
81            server_cwd,
82        }
83    }
84
85    /// Try to auto-start a session using `server_cwd` as the workdir.
86    ///
87    /// See [`AutoStartOutcome`] for the return variants.
88    pub(crate) async fn try_auto_session_start(&self) -> AutoStartOutcome {
89        // Check if server_cwd is a ProjectRoot (.git or justfile must exist)
90        let has_git = tokio::fs::try_exists(self.server_cwd.join(".git"))
91            .await
92            .unwrap_or(false);
93        let has_justfile = tokio::fs::try_exists(self.server_cwd.join("justfile"))
94            .await
95            .unwrap_or(false);
96        if !has_git && !has_justfile {
97            return AutoStartOutcome::NotProjectRoot;
98        }
99
100        // Canonicalize server_cwd
101        let canonical = match tokio::fs::canonicalize(&self.server_cwd).await {
102            Ok(p) => p,
103            Err(e) => return AutoStartOutcome::CanonicalizeFailed(e),
104        };
105
106        // Check allowed_dirs
107        if !self.config.is_workdir_allowed(&canonical) {
108            return AutoStartOutcome::NotAllowed(canonical);
109        }
110
111        // Double-checked locking: acquire write lock, then re-check None.
112        // If another task initialized the session between our fast-path read
113        // and this write lock, return AlreadyStarted so the caller can reuse
114        // the existing workdir instead of reporting a spurious error.
115        let mut guard = self.workdir.write().await;
116        if let Some(ref existing) = *guard {
117            return AutoStartOutcome::AlreadyStarted(existing.clone());
118        }
119        *guard = Some(canonical.clone());
120        drop(guard);
121
122        let justfile =
123            resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), &canonical);
124
125        AutoStartOutcome::Started(
126            SessionStartResponse {
127                workdir: canonical.to_string_lossy().into_owned(),
128                justfile: justfile.to_string_lossy().into_owned(),
129                mode: mode_label(&self.config),
130            },
131            canonical,
132        )
133    }
134
135    /// Return the current session workdir (with optional auto-start) and the auto-start response.
136    ///
137    /// - If session is already started, returns `(workdir, None)`.
138    /// - If not started and `server_cwd` is a ProjectRoot, auto-starts and returns `(workdir, Some(response))`.
139    /// - If a concurrent task initialized the session while we were in the slow path, returns `(workdir, None)`.
140    /// - Otherwise returns a specific error describing which precondition failed.
141    pub(crate) async fn workdir_or_auto(
142        &self,
143    ) -> Result<(PathBuf, Option<SessionStartResponse>), McpError> {
144        // Fast path: session already started
145        {
146            let guard = self.workdir.read().await;
147            if let Some(ref wd) = *guard {
148                return Ok((wd.clone(), None));
149            }
150        }
151
152        // Slow path: try auto-start
153        match self.try_auto_session_start().await {
154            AutoStartOutcome::Started(resp, wd) => Ok((wd, Some(resp))),
155            AutoStartOutcome::AlreadyStarted(wd) => Ok((wd, None)),
156            AutoStartOutcome::NotProjectRoot => Err(McpError::internal_error(
157                format!(
158                    "session not started. server startup CWD {:?} is not a ProjectRoot (no .git or justfile). Call session_start with an explicit workdir.",
159                    self.server_cwd
160                ),
161                None,
162            )),
163            AutoStartOutcome::CanonicalizeFailed(e) => Err(McpError::internal_error(
164                format!(
165                    "session not started. failed to canonicalize server startup CWD {:?}: {e}. Call session_start with an explicit workdir.",
166                    self.server_cwd
167                ),
168                None,
169            )),
170            AutoStartOutcome::NotAllowed(path) => Err(McpError::internal_error(
171                format!(
172                    "session not started. server startup CWD {:?} is not in allowed_dirs. Call session_start with an allowed workdir.",
173                    path
174                ),
175                None,
176            )),
177        }
178    }
179}
180
181// =============================================================================
182// ServerHandler impl
183// =============================================================================
184
185impl ServerHandler for TaskMcpServer {
186    fn get_info(&self) -> ServerInfo {
187        ServerInfo {
188            protocol_version: ProtocolVersion::V_2025_03_26,
189            capabilities: ServerCapabilities::builder().enable_tools().build(),
190            server_info: Implementation {
191                name: "task-mcp".to_string(),
192                title: Some("Task MCP — Agent-safe Task Runner".to_string()),
193                description: Some(
194                    "Execute predefined justfile tasks safely. \
195                     6 tools: session_start, info, init, list, run, logs."
196                        .to_string(),
197                ),
198                version: env!("CARGO_PKG_VERSION").to_string(),
199                icons: None,
200                website_url: None,
201            },
202            instructions: Some(
203                "Agent-safe task runner backed by just.\n\
204                 \n\
205                 - `session_start`: Set working directory explicitly. Optional when the \
206                 server was launched inside a ProjectRoot (a directory containing `.git` \
207                 or `justfile`) — in that case the first `init`/`list`/`run` call auto-starts \
208                 the session using the server's startup CWD. Call `session_start` explicitly \
209                 only when you need a different workdir (e.g. a subdirectory in a monorepo).\n\
210                 - `info`: Show current session state (workdir, mode, etc).\n\
211                 - `init`: Generate a justfile in the working directory.\n\
212                 - `list`: Show available tasks filtered by [allow-agent] tag.\n\
213                 - `run`: Execute a named task.\n\
214                 - `logs`: Retrieve execution logs of recent runs.\n\
215                 \n\
216                 When a call auto-starts the session, the response includes an \
217                 `auto_session_start` field with the chosen workdir, justfile, and mode. \
218                 Subsequent calls in the same session do not include this field.\n\
219                 \n\
220                 Only tasks explicitly marked agent-safe are exposed in default mode."
221                    .to_string(),
222            ),
223        }
224    }
225
226    async fn list_tools(
227        &self,
228        _request: Option<PaginatedRequestParams>,
229        _context: RequestContext<RoleServer>,
230    ) -> Result<ListToolsResult, McpError> {
231        Ok(ListToolsResult {
232            tools: self.tool_router.list_all(),
233            next_cursor: None,
234            meta: None,
235        })
236    }
237
238    async fn call_tool(
239        &self,
240        request: CallToolRequestParams,
241        context: RequestContext<RoleServer>,
242    ) -> Result<CallToolResult, McpError> {
243        let tool_ctx = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
244        self.tool_router.call(tool_ctx).await
245    }
246}
247
248// =============================================================================
249// Request / Response types
250// =============================================================================
251
252#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
253struct SessionStartRequest {
254    /// Working directory path. If omitted or empty, defaults to the server's startup CWD.
255    pub workdir: Option<String>,
256}
257
258/// Response payload describing a session's working directory, resolved justfile,
259/// and active task mode. Returned by `session_start`, and also embedded as
260/// `auto_session_start` in other tools' responses when the session was
261/// automatically started by that call.
262#[derive(Debug, Clone, Serialize)]
263pub(crate) struct SessionStartResponse {
264    /// Canonicalized absolute path of the working directory.
265    pub workdir: String,
266    /// Resolved path to the justfile used in this session.
267    pub justfile: String,
268    /// Active task mode: "agent-only" or "all".
269    pub mode: String,
270}
271
272#[derive(Debug, Clone, Serialize)]
273struct InfoResponse {
274    /// Whether session_start has been called.
275    pub session_started: bool,
276    /// Current working directory (None if session_start not yet called).
277    pub workdir: Option<String>,
278    /// Resolved justfile path (None if session_start not yet called).
279    pub justfile: Option<String>,
280    /// Active task mode: "agent-only" or "all".
281    pub mode: String,
282    /// CWD at server startup.
283    pub server_cwd: String,
284    /// Whether global justfile merging is enabled.
285    pub load_global: bool,
286    /// Resolved global justfile path (None when load_global=false or global file not found).
287    pub global_justfile: Option<String>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
291struct InitRequest {
292    /// Project type: "rust" (default) or "vite-react".
293    pub project_type: Option<String>,
294    /// Path to a custom template file (must be under session workdir). Overrides TASK_MCP_INIT_TEMPLATE_FILE env.
295    pub template_file: Option<String>,
296}
297
298#[derive(Debug, Clone, Serialize)]
299struct InitResponse {
300    /// Path to the generated justfile.
301    pub justfile: String,
302    /// Project type used for template selection.
303    pub project_type: String,
304    /// Whether a custom template file was used.
305    pub custom_template: bool,
306    /// Present only when this call triggered an automatic session_start because
307    /// no session was active and the server's startup CWD is a ProjectRoot
308    /// (contains `.git` or `justfile`). Contains the resolved workdir, justfile
309    /// path, and mode. Absent on subsequent calls within the same session.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub auto_session_start: Option<SessionStartResponse>,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
315struct ListRequest {
316    /// Filter recipes by group name. If omitted, all agent-safe recipes are returned.
317    pub filter: Option<String>,
318    /// Justfile path override. Defaults to `justfile` in the current directory.
319    pub justfile: Option<String>,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
323struct RunRequest {
324    /// Name of the recipe to execute (must appear in `list` output).
325    pub task_name: String,
326    /// Named arguments to pass to the recipe.  Keys must match recipe parameter names.
327    pub args: Option<HashMap<String, String>>,
328    /// Execution timeout in seconds (default: 60).
329    pub timeout_secs: Option<u64>,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
333struct LogsRequest {
334    /// Retrieve the full output of a specific execution by its UUID.
335    /// If omitted, a summary of the 10 most recent executions is returned.
336    pub task_id: Option<String>,
337    /// When a `task_id` is provided, restrict the stdout to the last N lines.
338    /// Ignored when `task_id` is absent.
339    pub tail: Option<usize>,
340}
341
342// =============================================================================
343// Helpers
344// =============================================================================
345
346/// Resolve the justfile path taking a session workdir into account.
347///
348/// If `override_path` is provided it is used as-is (absolute or relative to CWD).
349/// Otherwise `{workdir}/justfile` is returned.
350fn resolve_justfile_with_workdir(
351    override_path: Option<&str>,
352    workdir: &std::path::Path,
353) -> PathBuf {
354    match override_path {
355        Some(p) => PathBuf::from(p),
356        None => workdir.join("justfile"),
357    }
358}
359
360/// Return the last `n` lines of `text`.  If `n` exceeds the line count, the
361/// full text is returned unchanged.
362fn tail_lines(text: &str, n: usize) -> String {
363    let lines: Vec<&str> = text.lines().collect();
364    if n >= lines.len() {
365        return text.to_string();
366    }
367    lines[lines.len() - n..].join("\n")
368}
369
370fn mode_label(config: &Config) -> String {
371    use crate::config::TaskMode;
372    match config.mode {
373        TaskMode::AgentOnly => "agent-only".to_string(),
374        TaskMode::All => "all".to_string(),
375    }
376}
377
378// =============================================================================
379// Tool implementations
380// =============================================================================
381
382#[tool_router]
383impl TaskMcpServer {
384    #[tool(
385        name = "session_start",
386        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.",
387        annotations(
388            read_only_hint = false,
389            destructive_hint = false,
390            idempotent_hint = true,
391            open_world_hint = false
392        )
393    )]
394    async fn session_start(
395        &self,
396        Parameters(req): Parameters<SessionStartRequest>,
397    ) -> Result<CallToolResult, McpError> {
398        // Determine the target directory: use provided path or fall back to server CWD.
399        let raw_path = match req.workdir.as_deref() {
400            Some(s) if !s.trim().is_empty() => PathBuf::from(s),
401            _ => self.server_cwd.clone(),
402        };
403
404        // Canonicalize (resolves symlinks, checks existence).
405        let canonical = tokio::fs::canonicalize(&raw_path).await.map_err(|e| {
406            McpError::invalid_params(
407                format!(
408                    "workdir {:?} does not exist or is not accessible: {e}",
409                    raw_path
410                ),
411                None,
412            )
413        })?;
414
415        // Verify against allowed_dirs.
416        if !self.config.is_workdir_allowed(&canonical) {
417            return Err(McpError::invalid_params(
418                format!(
419                    "workdir {:?} is not in the allowed directories list",
420                    canonical
421                ),
422                None,
423            ));
424        }
425
426        // Persist in session state.
427        *self.workdir.write().await = Some(canonical.clone());
428
429        let justfile =
430            resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), &canonical);
431
432        let response = SessionStartResponse {
433            workdir: canonical.to_string_lossy().into_owned(),
434            justfile: justfile.to_string_lossy().into_owned(),
435            mode: mode_label(&self.config),
436        };
437
438        let output = serde_json::to_string_pretty(&response)
439            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
440
441        Ok(CallToolResult {
442            content: vec![Content::text(output)],
443            structured_content: None,
444            is_error: Some(false),
445            meta: None,
446        })
447    }
448
449    #[tool(
450        name = "init",
451        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.",
452        annotations(
453            read_only_hint = false,
454            destructive_hint = false,
455            idempotent_hint = false,
456            open_world_hint = false
457        )
458    )]
459    async fn init(
460        &self,
461        Parameters(req): Parameters<InitRequest>,
462    ) -> Result<CallToolResult, McpError> {
463        let (workdir, auto) = self.workdir_or_auto().await?;
464
465        // Parse project type
466        let project_type = match req.project_type.as_deref() {
467            Some(s) => s
468                .parse::<template::ProjectType>()
469                .map_err(|e| McpError::invalid_params(e, None))?,
470            None => template::ProjectType::default(),
471        };
472
473        let justfile_path = workdir.join("justfile");
474
475        // Reject if justfile already exists
476        if justfile_path.exists() {
477            return Err(McpError::invalid_params(
478                format!(
479                    "justfile already exists at {}. Delete it first if you want to regenerate.",
480                    justfile_path.display()
481                ),
482                None,
483            ));
484        }
485
486        // Validate template_file is under workdir (prevent path traversal)
487        if let Some(ref tf) = req.template_file {
488            let template_path = std::fs::canonicalize(tf).map_err(|e| {
489                McpError::invalid_params(
490                    format!("template_file {tf:?} is not accessible: {e}"),
491                    None,
492                )
493            })?;
494            if !template_path.starts_with(&workdir) {
495                return Err(McpError::invalid_params(
496                    format!(
497                        "template_file must be under session workdir ({}). Got: {}",
498                        workdir.display(),
499                        template_path.display()
500                    ),
501                    None,
502                ));
503            }
504        }
505
506        // Resolve template content
507        let custom_template_used =
508            req.template_file.is_some() || self.config.init_template_file.is_some();
509
510        let content = template::resolve_template(
511            project_type,
512            req.template_file.as_deref(),
513            self.config.init_template_file.as_deref(),
514        )
515        .await
516        .map_err(|e| McpError::internal_error(e.to_string(), None))?;
517
518        // Write justfile
519        tokio::fs::write(&justfile_path, &content)
520            .await
521            .map_err(|e| {
522                McpError::internal_error(
523                    format!(
524                        "failed to write justfile at {}: {e}",
525                        justfile_path.display()
526                    ),
527                    None,
528                )
529            })?;
530
531        let response = InitResponse {
532            justfile: justfile_path.to_string_lossy().into_owned(),
533            project_type: project_type.to_string(),
534            custom_template: custom_template_used,
535            auto_session_start: auto,
536        };
537
538        let output = serde_json::to_string_pretty(&response)
539            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
540
541        Ok(CallToolResult {
542            content: vec![Content::text(output)],
543            structured_content: None,
544            is_error: Some(false),
545            meta: None,
546        })
547    }
548
549    #[tool(
550        name = "info",
551        description = "Show current session state: workdir, justfile path, mode, and server startup CWD.",
552        annotations(
553            read_only_hint = true,
554            destructive_hint = false,
555            idempotent_hint = true,
556            open_world_hint = false
557        )
558    )]
559    async fn info(&self) -> Result<CallToolResult, McpError> {
560        let current_workdir = self.workdir.read().await.clone();
561
562        let (session_started, workdir_str, justfile_str) = match current_workdir {
563            Some(ref wd) => {
564                let justfile =
565                    resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), wd);
566                (
567                    true,
568                    Some(wd.to_string_lossy().into_owned()),
569                    Some(justfile.to_string_lossy().into_owned()),
570                )
571            }
572            None => (false, None, None),
573        };
574
575        let global_justfile_str = self
576            .config
577            .global_justfile_path
578            .as_ref()
579            .map(|p| p.to_string_lossy().into_owned());
580
581        let response = InfoResponse {
582            session_started,
583            workdir: workdir_str,
584            justfile: justfile_str,
585            mode: mode_label(&self.config),
586            server_cwd: self.server_cwd.to_string_lossy().into_owned(),
587            load_global: self.config.load_global,
588            global_justfile: global_justfile_str,
589        };
590
591        let output = serde_json::to_string_pretty(&response)
592            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
593
594        Ok(CallToolResult {
595            content: vec![Content::text(output)],
596            structured_content: None,
597            is_error: Some(false),
598            meta: None,
599        })
600    }
601
602    #[tool(
603        name = "list",
604        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.",
605        annotations(
606            read_only_hint = true,
607            destructive_hint = false,
608            idempotent_hint = true,
609            open_world_hint = false
610        )
611    )]
612    async fn list(
613        &self,
614        Parameters(req): Parameters<ListRequest>,
615    ) -> Result<CallToolResult, McpError> {
616        let (justfile_path, workdir_opt, auto) = if req.justfile.is_some() {
617            // justfile parameter specified → session_start not required
618            let jp = just::resolve_justfile_path(
619                req.justfile
620                    .as_deref()
621                    .or(self.config.justfile_path.as_deref()),
622                None,
623            );
624            (jp, None, None)
625        } else {
626            // no justfile parameter → session_start is required (or auto-started)
627            let (wd, auto) = self.workdir_or_auto().await?;
628            let jp = just::resolve_justfile_path(self.config.justfile_path.as_deref(), Some(&wd));
629            (jp, Some(wd), auto)
630        };
631
632        let recipes = if self.config.load_global {
633            just::list_recipes_merged(
634                &justfile_path,
635                self.config.global_justfile_path.as_deref(),
636                &self.config.mode,
637                workdir_opt.as_deref(),
638            )
639            .await
640            .map_err(|e| McpError::internal_error(e.to_string(), None))?
641        } else {
642            just::list_recipes(&justfile_path, &self.config.mode, workdir_opt.as_deref())
643                .await
644                .map_err(|e| McpError::internal_error(e.to_string(), None))?
645        };
646
647        // Apply optional group filter
648        let filtered: Vec<_> = match &req.filter {
649            Some(group) => recipes
650                .into_iter()
651                .filter(|r| r.groups.iter().any(|g| g == group))
652                .collect(),
653            None => recipes,
654        };
655
656        let mut wrapped = serde_json::json!({ "recipes": filtered });
657        if let Some(auto_response) = auto {
658            wrapped.as_object_mut().expect("json object").insert(
659                "auto_session_start".to_string(),
660                serde_json::to_value(auto_response)
661                    .map_err(|e| McpError::internal_error(e.to_string(), None))?,
662            );
663        }
664        let output = serde_json::to_string_pretty(&wrapped)
665            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
666
667        Ok(CallToolResult {
668            content: vec![Content::text(output)],
669            structured_content: None,
670            is_error: Some(false),
671            meta: None,
672        })
673    }
674
675    #[tool(
676        name = "run",
677        description = "Execute a predefined task. Only tasks visible in `list` can be run.",
678        annotations(
679            read_only_hint = false,
680            destructive_hint = true,
681            idempotent_hint = false,
682            open_world_hint = false
683        )
684    )]
685    async fn run(
686        &self,
687        Parameters(req): Parameters<RunRequest>,
688    ) -> Result<CallToolResult, McpError> {
689        // session_start is required for run (auto-start if ProjectRoot)
690        let (workdir, auto) = self.workdir_or_auto().await?;
691        let justfile_path =
692            just::resolve_justfile_path(self.config.justfile_path.as_deref(), Some(&workdir));
693        let args = req.args.unwrap_or_default();
694        let timeout = Duration::from_secs(req.timeout_secs.unwrap_or(60));
695
696        let execution = if self.config.load_global {
697            just::execute_recipe_merged(
698                &req.task_name,
699                &args,
700                &justfile_path,
701                self.config.global_justfile_path.as_deref(),
702                timeout,
703                &self.config.mode,
704                Some(&workdir),
705            )
706            .await
707            .map_err(|e| McpError::internal_error(e.to_string(), None))?
708        } else {
709            just::execute_recipe(
710                &req.task_name,
711                &args,
712                &justfile_path,
713                timeout,
714                &self.config.mode,
715                Some(&workdir),
716            )
717            .await
718            .map_err(|e| McpError::internal_error(e.to_string(), None))?
719        };
720
721        // Persist to log store
722        self.log_store.push(execution.clone());
723
724        let is_error = execution.exit_code.map(|c| c != 0).unwrap_or(true);
725
726        let output = match auto {
727            Some(auto_response) => {
728                let mut val = serde_json::to_value(&execution)
729                    .map_err(|e| McpError::internal_error(e.to_string(), None))?;
730                if let Some(obj) = val.as_object_mut() {
731                    obj.insert(
732                        "auto_session_start".to_string(),
733                        serde_json::to_value(auto_response)
734                            .map_err(|e| McpError::internal_error(e.to_string(), None))?,
735                    );
736                }
737                serde_json::to_string_pretty(&val)
738                    .map_err(|e| McpError::internal_error(e.to_string(), None))?
739            }
740            None => serde_json::to_string_pretty(&execution)
741                .map_err(|e| McpError::internal_error(e.to_string(), None))?,
742        };
743
744        Ok(CallToolResult {
745            content: vec![Content::text(output)],
746            structured_content: None,
747            is_error: Some(is_error),
748            meta: None,
749        })
750    }
751
752    #[tool(
753        name = "logs",
754        description = "Retrieve execution logs. Returns recent task execution results.",
755        annotations(
756            read_only_hint = true,
757            destructive_hint = false,
758            idempotent_hint = true,
759            open_world_hint = false
760        )
761    )]
762    async fn logs(
763        &self,
764        Parameters(req): Parameters<LogsRequest>,
765    ) -> Result<CallToolResult, McpError> {
766        let output = match req.task_id.as_deref() {
767            Some(id) => {
768                match self.log_store.get(id) {
769                    None => {
770                        return Err(McpError::internal_error(
771                            format!("execution not found: {id}"),
772                            None,
773                        ));
774                    }
775                    Some(mut execution) => {
776                        // Apply tail filter if requested
777                        if let Some(n) = req.tail {
778                            execution.stdout = tail_lines(&execution.stdout, n);
779                        }
780                        serde_json::to_string_pretty(&execution)
781                            .map_err(|e| McpError::internal_error(e.to_string(), None))?
782                    }
783                }
784            }
785            None => {
786                let summaries = self.log_store.recent(10);
787                serde_json::to_string_pretty(&summaries)
788                    .map_err(|e| McpError::internal_error(e.to_string(), None))?
789            }
790        };
791
792        Ok(CallToolResult {
793            content: vec![Content::text(output)],
794            structured_content: None,
795            is_error: Some(false),
796            meta: None,
797        })
798    }
799}
800
801// =============================================================================
802// Tests
803// =============================================================================
804
805#[cfg(test)]
806impl TaskMcpServer {
807    /// Set the workdir directly (test-only helper).
808    pub(crate) async fn set_workdir_for_test(&self, path: PathBuf) {
809        *self.workdir.write().await = Some(path);
810    }
811
812    /// Read the current workdir (test-only helper).
813    pub(crate) async fn current_workdir(&self) -> Option<PathBuf> {
814        self.workdir.read().await.clone()
815    }
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821
822    fn make_server(server_cwd: PathBuf) -> TaskMcpServer {
823        TaskMcpServer::new(Config::default(), server_cwd)
824    }
825
826    fn make_server_with_allowed_dirs(
827        server_cwd: PathBuf,
828        allowed_dirs: Vec<PathBuf>,
829    ) -> TaskMcpServer {
830        let config = Config {
831            allowed_dirs,
832            ..Config::default()
833        };
834        TaskMcpServer::new(config, server_cwd)
835    }
836
837    // -------------------------------------------------------------------------
838    // try_auto_session_start
839    // -------------------------------------------------------------------------
840
841    /// .git ディレクトリがある ProjectRoot で auto-start が成功する。
842    #[tokio::test]
843    async fn test_try_auto_session_start_in_project_root() {
844        let tmpdir = tempfile::tempdir().expect("create tempdir");
845        std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
846
847        let server = make_server(tmpdir.path().to_path_buf());
848        let outcome = server.try_auto_session_start().await;
849
850        match outcome {
851            AutoStartOutcome::Started(resp, _wd) => {
852                assert_eq!(resp.mode, "agent-only");
853            }
854            other => panic!("auto-start should succeed in a ProjectRoot (.git), got {other:?}"),
855        }
856        assert!(
857            server.current_workdir().await.is_some(),
858            "workdir should be set after auto-start"
859        );
860    }
861
862    /// 2回目の呼び出しでは auto-start が発生しない (already started)。
863    #[tokio::test]
864    async fn test_second_call_no_auto_start() {
865        let tmpdir = tempfile::tempdir().expect("create tempdir");
866        std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
867
868        let server = make_server(tmpdir.path().to_path_buf());
869
870        // 1回目: auto-start 発生
871        let (_, auto1) = server
872            .workdir_or_auto()
873            .await
874            .expect("first call should succeed");
875        assert!(auto1.is_some(), "first call should trigger auto-start");
876
877        // 2回目: workdir は既に設定済み → auto なし
878        let (_, auto2) = server
879            .workdir_or_auto()
880            .await
881            .expect("second call should succeed");
882        assert!(
883            auto2.is_none(),
884            "second call must NOT return auto_session_start"
885        );
886    }
887
888    /// marker なし (非 ProjectRoot) では auto-start しない → error。
889    #[tokio::test]
890    async fn test_no_auto_start_in_non_project_root() {
891        let tmpdir = tempfile::tempdir().expect("create tempdir");
892        // .git も justfile も作成しない
893
894        let server = make_server(tmpdir.path().to_path_buf());
895        let result = server.workdir_or_auto().await;
896
897        let err = result.expect_err("should fail when no ProjectRoot marker");
898        assert!(
899            err.message.contains("not a ProjectRoot"),
900            "error message should identify 'not a ProjectRoot': {err:?}"
901        );
902    }
903
904    /// justfile のみ存在する場合でも auto-start が成功する。
905    #[tokio::test]
906    async fn test_justfile_marker_also_triggers() {
907        let tmpdir = tempfile::tempdir().expect("create tempdir");
908        // .git はなく justfile のみ
909        std::fs::write(tmpdir.path().join("justfile"), "").expect("create justfile");
910
911        let server = make_server(tmpdir.path().to_path_buf());
912        let outcome = server.try_auto_session_start().await;
913
914        assert!(
915            matches!(outcome, AutoStartOutcome::Started(_, _)),
916            "auto-start should succeed with only justfile marker, got {outcome:?}"
917        );
918    }
919
920    /// allowed_dirs 違反の場合は auto-start しない → error (原因が区別される)。
921    #[tokio::test]
922    async fn test_allowed_dirs_violation_no_auto_start() {
923        let tmpdir = tempfile::tempdir().expect("create tempdir");
924        std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
925
926        let other_dir = tempfile::tempdir().expect("create other tempdir");
927        let allowed = vec![other_dir.path().to_path_buf()];
928
929        let server = make_server_with_allowed_dirs(tmpdir.path().to_path_buf(), allowed);
930        let err = server
931            .workdir_or_auto()
932            .await
933            .expect_err("should fail when server_cwd is not in allowed_dirs");
934        assert!(
935            err.message.contains("allowed_dirs"),
936            "error message should identify the allowed_dirs violation: {err:?}"
937        );
938    }
939
940    /// HIGH-1 regression: `try_auto_session_start` が並行初期化済みの状態で
941    /// `AlreadyStarted` を返し、`workdir_or_auto` 経由では誤エラーにならない。
942    #[tokio::test]
943    async fn test_auto_start_already_started_variant() {
944        let tmpdir = tempfile::tempdir().expect("create tempdir");
945        std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
946
947        let server = make_server(tmpdir.path().to_path_buf());
948
949        // 並行初期化を模倣: 直接 workdir を設定してから slow path を叩く。
950        let pre_set = tmpdir.path().join("pre-set");
951        std::fs::create_dir(&pre_set).expect("create pre-set dir");
952        server.set_workdir_for_test(pre_set.clone()).await;
953
954        let outcome = server.try_auto_session_start().await;
955        match outcome {
956            AutoStartOutcome::AlreadyStarted(wd) => assert_eq!(wd, pre_set),
957            other => panic!("expected AlreadyStarted, got {other:?}"),
958        }
959    }
960
961    /// ProjectRoot でも明示 session_start (workdir=subdir) 後には auto がない。
962    #[tokio::test]
963    async fn test_explicit_session_start_overrides() {
964        let tmpdir = tempfile::tempdir().expect("create tempdir");
965        std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
966
967        // subdir を作って明示的に workdir をセット
968        let subdir = tmpdir.path().join("subdir");
969        std::fs::create_dir(&subdir).expect("create subdir");
970
971        let server = make_server(tmpdir.path().to_path_buf());
972        // 明示的な session_start を模倣して workdir を直接セット
973        server.set_workdir_for_test(subdir.clone()).await;
974
975        // workdir_or_auto を呼んでも auto は発生しない (already started)
976        let result = server.workdir_or_auto().await;
977        assert!(result.is_ok());
978        let (wd, auto) = result.unwrap();
979        assert!(
980            auto.is_none(),
981            "after explicit session_start, auto_session_start must be None"
982        );
983        // workdir は subdir (server_cwd ではない)
984        assert_eq!(
985            wd, subdir,
986            "workdir should be the explicitly set subdir, not server_cwd"
987        );
988    }
989}