Skip to main content

nika_mcp/
server.rs

1//! MCP Server for Nika — exposes workflow tools via Model Context Protocol
2//!
3//! Allows AI coding tools (Claude Code, Cursor, Copilot, etc.) to validate,
4//! run, and explore Nika workflows through MCP.
5
6use 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/// Nika MCP Server handler
16#[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/// Parameters for the nika_check tool
28#[derive(Debug, Deserialize, JsonSchema)]
29pub struct CheckParams {
30    /// Path to a .nika.yaml workflow file to validate
31    pub path: String,
32}
33
34/// Parameters for the nika_schema tool
35#[derive(Debug, Deserialize, JsonSchema)]
36pub struct SchemaParams {
37    /// Schema version (default: @0.12)
38    #[serde(default = "default_schema_version")]
39    pub version: String,
40}
41
42fn default_schema_version() -> String {
43    "0.12".to_string()
44}
45
46/// Parameters for the nika_error_lookup tool
47#[derive(Debug, Deserialize, JsonSchema)]
48pub struct ErrorLookupParams {
49    /// NIKA error code (e.g., "NIKA-040")
50    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    /// Validate a Nika .nika.yaml workflow file for syntax and semantic errors.
62    #[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        // Validate path: must be .nika.yaml, must be within cwd (no traversal)
71        let canonical = match validate_workflow_path(&params.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    /// List all .nika.yaml workflow files in the current directory and subdirectories.
107    #[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        // Use spawn_blocking to avoid blocking the async MCP handler with recursive fs scan
113        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    /// Get the Nika workflow schema reference for a specific version.
135    #[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    /// Look up a NIKA error code and get its description and fix.
147    #[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
201/// Run the MCP server on stdio transport
202pub 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
212// ─── Helpers ──────────────────────────────────────────────────────────────────
213
214fn 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            // Use symlink_metadata to avoid following symlinks
225            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
243/// Maximum results from workflow listing
244const MAX_WORKFLOW_RESULTS: usize = 500;
245
246/// Validate that a path is within the current working directory and has .nika.yaml extension
247fn validate_workflow_path(user_path: &str) -> Result<std::path::PathBuf, String> {
248    // Must have .nika.yaml extension
249    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    // ─── Path Validation Tests (RED → GREEN) ──────────────────────────────
296
297    #[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        // Create a temp .nika.yaml outside cwd
317        let result = validate_workflow_path("../../../etc/shadow.nika.yaml");
318        assert!(result.is_err());
319        // Should be either "File not found" or "Path traversal blocked"
320    }
321
322    #[test]
323    fn test_accepts_valid_nika_yaml_in_cwd() {
324        // This test needs an actual file to exist
325        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    // ─── Collect Workflows Tests ──────────────────────────────────────────
346
347    #[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        // Depth 6 should be rejected
376        collect_workflows(std::path::Path::new("."), &mut results, 6);
377        assert!(results.is_empty(), "Depth 6 should return no results");
378    }
379
380    // ─── Error Lookup Tests ───────────────────────────────────────────────
381
382    #[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        // Should return Template/Binding category
392        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}