1use rmcp::handler::server::tool::ToolRouter;
7use rmcp::handler::server::wrapper::Parameters;
8use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo};
9use rmcp::{tool, tool_handler, tool_router, ServerHandler};
10use schemars::JsonSchema;
11use serde::Deserialize;
12use tokio::process::Command as TokioCommand;
13use tokio::time::{timeout, Duration};
14
15#[derive(Clone)]
17pub struct NikaMcpServer {
18 tool_router: ToolRouter<Self>,
19}
20
21impl Default for NikaMcpServer {
22 fn default() -> Self {
23 Self::new()
24 }
25}
26
27#[derive(Debug, Deserialize, JsonSchema)]
29pub struct CheckParams {
30 pub path: String,
32}
33
34#[derive(Debug, Deserialize, JsonSchema)]
36pub struct SchemaParams {
37 #[serde(default = "default_schema_version")]
39 pub version: String,
40}
41
42fn default_schema_version() -> String {
43 "0.12".to_string()
44}
45
46#[derive(Debug, Deserialize, JsonSchema)]
48pub struct ErrorLookupParams {
49 pub code: String,
51}
52
53#[tool_router]
54impl NikaMcpServer {
55 pub fn new() -> Self {
56 Self {
57 tool_router: Self::tool_router(),
58 }
59 }
60
61 #[tool(
63 name = "nika_check",
64 description = "Validate a Nika .nika.yaml workflow file. Returns validation errors with NIKA-XXX codes if invalid, or confirmation if valid. Use when editing or creating .nika.yaml files."
65 )]
66 async fn check(
67 &self,
68 Parameters(params): Parameters<CheckParams>,
69 ) -> Result<CallToolResult, rmcp::ErrorData> {
70 let canonical = match validate_workflow_path(¶ms.path) {
72 Ok(p) => p,
73 Err(e) => {
74 return Ok(CallToolResult::error(vec![Content::text(e)]));
75 }
76 };
77
78 let path_str = canonical.to_string_lossy().to_string();
79 let output = timeout(
80 Duration::from_secs(30),
81 TokioCommand::new("nika")
82 .args(["check", &path_str])
83 .output(),
84 )
85 .await
86 .map_err(|_| rmcp::ErrorData::internal_error("nika check timed out after 30s", None))?
87 .map_err(|e| {
88 rmcp::ErrorData::internal_error(format!("Failed to run nika check: {}", e), None)
89 })?;
90
91 let stdout = String::from_utf8_lossy(&output.stdout);
92 let stderr = String::from_utf8_lossy(&output.stderr);
93 if output.status.success() {
94 Ok(CallToolResult::success(vec![Content::text(format!(
95 "Valid: {}",
96 params.path
97 ))]))
98 } else {
99 Ok(CallToolResult::error(vec![Content::text(format!(
100 "Validation errors:\n{}\n{}",
101 stdout, stderr
102 ))]))
103 }
104 }
105
106 #[tool(
108 name = "nika_list_workflows",
109 description = "List all .nika.yaml workflow files in the project. Use to discover available workflows."
110 )]
111 async fn list_workflows(&self) -> Result<CallToolResult, rmcp::ErrorData> {
112 let workflows = tokio::task::spawn_blocking(|| {
114 let mut wf = Vec::new();
115 collect_workflows(std::path::Path::new("."), &mut wf, 0);
116 wf
117 })
118 .await
119 .unwrap_or_default();
120
121 if workflows.is_empty() {
122 Ok(CallToolResult::success(vec![Content::text(
123 "No .nika.yaml files found in current directory.",
124 )]))
125 } else {
126 Ok(CallToolResult::success(vec![Content::text(format!(
127 "Found {} workflow(s):\n{}",
128 workflows.len(),
129 workflows.join("\n")
130 ))]))
131 }
132 }
133
134 #[tool(
136 name = "nika_schema",
137 description = "Get the Nika workflow YAML schema reference. Returns the 5 verbs, all fields, binding syntax, and transform catalog."
138 )]
139 async fn schema(
140 &self,
141 Parameters(_params): Parameters<SchemaParams>,
142 ) -> Result<CallToolResult, rmcp::ErrorData> {
143 Ok(CallToolResult::success(vec![Content::text(SCHEMA_REF)]))
144 }
145
146 #[tool(
148 name = "nika_error_lookup",
149 description = "Look up a NIKA-XXX error code. Returns the error description, category, and how to fix it. Use when debugging workflow validation or runtime errors."
150 )]
151 async fn error_lookup(
152 &self,
153 Parameters(params): Parameters<ErrorLookupParams>,
154 ) -> Result<CallToolResult, rmcp::ErrorData> {
155 let code = params.code.to_uppercase().replace("NIKA-", "");
156 let num: u32 = code.parse().unwrap_or(999);
157
158 let (category, description) = match num {
159 0..=9 => ("Workflow", "Workflow structure error (schema, tasks)"),
160 10..=19 => (
161 "Schema/Validation",
162 "Schema validation error (task IDs, fields)",
163 ),
164 20..=29 => ("DAG", "DAG error (circular deps, missing deps)"),
165 30..=39 => ("Provider", "Provider error (API key, model not found)"),
166 40..=49 => ("Template/Binding", "Template or binding resolution error"),
167 50..=59 => ("Path/Security", "Path, task, or security error"),
168 60..=69 => ("Output", "JSON/schema validation error"),
169 90..=99 => ("Execution", "Runtime execution error"),
170 100..=109 => ("MCP", "MCP server/tool error"),
171 110..=119 => ("Agent", "Agent loop error"),
172 200..=219 => ("File Tools", "Builtin file tool error"),
173 250..=259 => ("Media", "Media pipeline error"),
174 300..=309 => ("Structured Output", "Structured output error"),
175 _ => ("Unknown", "Unknown error code"),
176 };
177
178 Ok(CallToolResult::success(vec![Content::text(format!(
179 "NIKA-{:03}: {}\nCategory: {}\nFix: Run `nika check <file>` for detailed diagnostics.",
180 num, description, category
181 ))]))
182 }
183}
184
185#[tool_handler]
186impl ServerHandler for NikaMcpServer {
187 fn get_info(&self) -> ServerInfo {
188 ServerInfo {
189 instructions: Some(
190 "Nika workflow engine MCP server. Validate, list, and explore .nika.yaml workflows."
191 .into(),
192 ),
193 capabilities: ServerCapabilities::builder()
194 .enable_tools()
195 .build(),
196 ..Default::default()
197 }
198 }
199}
200
201pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
203 use rmcp::transport::stdio;
204 use rmcp::ServiceExt;
205
206 let handler = NikaMcpServer::new();
207 let server = handler.serve(stdio()).await?;
208 server.waiting().await?;
209 Ok(())
210}
211
212fn collect_workflows(dir: &std::path::Path, results: &mut Vec<String>, depth: usize) {
215 if depth > 5 || results.len() >= MAX_WORKFLOW_RESULTS {
216 return;
217 }
218 if let Ok(entries) = std::fs::read_dir(dir) {
219 for entry in entries.flatten() {
220 if results.len() >= MAX_WORKFLOW_RESULTS {
221 return;
222 }
223 let path = entry.path();
224 let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
226 if is_dir {
227 let name = entry.file_name().to_string_lossy().to_string();
228 if !name.starts_with('.') && name != "target" && name != "node_modules" {
229 collect_workflows(&path, results, depth + 1);
230 }
231 } else if path
232 .file_name()
233 .unwrap_or_default()
234 .to_string_lossy()
235 .ends_with(".nika.yaml")
236 {
237 results.push(path.display().to_string());
238 }
239 }
240 }
241}
242
243const MAX_WORKFLOW_RESULTS: usize = 500;
245
246fn validate_workflow_path(user_path: &str) -> Result<std::path::PathBuf, String> {
248 if !user_path.ends_with(".nika.yaml") {
250 return Err("Only .nika.yaml files can be validated".to_string());
251 }
252
253 let cwd =
254 std::env::current_dir().map_err(|e| format!("Cannot determine working directory: {e}"))?;
255 let canonical_cwd = cwd
256 .canonicalize()
257 .map_err(|e| format!("Cannot canonicalize cwd: {e}"))?;
258
259 let requested = cwd.join(user_path);
260 let canonical = requested
261 .canonicalize()
262 .map_err(|_| format!("File not found: {user_path}"))?;
263
264 if !canonical.starts_with(&canonical_cwd) {
265 return Err(format!("Path traversal blocked: {user_path}"));
266 }
267
268 Ok(canonical)
269}
270
271const SCHEMA_REF: &str = r#"# Nika Workflow Schema (v0.12)
272
273## 5 Verbs
274- infer: { prompt, system, temperature, max_tokens, content, extended_thinking }
275- exec: { command, shell, cwd, env, timeout_ms }
276- fetch: { url, method, headers, body/json, extract, selector, response }
277- invoke: { tool, mcp, params, resource }
278- agent: { prompt, tools, max_turns, system, mcp, guardrails, completion }
279
280## Task Fields
281id, description, provider, model, with, depends_on, output, for_each, as, concurrency, fail_fast, retry, timeout, structured, artifact, log
282
283## Bindings
284with: { alias: $task_id } → {{with.alias}}
285Transforms: upper, lower, trim, length, first, last, keys, values, flatten, sort, unique, to_json, parse_json, join(sep), split(sep), default(val)
286
287## Extract Modes (fetch:)
288markdown, article, text, selector, metadata, links, jsonpath, feed, llm_txt
289"#;
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
298 fn test_rejects_non_nika_extension() {
299 let result = validate_workflow_path("/etc/passwd");
300 assert!(result.is_err());
301 assert!(
302 result.unwrap_err().contains("Only .nika.yaml"),
303 "Should reject non-.nika.yaml files"
304 );
305 }
306
307 #[test]
308 fn test_rejects_yaml_without_nika_prefix() {
309 let result = validate_workflow_path("workflow.yaml");
310 assert!(result.is_err());
311 assert!(result.unwrap_err().contains("Only .nika.yaml"));
312 }
313
314 #[test]
315 fn test_rejects_path_traversal() {
316 let result = validate_workflow_path("../../../etc/shadow.nika.yaml");
318 assert!(result.is_err());
319 }
321
322 #[test]
323 fn test_accepts_valid_nika_yaml_in_cwd() {
324 let tmpdir = std::env::current_dir().unwrap();
326 let test_file = tmpdir.join("_test_valid.nika.yaml");
327 std::fs::write(&test_file, "schema: nika/workflow@0.12\ntasks: []").unwrap();
328
329 let result = validate_workflow_path("_test_valid.nika.yaml");
330 assert!(
331 result.is_ok(),
332 "Should accept .nika.yaml in cwd: {:?}",
333 result
334 );
335
336 std::fs::remove_file(&test_file).unwrap();
337 }
338
339 #[test]
340 fn test_rejects_absolute_path_outside_cwd() {
341 let result = validate_workflow_path("/tmp/evil.nika.yaml");
342 assert!(result.is_err());
343 }
344
345 #[test]
348 fn test_collect_workflows_respects_max_results() {
349 let mut results = Vec::new();
350 collect_workflows(std::path::Path::new("."), &mut results, 0);
351 assert!(
352 results.len() <= MAX_WORKFLOW_RESULTS,
353 "Should not exceed {} results, got {}",
354 MAX_WORKFLOW_RESULTS,
355 results.len()
356 );
357 }
358
359 #[test]
360 fn test_collect_workflows_skips_hidden_dirs() {
361 let mut results = Vec::new();
362 collect_workflows(std::path::Path::new("."), &mut results, 0);
363 for r in &results {
364 assert!(
365 !r.contains("/."),
366 "Should not include files from hidden dirs: {}",
367 r
368 );
369 }
370 }
371
372 #[test]
373 fn test_collect_workflows_respects_depth_limit() {
374 let mut results = Vec::new();
375 collect_workflows(std::path::Path::new("."), &mut results, 6);
377 assert!(results.is_empty(), "Depth 6 should return no results");
378 }
379
380 #[tokio::test]
383 async fn test_error_lookup_returns_category() {
384 let server = NikaMcpServer::new();
385 let result = server
386 .error_lookup(Parameters(ErrorLookupParams {
387 code: "NIKA-040".to_string(),
388 }))
389 .await
390 .unwrap();
391 let text = format!("{:?}", result);
393 assert!(
394 text.contains("Template") || text.contains("Binding"),
395 "NIKA-040 should be Template/Binding category"
396 );
397 }
398
399 #[tokio::test]
400 async fn test_error_lookup_handles_invalid_code() {
401 let server = NikaMcpServer::new();
402 let result = server
403 .error_lookup(Parameters(ErrorLookupParams {
404 code: "not-a-code".to_string(),
405 }))
406 .await
407 .unwrap();
408 let text = format!("{:?}", result);
409 assert!(
410 text.contains("Unknown"),
411 "Invalid code should return Unknown"
412 );
413 }
414}