1use 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#[derive(Debug, Deserialize, schemars::JsonSchema)]
23pub struct GetSpecParams {
24 pub id: String,
26}
27
28#[derive(Debug, Deserialize, schemars::JsonSchema)]
30pub struct GetTasksParams {
31 pub spec: String,
33}
34
35#[derive(Debug, Deserialize, schemars::JsonSchema)]
37pub struct GetTaskParams {
38 pub spec: String,
40 pub id: String,
42}
43
44#[derive(Debug, Deserialize, schemars::JsonSchema)]
46pub struct GetSkillParams {
47 pub name: String,
49}
50
51#[derive(Serialize, schemars::JsonSchema)]
53pub struct SpecsResponse {
54 pub specs: Vec<query::specs::SpecInfo>,
56}
57
58#[derive(Serialize, schemars::JsonSchema)]
60pub struct SpecResponse {
61 pub spec: Option<query::specs::SpecDetail>,
63}
64
65#[derive(Serialize, schemars::JsonSchema)]
67pub struct TasksResponse {
68 pub tasks: Vec<query::specs::TaskInfo>,
70}
71
72#[derive(Serialize, schemars::JsonSchema)]
74pub struct TaskResponse {
75 pub task: Option<query::specs::TaskInfo>,
77}
78
79#[derive(Serialize, schemars::JsonSchema)]
81pub struct SkillView {
82 pub name: String,
84 pub content: String,
86 pub source: String,
88}
89
90#[derive(Serialize, schemars::JsonSchema)]
92pub struct SkillResponse {
93 pub skill: Option<SkillView>,
95 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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}