Skip to main content
Glama
mcp.rs20.7 kB
use serde_json::json; use std::error::Error; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use tempfile::TempDir; fn docdex_bin() -> PathBuf { assert_cmd::cargo::cargo_bin!("docdexd").to_path_buf() } struct McpHarness { child: std::process::Child, stdin: std::process::ChildStdin, reader: BufReader<std::process::ChildStdout>, } impl McpHarness { fn spawn(repo: &Path) -> Result<Self, Box<dyn Error>> { let repo_str = repo.to_string_lossy().to_string(); let mut child = Command::new(docdex_bin()) .args([ "mcp", "--repo", repo_str.as_str(), "--log", "warn", "--max-results", "4", ]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn()?; let stdin = child .stdin .take() .ok_or("failed to take child stdin for MCP server")?; let stdout = child .stdout .take() .ok_or("failed to take child stdout for MCP server")?; Ok(Self { child, stdin, reader: BufReader::new(stdout), }) } fn shutdown(&mut self) { self.child.kill().ok(); self.child.wait().ok(); } } fn write_fixture_repo(repo_root: &Path) -> Result<(), Box<dyn Error>> { let docs_dir = repo_root.join("docs"); fs::create_dir_all(&docs_dir)?; fs::write( docs_dir.join("overview.md"), r#"# Overview This repository contains the MCP_ROADMAP notes used for testing. "#, )?; Ok(()) } fn setup_repo() -> Result<TempDir, Box<dyn Error>> { let temp = TempDir::new()?; write_fixture_repo(temp.path())?; Ok(temp) } fn send_line( stdin: &mut std::process::ChildStdin, payload: serde_json::Value, ) -> Result<(), Box<dyn Error>> { let text = serde_json::to_string(&payload)?; stdin.write_all(text.as_bytes())?; stdin.write_all(b"\n")?; stdin.flush()?; Ok(()) } fn parse_tool_result(resp: &serde_json::Value) -> Result<serde_json::Value, Box<dyn Error>> { let content = resp .get("result") .and_then(|v| v.get("content")) .and_then(|v| v.as_array()) .ok_or("tool result missing content array")?; let first_text = content .first() .and_then(|v| v.get("text")) .and_then(|v| v.as_str()) .ok_or("tool result missing text content")?; let parsed: serde_json::Value = serde_json::from_str(first_text)?; Ok(parsed) } fn read_line( reader: &mut BufReader<std::process::ChildStdout>, ) -> Result<serde_json::Value, Box<dyn Error>> { let mut line = String::new(); reader.read_line(&mut line)?; if line.trim().is_empty() { return Err("unexpected empty response line from MCP server".into()); } let value: serde_json::Value = serde_json::from_str(&line)?; Ok(value) } #[test] fn mcp_server_end_to_end() -> Result<(), Box<dyn Error>> { let repo = setup_repo()?; let mut harness = McpHarness::spawn(repo.path())?; // initialize send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} }), )?; let init_resp = read_line(&mut harness.reader)?; assert_eq!( init_resp.get("id").and_then(|v| v.as_i64()), Some(1), "initialize response should echo id" ); assert_eq!( init_resp .get("result") .and_then(|v| v.get("capabilities")) .and_then(|v| v.get("tools")) .map(|v| v.is_object()), Some(true), "initialize should advertise tools capability" ); // tools/list send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", }), )?; let list_resp = read_line(&mut harness.reader)?; let tools = list_resp .get("result") .and_then(|v| v.get("tools")) .and_then(|v| v.as_array()) .ok_or("tools/list should return tools array")?; let tool_names: Vec<String> = tools .iter() .filter_map(|tool| { tool.get("name") .and_then(|v| v.as_str()) .map(|s| s.to_string()) }) .collect(); assert!( tool_names.contains(&"docdex_search".to_string()), "tools/list should include docdex_search" ); assert!( tool_names.contains(&"docdex_index".to_string()), "tools/list should include docdex_index" ); assert!( tool_names.contains(&"docdex_stats".to_string()), "tools/list should include docdex_stats" ); // build index via tool send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "docdex_index", "arguments": { "paths": [] } } }), )?; let index_resp = read_line(&mut harness.reader)?; assert_eq!( index_resp.get("id").and_then(|v| v.as_i64()), Some(3), "index response should echo id" ); let index_body = parse_tool_result(&index_resp)?; assert_eq!( index_body.get("status").and_then(|v| v.as_str()), Some("ok"), "docdex_index should return status ok" ); // search for the test term send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "docdex_search", "arguments": { "query": "MCP_ROADMAP", "limit": 5 } } }), )?; let search_resp = read_line(&mut harness.reader)?; let search_body = parse_tool_result(&search_resp)?; let results = search_body .get("results") .and_then(|v| v.as_array()) .ok_or("docdex_search should return results array")?; assert!( !results.is_empty(), "docdex_search should return at least one hit for MCP_ROADMAP" ); // stats should report doc count send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": { "name": "docdex_stats", "arguments": {} } }), )?; let stats_resp = read_line(&mut harness.reader)?; let stats_body = parse_tool_result(&stats_resp)?; let num_docs = stats_body .get("num_docs") .and_then(|v| v.as_u64()) .ok_or("docdex_stats should include num_docs")?; assert!(num_docs > 0, "stats num_docs should be > 0"); let segments = stats_body .get("segments") .and_then(|v| v.as_u64()) .unwrap_or(0); assert!(segments > 0, "stats should report at least one segment"); // files listing should include known docs and totals send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": { "name": "docdex_files", "arguments": { "limit": 10, "offset": 0 } } }), )?; let files_resp = read_line(&mut harness.reader)?; let files_body = parse_tool_result(&files_resp)?; let files = files_body .get("results") .and_then(|v| v.as_array()) .ok_or("docdex_files should return results array")?; assert!( !files.is_empty(), "docdex_files should return at least one document entry" ); let total = files_body .get("total") .and_then(|v| v.as_u64()) .ok_or("docdex_files should return total")?; assert!( total >= files.len() as u64, "total should be >= returned rows" ); harness.shutdown(); Ok(()) } #[test] fn mcp_rejects_wrong_version() -> Result<(), Box<dyn Error>> { let repo = setup_repo()?; let mut harness = McpHarness::spawn(repo.path())?; send_line( &mut harness.stdin, json!({ "jsonrpc": "1.0", "id": 10, "method": "initialize", "params": {} }), )?; let resp = read_line(&mut harness.reader)?; let error_code = resp .get("error") .and_then(|v| v.get("code")) .and_then(|v| v.as_i64()); assert_eq!( error_code, Some(-32600), "wrong jsonrpc version should return invalid request error" ); harness.shutdown(); Ok(()) } #[test] fn mcp_unknown_tool_returns_error() -> Result<(), Box<dyn Error>> { let repo = setup_repo()?; let mut harness = McpHarness::spawn(repo.path())?; send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": { "name": "docdex.unknown", "arguments": {} } }), )?; let resp = read_line(&mut harness.reader)?; let error_code = resp .get("error") .and_then(|v| v.get("code")) .and_then(|v| v.as_i64()); assert_eq!( error_code, Some(-32601), "unknown tool should return method not found" ); harness.shutdown(); Ok(()) } #[test] fn mcp_search_empty_query_errors() -> Result<(), Box<dyn Error>> { let repo = setup_repo()?; let mut harness = McpHarness::spawn(repo.path())?; // index first send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 12, "method": "tools/call", "params": { "name": "docdex_index", "arguments": { "paths": [] } } }), )?; let _ = read_line(&mut harness.reader)?; // search with empty query should error send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 13, "method": "tools/call", "params": { "name": "docdex_search", "arguments": { "query": "" } } }), )?; let resp = read_line(&mut harness.reader)?; let error_code = resp .get("error") .and_then(|v| v.get("code")) .and_then(|v| v.as_i64()); assert_eq!( error_code, Some(-32602), "empty query should return invalid params error" ); harness.shutdown(); Ok(()) } #[test] fn mcp_files_pagination_and_invalid_params() -> Result<(), Box<dyn Error>> { let repo = setup_repo()?; let mut harness = McpHarness::spawn(repo.path())?; // index first send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 20, "method": "tools/call", "params": { "name": "docdex_index", "arguments": { "paths": [] } } }), )?; let _ = read_line(&mut harness.reader)?; // pagination with offset beyond total should return empty results but include total send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 21, "method": "tools/call", "params": { "name": "docdex_files", "arguments": { "limit": 5, "offset": 10_000 } } }), )?; let paged_resp = read_line(&mut harness.reader)?; let paged_body = parse_tool_result(&paged_resp)?; let total = paged_body .get("total") .and_then(|v| v.as_u64()) .ok_or("docdex_files should include total")?; let files = paged_body .get("results") .and_then(|v| v.as_array()) .ok_or("docdex_files should include results array")?; assert_eq!( files.len(), 0, "offset beyond total should return empty results" ); assert!( total >= files.len() as u64, "total should be present even when results are empty" ); // invalid params (wrong type) should return invalid params error code send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 22, "method": "tools/call", "params": { "name": "docdex_files", "arguments": { "limit": "not-a-number" } } }), )?; let invalid_resp = read_line(&mut harness.reader)?; let err_code = invalid_resp .get("error") .and_then(|v| v.get("code")) .and_then(|v| v.as_i64()); assert_eq!( err_code, Some(-32602), "invalid params should return code -32602" ); harness.shutdown(); Ok(()) } #[test] fn mcp_open_respects_ranges_and_bounds() -> Result<(), Box<dyn Error>> { let repo = setup_repo()?; let repo_root = repo.path(); let content = "\ Line1 Line2 Line3 Line4 Line5 "; std::fs::write(repo_root.join("docs").join("open.md"), content)?; let mut harness = McpHarness::spawn(repo_root)?; // Full file send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 30, "method": "tools/call", "params": { "name": "docdex_open", "arguments": { "path": "docs/open.md" } } }), )?; let full_resp = read_line(&mut harness.reader)?; let full_body = parse_tool_result(&full_resp)?; let full_content = full_body .get("content") .and_then(|v| v.as_str()) .ok_or("docdex_open should return content")?; assert!(full_content.contains("Line1") && full_content.contains("Line5")); // Range (lines 2-3) send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 31, "method": "tools/call", "params": { "name": "docdex_open", "arguments": { "path": "docs/open.md", "start_line": 2, "end_line": 3 } } }), )?; let range_resp = read_line(&mut harness.reader)?; let range_body = parse_tool_result(&range_resp)?; let range_content = range_body .get("content") .and_then(|v| v.as_str()) .ok_or("docdex_open range should return content")?; assert!( range_content.lines().count() == 2 && range_content.contains("Line2"), "range content should include only requested lines" ); // Reject parent dirs send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 32, "method": "tools/call", "params": { "name": "docdex_open", "arguments": { "path": "../open.md" } } }), )?; let bad_resp = read_line(&mut harness.reader)?; let err_code = bad_resp .get("error") .and_then(|v| v.get("code")) .and_then(|v| v.as_i64()); assert_eq!(err_code, Some(-32602), "parent dir should be rejected"); harness.shutdown(); Ok(()) } #[test] fn mcp_invalid_arg_shapes_return_errors() -> Result<(), Box<dyn Error>> { let repo = setup_repo()?; let mut harness = McpHarness::spawn(repo.path())?; // search with missing query send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 50, "method": "tools/call", "params": { "name": "docdex_search", "arguments": { "limit": 2 } } }), )?; let resp = read_line(&mut harness.reader)?; let err_code = resp .get("error") .and_then(|v| v.get("code")) .and_then(|v| v.as_i64()); assert_eq!( err_code, Some(-32602), "missing required field should return invalid params" ); // open with absolute path should be rejected send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 51, "method": "tools/call", "params": { "name": "docdex_open", "arguments": { "path": "/etc/passwd" } } }), )?; let resp = read_line(&mut harness.reader)?; let err_code = resp .get("error") .and_then(|v| v.get("code")) .and_then(|v| v.as_i64()); assert_eq!( err_code, Some(-32602), "absolute paths should be rejected with invalid params" ); // open with start > end send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 52, "method": "tools/call", "params": { "name": "docdex_open", "arguments": { "path": "docs/overview.md", "start_line": 10, "end_line": 1 } } }), )?; let resp = read_line(&mut harness.reader)?; let err_code = resp .get("error") .and_then(|v| v.get("code")) .and_then(|v| v.as_i64()); assert_eq!( err_code, Some(-32602), "start>end should be rejected with invalid params" ); // open with start beyond file send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 53, "method": "tools/call", "params": { "name": "docdex_open", "arguments": { "path": "docs/overview.md", "start_line": 10_000 } } }), )?; let resp = read_line(&mut harness.reader)?; let err_code = resp .get("error") .and_then(|v| v.get("code")) .and_then(|v| v.as_i64()); assert_eq!( err_code, Some(-32602), "start beyond file should be rejected with invalid params" ); // oversized file let big_path = repo.path().join("docs").join("big.md"); let big_content = "x".repeat(600_000); std::fs::write(&big_path, big_content)?; send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 54, "method": "tools/call", "params": { "name": "docdex_open", "arguments": { "path": "docs/big.md" } } }), )?; let resp = read_line(&mut harness.reader)?; let err_code = resp .get("error") .and_then(|v| v.get("code")) .and_then(|v| v.as_i64()); assert_eq!( err_code, Some(-32602), "oversized file should be rejected with invalid params" ); // resource templates list should return docdex_file send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 55, "method": "resources/templates/list" }), )?; let resp = read_line(&mut harness.reader)?; let templates = resp .get("result") .and_then(|v| v.get("resourceTemplates")) .and_then(|v| v.as_array()) .ok_or("resources/templates/list should return array")?; let has_docdex = templates.iter().any(|tpl| { tpl.get("name") .and_then(|v| v.as_str()) .map(|name| name == "docdex_file") .unwrap_or(false) }); assert!(has_docdex, "resource templates should include docdex_file"); // resources/read should resolve docdex_file send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 56, "method": "resources/read", "params": { "uri": "docdex://docs/overview.md" } }), )?; let read_resp = read_line(&mut harness.reader)?; let content = read_resp .get("result") .and_then(|v| v.get("content")) .and_then(|v| v.as_str()) .unwrap_or_default(); assert!( !content.is_empty(), "resources/read should return file content for docdex_file" ); harness.shutdown(); Ok(()) } #[test] fn mcp_initialize_rejects_wrong_workspace_root() -> Result<(), Box<dyn Error>> { let repo = setup_repo()?; let mut harness = McpHarness::spawn(repo.path())?; send_line( &mut harness.stdin, json!({ "jsonrpc": "2.0", "id": 40, "method": "initialize", "params": { "workspace_root": "/tmp/not-the-repo" } }), )?; let resp = read_line(&mut harness.reader)?; let err_code = resp .get("error") .and_then(|v| v.get("code")) .and_then(|v| v.as_i64()); assert_eq!( err_code, Some(-32600), "workspace root mismatch should return invalid request" ); harness.shutdown(); Ok(()) }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/bekirdag/docdex'

If you have feedback or need assistance with the MCP directory API, please join our Discord server