Skip to main content

git_paw/mcp/tools/
project.rs

1//! Project-knowledge tools: `get_specs`, `get_spec`, `get_tasks`, `get_task`,
2//! `get_dependency_graph`, `get_skill`.
3//!
4//! Spec tools handle all three backends via the shared discovery used by
5//! `git paw start --from-all-specs`. `get_skill` renders a named agent skill
6//! through the existing resolution + `{{...}}` substitution pipeline
7//! (read-only: no disk write, no watcher, no version endpoint).
8
9use rmcp::handler::server::wrapper::{Json, Parameters};
10use rmcp::{schemars, tool, tool_router};
11use serde::{Deserialize, Serialize};
12
13use crate::config;
14use crate::git;
15use crate::mcp::query;
16use crate::mcp::query::specs::DependencyGraph;
17use crate::mcp::server::GitPawMcpServer;
18use crate::skills::{self, GateCommands, Source};
19use crate::specs::{self, SpecBackendKind};
20
21/// Parameters for [`GitPawMcpServer::get_spec`].
22#[derive(Debug, Deserialize, schemars::JsonSchema)]
23pub struct GetSpecParams {
24    /// Spec id (directory or file stem).
25    pub id: String,
26}
27
28/// Parameters for [`GitPawMcpServer::get_tasks`].
29#[derive(Debug, Deserialize, schemars::JsonSchema)]
30pub struct GetTasksParams {
31    /// Spec id whose tasks to return.
32    pub spec: String,
33}
34
35/// Parameters for [`GitPawMcpServer::get_task`].
36#[derive(Debug, Deserialize, schemars::JsonSchema)]
37pub struct GetTaskParams {
38    /// Spec id the task belongs to.
39    pub spec: String,
40    /// Task id (e.g. "T009" for Spec Kit, or the sequence number for `OpenSpec`).
41    pub id: String,
42}
43
44/// Parameters for [`GitPawMcpServer::get_skill`].
45#[derive(Debug, Deserialize, schemars::JsonSchema)]
46pub struct GetSkillParams {
47    /// Skill name (e.g. "coordination", "supervisor").
48    pub name: String,
49}
50
51/// Response for `get_specs`.
52#[derive(Serialize, schemars::JsonSchema)]
53pub struct SpecsResponse {
54    /// Discovered specs.
55    pub specs: Vec<query::specs::SpecInfo>,
56}
57
58/// Response for `get_spec`.
59#[derive(Serialize, schemars::JsonSchema)]
60pub struct SpecResponse {
61    /// Spec detail, or null when not found.
62    pub spec: Option<query::specs::SpecDetail>,
63}
64
65/// Response for `get_tasks`.
66#[derive(Serialize, schemars::JsonSchema)]
67pub struct TasksResponse {
68    /// Tasks for the spec.
69    pub tasks: Vec<query::specs::TaskInfo>,
70}
71
72/// Response for `get_task`.
73#[derive(Serialize, schemars::JsonSchema)]
74pub struct TaskResponse {
75    /// Matching task, or null.
76    pub task: Option<query::specs::TaskInfo>,
77}
78
79/// A rendered skill view.
80#[derive(Serialize, schemars::JsonSchema)]
81pub struct SkillView {
82    /// Skill name.
83    pub name: String,
84    /// Rendered content (post `{{...}}` substitution).
85    pub content: String,
86    /// Source: "standard" | "`user_override`" | "embedded".
87    pub source: String,
88}
89
90/// Response for `get_skill`.
91#[derive(Serialize, schemars::JsonSchema)]
92pub struct SkillResponse {
93    /// Rendered skill, or null when unknown/unrenderable.
94    pub skill: Option<SkillView>,
95    /// Human-readable note when `skill` is null.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub message: Option<String>,
98}
99
100#[tool_router(router = project_router, vis = "pub(crate)")]
101impl GitPawMcpServer {
102    /// `get_specs` — discovered specs across all backends.
103    #[tool(
104        description = "List discovered specs across OpenSpec, Markdown, and Spec Kit backends. \
105                       Each carries id, backend, title, status, and path. Empty when none exist."
106    )]
107    pub(crate) fn get_specs(&self) -> Json<SpecsResponse> {
108        Json(SpecsResponse {
109            specs: query::specs::list_specs(&self.ctx),
110        })
111    }
112
113    /// `get_spec` — full content of a named spec.
114    #[tool(
115        description = "Return the discovered artifacts (proposal/design/tasks/specs for OpenSpec; \
116                       spec/plan/tasks/checklists for Spec Kit; body for Markdown) of a named spec \
117                       with their content, or { \"spec\": null } when not found."
118    )]
119    pub(crate) fn get_spec(&self, Parameters(p): Parameters<GetSpecParams>) -> Json<SpecResponse> {
120        Json(SpecResponse {
121            spec: query::specs::get_spec(&self.ctx, &p.id),
122        })
123    }
124
125    /// `get_tasks` — tasks for a named spec.
126    #[tool(
127        description = "List the tasks for a named spec: id, phase, parallel marker, description, \
128                       and completion state. Empty when the spec has no tasks or is not found."
129    )]
130    pub(crate) fn get_tasks(
131        &self,
132        Parameters(p): Parameters<GetTasksParams>,
133    ) -> Json<TasksResponse> {
134        Json(TasksResponse {
135            tasks: query::specs::get_tasks(&self.ctx, &p.spec),
136        })
137    }
138
139    /// `get_task` — a single task within a spec.
140    #[tool(
141        description = "Return a single task by id within a spec, or { \"task\": null } when the \
142                       spec or task id is not found."
143    )]
144    pub(crate) fn get_task(&self, Parameters(p): Parameters<GetTaskParams>) -> Json<TaskResponse> {
145        let task = query::specs::get_tasks(&self.ctx, &p.spec)
146            .into_iter()
147            .find(|t| t.id == p.id);
148        Json(TaskResponse { task })
149    }
150
151    /// `get_dependency_graph` — inter-spec `[[ref]]` dependency graph.
152    #[tool(
153        description = "Return the spec dependency graph derived from [[other-spec]] cross-references \
154                       in proposals, as { nodes, edges }."
155    )]
156    pub(crate) fn get_dependency_graph(&self) -> Json<DependencyGraph> {
157        Json(query::specs::dependency_graph(&self.ctx))
158    }
159
160    /// `get_skill` — rendered content of a named agent skill.
161    #[tool(
162        description = "Return the rendered content of a named agent skill (post {{...}} \
163                       substitution) plus its source (standard | user_override | embedded). \
164                       Read-only — no disk write. Unknown skills return { \"skill\": null } with a \
165                       message, not a transport error."
166    )]
167    pub(crate) fn get_skill(
168        &self,
169        Parameters(p): Parameters<GetSkillParams>,
170    ) -> Json<SkillResponse> {
171        let root = &self.ctx.root;
172        match skills::resolve(&p.name) {
173            Ok(template) => {
174                let cfg = config::load_config(root, None).unwrap_or_default();
175                let project = git::project_name(root);
176                let branch = git::current_branch(root).unwrap_or_else(|_| "main".to_string());
177                let broker_url = self
178                    .ctx
179                    .broker_url
180                    .clone()
181                    .unwrap_or_else(|| "http://127.0.0.1:9119".to_string());
182                let backends = match specs::resolved_spec_type(&cfg, root).as_deref() {
183                    Some("speckit") => vec![SpecBackendKind::SpecKit],
184                    Some("markdown") => vec![SpecBackendKind::Markdown],
185                    Some("openspec") => vec![SpecBackendKind::OpenSpec],
186                    _ => Vec::new(),
187                };
188                // Read-only render: gate commands are not wired here (the MCP
189                // view is a static skill preview, not a launch), so they render
190                // as "(not configured)".
191                let gates = GateCommands {
192                    test_command: None,
193                    lint_command: None,
194                    build_command: None,
195                    doc_build_command: None,
196                    spec_validate_command: None,
197                    fmt_check_command: None,
198                    security_audit_command: None,
199                    doc_tool_command: None,
200                };
201                let content =
202                    skills::render(&template, &branch, &broker_url, &project, &gates, &backends);
203                let source = match template.source {
204                    Source::Embedded => "embedded",
205                    Source::AgentsStandard => "standard",
206                    Source::User => "user_override",
207                };
208                Json(SkillResponse {
209                    skill: Some(SkillView {
210                        name: template.name,
211                        content,
212                        source: source.to_string(),
213                    }),
214                    message: None,
215                })
216            }
217            Err(skills::SkillError::UnknownSkill { name }) => Json(SkillResponse {
218                skill: None,
219                message: Some(format!("unknown skill: {name}")),
220            }),
221            Err(e) => Json(SkillResponse {
222                skill: None,
223                message: Some(e.to_string()),
224            }),
225        }
226    }
227}